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.
Le composant principal à placer dans la navbar. Deux états : bouton CTA ambre (non connecté) → badge discret avec point vert (connecté).
Aperçu Continue with GrinGhost non connecté
Connected with GrinGhostconnecté
'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 '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édits 10 crédits — solde insuffisant
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 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 Solde normal · Solde faible (< 10 cr)
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.
// 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])