edtech / apps /admin /src /pages /OnboardingWizard.tsx
CognxSafeTrack
feat(i18n): complete admin app internationalization across all pages
d80fec4
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';
// ─── Modes ───────────────────────────────────────────────────────────────────
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' },
];
// ─── Slug helpers ─────────────────────────────────────────────────────────────
function toSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
// ─── Steps ───────────────────────────────────────────────────────────────────
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 },
];
// ─── Wizard ──────────────────────────────────────────────────────────────────
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');
}
};
// Auto-generate slug from name unless user edited it manually
useEffect(() => {
if (!org.slugTouched) {
setOrg(s => ({ ...s, slug: toSlug(s.name) }));
}
}, [org.name, org.slugTouched]);
useEffect(() => { initMetaSDK(); }, []);
// ── Navigation ──────────────────────────────────────────────────────────────
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));
// ── Submit ──────────────────────────────────────────────────────────────────
const handleSubmit = async () => {
setLoading(true);
try {
// 1. Create organisation + admin user
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;
// 2. Connect WhatsApp if provided
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);
}
};
// ── Embedded Signup ─────────────────────────────────────────────────────────
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'));
}
};
// ── Render ──────────────────────────────────────────────────────────────────
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>
);
}