CognxSafeTrack
fix(admin): replace vague Meta hints with direct clickable links in Direct Setup modal
295c327 | import { useState, useEffect } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { Building2, Plus, MessageSquare, ShieldCheck, Activity, Loader2, X, RefreshCw, CheckCircle2, XCircle, AlertTriangle, Info } from 'lucide-react'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { api } from '../lib/api'; | |
| import { useAuth } from '../lib/auth'; | |
| import { launchEmbeddedSignup } from '../lib/meta-signup'; | |
| import MetaVerificationGuide from '../components/MetaVerificationGuide'; | |
| import { useToast } from '../hooks/useToast'; | |
| import { logError, logWarn } from '../lib/logger'; | |
| interface Organization { | |
| id: string; | |
| name: string; | |
| mode: 'EDTECH' | 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE'; | |
| wabaId?: string; | |
| phoneNumbers?: { id: string; phoneNumber: string }[]; | |
| subscriptionPlan?: 'STARTER' | 'GROWTH' | 'SCALE' | 'ENTERPRISE'; | |
| subscriptionStatus?: string; | |
| isHardStopped?: boolean; | |
| personalityConfig?: { | |
| botName?: string; | |
| coreMission?: string; | |
| toneDescription?: string; | |
| }; | |
| } | |
| interface MetaStatus { | |
| configured: boolean; | |
| wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN'; | |
| businessId?: string; | |
| businessName?: string; | |
| businessVerified?: boolean; | |
| messagingLimitTier?: string; | |
| qualityRating?: 'GREEN' | 'YELLOW' | 'RED' | 'UNKNOWN'; | |
| syncedAt?: string; | |
| error?: string; | |
| loading?: boolean; | |
| } | |
| interface PersonalityModalProps { | |
| org: Organization; | |
| onClose: () => void; | |
| onSave: (config: any) => void; | |
| } | |
| export default function ClientsManagementView() { | |
| const { t } = useTranslation(); | |
| const toast = useToast(); | |
| const { token, user } = useAuth(); | |
| const isSuperAdmin = user?.role === 'SUPER_ADMIN'; | |
| const [clients, setClients] = useState<Organization[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [isModalOpen, setIsModalOpen] = useState(false); | |
| const [creditOrg, setCreditOrg] = useState<Organization | null>(null); | |
| const [creditAmount, setCreditAmount] = useState(''); | |
| const [isSavingCredits, setIsSavingCredits] = useState(false); | |
| const [selectedOrgForPersonality, setSelectedOrgForPersonality] = useState<Organization | null>(null); | |
| const [newOrg, setNewOrg] = useState({ | |
| name: '', | |
| slug: '', | |
| adminEmail: '', | |
| adminName: '', | |
| password: '', | |
| mode: 'PEDAGOGY' as 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE', | |
| useCase: 'EDUCATION' as 'EDUCATION' | 'CRM_WHATSAPP' | |
| }); | |
| const [isCreating, setIsCreating] = useState(false); | |
| const [createErrors, setCreateErrors] = useState<Record<string, string>>({}); | |
| const [showGuide, setShowGuide] = useState(false); | |
| const [billingOrg, setBillingOrg] = useState<Organization | null>(null); | |
| const [billingPlan, setBillingPlan] = useState<string>('STARTER'); | |
| const [isSavingPlan, setIsSavingPlan] = useState(false); | |
| const [metaStatuses, setMetaStatuses] = useState<Record<string, MetaStatus>>({}); | |
| const [setupOrg, setSetupOrg] = useState<Organization | null>(null); | |
| const [directSetup, setDirectSetup] = useState({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: '' }); | |
| const [isDirectSetupSaving, setIsDirectSetupSaving] = useState(false); | |
| const fetchMetaStatus = async (orgId: string, force = false) => { | |
| setMetaStatuses(prev => ({ ...prev, [orgId]: { ...prev[orgId], configured: false, loading: true } })); | |
| try { | |
| const status = force | |
| ? await api.post(`/v1/organizations/${orgId}/meta-status/refresh`, {}, token!) | |
| : await api.get(`/v1/organizations/${orgId}/meta-status`, token!); | |
| setMetaStatuses(prev => ({ ...prev, [orgId]: { ...status, loading: false } })); | |
| } catch (err) { | |
| logWarn('[Clients] fetchMetaStatus failed', err); | |
| setMetaStatuses(prev => ({ ...prev, [orgId]: { configured: false, loading: false, error: t('common.error') } })); | |
| } | |
| }; | |
| const fetchClients = async () => { | |
| if (!token) return; | |
| try { | |
| const data = await api.get('/v1/organizations', token); | |
| setClients(data); | |
| // Fetch Meta status for all orgs in parallel (non-blocking) | |
| data.forEach((org: Organization) => fetchMetaStatus(org.id)); | |
| } catch (error) { | |
| logError("Failed to fetch organizations:", error); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchClients(); | |
| }, [token]); | |
| const handleDirectSetupSubmit = async () => { | |
| if (!setupOrg || !directSetup.wabaId) return; | |
| setIsDirectSetupSaving(true); | |
| try { | |
| await api.post(`/v1/organizations/${setupOrg.id}/whatsapp-setup`, { | |
| wabaId: directSetup.wabaId, | |
| ...(directSetup.accessToken && { accessToken: directSetup.accessToken }), | |
| ...(directSetup.phoneNumberId && { phoneNumberId: directSetup.phoneNumberId }), | |
| }, token!); | |
| if (directSetup.metaBusinessId) { | |
| await api.put(`/v1/organizations/${setupOrg.id}`, { metaBusinessId: directSetup.metaBusinessId }, token!); | |
| } | |
| toast.success(t('clients.wa_setup_modal.success')); | |
| setSetupOrg(null); | |
| setDirectSetup({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: '' }); | |
| await fetchClients(); | |
| setTimeout(() => fetchMetaStatus(setupOrg.id, true), 1000); | |
| } catch (err: any) { | |
| toast.error(`${t('clients.toast.error_prefix')}${err.message}`); | |
| } finally { | |
| setIsDirectSetupSaving(false); | |
| } | |
| }; | |
| const handleCreateOrg = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| const errors: Record<string, string> = {}; | |
| if (!newOrg.name.trim()) errors.name = t('clients.validation.name_required'); | |
| if (!newOrg.slug.trim()) errors.slug = t('clients.validation.slug_required'); | |
| else if (!/^[a-z0-9-]+$/.test(newOrg.slug)) errors.slug = t('clients.validation.slug_format'); | |
| if (!newOrg.adminName.trim()) errors.adminName = t('clients.validation.admin_name_required'); | |
| if (!newOrg.adminEmail.trim()) errors.adminEmail = t('clients.validation.email_required'); | |
| else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newOrg.adminEmail)) errors.adminEmail = t('clients.validation.email_invalid'); | |
| if (!newOrg.password || newOrg.password.length < 8) errors.password = t('clients.validation.password_min'); | |
| if (Object.keys(errors).length > 0) { setCreateErrors(errors); return; } | |
| setCreateErrors({}); | |
| setIsCreating(true); | |
| try { | |
| await api.post('/v1/organizations', newOrg, token!); | |
| await fetchClients(); | |
| setIsModalOpen(false); | |
| setNewOrg({ name: '', slug: '', adminEmail: '', adminName: '', password: '', mode: 'PEDAGOGY', useCase: 'EDUCATION' }); | |
| } catch (error) { | |
| logError("Failed to create organization:", error); | |
| toast.error(t('clients.create_modal.error_create')); | |
| } finally { | |
| setIsCreating(false); | |
| } | |
| }; | |
| const handleEmbeddedSignup = async (orgId: string) => { | |
| try { | |
| const result = await launchEmbeddedSignup(); | |
| await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, { | |
| wabaId: result.waba_id, | |
| accessToken: result.code, | |
| phoneNumberId: result.phone_number_id, | |
| phoneNumber: '' | |
| }, token!); | |
| toast.success(t('clients.toast.wa_connected')); | |
| fetchClients(); | |
| } catch (error: any) { | |
| toast.error(`${t('clients.toast.error_prefix')}${error.message}`); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400"> | |
| <Loader2 className="w-8 h-8 animate-spin mb-4" /> | |
| <p className="font-medium">{t('clients.loading')}</p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="p-8 max-w-6xl mx-auto"> | |
| <div className="flex items-center justify-between mb-8"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight text-slate-900">{t('clients.page_title')}</h1> | |
| <p className="text-slate-500 mt-2">{t('clients.page_subtitle')}</p> | |
| </div> | |
| <button | |
| onClick={() => setIsModalOpen(true)} | |
| className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl font-medium hover:bg-slate-800 transition shadow-sm" | |
| > | |
| <Plus className="w-4 h-4" /> {t('clients.new_org_button')} | |
| </button> | |
| </div> | |
| <div className="grid gap-6"> | |
| {clients.length === 0 ? ( | |
| <div className="text-center py-20 bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200"> | |
| <Building2 className="w-12 h-12 text-slate-300 mx-auto mb-4" /> | |
| <h3 className="text-lg font-bold text-slate-900">{t('clients.no_clients_title')}</h3> | |
| <p className="text-slate-500">{t('clients.no_clients_desc')}</p> | |
| </div> | |
| ) : clients.map(client => ( | |
| <div key={client.id} className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition"> | |
| <div className="flex items-start justify-between"> | |
| <div className="flex gap-4"> | |
| <div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center"> | |
| <Building2 className="w-6 h-6 text-slate-600" /> | |
| </div> | |
| <div> | |
| <h3 className="text-xl font-bold text-slate-900">{client.name}</h3> | |
| <div className="flex items-center gap-3 mt-1"> | |
| <span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold ${ | |
| client.phoneNumbers?.length ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700' | |
| }`}> | |
| {client.phoneNumbers?.length ? t('clients.status.operational') : t('clients.status.config_required')} | |
| </span> | |
| <span className="text-xs text-slate-400">ID: {client.id}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex gap-2"> | |
| {client.phoneNumbers?.length ? ( | |
| <div className="flex items-center gap-4 text-sm"> | |
| <div className="text-right"> | |
| <p className="font-medium text-slate-700">{client.phoneNumbers[0].phoneNumber}</p> | |
| <p className="text-xs text-slate-400">ID: {client.phoneNumbers[0].id}</p> | |
| </div> | |
| <div className="h-10 w-px bg-slate-100"></div> | |
| {metaStatuses[client.id] && !metaStatuses[client.id].loading && !metaStatuses[client.id].configured ? ( | |
| <button | |
| onClick={() => { setSetupOrg(client); setDirectSetup({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: client.phoneNumbers?.[0]?.id || '' }); }} | |
| className="flex items-center gap-2 bg-amber-500 text-white px-4 py-2 rounded-xl font-semibold hover:bg-amber-600 transition text-xs" | |
| > | |
| <AlertTriangle className="w-3.5 h-3.5" /> {t('clients.actions.reconfigure_wa')} | |
| </button> | |
| ) : ( | |
| <div className="flex items-center gap-2 text-emerald-600"> | |
| <Activity className="w-4 h-4" /> | |
| <span className="font-medium text-xs">{t('clients.status.online')}</span> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={() => handleEmbeddedSignup(client.id)} | |
| className="flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-100" | |
| > | |
| <MessageSquare className="w-4 h-4" /> {t('clients.actions.connect_wa')} | |
| </button> | |
| {isSuperAdmin && ( | |
| <button | |
| onClick={() => { setSetupOrg(client); setDirectSetup({ wabaId: client.wabaId || '', metaBusinessId: '', accessToken: '', phoneNumberId: '' }); }} | |
| className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-slate-800 transition text-xs" | |
| title={t('clients.actions.direct_setup')} | |
| > | |
| <ShieldCheck className="w-3.5 h-3.5" /> {t('clients.actions.direct_setup')} | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| <button | |
| onClick={() => setSelectedOrgForPersonality(client)} | |
| className="flex items-center gap-2 bg-slate-100 text-slate-700 px-4 py-2.5 rounded-xl font-semibold hover:bg-slate-200 transition" | |
| > | |
| <Activity className="w-4 h-4" /> {t('clients.actions.personality_studio')} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mt-8 pt-6 border-t border-slate-50 grid grid-cols-4 gap-6"> | |
| <MetaStatusCell | |
| ms={metaStatuses[client.id]} | |
| onRefresh={() => fetchMetaStatus(client.id, true)} | |
| onGuide={() => setShowGuide(true)} | |
| /> | |
| <BusinessVerificationCell ms={metaStatuses[client.id]} /> | |
| <DailyLimitCell ms={metaStatuses[client.id]} /> | |
| <div className="flex items-center justify-end gap-4"> | |
| {isSuperAdmin && ( | |
| <button | |
| onClick={() => { setCreditOrg(client); setCreditAmount(''); }} | |
| className="text-sm font-bold text-emerald-600 hover:text-emerald-700 underline underline-offset-4" | |
| > | |
| {t('clients.actions.ai_credits')} | |
| </button> | |
| )} | |
| {client.isHardStopped && ( | |
| <span className="text-[10px] font-bold bg-red-100 text-red-600 px-2 py-0.5 rounded-full"> | |
| {t('clients.status.suspended')} | |
| </span> | |
| )} | |
| <button | |
| onClick={() => { setBillingOrg(client); setBillingPlan(client.subscriptionPlan ?? 'STARTER'); }} | |
| className="text-sm font-bold text-indigo-600 hover:text-indigo-700 underline underline-offset-4" | |
| > | |
| {t('clients.actions.billing_details')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Modal du Guide de VΓ©rification */} | |
| {showGuide && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xl flex items-center justify-center p-6 z-[100] animate-in fade-in duration-300"> | |
| <div className="w-full max-w-5xl max-h-[90vh] overflow-y-auto"> | |
| <div className="relative"> | |
| <button | |
| onClick={() => setShowGuide(false)} | |
| className="absolute -top-4 -right-4 bg-white p-3 rounded-full shadow-xl hover:scale-110 transition z-[110]" | |
| > | |
| <X className="w-6 h-6 text-slate-900" /> | |
| </button> | |
| <MetaVerificationGuide /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Modal de crΓ©ation d'organisation */} | |
| {isModalOpen && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-end sm:items-center justify-center sm:p-4 z-50"> | |
| <div className="bg-white rounded-t-[2rem] sm:rounded-[2rem] shadow-2xl w-full sm:max-w-xl flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"> | |
| {/* Fixed header */} | |
| <div className="shrink-0 flex items-center justify-between px-8 pt-8 pb-5 border-b border-slate-100"> | |
| <h2 className="text-2xl font-black text-slate-900">{t('clients.create_modal.title')}</h2> | |
| <button onClick={() => { setIsModalOpen(false); setCreateErrors({}); }} className="p-2 hover:bg-slate-100 rounded-full transition"> | |
| <X className="w-5 h-5 text-slate-400" /> | |
| </button> | |
| </div> | |
| {/* Scrollable body */} | |
| <form onSubmit={handleCreateOrg} className="flex flex-col flex-1 min-h-0"> | |
| <div className="overflow-y-auto flex-1 px-8 py-6 space-y-6"> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div className="col-span-2"> | |
| <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.company_name_label')} <span className="text-red-400">*</span></label> | |
| <input | |
| type="text" | |
| placeholder={t('clients.create_modal.company_name_placeholder')} | |
| value={newOrg.name} | |
| onChange={e => { setNewOrg({...newOrg, name: e.target.value}); setCreateErrors(p => ({...p, name: ''})); }} | |
| className={`w-full border rounded-xl px-4 py-3 outline-none focus:ring-2 transition ${createErrors.name ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} | |
| /> | |
| {createErrors.name && <p className="text-xs text-red-500 mt-1">{createErrors.name}</p>} | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.slug_label')} <span className="text-red-400">*</span></label> | |
| <input | |
| type="text" | |
| placeholder={t('clients.create_modal.slug_placeholder')} | |
| value={newOrg.slug} | |
| onChange={e => { setNewOrg({...newOrg, slug: e.target.value.toLowerCase().replace(/\s+/g, '-')}); setCreateErrors(p => ({...p, slug: ''})); }} | |
| className={`w-full border rounded-xl px-4 py-3 outline-none focus:ring-2 transition font-mono text-sm ${createErrors.slug ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} | |
| /> | |
| {createErrors.slug && <p className="text-xs text-red-500 mt-1">{createErrors.slug}</p>} | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.use_case_label')}</label> | |
| <select | |
| value={newOrg.mode} | |
| onChange={e => setNewOrg({...newOrg, mode: e.target.value as any})} | |
| className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition bg-white" | |
| > | |
| <option value="CRM_MARKETING">{t('clients.create_modal.mode_crm')}</option> | |
| <option value="PEDAGOGY">{t('clients.create_modal.mode_pedagogy')}</option> | |
| <option value="CUSTOMER_SERVICE">{t('clients.create_modal.mode_service')}</option> | |
| </select> | |
| </div> | |
| <div className="col-span-2"> | |
| <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.workstream_label')}</label> | |
| <select | |
| value={newOrg.useCase} | |
| onChange={e => { | |
| const val = e.target.value as any; | |
| setNewOrg({ | |
| ...newOrg, | |
| useCase: val, | |
| mode: val === 'CRM_WHATSAPP' ? 'CRM_MARKETING' : 'PEDAGOGY' | |
| }); | |
| }} | |
| className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition bg-white" | |
| > | |
| <option value="EDUCATION">{t('clients.create_modal.use_case_education')}</option> | |
| <option value="CRM_WHATSAPP">{t('clients.create_modal.use_case_crm')}</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div className="p-6 bg-slate-50 rounded-2xl border border-slate-100 space-y-4"> | |
| <h4 className="text-xs font-bold text-slate-500 uppercase tracking-widest">{t('clients.create_modal.admin_section')}</h4> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.create_modal.admin_name_label')} <span className="text-red-400">*</span></label> | |
| <input | |
| type="text" | |
| placeholder={t('clients.create_modal.admin_name_placeholder')} | |
| value={newOrg.adminName} | |
| onChange={e => { setNewOrg({...newOrg, adminName: e.target.value}); setCreateErrors(p => ({...p, adminName: ''})); }} | |
| className={`w-full border rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 transition ${createErrors.adminName ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} | |
| /> | |
| {createErrors.adminName && <p className="text-xs text-red-500 mt-1">{createErrors.adminName}</p>} | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.create_modal.admin_email_label')} <span className="text-red-400">*</span></label> | |
| <input | |
| type="email" | |
| placeholder={t('clients.create_modal.admin_email_placeholder')} | |
| value={newOrg.adminEmail} | |
| onChange={e => { setNewOrg({...newOrg, adminEmail: e.target.value}); setCreateErrors(p => ({...p, adminEmail: ''})); }} | |
| className={`w-full border rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 transition ${createErrors.adminEmail ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} | |
| /> | |
| {createErrors.adminEmail && <p className="text-xs text-red-500 mt-1">{createErrors.adminEmail}</p>} | |
| </div> | |
| <div className="col-span-2"> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.create_modal.password_label')} <span className="text-red-400">*</span></label> | |
| <input | |
| type="password" | |
| placeholder={t('clients.create_modal.password_placeholder')} | |
| value={newOrg.password} | |
| onChange={e => { setNewOrg({...newOrg, password: e.target.value}); setCreateErrors(p => ({...p, password: ''})); }} | |
| className={`w-full border rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 transition ${createErrors.password ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} | |
| /> | |
| {createErrors.password && <p className="text-xs text-red-500 mt-1">{createErrors.password}</p>} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Fixed footer */} | |
| <div className="shrink-0 px-8 pb-8 pt-4 border-t border-slate-100"> | |
| <button | |
| type="submit" | |
| disabled={isCreating} | |
| className="w-full bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition disabled:opacity-50 flex items-center justify-center gap-2 shadow-xl shadow-slate-200" | |
| > | |
| {isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : t('clients.create_modal.submit')} | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| )} | |
| {/* Billing Modal */} | |
| {billingOrg && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6 z-[100]"> | |
| <div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg"> | |
| <div className="flex items-center justify-between p-6 border-b border-slate-100"> | |
| <h2 className="text-xl font-bold text-slate-900">{t('clients.billing_modal.title')}</h2> | |
| <button onClick={() => setBillingOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition"> | |
| <X className="w-5 h-5 text-slate-500" /> | |
| </button> | |
| </div> | |
| <div className="p-6 space-y-4"> | |
| <div className="flex items-center gap-3 bg-slate-50 rounded-2xl p-4"> | |
| <Building2 className="w-8 h-8 text-slate-400 shrink-0" /> | |
| <div> | |
| <p className="font-bold text-slate-900">{billingOrg.name}</p> | |
| <p className="text-xs text-slate-400 font-mono">{billingOrg.id}</p> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 gap-3 text-sm"> | |
| <div className="bg-slate-50 rounded-2xl p-4"> | |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.mode_label')}</p> | |
| <p className="font-semibold text-slate-800">{billingOrg.mode}</p> | |
| </div> | |
| <div className="bg-slate-50 rounded-2xl p-4"> | |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.waba_status_label')}</p> | |
| <WabaStatusBadge ms={metaStatuses[billingOrg.id]} /> | |
| </div> | |
| <div className="bg-slate-50 rounded-2xl p-4"> | |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.daily_limit_label')}</p> | |
| <BillingTierDisplay ms={metaStatuses[billingOrg.id]} /> | |
| </div> | |
| <div className="bg-slate-50 rounded-2xl p-4"> | |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.meta_business_label')}</p> | |
| <BusinessBadge ms={metaStatuses[billingOrg.id]} /> | |
| </div> | |
| </div> | |
| {billingOrg.wabaId && ( | |
| <div className="bg-slate-50 rounded-2xl p-4 text-sm"> | |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.waba_id_label')}</p> | |
| <p className="font-mono text-slate-700">{billingOrg.wabaId}</p> | |
| </div> | |
| )} | |
| </div> | |
| <div className="px-6 pb-4"> | |
| <div className="bg-slate-50 rounded-2xl p-4"> | |
| <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">{t('clients.billing_modal.plan_label')}</p> | |
| <select | |
| value={billingPlan} | |
| onChange={e => setBillingPlan(e.target.value)} | |
| className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm bg-white outline-none focus:ring-2 focus:ring-indigo-400 mb-3" | |
| > | |
| <option value="STARTER">{t('clients.billing_modal.plan_starter')}</option> | |
| <option value="GROWTH">{t('clients.billing_modal.plan_growth')}</option> | |
| <option value="SCALE">{t('clients.billing_modal.plan_scale')}</option> | |
| <option value="ENTERPRISE">{t('clients.billing_modal.plan_enterprise')}</option> | |
| </select> | |
| <button | |
| disabled={isSavingPlan || billingPlan === (billingOrg?.subscriptionPlan ?? 'STARTER')} | |
| onClick={async () => { | |
| if (!billingOrg) return; | |
| setIsSavingPlan(true); | |
| try { | |
| await api.put(`/v1/organizations/${billingOrg.id}`, { subscriptionPlan: billingPlan }, token!); | |
| setBillingOrg({ ...billingOrg, subscriptionPlan: billingPlan as any }); | |
| await fetchClients(); | |
| toast.success(t('clients.billing_modal.plan_updated')); | |
| } catch (err: any) { | |
| toast.error(`${t('clients.toast.error_prefix')}${err.message}`); | |
| } finally { | |
| setIsSavingPlan(false); | |
| } | |
| }} | |
| className="w-full py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-xl font-semibold text-sm transition flex items-center justify-center gap-2" | |
| > | |
| {isSavingPlan ? <Loader2 className="w-4 h-4 animate-spin" /> : t('clients.billing_modal.apply_plan')} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="p-6 border-t border-slate-100"> | |
| <button | |
| onClick={() => setBillingOrg(null)} | |
| className="w-full py-3 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-700 transition" | |
| > | |
| {t('clients.billing_modal.close')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Super-admin: Credit Allocation Modal */} | |
| {creditOrg && isSuperAdmin && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-end sm:items-center justify-center sm:p-4 z-[110]"> | |
| <div className="bg-white rounded-t-[2rem] sm:rounded-[2rem] shadow-2xl w-full sm:max-w-md flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"> | |
| <div className="shrink-0 flex items-center justify-between px-8 pt-8 pb-5 border-b border-slate-100"> | |
| <div> | |
| <h2 className="text-xl font-black text-slate-900">{t('clients.credits_modal.title')}</h2> | |
| <p className="text-sm text-slate-500 mt-0.5">{creditOrg.name}</p> | |
| </div> | |
| <button onClick={() => setCreditOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition"> | |
| <X className="w-5 h-5 text-slate-400" /> | |
| </button> | |
| </div> | |
| <div className="overflow-y-auto flex-1 px-8 py-6 space-y-5"> | |
| <div className="bg-slate-50 rounded-2xl p-4 flex items-center justify-between"> | |
| <span className="text-sm text-slate-500">{t('clients.credits_modal.current_balance')}</span> | |
| <span className="text-base font-bold text-slate-900"> | |
| {(creditOrg as any).walletBalance ?? 'β'} {t('clients.credits_modal.credits_unit')} | |
| </span> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold uppercase text-slate-400 mb-2"> | |
| {t('clients.credits_modal.add_credits_label')} | |
| </label> | |
| <input | |
| type="number" | |
| min="1" | |
| value={creditAmount} | |
| onChange={e => setCreditAmount(e.target.value)} | |
| placeholder={t('clients.credits_modal.add_credits_placeholder')} | |
| className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition font-mono text-lg" | |
| /> | |
| <p className="text-xs text-slate-400 mt-1.5">{t('clients.credits_modal.rate_hint')}</p> | |
| </div> | |
| </div> | |
| <div className="shrink-0 px-8 pb-8 pt-4 border-t border-slate-100"> | |
| <button | |
| type="button" | |
| disabled={isSavingCredits || !creditAmount || Number(creditAmount) <= 0} | |
| onClick={async () => { | |
| if (!creditAmount || Number(creditAmount) <= 0) return; | |
| setIsSavingCredits(true); | |
| try { | |
| const data = await api.post('/v1/billing/admin/allocate', { organizationId: creditOrg.id, amount: Number(creditAmount) }, token!); | |
| toast.success(`${creditAmount} ${t('clients.credits_modal.credits_unit')} β ${data.newBalance}`); | |
| setCreditOrg(null); | |
| setCreditAmount(''); | |
| } catch { | |
| toast.error(t('clients.credits_modal.error')); | |
| } finally { | |
| setIsSavingCredits(false); | |
| } | |
| }} | |
| className="w-full bg-emerald-600 text-white py-4 rounded-2xl font-bold hover:bg-emerald-700 transition disabled:opacity-50 flex items-center justify-center gap-2" | |
| > | |
| {isSavingCredits ? <Loader2 className="w-4 h-4 animate-spin" /> : t('clients.credits_modal.submit')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Personality Studio Modal */} | |
| {selectedOrgForPersonality && ( | |
| <PersonalityStudioModal | |
| org={selectedOrgForPersonality} | |
| onClose={() => setSelectedOrgForPersonality(null)} | |
| onSave={async (config) => { | |
| try { | |
| await api.patch(`/v1/organizations/${selectedOrgForPersonality.id}/personality`, config, token!); | |
| toast.success(t('clients.personality_modal.success')); | |
| fetchClients(); | |
| setSelectedOrgForPersonality(null); | |
| } catch (err: any) { | |
| toast.error(`${t('clients.toast.error_prefix')}${err.message}`); | |
| } | |
| }} | |
| /> | |
| )} | |
| {/* WhatsApp Setup Modal */} | |
| {setupOrg && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4 z-50"> | |
| <div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg p-8 animate-in zoom-in-95 duration-200"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div> | |
| <h2 className="text-xl font-black text-slate-900">{t('clients.wa_setup_modal.title')}</h2> | |
| <p className="text-sm text-slate-500 mt-0.5">{setupOrg.name}</p> | |
| </div> | |
| <button onClick={() => setSetupOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition"> | |
| <X className="w-5 h-5 text-slate-400" /> | |
| </button> | |
| </div> | |
| {/* Option A β Direct (account dΓ©jΓ sur Meta) */} | |
| <div className="border border-indigo-100 bg-indigo-50/40 rounded-2xl p-5 mb-4"> | |
| <p className="text-xs font-bold uppercase tracking-wider text-indigo-500 mb-3">{t('clients.wa_setup_modal.already_on_meta')}</p> | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.wa_setup_modal.waba_id_label')} <span className="text-red-400">*</span></label> | |
| <input | |
| type="text" | |
| placeholder={t('clients.wa_setup_modal.waba_id_placeholder')} | |
| value={directSetup.wabaId} | |
| onChange={e => setDirectSetup(s => ({ ...s, wabaId: e.target.value }))} | |
| className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400" | |
| /> | |
| <p className="text-[10px] text-slate-400 mt-1"> | |
| <a href="https://business.facebook.com/settings/whatsapp-business-accounts" target="_blank" rel="noreferrer" className="text-indigo-500 underline">{t('clients.wa_setup_modal.waba_id_hint_link')}</a> | |
| </p> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.wa_setup_modal.business_id_label')} <span className="text-slate-300">{t('clients.wa_setup_modal.business_id_optional')}</span></label> | |
| <input | |
| type="text" | |
| placeholder={t('clients.wa_setup_modal.business_id_placeholder')} | |
| value={directSetup.metaBusinessId} | |
| onChange={e => setDirectSetup(s => ({ ...s, metaBusinessId: e.target.value }))} | |
| className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400" | |
| /> | |
| <p className="text-[10px] text-slate-400 mt-1"> | |
| <a href="https://business.facebook.com/settings/info" target="_blank" rel="noreferrer" className="text-indigo-500 underline">{t('clients.wa_setup_modal.business_id_hint_link')}</a> | |
| </p> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.wa_setup_modal.token_label')} <span className="text-slate-300">{t('clients.wa_setup_modal.token_optional')}</span></label> | |
| <input | |
| type="password" | |
| placeholder={t('clients.wa_setup_modal.token_placeholder')} | |
| value={directSetup.accessToken} | |
| onChange={e => setDirectSetup(s => ({ ...s, accessToken: e.target.value }))} | |
| className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400" | |
| /> | |
| <p className="text-[10px] text-slate-400 mt-1"> | |
| <a href="https://business.facebook.com/settings/system-users" target="_blank" rel="noreferrer" className="text-indigo-500 underline">{t('clients.wa_setup_modal.token_hint_link')}</a> | |
| </p> | |
| </div> | |
| {directSetup.phoneNumberId && ( | |
| <p className="text-xs text-slate-400">{t('clients.wa_setup_modal.phone_number_detected')} <span className="font-mono text-slate-600">{directSetup.phoneNumberId}</span></p> | |
| )} | |
| </div> | |
| <button | |
| onClick={handleDirectSetupSubmit} | |
| disabled={!directSetup.wabaId || isDirectSetupSaving} | |
| className="mt-4 w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white py-3 rounded-xl font-bold text-sm transition flex items-center justify-center gap-2" | |
| > | |
| {isDirectSetupSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle2 className="w-4 h-4" />} | |
| {t('clients.wa_setup_modal.save_config')} | |
| </button> | |
| </div> | |
| {/* Option B β Embedded Signup (nouveaux clients) */} | |
| <div className="border border-slate-100 rounded-2xl p-5"> | |
| <p className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">{t('clients.wa_setup_modal.new_account_heading')}</p> | |
| <p className="text-xs text-slate-500 mb-3">{t('clients.wa_setup_modal.new_account_desc')}</p> | |
| <button | |
| onClick={() => { setSetupOrg(null); handleEmbeddedSignup(setupOrg.id); }} | |
| className="w-full border border-slate-200 hover:bg-slate-50 text-slate-700 py-3 rounded-xl font-semibold text-sm transition flex items-center justify-center gap-2" | |
| > | |
| <svg className="w-4 h-4" viewBox="0 0 24 24" fill="#1877F2"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> | |
| {t('clients.wa_setup_modal.connect_facebook')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Meta Compliance Footer */} | |
| <div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100"> | |
| <h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">{t('clients.tier.meta_compliance')}</h4> | |
| <div className="flex gap-8 opacity-60 grayscale hover:grayscale-0 transition duration-500"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/b/be/Facebook_Messenger_logo_2020.svg" className="h-6" alt="Meta" /> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/6/6b/WhatsApp.svg" className="h-6" alt="WhatsApp" /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββ Meta status helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function dailyLimitLabel(ms: MetaStatus | undefined, t: (key: string) => string): string { | |
| if (!ms || ms.loading) return 'β¦'; | |
| if (!ms.configured) return 'β'; | |
| const tierMap: Record<string, string> = { | |
| TIER_50: t('clients.tier.tier_50'), | |
| TIER_250: t('clients.tier.tier_250'), | |
| TIER_1K: t('clients.tier.tier_1k'), | |
| TIER_10K: t('clients.tier.tier_10k'), | |
| TIER_100K: t('clients.tier.tier_100k'), | |
| UNLIMITED: t('clients.tier.unlimited'), | |
| }; | |
| if (ms.messagingLimitTier) return tierMap[ms.messagingLimitTier] ?? ms.messagingLimitTier; | |
| return 'β'; | |
| } | |
| function WabaStatusBadge({ ms }: { ms?: MetaStatus }) { | |
| const { t } = useTranslation(); | |
| if (!ms || ms.loading) return <span className="text-slate-400 text-xs animate-pulse">{t('clients.waba.checking')}</span>; | |
| if (!ms.configured) return <span className="text-slate-400 font-medium text-xs">{t('clients.waba.not_connected')}</span>; | |
| const map: Record<string, { labelKey: string; cls: string }> = { | |
| APPROVED: { labelKey: 'clients.waba.approved', cls: 'text-emerald-600' }, | |
| PENDING: { labelKey: 'clients.waba.pending', cls: 'text-amber-600' }, | |
| REJECTED: { labelKey: 'clients.waba.rejected', cls: 'text-red-600' }, | |
| BANNED: { labelKey: 'clients.waba.banned', cls: 'text-red-600' }, | |
| UNKNOWN: { labelKey: 'clients.waba.unknown', cls: 'text-slate-500' }, | |
| }; | |
| const s = map[ms.wabaStatus ?? 'UNKNOWN'] ?? map.UNKNOWN; | |
| return <span className={`font-semibold text-sm ${s.cls}`}>{t(s.labelKey)}</span>; | |
| } | |
| function BusinessBadge({ ms }: { ms?: MetaStatus }) { | |
| const { t } = useTranslation(); | |
| if (!ms || ms.loading) return <span className="text-slate-400 text-xs animate-pulse">{t('clients.waba.checking')}</span>; | |
| if (!ms.configured || ms.businessId === undefined) return <span className="text-slate-400 font-medium text-xs">β</span>; | |
| return ms.businessVerified | |
| ? <span className="font-semibold text-sm text-emerald-600">{t('clients.waba.verified')}</span> | |
| : <span className="font-semibold text-sm text-amber-600">{t('clients.waba.not_verified')}</span>; | |
| } | |
| function MetaStatusCell({ ms, onRefresh, onGuide }: { ms?: MetaStatus; onRefresh: () => void; onGuide: () => void }) { | |
| const { t } = useTranslation(); | |
| const isApproved = ms?.wabaStatus === 'APPROVED'; | |
| const isLoading = !ms || ms.loading; | |
| return ( | |
| <div className="flex items-center gap-3"> | |
| <div className={`p-2 rounded-lg ${isLoading ? 'bg-slate-50' : isApproved ? 'bg-emerald-50' : 'bg-amber-50'}`}> | |
| {isLoading | |
| ? <ShieldCheck className="w-4 h-4 text-slate-300 animate-pulse" /> | |
| : isApproved | |
| ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> | |
| : <AlertTriangle className="w-4 h-4 text-amber-500" />} | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-1.5"> | |
| <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">{t('clients.cells.waba_header')}</p> | |
| <button onClick={onRefresh} title={t('clients.waba.refresh_tooltip')} className="text-slate-300 hover:text-slate-500 transition"> | |
| <RefreshCw className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <WabaStatusBadge ms={ms} /> | |
| {ms && !isLoading && !isApproved && ms.configured && ( | |
| <button onClick={onGuide} className="text-[10px] text-indigo-600 hover:underline font-bold whitespace-nowrap"> | |
| {t('clients.waba.what_to_do')} | |
| </button> | |
| )} | |
| </div> | |
| {ms?.error && <p className="text-[10px] text-red-400 truncate">{ms.error}</p>} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function BusinessVerificationCell({ ms }: { ms?: MetaStatus }) { | |
| const { t } = useTranslation(); | |
| const isLoading = !ms || ms.loading; | |
| return ( | |
| <div className="flex items-center gap-3"> | |
| <div className={`p-2 rounded-lg ${isLoading ? 'bg-slate-50' : ms?.businessVerified ? 'bg-emerald-50' : ms?.businessId !== undefined ? 'bg-amber-50' : 'bg-slate-50'}`}> | |
| {isLoading | |
| ? <ShieldCheck className="w-4 h-4 text-slate-300 animate-pulse" /> | |
| : ms?.businessVerified | |
| ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> | |
| : ms?.businessId !== undefined | |
| ? <XCircle className="w-4 h-4 text-amber-500" /> | |
| : <ShieldCheck className="w-4 h-4 text-slate-300" />} | |
| </div> | |
| <div> | |
| <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">{t('clients.cells.business_header')}</p> | |
| <div className="flex items-center gap-2"> | |
| <BusinessBadge ms={ms} /> | |
| {ms && !isLoading && ms.configured && ms.businessId !== undefined && !ms.businessVerified && ( | |
| <a | |
| href="https://business.facebook.com/settings/security" | |
| target="_blank" rel="noreferrer" | |
| className="text-[10px] text-indigo-600 hover:underline font-bold whitespace-nowrap" | |
| > | |
| {t('clients.waba.verify_link')} | |
| </a> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function QualityDot({ rating }: { rating?: string }) { | |
| const { t } = useTranslation(); | |
| const map: Record<string, string> = { | |
| GREEN: 'bg-emerald-400', | |
| YELLOW: 'bg-amber-400', | |
| RED: 'bg-red-400', | |
| UNKNOWN: 'bg-slate-300', | |
| }; | |
| const cls = map[rating ?? 'UNKNOWN'] ?? 'bg-slate-300'; | |
| return <span className={`inline-block w-2 h-2 rounded-full ${cls}`} title={`${t('clients.quality.dot_title')}${rating ?? 'β'}`} />; | |
| } | |
| const TIER_CONFIG: Record<string, { textCls: string; bgCls: string; iconCls: string; dotCls: string; order: number }> = { | |
| TIER_50: { textCls: 'text-red-600', bgCls: 'bg-red-50', iconCls: 'text-red-400', dotCls: 'bg-red-400', order: 0 }, | |
| TIER_250: { textCls: 'text-amber-600', bgCls: 'bg-amber-50', iconCls: 'text-amber-400', dotCls: 'bg-amber-400', order: 1 }, | |
| TIER_1K: { textCls: 'text-blue-600', bgCls: 'bg-blue-50', iconCls: 'text-blue-400', dotCls: 'bg-blue-400', order: 2 }, | |
| TIER_10K: { textCls: 'text-emerald-600', bgCls: 'bg-emerald-50', iconCls: 'text-emerald-400', dotCls: 'bg-emerald-400', order: 3 }, | |
| TIER_100K: { textCls: 'text-emerald-600', bgCls: 'bg-emerald-50', iconCls: 'text-emerald-400', dotCls: 'bg-emerald-400', order: 4 }, | |
| UNLIMITED: { textCls: 'text-emerald-600', bgCls: 'bg-emerald-50', iconCls: 'text-emerald-400', dotCls: 'bg-emerald-400', order: 5 }, | |
| }; | |
| function BillingTierDisplay({ ms }: { ms?: MetaStatus }) { | |
| const { t } = useTranslation(); | |
| const [showInfo, setShowInfo] = useState(false); | |
| const tierCfg = TIER_CONFIG[ms?.messagingLimitTier ?? '']; | |
| return ( | |
| <> | |
| <div className="flex items-center gap-1.5"> | |
| <span className={`font-semibold ${tierCfg?.textCls ?? 'text-slate-800'}`}>{dailyLimitLabel(ms, t)}</span> | |
| {ms?.qualityRating && <QualityDot rating={ms.qualityRating} />} | |
| {ms?.configured && ( | |
| <button onClick={() => setShowInfo(true)} title={t('clients.cells.daily_limit_info_tooltip')} className="text-slate-300 hover:text-indigo-500 transition"> | |
| <Info className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| <TierInfoModal ms={ms} open={showInfo} onClose={() => setShowInfo(false)} /> | |
| </> | |
| ); | |
| } | |
| function TierInfoModal({ ms, open, onClose }: { ms?: MetaStatus; open: boolean; onClose: () => void }) { | |
| const { t } = useTranslation(); | |
| const currentTierOrder = TIER_CONFIG[ms?.messagingLimitTier ?? '']?.order ?? -1; | |
| const TIERS_IN_ORDER = [ | |
| { key: 'TIER_50', labelKey: 'clients.tier.tier_50', descKey: 'clients.tier_modal.tier_50_desc' }, | |
| { key: 'TIER_250', labelKey: 'clients.tier.tier_250', descKey: 'clients.tier_modal.tier_250_desc' }, | |
| { key: 'TIER_1K', labelKey: 'clients.tier.tier_1k', descKey: 'clients.tier_modal.tier_1k_desc' }, | |
| { key: 'TIER_10K', labelKey: 'clients.tier.tier_10k', descKey: 'clients.tier_modal.tier_10k_desc' }, | |
| { key: 'TIER_100K', labelKey: 'clients.tier.tier_100k', descKey: 'clients.tier_modal.tier_100k_desc' }, | |
| { key: 'UNLIMITED', labelKey: 'clients.tier.unlimited', descKey: 'clients.tier_modal.unlimited_desc' }, | |
| ]; | |
| const QUALITY_INFO = { | |
| GREEN: { dotCls: 'bg-emerald-400', ringCls: 'ring-emerald-300 bg-emerald-50', labelKey: 'clients.quality.good_label', descKey: 'clients.quality.good_desc' }, | |
| YELLOW: { dotCls: 'bg-amber-400', ringCls: 'ring-amber-300 bg-amber-50', labelKey: 'clients.quality.medium_label', descKey: 'clients.quality.medium_desc' }, | |
| RED: { dotCls: 'bg-red-400', ringCls: 'ring-red-300 bg-red-50', labelKey: 'clients.quality.risky_label', descKey: 'clients.quality.risky_desc' }, | |
| }; | |
| useEffect(() => { | |
| if (!open) return; | |
| const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; | |
| document.addEventListener('keydown', onKey); | |
| document.body.style.overflow = 'hidden'; | |
| return () => { | |
| document.removeEventListener('keydown', onKey); | |
| document.body.style.overflow = ''; | |
| }; | |
| }, [open, onClose]); | |
| return ( | |
| <AnimatePresence> | |
| {open && ( | |
| <motion.div | |
| className="fixed inset-0 z-[200] flex items-end sm:items-center justify-center sm:p-4" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| onClick={onClose} | |
| > | |
| {/* Backdrop */} | |
| <div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" /> | |
| {/* Panel β bottom sheet on mobile, centered card on desktop */} | |
| <motion.div | |
| className="relative bg-white w-full sm:max-w-md sm:rounded-2xl rounded-t-3xl shadow-2xl flex flex-col" | |
| style={{ maxHeight: '85dvh' }} | |
| initial={{ y: '100%' }} | |
| animate={{ y: 0 }} | |
| exit={{ y: '100%' }} | |
| transition={{ type: 'spring', damping: 32, stiffness: 320, mass: 0.8 }} | |
| onClick={e => e.stopPropagation()} | |
| > | |
| {/* Drag handle β mobile only */} | |
| <div className="flex justify-center pt-3 pb-0 sm:hidden shrink-0"> | |
| <div className="w-9 h-1 bg-slate-200 rounded-full" /> | |
| </div> | |
| {/* Sticky header */} | |
| <div className="flex items-center justify-between px-6 pt-4 pb-3 sm:pt-6 shrink-0"> | |
| <h3 className="font-bold text-slate-900 text-base">{t('clients.tier_modal.title')}</h3> | |
| <button onClick={onClose} className="p-1.5 hover:bg-slate-100 rounded-lg transition -mr-1"> | |
| <X className="w-4 h-4 text-slate-500" /> | |
| </button> | |
| </div> | |
| {/* Scrollable body */} | |
| <div className="overflow-y-auto overscroll-contain px-6 pb-8 space-y-5"> | |
| {/* Tier ladder */} | |
| <div className="space-y-1.5"> | |
| <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">{t('clients.tier_modal.levels_heading')}</p> | |
| {TIERS_IN_ORDER.map(({ key, labelKey, descKey }, i) => { | |
| const isCurrent = key === ms?.messagingLimitTier; | |
| const isPast = i < currentTierOrder; | |
| const cfg = TIER_CONFIG[key]; | |
| return ( | |
| <div key={key} className={`flex items-start gap-3 p-3 rounded-xl transition-all ${ | |
| isCurrent ? 'ring-2 ring-indigo-200 bg-indigo-50' : isPast ? 'opacity-30' : 'opacity-50' | |
| }`}> | |
| <span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${isCurrent ? (cfg?.dotCls ?? 'bg-indigo-400') : 'bg-slate-300'}`} /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <span className={`text-sm font-semibold ${isCurrent ? cfg?.textCls : 'text-slate-500'}`}>{t(labelKey)}</span> | |
| {isCurrent && <span className="text-[10px] font-bold bg-indigo-100 text-indigo-600 px-2 py-0.5 rounded-full">{t('clients.tier_modal.current_badge')}</span>} | |
| </div> | |
| <p className="text-[11px] text-slate-400 mt-0.5 leading-snug">{t(descKey)}</p> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Quality rating */} | |
| {ms?.qualityRating && ms.qualityRating !== 'UNKNOWN' && ( | |
| <div className="space-y-2"> | |
| <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{t('clients.tier_modal.quality_heading')}</p> | |
| <div className="flex gap-2"> | |
| {(['GREEN', 'YELLOW', 'RED'] as const).map(r => { | |
| const isCurrent = ms.qualityRating === r; | |
| const q = QUALITY_INFO[r]; | |
| return ( | |
| <div key={r} className={`flex-1 p-3 rounded-xl text-center transition-all ${ | |
| isCurrent ? `ring-2 ring-offset-1 ${q.ringCls}` : 'bg-slate-50 opacity-30' | |
| }`}> | |
| <span className={`inline-block w-3 h-3 rounded-full ${q.dotCls} mb-1.5`} /> | |
| <p className="text-xs font-bold text-slate-700">{t(q.labelKey)}</p> | |
| <p className="text-[10px] text-slate-500 mt-0.5 leading-snug">{t(q.descKey)}</p> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| <p className="text-[11px] text-slate-400 text-center leading-relaxed"> | |
| {t('clients.tier_modal.meta_auto_update')}<br /> | |
| {t('clients.tier_modal.levels_auto')} | |
| </p> | |
| </div> | |
| </motion.div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| } | |
| function DailyLimitCell({ ms }: { ms?: MetaStatus }) { | |
| const { t } = useTranslation(); | |
| const [showInfo, setShowInfo] = useState(false); | |
| const isLoading = !ms || ms.loading; | |
| const tierCfg = TIER_CONFIG[ms?.messagingLimitTier ?? '']; | |
| return ( | |
| <> | |
| <div className="flex items-center gap-3"> | |
| <div className={`p-2 rounded-lg ${tierCfg?.bgCls ?? 'bg-indigo-50'}`}> | |
| <MessageSquare className={`w-4 h-4 ${tierCfg?.iconCls ?? 'text-indigo-500'}`} /> | |
| </div> | |
| <div> | |
| <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">{t('clients.cells.daily_limit_header')}</p> | |
| <div className="flex items-center gap-1.5"> | |
| {isLoading | |
| ? <span className="text-slate-400 text-xs animate-pulse">β¦</span> | |
| : <p className={`text-sm font-semibold ${tierCfg?.textCls ?? 'text-slate-700'}`}>{dailyLimitLabel(ms, t)}</p> | |
| } | |
| {!isLoading && ms?.qualityRating && <QualityDot rating={ms.qualityRating} />} | |
| {!isLoading && ms?.configured && ( | |
| <button onClick={() => setShowInfo(true)} title={t('clients.cells.daily_limit_info_tooltip')} className="text-slate-300 hover:text-indigo-500 transition ml-0.5"> | |
| <Info className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <TierInfoModal ms={ms} open={showInfo} onClose={() => setShowInfo(false)} /> | |
| </> | |
| ); | |
| } | |
| // βββ Personality Studio Modal βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function PersonalityStudioModal({ org, onClose, onSave }: PersonalityModalProps) { | |
| const { t } = useTranslation(); | |
| const [config, setConfig] = useState(org.personalityConfig || { | |
| botName: '', | |
| coreMission: '', | |
| toneDescription: '' | |
| }); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| setIsSaving(true); | |
| await onSave(config); | |
| setIsSaving(false); | |
| }; | |
| return ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4 z-[60]"> | |
| <div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl p-10 animate-in fade-in zoom-in-95 duration-300"> | |
| <div className="flex items-center justify-between mb-8"> | |
| <div className="flex items-center gap-4"> | |
| <div className="w-12 h-12 bg-indigo-50 rounded-2xl flex items-center justify-center"> | |
| <Activity className="w-6 h-6 text-indigo-600" /> | |
| </div> | |
| <div> | |
| <h2 className="text-2xl font-black text-slate-900">{t('clients.personality_modal.title')}</h2> | |
| <p className="text-slate-500 font-medium text-sm">{t('clients.personality_modal.subtitle', { orgName: org.name })}</p> | |
| </div> | |
| </div> | |
| <button onClick={onClose} className="p-3 hover:bg-slate-100 rounded-full transition"> | |
| <X className="w-6 h-6 text-slate-400" /> | |
| </button> | |
| </div> | |
| <form onSubmit={handleSubmit} className="space-y-8"> | |
| <div className="grid gap-6"> | |
| <div> | |
| <label className="block text-sm font-bold text-slate-700 mb-2.5">{t('clients.personality_modal.bot_name_label')}</label> | |
| <input | |
| type="text" | |
| placeholder={t('clients.personality_modal.bot_name_placeholder')} | |
| value={config.botName} | |
| onChange={e => setConfig({...config, botName: e.target.value})} | |
| className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition font-medium" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-bold text-slate-700 mb-2.5">{t('clients.personality_modal.mission_label')}</label> | |
| <textarea | |
| placeholder={t('clients.personality_modal.mission_placeholder')} | |
| value={config.coreMission} | |
| onChange={e => setConfig({...config, coreMission: e.target.value})} | |
| className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition min-h-[120px] font-medium leading-relaxed" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-bold text-slate-700 mb-2.5">{t('clients.personality_modal.tone_label')}</label> | |
| <textarea | |
| placeholder={t('clients.personality_modal.tone_placeholder')} | |
| value={config.toneDescription} | |
| onChange={e => setConfig({...config, toneDescription: e.target.value})} | |
| className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition min-h-[120px] font-medium leading-relaxed" | |
| /> | |
| </div> | |
| </div> | |
| <div className="flex gap-4 pt-4"> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| className="flex-1 px-6 py-4 rounded-2xl font-bold text-slate-600 hover:bg-slate-50 transition" | |
| > | |
| {t('clients.personality_modal.cancel')} | |
| </button> | |
| <button | |
| type="submit" | |
| disabled={isSaving} | |
| className="flex-[2] bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition disabled:opacity-50 flex items-center justify-center gap-3 shadow-xl shadow-slate-200" | |
| > | |
| {isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldCheck className="w-5 h-5" />} | |
| {t('clients.personality_modal.submit')} | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| ); | |
| } | |