Guide développeur
Interface & intégration Patterns UI basés sur le starter gringhost-nextjs-starter. @gringhost/react gère le bouton, le solde et la confirmation — tu construis le reste.
Interface du starter# Le starter gringhost-nextjs-starter est l'implémentation de référence. Clone-le, configure 4 variables d'env, puis construis par-dessus.
GitHub
nicolas-sayavongsa/gringhost-nextjs-starter
Next.js 16 · @gringhost/react · Supabase · OpenAI
Explique les crédits GrinGhost en 2 phrases.
Les crédits GrinGhost sont un wallet universel utilisable sur tous les sites compatibles. 1 crédit = $0,0001 — tu ne paies que ce que tu consommes.
Configure OPENAI_API_KEY dans .env.local pour activer le chat.
Envoie un message…
Envoyer
Ce que tu vois ci-dessus : header avec GrinGhostButton (solde + menu), indicateur de débit −1 cr, bulles de chat, message système (fond jaune), formulaire.
Composant autonome du package @gringhost/react. Pas de props. Gère intégralement login, menu, solde et confirmation de paiement.
Continue with GrinGhostNon connecté
Quand une action est en attente, le bouton passe en ambre et ouvre automatiquement un panneau de confirmation :
Acheter des crédits
Se déconnecter
Le prix affiché (1 crédit) vient directement de la DB GrinGhost — pas du serveur du dev. Impossible à falsifier.
// Installation
npm install @gringhost/react
// app/providers.tsx
'use client'
import { GrinGhostProvider } from '@gringhost/react'
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
export default function Providers({ children }: { children: React.ReactNode }) {
return <GrinGhostProvider supabase={supabase}>{children}</GrinGhostProvider>
}
// Dans ton header
import { GrinGhostButton } from '@gringhost/react'
<GrinGhostButton /> // pas de props Place GrinGhostButton une seule fois dans toute l'app. La confirmation s'ouvre dans ce même bouton quand une action est déclenchée via useGrinGhostAction.
Hook useGrinGhost Accède à l'état auth et au solde depuis n'importe quel composant client :
'use client'
import { useGrinGhost } from '@gringhost/react'
const { user, isLoaded, credits, loadCredits } = useGrinGhost()
if (!isLoaded) return null // auth pas encore chargée
if (!user) return <LoginPrompt /> // non connecté
// user.email, credits (null si non chargé, number sinon)
// loadCredits() — recharge le solde depuis /api/internal/credits Indicateur de débit# Après une action réussie, affiche le coût débité à côté du bouton. Pattern exact du starter :
const [deducted, setDeducted] = useState<number | null>(null)
const { loadCredits } = useGrinGhost()
const { prepare } = useGrinGhostAction('/api/internal/chat/prepare')
async function send() {
setDeducted(null)
const result = await prepare()
if (!result) return // annulé ou timeout
const res = await fetch('/api/internal/chat', { /* ... */ })
const data = await res.json()
if (res.ok) {
if (result.creditsCost) setDeducted(result.creditsCost) // affiche −X cr
loadCredits() // met à jour le solde dans GrinGhostButton
}
}
// Dans le header, à côté de <GrinGhostButton /> :
{deducted !== null && deducted > 0 && (
<span style={{ fontSize: '11px', color: '#e03030', fontWeight: '600' }}>
−{deducted} cr
</span>
)} Gestion des erreurs# Trois cas à gérer côté client : crédits insuffisants (402), session expirée, et clé IA manquante.
Crédits insuffisants.
Session expirée — reconnecte-toi via GrinGhost.
Configure OPENAI_API_KEY dans .env.local, puis relance le serveur.
const res = await fetch('/api/internal/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, history, session_token: result.sessionToken }),
})
const data = await res.json()
if (res.status === 503 && data.error === 'openai_key_missing') {
addSystemMessage('Configure OPENAI_API_KEY dans .env.local, puis relance le serveur.')
} else if (res.status === 402) {
addSystemMessage('Crédits insuffisants.')
// ou : window.open('https://www.gringhost.com/buy', '_blank')
} else if (res.status === 401) {
addSystemMessage('Session expirée — reconnecte-toi via GrinGhost.')
} else if (!res.ok) {
addSystemMessage(data.error ?? 'Erreur serveur.')
} else {
addMessage({ role: 'assistant', text: data.reply })
if (result.creditsCost) setDeducted(result.creditsCost)
loadCredits()
}
// Type de message système (fond jaune dans le chat)
type Message = { role: 'user' | 'assistant'; text: string; system?: boolean }
function addSystemMessage(text: string) {
setMessages(prev => [...prev, { role: 'assistant', text, system: true }])
}
// Rendu d'une bulle système
<div style={{
background: m.system ? '#fff8e1' : '#fff',
border: `1px solid ${m.system ? '#ffe082' : '#e8e8e8'}`,
color: m.system ? '#b45309' : '#111',
}}>
{m.text}
</div> Animation de chargement# Trois points qui clignotent pendant l'appel IA. Tiré directement du starter :
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{
padding: '12px 16px',
borderRadius: '14px 14px 14px 3px',
background: '#fff',
border: '1px solid #e8e8e8',
display: 'flex', gap: '5px', alignItems: 'center',
}}>
<style>{`@keyframes blink{0%,80%,100%{opacity:.15}40%{opacity:1}}`}</style>
{[0, 0.18, 0.36].map(d => (
<div key={d} style={{
width: '6px', height: '6px', borderRadius: '50%',
background: '#aaa',
animation: `blink 1.1s ${d}s infinite`,
}} />
))}
</div>
</div>
)} Pattern page complète# Structure complète d'une page avec chat IA payant — le pattern exact du starter :
'use client'
import { useState, useRef, useEffect } from 'react'
import { GrinGhostButton, useGrinGhost, useGrinGhostAction } from '@gringhost/react'
type Message = { role: 'user' | 'assistant'; text: string; system?: boolean }
export default function Home() {
const { user, isLoaded, loadCredits } = useGrinGhost()
const { prepare } = useGrinGhostAction('/api/internal/chat/prepare')
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [deducted, setDeducted] = useState<number | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, loading])
async function send(e: React.FormEvent) {
e.preventDefault()
if (!input.trim() || loading) return
const userMsg = input.trim()
setInput('')
setDeducted(null)
setMessages(prev => [...prev, { role: 'user', text: userMsg }])
setLoading(true)
const result = await prepare()
if (!result) { setMessages(prev => prev.slice(0, -1)); setLoading(false); return }
const res = await fetch('/api/internal/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMsg, history: messages.filter(m => !m.system), session_token: result.sessionToken }),
})
const data = await res.json()
if (res.status === 503 && data.error === 'openai_key_missing') {
setMessages(prev => [...prev.slice(0, -1), { role: 'assistant', text: 'Configure OPENAI_API_KEY dans .env.local.', system: true }])
} else if (res.status === 402) {
setMessages(prev => [...prev.slice(0, -1), { role: 'assistant', text: 'Crédits insuffisants.', system: true }])
} else if (!res.ok) {
setMessages(prev => [...prev.slice(0, -1), { role: 'assistant', text: data.error ?? 'Erreur serveur.', system: true }])
} else {
setMessages(prev => [...prev, { role: 'assistant', text: data.reply }])
if (result.creditsCost) setDeducted(result.creditsCost)
loadCredits()
}
setLoading(false)
}
if (!isLoaded) return null
return (
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column', background: '#fafafa' }}>
{/* Header */}
<header style={{
background: '#fff', borderBottom: '1px solid #e8e8e8',
padding: '0 20px', height: '52px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
position: 'sticky', top: 0, zIndex: 10,
}}>
<span style={{ fontWeight: 800, fontSize: '15px', color: '#111' }}>Mon App</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{deducted != null && deducted > 0 && (
<span style={{ fontSize: '11px', color: '#e03030', fontWeight: 600 }}>−{deducted} cr</span>
)}
<GrinGhostButton />
</div>
</header>
{/* Messages */}
<main style={{ flex: 1, padding: '24px 20px 120px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '100%', maxWidth: '620px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{messages.map((m, i) => (
<div key={i} style={{ display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
<div style={{
maxWidth: '78%', padding: '10px 14px', fontSize: '14px', lineHeight: 1.65,
borderRadius: m.role === 'user' ? '14px 14px 3px 14px' : '14px 14px 14px 3px',
background: m.role === 'user' ? '#2a1a4a' : m.system ? '#fff8e1' : '#fff',
border: m.role === 'assistant' ? `1px solid ${m.system ? '#ffe082' : '#e8e8e8'}` : 'none',
color: m.role === 'user' ? '#fff' : m.system ? '#b45309' : '#111',
}}>
{m.text}
</div>
</div>
))}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div style={{ padding: '12px 16px', borderRadius: '14px 14px 14px 3px', background: '#fff', border: '1px solid #e8e8e8', display: 'flex', gap: '5px', alignItems: 'center' }}>
<style>{`@keyframes blink{0%,80%,100%{opacity:.15}40%{opacity:1}}`}</style>
{[0, 0.18, 0.36].map(d => <div key={d} style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#aaa', animation: `blink 1.1s ${d}s infinite` }} />)}
</div>
</div>
)}
<div ref={bottomRef} />
</div>
</main>
{/* Formulaire */}
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(250,250,250,0.92)', backdropFilter: 'blur(12px)', borderTop: '1px solid #e8e8e8', padding: '12px 20px 20px' }}>
<form onSubmit={send} style={{ display: 'flex', gap: '8px', maxWidth: '620px', margin: '0 auto' }}>
<input
value={input} onChange={e => setInput(e.target.value)}
placeholder="Envoie un message…"
style={{ flex: 1, padding: '12px 16px', border: '1px solid #ddd', borderRadius: '12px', fontSize: '14px', outline: 'none', fontFamily: 'inherit' }}
/>
<button
type="submit" disabled={!input.trim() || loading}
style={{ padding: '12px 22px', background: input.trim() && !loading ? '#2a1a4a' : '#e5e5e5', color: input.trim() && !loading ? '#f3f4f6' : '#bbb', border: 'none', borderRadius: '12px', fontSize: '14px', fontWeight: 700, cursor: input.trim() && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? '…' : 'Envoyer'}
</button>
</form>
</div>
</div>
)
}