Guide développeur

Composants UI

Composants React prêts à copier pour intégrer GrinGhost dans ton interface. Aucune dépendance externe — du JSX pur compatible avec n'importe quel projet React.

GringhostButton#

Le composant principal à placer dans la navbar. Deux états : bouton CTA ambre (non connecté) → badge discret avec point vert (connecté).

Aperçu

non connecté
Connected with GrinGhostconnecté
ts
'use client'

const C    = '#fbbf24'
const DARK = '#2a1a4a'
const CARD = '#3b2a5e'

interface GringhostButtonProps {
  connected?: boolean
  onConnect?: () => void
}

export function GringhostButton({ connected = false, onConnect }: GringhostButtonProps) {
  if (!connected) {
    return (
      <button
        onClick={onConnect}
        style={{
          background: C, color: DARK, border: 'none',
          borderRadius: '9999px', padding: '10px 22px',
          fontSize: '13px', fontWeight: 600, cursor: 'pointer',
        }}
      >
        Continue with GrinGhost
      </button>
    )
  }

  return (
    <span style={{
      background: CARD, color: '#f3f4f6',
      border: '1px solid rgba(255,255,255,0.08)',
      borderRadius: '9999px', padding: '6px 14px',
      fontSize: '12px', fontWeight: 500,
      display: 'inline-flex', alignItems: 'center', gap: '6px',
    }}>
      <span style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
      Connected with GrinGhost
    </span>
  )
}

Utilisation avec Supabase

ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { GringhostButton } from '@/components/GringhostButton'

export function Navbar() {
  const [connected, setConnected] = useState(false)
  const supabase = createClient()

  useEffect(() => {
    supabase.auth.getUser().then(({ data }) => setConnected(!!data.user))
  }, [])

  return (
    <nav>
      <GringhostButton
        connected={connected}
        onConnect={() => supabase.auth.signInWithOAuth({
          provider: 'custom:gringhost',
          options: { redirectTo: window.location.origin + '/auth/callback' },
        })}
      />
    </nav>
  )
}

CostBadge#

Affiche le coût en crédits d'une action avant que l'utilisateur la déclenche. Devient rouge si le solde est insuffisant.

Aperçu

10 crédits10 crédits — solde insuffisant
ts
interface CostBadgeProps {
  credits: number
  balance?: number
}

export function CostBadge({ credits, balance }: CostBadgeProps) {
  const insufficient = balance != null && balance < credits

  return (
    <span style={{
      background: insufficient ? 'rgba(239,68,68,0.1)'    : 'rgba(251,191,36,0.12)',
      color:      insufficient ? '#f87171'                 : '#fbbf24',
      border:     `1px solid ${insufficient ? 'rgba(239,68,68,0.25)' : 'rgba(251,191,36,0.25)'}`,
      fontSize: '11px', fontWeight: 700,
      padding: '3px 9px', borderRadius: '100px',
      display: 'inline-flex', alignItems: 'center', gap: '4px',
    }}>
      {credits} crédit{credits > 1 ? 's' : ''}
      {insufficient && ' — solde insuffisant'}
    </span>
  )
}

InsufficientCredits#

Bannière à afficher quand l'API /api/v1/deduct retourne une erreur 402.

Aperçu

Crédits insuffisants

Il te reste 3 crédits. Cette action en requiert 20.

Acheter des crédits
ts
const GRINGHOST_BUY_URL = 'https://gringhost.com/buy'

interface InsufficientCreditsProps {
  remaining: number
  required: number
  onDismiss?: () => void
}

export function InsufficientCredits({ remaining, required, onDismiss }: InsufficientCreditsProps) {
  return (
    <div style={{
      background: 'rgba(239,68,68,0.08)',
      border: '1px solid rgba(239,68,68,0.2)',
      borderRadius: '8px', padding: '12px 16px',
      display: 'flex', alignItems: 'center',
      justifyContent: 'space-between', gap: '16px',
    }}>
      <div>
        <p style={{ margin: '0 0 2px', fontSize: '13px', fontWeight: 600, color: '#fca5a5' }}>
          Crédits insuffisants
        </p>
        <p style={{ margin: 0, fontSize: '12px', color: 'rgba(252,165,165,0.7)' }}>
          Il te reste {remaining} crédit{remaining > 1 ? 's' : ''}.
          Cette action en requiert {required}.
        </p>
      </div>
      <div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
        {onDismiss && (
          <button onClick={onDismiss} style={{ fontSize: '12px', color: '#fca5a5', background: 'none', border: 'none', cursor: 'pointer' }}>
            Fermer
          </button>
        )}
        <a href={GRINGHOST_BUY_URL} target="_blank"
          style={{ background: '#ef4444', color: '#fff', fontSize: '12px', fontWeight: 600, padding: '6px 14px', borderRadius: '7px', textDecoration: 'none' }}>
          Acheter des crédits
        </a>
      </div>
    </div>
  )
}

// Après un appel à /api/v1/deduct
if (res.status === 402) {
  setError({ remaining: data.remaining_credits, required: 20 })
}

BalanceDisplay#

Affiche le solde dans une navbar ou sidebar. Se met à jour après chaque déduction via remaining_credits.

Aperçu

247cr
3cr

Solde normal · Solde faible (< 10 cr)

ts
interface BalanceDisplayProps {
  credits: number
  lowThreshold?: number
}

export function BalanceDisplay({ credits, lowThreshold = 10 }: BalanceDisplayProps) {
  const low = credits < lowThreshold

  return (
    <div style={{
      display: 'inline-flex', alignItems: 'center', gap: '6px',
      background: low ? 'rgba(239,68,68,0.08)'     : 'rgba(251,191,36,0.1)',
      border:     `1px solid ${low ? 'rgba(239,68,68,0.2)' : 'rgba(251,191,36,0.2)'}`,
      borderRadius: '7px', padding: '5px 10px',
    }}>
      <span style={{ fontSize: '12px', fontWeight: 700, color: low ? '#f87171' : '#fbbf24' }}>
        {credits.toLocaleString()}
      </span>
      <span style={{ fontSize: '12px', color: low ? '#f87171' : '#fbbf24', opacity: 0.6 }}>cr</span>
    </div>
  )
}

Récupérer le solde en temps réel#

Après chaque déduction, recharge le solde pour mettre à jour l'UI.

ts
// Méthode 1 — depuis la réponse de deduct (le plus simple)
async function runAction() {
  const res = await fetch('https://gringhost.com/api/v1/deduct', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
    body: JSON.stringify({ user_id: gringhostId, product_id: 'analyze_image' }),
  })
  const data = await res.json()
  if (res.ok) setCredits(data.remaining_credits)  // mis à jour directement
}

// Méthode 2 — realtime Supabase
useEffect(() => {
  const channel = supabase
    .channel('credits')
    .on('postgres_changes', {
      event: 'UPDATE', schema: 'public', table: 'profiles',
      filter: `id=eq.${user.id}`,
    }, payload => setCredits(payload.new.credits))
    .subscribe()

  return () => supabase.removeChannel(channel)
}, [user.id])