| import { useEffect, useState } from 'react'; |
| import { useNavigate } from 'react-router-dom'; |
| import { useTranslation } from 'react-i18next'; |
| import { |
| CheckCircle2, ArrowRight, ArrowLeft, |
| Building2, UserCircle, Smartphone, SkipForward, |
| Loader2, Eye, EyeOff |
| } from 'lucide-react'; |
| import { useAuth } from '../lib/auth'; |
| import { api } from '../lib/api'; |
| import { initMetaSDK, launchEmbeddedSignup } from '../lib/meta-signup'; |
| import { useToast } from '../hooks/useToast'; |
| import { logError, logWarn } from '../lib/logger'; |
|
|
| |
|
|
| const MODE_KEYS = [ |
| { value: 'EDTECH', labelKey: 'onboarding.mode_edtech_label', descKey: 'onboarding.mode_edtech_desc' }, |
| { value: 'CRM_MARKETING', labelKey: 'onboarding.mode_crm_label', descKey: 'onboarding.mode_crm_desc' }, |
| { value: 'AI_AGENT', labelKey: 'onboarding.mode_ai_label', descKey: 'onboarding.mode_ai_desc' }, |
| { value: 'CUSTOMER_SERVICE', labelKey: 'onboarding.mode_customer_service_label', descKey: 'onboarding.mode_customer_service_desc' }, |
| ]; |
|
|
| |
|
|
| function toSlug(name: string): string { |
| return name |
| .toLowerCase() |
| .normalize('NFD').replace(/[̀-ͯ]/g, '') |
| .replace(/[^a-z0-9]+/g, '-') |
| .replace(/^-+|-+$/g, '') |
| .slice(0, 40); |
| } |
|
|
| |
|
|
| const STEP_KEYS = [ |
| { id: 'org', titleKey: 'onboarding.step_org', icon: Building2 }, |
| { id: 'admin', titleKey: 'onboarding.step_admin', icon: UserCircle }, |
| { id: 'whatsapp', titleKey: 'onboarding.step_whatsapp', icon: Smartphone }, |
| ]; |
|
|
| |
|
|
| export default function OnboardingWizard() { |
| const { t } = useTranslation(); |
| const [step, setStep] = useState(0); |
| const [loading, setLoading] = useState(false); |
| const [showPass, setShowPass] = useState(false); |
| const { token } = useAuth(); |
| const navigate = useNavigate(); |
| const toast = useToast(); |
|
|
| const [org, setOrg] = useState({ |
| name: '', |
| slug: '', |
| slugTouched: false, |
| mode: 'EDTECH', |
| }); |
|
|
| const [admin, setAdmin] = useState({ |
| adminName: '', |
| adminEmail: '', |
| password: '', |
| }); |
|
|
| const [wa, setWa] = useState({ |
| wabaId: '', |
| metaBusinessId: '', |
| accessToken: '', |
| skip: false, |
| }); |
|
|
| const [tokenValidState, setTokenValidState] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle'); |
|
|
| const validateToken = async (tok: string) => { |
| if (!tok.trim() || !token) return; |
| setTokenValidState('checking'); |
| try { |
| const res = await api.post('/v1/organizations/whatsapp-validate-token', { token: tok }, token); |
| setTokenValidState(res.valid ? 'valid' : 'invalid'); |
| } catch { |
| setTokenValidState('idle'); |
| } |
| }; |
|
|
| |
| useEffect(() => { |
| if (!org.slugTouched) { |
| setOrg(s => ({ ...s, slug: toSlug(s.name) })); |
| } |
| }, [org.name, org.slugTouched]); |
|
|
| useEffect(() => { initMetaSDK(); }, []); |
|
|
| |
|
|
| const canNext = () => { |
| if (step === 0) return org.name.trim().length >= 2 && org.slug.length >= 3; |
| if (step === 1) return admin.adminEmail.includes('@') && admin.adminName.trim().length >= 2; |
| return true; |
| }; |
|
|
| const next = () => setStep(s => Math.min(s + 1, STEP_KEYS.length - 1)); |
| const back = () => setStep(s => Math.max(s - 1, 0)); |
|
|
| |
|
|
| const handleSubmit = async () => { |
| setLoading(true); |
| try { |
| |
| const result = await api.post('/v1/organizations', { |
| name: org.name.trim(), |
| slug: org.slug, |
| mode: org.mode, |
| adminName: admin.adminName.trim(), |
| adminEmail: admin.adminEmail.trim().toLowerCase(), |
| ...(admin.password && { password: admin.password }), |
| }, token) as { id: string; admin: { tempPassword: string } }; |
|
|
| const newOrgId = result.id; |
|
|
| |
| if (!wa.skip && wa.wabaId) { |
| await api.post(`/v1/organizations/${newOrgId}/whatsapp-setup`, { |
| wabaId: wa.wabaId.trim(), |
| ...(wa.accessToken && { accessToken: wa.accessToken.trim() }), |
| }, token); |
|
|
| if (wa.metaBusinessId) { |
| await api.put(`/v1/organizations/${newOrgId}`, { |
| metaBusinessId: wa.metaBusinessId.trim(), |
| }, token); |
| } |
| } |
|
|
| navigate('/clients'); |
| } catch (err: any) { |
| logError(err); |
| toast.error(err.message || t('onboarding.create_error')); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| |
|
|
| const handleEmbeddedSignup = async () => { |
| try { |
| const result = await launchEmbeddedSignup(); |
| setWa(s => ({ |
| ...s, |
| wabaId: result.waba_id, |
| accessToken: result.code, |
| skip: false, |
| })); |
| } catch (err) { |
| logWarn('[OnboardingWizard] Facebook login failed', err); |
| toast.error(t('onboarding.fb_error')); |
| } |
| }; |
|
|
| |
|
|
| return ( |
| <div className="max-w-2xl mx-auto py-12 px-6"> |
| <div className="bg-white rounded-3xl shadow-xl overflow-hidden border border-gray-100"> |
| |
| {/* Step bar */} |
| <div className="flex border-b border-gray-50"> |
| {STEP_KEYS.map((s, i) => { |
| const Icon = s.icon; |
| return ( |
| <div key={s.id} className={`flex-1 flex items-center justify-center gap-2 py-5 border-b-2 transition-all ${ |
| i <= step ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-gray-400' |
| }`}> |
| <div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${ |
| i < step ? 'bg-indigo-100 text-indigo-600' : i === step ? 'bg-indigo-600 text-white' : 'bg-gray-100' |
| }`}> |
| {i < step ? <CheckCircle2 className="w-4 h-4" /> : <Icon className="w-4 h-4" />} |
| </div> |
| <span className="hidden md:block font-medium text-sm">{t(s.titleKey)}</span> |
| </div> |
| ); |
| })} |
| </div> |
| |
| {/* Content */} |
| <div className="p-10 min-h-[420px]"> |
| |
| {/* Step 0 — Organisation */} |
| {step === 0 && ( |
| <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4"> |
| <div> |
| <h2 className="text-2xl font-bold text-gray-900">{t('onboarding.org_title')}</h2> |
| <p className="text-gray-400 text-sm mt-1">{t('onboarding.org_subtitle')}</p> |
| </div> |
| |
| <div className="space-y-4"> |
| {/* Nom */} |
| <div> |
| <label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.org_name_label')}</label> |
| <input |
| type="text" |
| value={org.name} |
| onChange={e => setOrg(s => ({ ...s, name: e.target.value }))} |
| placeholder="ex: École Polytechnique de Dakar" |
| className="w-full px-4 py-3 rounded-xl border border-gray-200 outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition" |
| /> |
| </div> |
| |
| {/* Slug */} |
| <div> |
| <label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.slug_label')}</label> |
| <div className="flex items-center gap-0 border border-gray-200 rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-indigo-500/20 focus-within:border-indigo-500"> |
| <span className="px-3 py-3 bg-gray-50 text-gray-400 text-sm font-mono border-r border-gray-200 whitespace-nowrap">xamle.studio/</span> |
| <input |
| type="text" |
| value={org.slug} |
| onChange={e => setOrg(s => ({ ...s, slug: toSlug(e.target.value), slugTouched: true }))} |
| placeholder="ecole-poly-dakar" |
| className="flex-1 px-3 py-3 outline-none text-sm font-mono bg-white" |
| /> |
| </div> |
| <p className="text-[11px] text-gray-400 mt-1">{t('onboarding.slug_hint')}</p> |
| </div> |
| |
| {/* Mode */} |
| <div> |
| <label className="block text-xs font-bold uppercase text-gray-400 mb-2">{t('onboarding.mode_label')}</label> |
| <div className="grid grid-cols-2 gap-2"> |
| {MODE_KEYS.map(m => ( |
| <button |
| key={m.value} |
| type="button" |
| onClick={() => setOrg(s => ({ ...s, mode: m.value }))} |
| className={`text-left p-3.5 rounded-xl border-2 transition-all ${ |
| org.mode === m.value |
| ? 'border-indigo-500 bg-indigo-50/60' |
| : 'border-gray-100 hover:border-gray-200 bg-white' |
| }`} |
| > |
| <p className="font-semibold text-sm text-gray-800">{t(m.labelKey)}</p> |
| <p className="text-[11px] text-gray-400 mt-0.5 leading-snug">{t(m.descKey)}</p> |
| </button> |
| ))} |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Step 1 — Admin */} |
| {step === 1 && ( |
| <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4"> |
| <div> |
| <h2 className="text-2xl font-bold text-gray-900">{t('onboarding.admin_title')}</h2> |
| <p className="text-gray-400 text-sm mt-1">{t('onboarding.admin_subtitle')}</p> |
| </div> |
| |
| <div className="space-y-4"> |
| <div> |
| <label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.admin_name_label')}</label> |
| <input |
| type="text" |
| value={admin.adminName} |
| onChange={e => setAdmin(s => ({ ...s, adminName: e.target.value }))} |
| placeholder="Prénom Nom" |
| className="w-full px-4 py-3 rounded-xl border border-gray-200 outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition" |
| /> |
| </div> |
| |
| <div> |
| <label className="block text-xs font-bold uppercase text-gray-400 mb-1.5">{t('onboarding.admin_email_label')}</label> |
| <input |
| type="email" |
| value={admin.adminEmail} |
| onChange={e => setAdmin(s => ({ ...s, adminEmail: e.target.value }))} |
| placeholder="admin@ecole.sn" |
| className="w-full px-4 py-3 rounded-xl border border-gray-200 outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition" |
| /> |
| </div> |
| |
| <div> |
| <label className="block text-xs font-bold uppercase text-gray-400 mb-1.5"> |
| {t('onboarding.admin_pass_label')} <span className="text-gray-300 font-normal normal-case">({t('onboarding.admin_pass_optional')})</span> |
| </label> |
| <div className="relative"> |
| <input |
| type={showPass ? 'text' : 'password'} |
| value={admin.password} |
| onChange={e => setAdmin(s => ({ ...s, password: e.target.value }))} |
| placeholder={t('onboarding.admin_pass_placeholder')} |
| className="w-full px-4 py-3 pr-10 rounded-xl border border-gray-200 outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition" |
| /> |
| <button |
| type="button" |
| onClick={() => setShowPass(v => !v)} |
| className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" |
| > |
| {showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} |
| </button> |
| </div> |
| <p className="text-[11px] text-gray-400 mt-1">{t('onboarding.admin_pass_hint')}</p> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Step 2 — WhatsApp */} |
| {step === 2 && ( |
| <div className="space-y-5 animate-in fade-in slide-in-from-bottom-4"> |
| <div> |
| <h2 className="text-2xl font-bold text-gray-900">{t('onboarding.wa_title')}</h2> |
| <p className="text-gray-400 text-sm mt-1">{t('onboarding.wa_subtitle')}</p> |
| </div> |
| |
| {/* Contextual help block */} |
| {!wa.wabaId && ( |
| <details className="bg-blue-50 border border-blue-100 rounded-2xl group"> |
| <summary className="p-4 cursor-pointer list-none flex items-center gap-2 text-blue-700 text-sm font-medium select-none"> |
| <span>❓</span> {t('onboarding.whatsapp_help_title')} |
| <span className="ml-auto text-blue-400 group-open:rotate-180 transition-transform">▾</span> |
| </summary> |
| <div className="px-4 pb-4 text-xs text-blue-800 space-y-2 leading-relaxed"> |
| <div className="flex gap-2"> |
| <span className="font-bold shrink-0">WABA ID</span> |
| <span><a href="https://business.facebook.com/wa/manage/home" target="_blank" rel="noreferrer" className="underline font-medium">WhatsApp Manager</a> → {t('onboarding.help_waba_id')}</span> |
| </div> |
| <div className="flex gap-2"> |
| <span className="font-bold shrink-0">Business ID</span> |
| <span><a href="https://business.facebook.com/settings/info" target="_blank" rel="noreferrer" className="underline font-medium">Meta Business</a> — {t('onboarding.help_business_id')}</span> |
| </div> |
| <div className="flex gap-2"> |
| <span className="font-bold shrink-0">Token</span> |
| <span>{t('onboarding.help_token')}</span> |
| </div> |
| <div className="mt-3 p-2 bg-blue-100 rounded-xl text-blue-700"> |
| 💡 {t('onboarding.help_new_to_meta')} |
| </div> |
| </div> |
| </details> |
| )} |
| |
| {wa.wabaId ? ( |
| /* Already captured via Embedded Signup */ |
| <div className="p-4 bg-emerald-50 border border-emerald-200 rounded-2xl flex items-center gap-3"> |
| <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" /> |
| <div> |
| <p className="font-semibold text-emerald-700 text-sm">{t('onboarding.fb_account_connected')}</p> |
| <p className="text-[11px] text-emerald-600 font-mono mt-0.5">WABA : {wa.wabaId}</p> |
| </div> |
| <button onClick={() => setWa(s => ({ ...s, wabaId: '', accessToken: '' }))} className="ml-auto text-xs text-emerald-500 hover:underline">{t('common.edit')}</button> |
| </div> |
| ) : ( |
| <div className="space-y-3"> |
| {/* Option A — Direct */} |
| <div className="border border-indigo-100 bg-indigo-50/40 rounded-2xl p-5"> |
| <p className="text-xs font-bold uppercase tracking-wider text-indigo-500 mb-3">{t('onboarding.already_configured')}</p> |
| <div className="space-y-4"> |
| |
| {/* Step 1 — WABA ID */} |
| <div className="flex gap-3"> |
| <span className="mt-0.5 w-5 h-5 rounded-full bg-indigo-100 text-indigo-600 text-[10px] font-bold flex items-center justify-center shrink-0">1</span> |
| <div className="flex-1"> |
| <label className="block text-xs font-bold text-slate-500 mb-1">WABA ID <span className="text-red-400">*</span></label> |
| <input |
| type="text" |
| placeholder="ex: 938685848818318" |
| value={wa.wabaId} |
| onChange={e => setWa(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/wa/manage/home" target="_blank" rel="noreferrer" className="text-indigo-500 underline">WhatsApp Manager</a> → colonne "Compte WhatsApp Business" |
| </p> |
| </div> |
| </div> |
| |
| {/* Step 2 — Business ID */} |
| <div className="flex gap-3"> |
| <span className="mt-0.5 w-5 h-5 rounded-full bg-indigo-100 text-indigo-600 text-[10px] font-bold flex items-center justify-center shrink-0">2</span> |
| <div className="flex-1"> |
| <label className="block text-xs font-bold text-slate-500 mb-1">Business ID Meta <span className="text-slate-300">(recommandé)</span></label> |
| <input |
| type="text" |
| placeholder="ex: 25855038707486178" |
| value={wa.metaBusinessId} |
| onChange={e => setWa(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">Paramètres Meta → Informations de l'entreprise</a> |
| </p> |
| </div> |
| </div> |
| |
| {/* Step 3 — Token */} |
| <div className="flex gap-3"> |
| <span className="mt-0.5 w-5 h-5 rounded-full bg-indigo-100 text-indigo-600 text-[10px] font-bold flex items-center justify-center shrink-0">3</span> |
| <div className="flex-1"> |
| <label className="block text-xs font-bold text-slate-500 mb-1"> |
| Token système <span className="text-slate-300">(optionnel)</span> |
| </label> |
| <div className="relative"> |
| <input |
| type="password" |
| placeholder="EAAxxxxxxx... — vide = token plateforme" |
| value={wa.accessToken} |
| onChange={e => { setWa(s => ({ ...s, accessToken: e.target.value })); setTokenValidState('idle'); }} |
| onBlur={e => validateToken(e.target.value)} |
| className={`w-full border rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 pr-8 ${ |
| tokenValidState === 'valid' ? 'border-emerald-400 focus:ring-emerald-300' : |
| tokenValidState === 'invalid' ? 'border-red-400 focus:ring-red-300' : |
| 'border-slate-200 focus:ring-indigo-500/20 focus:border-indigo-400' |
| }`} |
| /> |
| {tokenValidState === 'checking' && ( |
| <span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-slate-400 animate-pulse">…</span> |
| )} |
| {tokenValidState === 'valid' && ( |
| <span className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-500 text-sm">✓</span> |
| )} |
| {tokenValidState === 'invalid' && ( |
| <span className="absolute right-3 top-1/2 -translate-y-1/2 text-red-500 text-sm">✗</span> |
| )} |
| </div> |
| {tokenValidState === 'valid' && ( |
| <p className="text-[10px] text-emerald-600 mt-1">✅ {t('onboarding.token_valid_msg')}</p> |
| )} |
| {tokenValidState === 'invalid' && ( |
| <p className="text-[10px] text-red-500 mt-1">❌ {t('onboarding.token_invalid_msg')}</p> |
| )} |
| {tokenValidState === 'idle' && ( |
| <p className="text-[10px] text-slate-400 mt-1">{t('onboarding.token_idle_hint')}</p> |
| )} |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* Separator */} |
| <div className="flex items-center gap-3 text-slate-300"> |
| <div className="flex-1 h-px bg-slate-100" /> |
| <span className="text-xs font-medium">ou</span> |
| <div className="flex-1 h-px bg-slate-100" /> |
| </div> |
| |
| {/* Option B — Embedded Signup */} |
| <div className="border border-slate-100 rounded-2xl p-5"> |
| <p className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-2">{t('onboarding.new_account_via_fb')}</p> |
| <p className="text-xs text-slate-500 mb-3">{t('onboarding.new_account_desc')}</p> |
| <button |
| onClick={handleEmbeddedSignup} |
| className="w-full bg-[#1877F2] hover:bg-[#166fe5] text-white py-3 rounded-xl font-bold text-sm transition flex items-center justify-center gap-2" |
| > |
| <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><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> |
| Se connecter avec Facebook |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {/* Skip */} |
| <button |
| onClick={() => setWa(s => ({ ...s, skip: true, wabaId: '', accessToken: '', metaBusinessId: '' }))} |
| className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition mx-auto" |
| > |
| <SkipForward className="w-3.5 h-3.5" /> |
| {t('onboarding.skip_whatsapp')} |
| </button> |
| </div> |
| )} |
| </div> |
| |
| {/* Footer */} |
| <div className="bg-gray-50 px-10 py-5 flex justify-between items-center border-t border-gray-100"> |
| <button |
| onClick={back} |
| disabled={step === 0} |
| className="flex items-center gap-2 text-sm font-bold text-gray-400 hover:text-gray-600 disabled:opacity-0 transition" |
| > |
| <ArrowLeft className="w-4 h-4" /> {t('common.back')} |
| </button> |
| |
| <button |
| onClick={step === STEP_KEYS.length - 1 ? handleSubmit : next} |
| disabled={loading || !canNext()} |
| className="bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-3 shadow-lg shadow-indigo-500/30 transition disabled:opacity-40" |
| > |
| {loading |
| ? <><Loader2 className="w-4 h-4 animate-spin" /> {t('onboarding.creating')}</> |
| : step === STEP_KEYS.length - 1 |
| ? <><CheckCircle2 className="w-4 h-4" /> {t('onboarding.create_org')}</> |
| : <>{t('common.next')} <ArrowRight className="w-4 h-4" /></> |
| } |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|