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

GrinGhost Starter
−1 cr
GrinGhost · 4 999 cr
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.

GrinGhostButton#

Composant autonome du package @gringhost/react. Pas de props. Gère intégralement login, menu, solde et confirmation de paiement.

Non connecté
GrinGhost · 5 000 cr
Connecté (menu fermé)
GrinGhost· Confirmer
Action en attente

Quand une action est en attente, le bouton passe en ambre et ouvre automatiquement un panneau de confirmation :

Confirmation

18s

Chat IA

Coût : 1 crédit

Confirmer
Annuler

Compte

[email protected]

4 999 crédits

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.

ts
// 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 :

ts
'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 :

−1 cr
GrinGhost · 4 999 cr
ts
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.
ts
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 :

ts
{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 :

ts
'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>
  )
}