| import { useState, useEffect, useRef } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { Brain, MessageCircle, TrendingUp, Send, Loader2, AlertCircle, Zap, X, Flame, HelpCircle, Download } from 'lucide-react'; |
| import { api } from '@/lib/api'; |
| import { useAuth } from '@/lib/auth'; |
| import { useTenant } from '@/lib/tenant'; |
|
|
| const SUPPORT_WA_NUMBER = import.meta.env.VITE_SUPPORT_WA_NUMBER as string | undefined; |
| const SUPPORT_WA_URL = SUPPORT_WA_NUMBER |
| ? `https://wa.me/${SUPPORT_WA_NUMBER}?text=Bonjour%2C%20je%20souhaite%20recharger%20mes%20cr%C3%A9dits%20Xaml%C3%A9.` |
| : null; |
|
|
| function RechargeModal({ onClose }: { onClose: () => void }) { |
| const { t } = useTranslation(); |
|
|
| const CREDIT_PACKS = [ |
| { labelKey: 'billing.pack_500_label', priceKey: 'billing.pack_500_price', credits: 500, popular: false }, |
| { labelKey: 'billing.pack_2000_label', priceKey: 'billing.pack_2000_price', credits: 2000, popular: true }, |
| { labelKey: 'billing.pack_5000_label', priceKey: 'billing.pack_5000_price', credits: 5000, popular: false }, |
| ]; |
|
|
| useEffect(() => { |
| document.body.style.overflow = 'hidden'; |
| const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); |
| window.addEventListener('keydown', onKey); |
| return () => { |
| document.body.style.overflow = ''; |
| window.removeEventListener('keydown', onKey); |
| }; |
| }, [onClose]); |
|
|
| return ( |
| <div |
| className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center sm:p-4" |
| onClick={onClose} |
| > |
| <div |
| className="bg-white w-full sm:max-w-md rounded-t-3xl sm:rounded-2xl shadow-2xl flex flex-col max-h-[90vh]" |
| onClick={e => e.stopPropagation()} |
| > |
| <div className="flex items-center justify-between px-6 py-5 border-b border-slate-100 shrink-0"> |
| <div> |
| <h2 className="text-lg font-bold text-slate-900">{t('billing.modal_title')}</h2> |
| <p className="text-xs text-slate-400 mt-0.5">{t('billing.modal_rate')}</p> |
| </div> |
| <button |
| onClick={onClose} |
| className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-400 transition-colors" |
| > |
| <X className="w-5 h-5" /> |
| </button> |
| </div> |
| |
| <div className="overflow-y-auto flex-1 px-6 py-5 space-y-3"> |
| {CREDIT_PACKS.map(pack => ( |
| <a |
| key={pack.credits} |
| href={SUPPORT_WA_URL ? `${SUPPORT_WA_URL}%20-%20Pack%20${t(pack.labelKey)}` : '#'} |
| target={SUPPORT_WA_URL ? '_blank' : undefined} |
| rel="noopener noreferrer" |
| className={`relative flex items-center justify-between p-4 border rounded-xl transition-all group ${pack.popular ? 'border-indigo-400 bg-indigo-50' : 'border-slate-200 hover:border-indigo-300 hover:bg-indigo-50'}`} |
| > |
| {pack.popular && ( |
| <span className="absolute -top-2.5 left-4 px-2 py-0.5 bg-indigo-600 text-white text-xs font-bold rounded-full"> |
| {t('billing.modal_popular')} |
| </span> |
| )} |
| <div> |
| <p className="font-semibold text-slate-800 group-hover:text-indigo-700">{t(pack.labelKey)}</p> |
| <p className="text-xs text-slate-400 mt-0.5">{t('billing.modal_ai_messages', { count: pack.credits })}</p> |
| </div> |
| <span className="text-indigo-600 font-bold shrink-0 ml-4">{t(pack.priceKey)}</span> |
| </a> |
| ))} |
| </div> |
| |
| <div className="px-6 py-4 border-t border-slate-100 shrink-0"> |
| <p className="text-xs text-slate-400 text-center"> |
| {t('billing.modal_footer')} |
| </p> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| interface WalletData { |
| walletBalance: number; |
| isHardStopped: boolean; |
| transactions: Array<{ |
| id: string; |
| amount: number; |
| balanceAfter: number; |
| type: string; |
| description: string | null; |
| byok: boolean; |
| createdAt: string; |
| }>; |
| } |
|
|
| interface BillingSummary { |
| plan: string; |
| planLabel: string; |
| period: { start: string; end: string }; |
| ai: { |
| creditsUsed: number; |
| creditsLimit: number; |
| percentUsed: number; |
| totalCalls: number; |
| costFcfa: number; |
| }; |
| whatsapp: { messagesSent: number }; |
| } |
|
|
| interface HistoryDay { |
| date: string; |
| aiCalls: number; |
| whatsappMessages: number; |
| costFcfa: number; |
| } |
|
|
| interface BreakdownItem { |
| feature: string; |
| calls: number; |
| } |
|
|
| interface ChatMessage { |
| role: 'user' | 'assistant'; |
| text: string; |
| } |
|
|
| function DaysRemainingBadge({ walletBalance, transactions }: { walletBalance: number; transactions: WalletData['transactions'] }) { |
| const { t } = useTranslation(); |
| const sevenDaysAgo = Date.now() - 7 * 86_400_000; |
| const weeklyDebit = transactions |
| .filter(tx => tx.amount < 0 && new Date(tx.createdAt).getTime() > sevenDaysAgo) |
| .reduce((sum, tx) => sum + Math.abs(tx.amount), 0); |
|
|
| if (weeklyDebit === 0) return null; |
|
|
| const dailyBurn = weeklyDebit / 7; |
| const days = Math.floor(walletBalance / dailyBurn); |
|
|
| const color = days <= 3 ? 'text-red-600 bg-red-50' : days <= 10 ? 'text-amber-600 bg-amber-50' : 'text-emerald-600 bg-emerald-50'; |
| const icon = days <= 3 ? '🔴' : days <= 10 ? '🟡' : '🟢'; |
| const label = days > 1 |
| ? t('billing.days_remaining_plural', { days }) |
| : t('billing.days_remaining', { days }); |
|
|
| return ( |
| <span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${color}`}> |
| {icon} <strong>{label}</strong> |
| </span> |
| ); |
| } |
|
|
| export default function BillingPage() { |
| const { t } = useTranslation(); |
| const { token, user } = useAuth(); |
| const { selectedOrgId } = useTenant(); |
| const orgId = selectedOrgId; |
|
|
| const [summary, setSummary] = useState<BillingSummary | null>(null); |
| const [wallet, setWallet] = useState<WalletData | null>(null); |
| const [history, setHistory] = useState<HistoryDay[]>([]); |
| const [breakdown, setBreakdown] = useState<BreakdownItem[]>([]); |
| const [loading, setLoading] = useState(true); |
| const [error, setError] = useState<string | null>(null); |
| const [showRecharge, setShowRecharge] = useState(false); |
|
|
| const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]); |
| const [chatInput, setChatInput] = useState(''); |
| const [chatLoading, setChatLoading] = useState(false); |
| const chatEndRef = useRef<HTMLDivElement>(null); |
|
|
| const QUICK_QUESTIONS = [ |
| t('billing.quick_q1'), |
| t('billing.quick_q2'), |
| t('billing.quick_q3'), |
| t('billing.quick_q4'), |
| ]; |
|
|
| const TX_LABELS: Record<string, string> = { |
| TOP_UP_MANUAL: t('billing.tx_TOP_UP_MANUAL'), |
| TOP_UP_PAYMENT: t('billing.tx_TOP_UP_PAYMENT'), |
| ADJUSTMENT: t('billing.tx_ADJUSTMENT'), |
| DEBIT_AI: t('billing.tx_DEBIT_AI'), |
| DEBIT_WHATSAPP: t('billing.tx_DEBIT_WHATSAPP'), |
| DEBIT_BROADCAST: t('billing.tx_DEBIT_BROADCAST'), |
| }; |
|
|
| useEffect(() => { |
| if (!orgId || !token) return; |
| setLoading(true); |
| Promise.all([ |
| api.get('/v1/billing/summary', token, orgId), |
| api.get('/v1/billing/history?days=30', token, orgId), |
| api.get('/v1/billing/breakdown', token, orgId), |
| api.get('/v1/billing/wallet', token, orgId), |
| ]).then(([s, h, b, w]) => { |
| setSummary(s); |
| setHistory(h.history ?? []); |
| setBreakdown(b.breakdown ?? []); |
| setWallet(w); |
| }).catch(err => setError(err.message)) |
| .finally(() => setLoading(false)); |
| }, [orgId, token]); |
|
|
| useEffect(() => { |
| chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| }, [chatMessages]); |
|
|
| const sendChat = async (question?: string) => { |
| const q = (question ?? chatInput).trim(); |
| if (!q || chatLoading || !orgId || !token) return; |
| setChatInput(''); |
| setChatMessages(prev => [...prev, { role: 'user', text: q }]); |
| setChatLoading(true); |
| try { |
| const res = await api.post('/v1/billing/chat', { question: q, language: user?.language ?? 'FR' }, token, orgId); |
| setChatMessages(prev => [...prev, { role: 'assistant', text: res.answer }]); |
| } catch { |
| setChatMessages(prev => [...prev, { role: 'assistant', text: t('billing.chat_error') }]); |
| } finally { |
| setChatLoading(false); |
| } |
| }; |
|
|
| function downloadTransactionsCSV() { |
| if (!wallet?.transactions.length) return; |
| const rows = [ |
| [ |
| t('billing.csv_col_date'), |
| t('billing.csv_col_type'), |
| t('billing.csv_col_desc'), |
| t('billing.csv_col_amount'), |
| t('billing.csv_col_balance'), |
| ], |
| ...wallet.transactions.map(tx => [ |
| new Date(tx.createdAt).toLocaleString(), |
| TX_LABELS[tx.type] ?? tx.type, |
| tx.description ?? '', |
| tx.amount.toString(), |
| tx.balanceAfter >= 0 ? tx.balanceAfter.toString() : '', |
| ]), |
| ]; |
| const csv = rows.map(r => r.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n'); |
| const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `transactions-${new Date().toISOString().slice(0, 10)}.csv`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| } |
| |
| const maxCalls = Math.max(...history.map(d => d.aiCalls + d.whatsappMessages), 1); |
| |
| if (!orgId) { |
| return ( |
| <div className="flex items-center justify-center h-64 text-slate-400"> |
| <p className="text-lg font-medium">{t('billing.select_org')}</p> |
| </div> |
| ); |
| } |
| |
| if (loading) { |
| return ( |
| <div className="flex items-center justify-center h-64"> |
| <Loader2 className="w-8 h-8 animate-spin text-indigo-500" /> |
| </div> |
| ); |
| } |
| |
| if (error) { |
| return ( |
| <div className="flex items-center gap-3 p-6 text-red-600 bg-red-50 rounded-xl m-6"> |
| <AlertCircle className="w-5 h-5 flex-shrink-0" /> |
| <span>{t('billing.error_prefix')} {error}</span> |
| </div> |
| ); |
| } |
| |
| if (!summary) return null; |
| |
| const periodLabel = new Date(summary.period.start).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); |
| const walletExhausted = wallet ? (wallet.isHardStopped || wallet.walletBalance <= 0) : false; |
| const walletLow = wallet ? (wallet.walletBalance > 0 && wallet.walletBalance <= 200) : false; |
| |
| return ( |
| <div className="p-6 max-w-5xl mx-auto space-y-6"> |
| {showRecharge && <RechargeModal onClose={() => setShowRecharge(false)} />} |
| |
| {/* Suspended service alert */} |
| {walletExhausted && ( |
| <div className="flex items-center justify-between gap-4 px-5 py-4 bg-red-50 border border-red-200 rounded-2xl"> |
| <div className="flex items-center gap-3 text-red-700"> |
| <AlertCircle className="w-5 h-5 shrink-0" /> |
| <div> |
| <p className="font-bold text-sm">{t('billing.alert_exhausted_title')}</p> |
| <p className="text-xs text-red-500 mt-0.5">{t('billing.alert_exhausted_desc')}</p> |
| </div> |
| </div> |
| <button |
| onClick={() => setShowRecharge(true)} |
| className="shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-bold rounded-xl transition-colors" |
| > |
| {t('billing.alert_recharge_btn')} |
| </button> |
| </div> |
| )} |
| |
| {/* Low balance alert */} |
| {!walletExhausted && walletLow && ( |
| <div className="flex items-center justify-between gap-4 px-5 py-4 bg-amber-50 border border-amber-200 rounded-2xl"> |
| <div className="flex items-center gap-3 text-amber-700"> |
| <AlertCircle className="w-5 h-5 shrink-0" /> |
| <div> |
| <p className="font-bold text-sm">{t('billing.alert_low_title', { count: wallet!.walletBalance })}</p> |
| <p className="text-xs text-amber-600 mt-0.5">{t('billing.alert_low_desc')}</p> |
| </div> |
| </div> |
| <button |
| onClick={() => setShowRecharge(true)} |
| className="shrink-0 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white text-sm font-bold rounded-xl transition-colors" |
| > |
| {t('billing.alert_recharge_btn')} |
| </button> |
| </div> |
| )} |
| |
| {/* Header */} |
| <div className="flex items-center justify-between"> |
| <div> |
| <h1 className="text-2xl font-bold text-slate-900">{t('billing.page_title')}</h1> |
| <p className="text-slate-500 text-sm mt-1">{t('billing.period_label')} {periodLabel}</p> |
| </div> |
| <button |
| onClick={() => setShowRecharge(true)} |
| className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-xl transition-colors shadow-sm" |
| > |
| <Zap className="w-4 h-4" /> |
| {t('billing.recharge_btn')} |
| </button> |
| </div> |
| |
| {/* Wallet card */} |
| {wallet && ( |
| <div className={`rounded-2xl border p-6 shadow-sm ${walletExhausted ? 'border-red-200 bg-red-50' : walletLow ? 'border-amber-200 bg-amber-50' : 'bg-white border-slate-200'}`}> |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> |
| <div className="flex items-center gap-4"> |
| <div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0 ${walletExhausted ? 'bg-red-100' : walletLow ? 'bg-amber-100' : 'bg-emerald-100'}`}> |
| 💳 |
| </div> |
| <div> |
| <p className="text-sm font-medium text-slate-500">{t('billing.wallet_label')}</p> |
| <p className={`text-4xl font-black leading-tight ${walletExhausted ? 'text-red-600' : walletLow ? 'text-amber-600' : 'text-slate-900'}`}> |
| {wallet.walletBalance.toLocaleString()} |
| <span className="text-lg font-normal text-slate-400 ml-1.5">{t('billing.wallet_unit')}</span> |
| </p> |
| <p className="text-sm text-slate-400 mt-1"> |
| {t('billing.wallet_fcfa', { amount: (wallet.walletBalance * 10).toLocaleString() })} |
| </p> |
| <div className="mt-2"> |
| <DaysRemainingBadge walletBalance={wallet.walletBalance} transactions={wallet.transactions} /> |
| </div> |
| </div> |
| </div> |
| <div className="flex flex-col items-start sm:items-end gap-2 shrink-0"> |
| <button |
| onClick={() => setShowRecharge(true)} |
| className="px-5 py-2.5 bg-slate-900 hover:bg-slate-700 text-white rounded-xl font-bold text-sm transition-colors" |
| > |
| {t('billing.wallet_recharge_btn')} |
| </button> |
| <p className="text-xs text-slate-400">{t('billing.wallet_rate')}</p> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Metric cards */} |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
| |
| {/* AI messages this month */} |
| <div className="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm"> |
| <div className="flex items-center gap-3 mb-3"> |
| <div className="w-10 h-10 bg-indigo-100 rounded-xl flex items-center justify-center"> |
| <Brain className="w-5 h-5 text-indigo-600" /> |
| </div> |
| <p className="text-sm font-semibold text-slate-600">{t('billing.ai_messages_title')}</p> |
| </div> |
| <p className="text-3xl font-black text-slate-900"> |
| {summary.ai.totalCalls.toLocaleString()} |
| </p> |
| <p className="text-sm text-slate-400 mt-1"> |
| {t('billing.ai_cost_label', { cost: summary.ai.costFcfa.toLocaleString() })} |
| </p> |
| {summary.ai.percentUsed > 85 && ( |
| <div className="mt-3"> |
| <div className="w-full bg-slate-100 rounded-full h-2 mb-1"> |
| <div className="h-2 rounded-full bg-red-500" style={{ width: `${Math.min(summary.ai.percentUsed, 100)}%` }} /> |
| </div> |
| <button |
| onClick={() => setShowRecharge(true)} |
| className="flex items-center gap-1.5 text-xs text-red-600 hover:underline mt-1" |
| > |
| <AlertCircle className="w-3.5 h-3.5" /> |
| {t('billing.ai_quota_warning', { pct: summary.ai.percentUsed })} |
| </button> |
| </div> |
| )} |
| </div> |
| |
| {/* WhatsApp messages this month */} |
| <div className="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm"> |
| <div className="flex items-center gap-3 mb-3"> |
| <div className="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center"> |
| <MessageCircle className="w-5 h-5 text-emerald-600" /> |
| </div> |
| <p className="text-sm font-semibold text-slate-600">{t('billing.wa_messages_title')}</p> |
| </div> |
| <p className="text-3xl font-black text-slate-900"> |
| {summary.whatsapp.messagesSent.toLocaleString()} |
| </p> |
| <div className="mt-3 bg-emerald-50 rounded-xl px-3 py-2.5"> |
| <p className="text-xs text-emerald-700"> |
| {t('billing.wa_free_note')} |
| </p> |
| </div> |
| </div> |
| </div> |
| |
| {/* 30-day activity chart */} |
| <div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm"> |
| <div className="flex items-center gap-2 mb-1"> |
| <TrendingUp className="w-4 h-4 text-slate-400" /> |
| <h2 className="text-sm font-semibold text-slate-700">{t('billing.activity_title')}</h2> |
| </div> |
| <p className="text-xs text-slate-400 mb-4">{t('billing.activity_subtitle')}</p> |
| {history.length === 0 ? ( |
| <p className="text-slate-400 text-sm text-center py-8">{t('billing.activity_empty')}</p> |
| ) : ( |
| <div className="flex items-end gap-1 h-28 overflow-x-auto pb-2"> |
| {history.map(day => { |
| const total = day.aiCalls + day.whatsappMessages; |
| const heightPct = Math.round((total / maxCalls) * 100); |
| const dateLabel = new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' }); |
| return ( |
| <div key={day.date} className="flex flex-col items-center gap-1 flex-1 min-w-[24px] group relative"> |
| <div |
| className="w-full rounded-t-sm bg-indigo-300 group-hover:bg-indigo-500 transition-colors cursor-pointer" |
| style={{ height: `${Math.max(heightPct, 2)}%` }} |
| title={`${dateLabel} — IA: ${day.aiCalls} msg | WA: ${day.whatsappMessages} msg`} |
| /> |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| |
| {/* Feature breakdown */} |
| {breakdown.length > 0 && ( |
| <div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm"> |
| <h2 className="text-sm font-semibold text-slate-700 mb-1">{t('billing.breakdown_title')}</h2> |
| <p className="text-xs text-slate-400 mb-4">{t('billing.breakdown_subtitle')}</p> |
| <div className="space-y-3"> |
| {breakdown.slice(0, 5).map(item => { |
| const totalCalls = breakdown.reduce((s, i) => s + i.calls, 0); |
| const pct = totalCalls > 0 ? Math.round((item.calls / totalCalls) * 100) : 0; |
| const featureKey = `billing.feature_${item.feature}` as const; |
| return ( |
| <div key={item.feature} className="flex items-center gap-3"> |
| <span className="text-sm text-slate-600 w-48 flex-shrink-0 truncate"> |
| {t(featureKey, { defaultValue: item.feature })} |
| </span> |
| <div className="flex-1 bg-slate-100 rounded-full h-2.5"> |
| <div className="h-2.5 rounded-full bg-indigo-400" style={{ width: `${pct}%` }} /> |
| </div> |
| <span className="text-sm font-semibold text-slate-700 w-10 text-right">{pct}%</span> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| )} |
| |
| {/* Transaction history */} |
| {wallet && wallet.transactions.length > 0 && ( |
| <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> |
| <div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between"> |
| <div> |
| <h2 className="text-sm font-semibold text-slate-700">{t('billing.transactions_title')}</h2> |
| <p className="text-xs text-slate-400 mt-0.5">{t('billing.transactions_subtitle')}</p> |
| </div> |
| <button |
| onClick={downloadTransactionsCSV} |
| className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded-lg transition-colors border border-slate-200 hover:border-indigo-200" |
| > |
| <Download className="w-3.5 h-3.5" /> |
| {t('billing.csv_btn')} |
| </button> |
| </div> |
| <div className="divide-y divide-slate-50"> |
| {wallet.transactions.filter(tx => !tx.byok).map(tx => { |
| const isCredit = tx.amount > 0; |
| return ( |
| <div key={tx.id} className="flex items-center justify-between px-6 py-3 hover:bg-slate-50 transition-colors"> |
| <div className="flex items-center gap-3 min-w-0"> |
| <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 ${isCredit ? 'bg-emerald-100' : 'bg-slate-100'}`}> |
| {isCredit ? '➕' : '▪️'} |
| </div> |
| <div className="min-w-0"> |
| <p className="text-sm font-medium text-slate-700 truncate"> |
| {TX_LABELS[tx.type] ?? tx.type} |
| </p> |
| <p className="text-xs text-slate-400"> |
| {new Date(tx.createdAt).toLocaleDateString(undefined, { |
| day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' |
| })} |
| </p> |
| </div> |
| </div> |
| <div className="text-right ml-4 flex-shrink-0"> |
| <p className={`text-sm font-bold ${isCredit ? 'text-emerald-600' : 'text-slate-700'}`}> |
| {isCredit ? '+' : ''}{tx.amount.toLocaleString()} {t('billing.wallet_unit')} |
| </p> |
| <p className="text-xs text-slate-400"> |
| {t('billing.balance_label')} {tx.balanceAfter >= 0 ? tx.balanceAfter.toLocaleString() : '—'} |
| </p> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| )} |
| |
| {/* Billing AI assistant */} |
| <div className="bg-white rounded-2xl border border-indigo-100 shadow-sm overflow-hidden"> |
| <div className="px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-indigo-50 to-white"> |
| <div className="flex items-center gap-2"> |
| <HelpCircle className="w-4 h-4 text-indigo-500" /> |
| <h2 className="text-sm font-bold text-slate-800">{t('billing.chat_title')}</h2> |
| </div> |
| <p className="text-xs text-slate-400 mt-0.5">{t('billing.chat_subtitle')}</p> |
| </div> |
| |
| {/* Suggested questions */} |
| {chatMessages.length === 0 && ( |
| <div className="px-4 pt-4 flex flex-wrap gap-2"> |
| {QUICK_QUESTIONS.map(q => ( |
| <button |
| key={q} |
| onClick={() => sendChat(q)} |
| disabled={chatLoading} |
| className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-indigo-100 hover:text-indigo-700 text-slate-600 rounded-full transition-colors border border-transparent hover:border-indigo-200" |
| > |
| {q} |
| </button> |
| ))} |
| </div> |
| )} |
| |
| {/* Messages */} |
| <div className="p-4 space-y-3 max-h-72 overflow-y-auto"> |
| {chatMessages.length === 0 && ( |
| <p className="text-slate-300 text-sm text-center py-6"> |
| <Flame className="w-8 h-8 mx-auto mb-2 text-slate-200" /> |
| {t('billing.chat_empty')} |
| </p> |
| )} |
| {chatMessages.map((msg, i) => ( |
| <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> |
| <div className={`max-w-xs lg:max-w-md px-4 py-2.5 rounded-2xl text-sm leading-relaxed whitespace-pre-wrap ${ |
| msg.role === 'user' |
| ? 'bg-indigo-600 text-white rounded-br-sm' |
| : 'bg-slate-100 text-slate-800 rounded-bl-sm' |
| }`}> |
| {msg.text} |
| </div> |
| </div> |
| ))} |
| {chatLoading && ( |
| <div className="flex justify-start"> |
| <div className="bg-slate-100 px-4 py-3 rounded-2xl rounded-bl-sm flex items-center gap-1.5"> |
| <span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> |
| <span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> |
| <span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> |
| </div> |
| </div> |
| )} |
| <div ref={chatEndRef} /> |
| </div> |
| |
| {/* Input */} |
| <div className="px-4 py-3 border-t border-slate-100 flex gap-2"> |
| <input |
| type="text" |
| value={chatInput} |
| onChange={e => setChatInput(e.target.value)} |
| onKeyDown={e => e.key === 'Enter' && sendChat()} |
| placeholder={t('billing.chat_placeholder')} |
| className="flex-1 text-sm border border-slate-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-indigo-300 focus:border-indigo-300" |
| disabled={chatLoading} |
| /> |
| <button |
| onClick={() => sendChat()} |
| disabled={!chatInput.trim() || chatLoading} |
| className="bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-xl px-4 py-2.5 flex items-center gap-1 text-sm font-medium transition-colors" |
| > |
| <Send className="w-4 h-4" /> |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| |