/**
* Oveyo PWA — App Root
* 5 onglets + vue commerçant + sheets
*/
const LuApp = window.LucideReact || {};
const {
GradientBar: GBar,
FlashProvider: FP, useFlash: ufApp, Sheet: ShComp, Spinner: SpApp,
} = window.OveyoUI;
const {
loadState: lS, saveState: sS, defaultState: dS, api: apiApp,
useOnlineStatus: uOn, useCatalog: ucApp,
} = window.OveyoState;
const {
HomePage, TopBar, MapPage, ReportPage, PharmacyPage, ProfilePage, MerchantView,
} = window.OveyoPages;
// ============================================================
// TAB BAR — Vivinter style
// ============================================================
const TabBar = ({ active, onChange }) => {
const { Home, MapPin, Camera, Cross, User } = LuApp;
const tabs = [
{ id: 'home', icon: Home, label: 'Accueil' },
{ id: 'map', icon: MapPin, label: 'Carte' },
{ id: 'report', icon: Camera, label: 'Partager', primary: true },
{ id: 'pharmacy', icon: Cross, label: 'Pharmacie' },
{ id: 'profile', icon: User, label: 'Moi' },
];
return (
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = active === tab.id;
if (tab.primary) {
return (
onChange(tab.id)}
className="flex flex-col items-center -mt-7"
aria-label={tab.label}
>
{tab.label}
);
}
return (
onChange(tab.id)}
className="flex flex-col items-center gap-1 px-2 py-1"
aria-label={tab.label}
>
{tab.label}
);
})}
);
};
// ============================================================
// SHEET : LOGIN OTP WhatsApp
// ============================================================
const LoginSheet = ({ open, onClose, onSuccess }) => {
const { ChevronRight, Loader2, Lock } = LuApp;
const flash = ufApp();
const [step, setStep] = React.useState('phone');
const [phone, setPhone] = React.useState('');
const [code, setCode] = React.useState('');
const [loading, setLoading] = React.useState(false);
const requestOtp = async () => {
const cleanPhone = '221' + phone.replace(/\D/g, '');
if (cleanPhone.length < 11) {
flash.show('error', 'Numéro invalide');
return;
}
setLoading(true);
try {
await apiApp('/api/pwa/otp/request', {
method: 'POST',
body: JSON.stringify({ phone: cleanPhone }),
});
setStep('code');
flash.show('success', `Code envoyé sur WhatsApp au +${cleanPhone}`);
} catch (e) {
flash.show('error', "Impossible d'envoyer le code");
} finally {
setLoading(false);
}
};
const verifyOtp = async () => {
const cleanPhone = '221' + phone.replace(/\D/g, '');
setLoading(true);
try {
const data = await apiApp('/api/pwa/otp/verify', {
method: 'POST',
body: JSON.stringify({ phone: cleanPhone, code }),
});
onSuccess({
phone: cleanPhone,
pseudonym: data.pseudonym,
neighborhood_slug: data.neighborhood_slug,
is_ambassador: data.is_ambassador,
});
flash.show('success', '✅ Connexion réussie');
setStep('phone'); setPhone(''); setCode('');
} catch (e) {
flash.show('error', 'Code invalide ou expiré');
} finally {
setLoading(false);
}
};
return (
{step === 'phone' ? (
On va t'envoyer un code à 6 chiffres par WhatsApp pour vérifier ton numéro.
{loading ? : }
Recevoir le code
Ton numéro reste privé. Il sert à attribuer tes prix partagés et débloquer ton espace ambassadeur.
) : (
)}
);
};
// ============================================================
// SHEET : ALERTE FRAUDE
// ============================================================
const ReportFraudSheet = ({ open, onClose, state, onOpenSheet }) => {
const { Send, Shield, Loader2, Lock } = LuApp;
const flash = ufApp();
const [description, setDescription] = React.useState('');
const [shopName, setShopName] = React.useState('');
// V3.8 — champs libres au lieu d'un select des 50 quartiers
const [quartierInput, setQuartierInput] = React.useState('');
const [villeInput, setVilleInput] = React.useState('Dakar');
const [loading, setLoading] = React.useState(false);
// V4.0 — Login obligatoire pour signaler une fraude
// (l'affichage reste anonyme côté communauté, mais on trace côté backend
// pour empêcher les abus de signalement)
const userPhone = state?.user?.phone;
const isLoggedIn = typeof userPhone === 'string' && userPhone.length >= 10;
const goToLogin = () => {
onClose();
if (typeof onOpenSheet === 'function') {
setTimeout(() => onOpenSheet('login'), 300);
}
};
const submit = async () => {
if (!isLoggedIn) {
flash.show('info', 'Connecte-toi avec WhatsApp pour signaler un écart de prix');
goToLogin();
return;
}
// V3.8 — validation client AVANT l'envoi (le backend exige min 15 chars)
const desc = description.trim();
if (desc.length < 15) {
flash.show('error', 'Décris l\'écart en au moins 15 caractères');
return;
}
setLoading(true);
try {
await apiApp('/api/pwa/fraud-alert', {
method: 'POST',
body: JSON.stringify({
description: desc,
shop_name: shopName.trim() || null,
neighborhood_name: quartierInput.trim() || null,
city_name: villeInput.trim() || null,
reported_by: userPhone, // V4.0 — tracé côté backend
}),
});
flash.show('success', '✅ Alerte envoyée anonymement');
setDescription(''); setShopName('');
setQuartierInput(''); setVilleInput('Dakar');
onClose();
} catch (e) {
const status = e?.status;
if (status === 422) {
flash.show('error', 'Description trop courte ou invalide (min 15 caractères)');
} else if (status === 429) {
flash.show('error', "Trop d'alertes récentes, réessaie dans 1h");
} else {
flash.show('error', "Erreur d'envoi, réessaie");
}
} finally { setLoading(false); }
};
// V4.0 — Vue Connexion requise dans le sheet
if (!isLoggedIn) {
return (
Connexion requise
Pour signaler un écart de prix, tu dois te connecter avec WhatsApp.
Ton signalement reste 100% anonyme pour la communauté. Aucun numéro n'est partagé.
📱 Me connecter avec WhatsApp
);
}
return (
);
};
// ============================================================
// SHEET : MES SIGNALEMENTS (paginated)
// ============================================================
const MyReportsSheet = ({ open, onClose, state }) => {
const { Eye, Loader2, TrendingUp, Check, X } = LuApp;
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(false);
const [total, setTotal] = React.useState(0);
React.useEffect(() => {
if (!open || !state.user.phone) return;
setLoading(true);
apiApp(`/api/pwa/observations/mine?phone=${encodeURIComponent(state.user.phone)}&limit=50`)
.then(d => { setItems(d.items || []); setTotal(d.total || 0); })
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, [open, state.user.phone]);
return (
{loading ? (
) : items.length === 0 ? (
Aucun prix partagé pour l'instant.
Va sur l'onglet Partager pour commencer !
) : (
{total} prix partagé{total > 1 ? 's' : ''} au total
{items.map((r, i) => {
const overpriced = r.price && r.price_official && r.price > r.price_official;
return (
{r.icon}
{r.product_name}
{r.shop_name || 'Boutique non précisée'} · {r.neighborhood_name}
{new Date(r.created_at + 'Z').toLocaleString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
{r.price ? (
<>
{r.price} F
{r.is_abuse &&
Abus
}
{!r.available &&
Rupture
}
>
) : (
—
)}
);
})}
)}
);
};
// ============================================================
// SHEET : MES BADGES (gagnés + verrouillés)
// ============================================================
const MyBadgesSheet = ({ open, onClose, state }) => {
const [allBadges, setAllBadges] = React.useState([]);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!open || !state.user.phone) return;
setLoading(true);
apiApp(`/api/pwa/ambassador/me?phone=${encodeURIComponent(state.user.phone)}`)
.then(d => setAllBadges(d.all_badges || []))
.catch(() => setAllBadges([]))
.finally(() => setLoading(false));
}, [open, state.user.phone]);
const earned = allBadges.filter(b => b.earned);
const locked = allBadges.filter(b => !b.earned);
return (
{loading ? (
) : (
{earned.length > 0 && (
Débloqués ({earned.length})
{earned.map((b, i) => (
))}
)}
{locked.length > 0 && (
À débloquer ({locked.length})
{locked.map((b, i) => (
{b.name}
{b.description}
{!!b.threshold && (
Objectif : {b.threshold} prix partagés
)}
))}
)}
{earned.length === 0 && locked.length === 0 && (
Aucun badge disponible
)}
)}
);
};
// ============================================================
// SHEET : MODE HORS-LIGNE
// ============================================================
const OfflineModeSheet = ({ open, onClose }) => {
const { WifiOff, RefreshCw, Check, Wifi } = LuApp;
const online = uOn();
const [swStatus, setSwStatus] = React.useState('checking');
React.useEffect(() => {
if (!open) return;
if (!('serviceWorker' in navigator)) {
setSwStatus('unsupported');
return;
}
navigator.serviceWorker.getRegistration().then((reg) => {
setSwStatus(reg ? 'active' : 'inactive');
}).catch(() => setSwStatus('inactive'));
}, [open]);
const reloadSW = async () => {
if (!('serviceWorker' in navigator)) return;
try {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
await reg.update();
window.location.reload();
} else {
window.location.reload();
}
} catch (e) {
window.location.reload();
}
};
return (
{/* État réseau temps réel */}
{online
?
: }
{online ? 'Connecté' : 'Hors ligne'}
{online
? 'Tes prix sont partagés en temps réel'
: 'Tes prix seront partagés au retour du réseau'}
{/* Bandeau "Toujours actif" */}
État du mode hors-ligne
Toujours actif
Le mode hors-ligne d'Oveyo est activé en permanence . Aucun bouton à activer — ton téléphone détecte automatiquement quand tu perds le réseau et bascule en file d'attente locale. Volontaire pour les zones avec réseau capricieux.
{/* Détails techniques */}
Comment ça marche
L'app fonctionne même sans réseau (pages, prix, photos déjà chargées)
Tes prix partagés sont mis en file d'attente locale
Synchronisation automatique dès que la connexion revient
Aucune donnée perdue, même si tu fermes l'app
{/* Statut Service Worker */}
Service worker :
{swStatus === 'active' ? 'actif' : swStatus === 'checking' ? 'vérification...' : swStatus === 'unsupported' ? 'non supporté' : 'inactif'}
{/* Bouton recharger l'app */}
Recharger l'app pour mettre à jour
);
};
// ============================================================
// SHEET : NOTIFICATIONS PUSH (vraie demande de permission)
// ============================================================
const NotificationsSheet = ({ open, onClose, state, onOpenSheet }) => {
const { BellRing, Check, X, Loader2, Smartphone } = LuApp;
const flash = ufApp();
const [permission, setPermission] = React.useState(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
);
const [hasSubscription, setHasSubscription] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [errorDetail, setErrorDetail] = React.useState(null);
const userPhone = state?.user?.phone;
const isLoggedIn = typeof userPhone === 'string' && userPhone.length >= 10 && userPhone.length <= 15;
const isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = (
typeof navigator !== 'undefined' && navigator.standalone === true
) || (
typeof window !== 'undefined' && window.matchMedia &&
window.matchMedia('(display-mode: standalone)').matches
);
const iosNeedsInstall = isIOS && !isStandalone;
const supportNotif = typeof Notification !== 'undefined';
const supportPush = typeof navigator !== 'undefined' && 'serviceWorker' in navigator
&& typeof window !== 'undefined' && 'PushManager' in window;
React.useEffect(() => {
if (!open) return;
setErrorDetail(null);
if (typeof Notification !== 'undefined') {
setPermission(Notification.permission);
}
if (supportPush) {
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => setHasSubscription(!!sub))
.catch(() => setHasSubscription(false));
} else {
setHasSubscription(false);
}
}, [open]);
const goToLogin = () => {
if (typeof onOpenSheet === 'function') {
onClose();
// V3.5 — Bug #7 : 500ms (au lieu de 250) pour laisser le flash respirer
// entre la fermeture d'un sheet et l'ouverture du suivant.
setTimeout(() => onOpenSheet('login'), 500);
}
};
const enableNotifications = async () => {
setErrorDetail(null);
if (!isLoggedIn) {
setErrorDetail("Connecte-toi avec ton numéro WhatsApp pour activer les notifications.");
return;
}
if (!supportNotif) {
setErrorDetail("Ce navigateur ne propose pas l'API Notification");
return;
}
if (!supportPush) {
setErrorDetail("Ce navigateur ne supporte pas les notifications push (PushManager indisponible)");
return;
}
if (iosNeedsInstall) {
setErrorDetail("Sur iPhone, ajoute d'abord Oveyo à l'écran d'accueil (bouton Partager → Sur l'écran d'accueil)");
return;
}
setLoading(true);
try {
const result = await Notification.requestPermission();
setPermission(result);
if (result !== 'granted') {
if (result === 'denied') {
setErrorDetail("Tu as refusé les notifications. Pour les autoriser : Réglages navigateur → Site app.oveyo.sn → Notifications.");
} else {
setErrorDetail("Permission non accordée. Tu peux réessayer.");
}
setLoading(false);
return;
}
let public_key = null;
try {
const r = await apiApp('/api/pwa/push/vapid-key');
public_key = r.public_key;
} catch (e) {
setErrorDetail("Notifications activées dans le navigateur, mais le serveur push n'est pas joignable.");
flash.show('warning', 'Notifications OS OK — push serveur indisponible');
setLoading(false);
return;
}
if (!public_key) {
setErrorDetail("Le serveur push n'est pas configuré (clé VAPID absente).");
flash.show('warning', 'Notifications OS OK — push serveur pas configuré');
setLoading(false);
return;
}
const pad = '='.repeat((4 - public_key.length % 4) % 4);
const b64 = (public_key + pad).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(b64);
const buf = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
// V3.8 P3 — Timeout 8s pour serviceWorker.ready (peut hang sur iOS si SW cassé)
const swReadyWithTimeout = Promise.race([
navigator.serviceWorker.ready,
new Promise((_, rej) => setTimeout(() => rej(new Error('Service Worker indisponible (timeout 8s)')), 8000))
]);
const reg = await swReadyWithTimeout;
// V3.8 P3 — Timeout 8s sur pushManager.subscribe aussi
const subWithTimeout = Promise.race([
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: buf,
}),
new Promise((_, rej) => setTimeout(() => rej(new Error("Abonnement push impossible (timeout 8s) — vérifie que les notifications sont autorisées dans les Réglages")), 8000))
]);
const sub = await subWithTimeout;
try {
await apiApp('/api/pwa/push/subscribe', {
method: 'POST',
body: JSON.stringify({
phone: userPhone,
subscription: sub.toJSON(),
}),
});
setHasSubscription(true);
flash.show('success', '🔔 Notifications activées !');
} catch (e) {
setErrorDetail("Notifications OS OK mais l'enregistrement serveur a échoué.");
flash.show('warning', 'Notifications OS OK — enregistrement serveur KO');
}
} catch (e) {
console.error('[push] enable error', e);
setErrorDetail('Erreur : ' + (e.message || 'inconnue'));
flash.show('error', "Erreur lors de l'activation");
} finally {
setLoading(false);
}
};
const disableNotifications = async () => {
setErrorDetail(null);
setLoading(true);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
const endpoint = sub.endpoint;
await sub.unsubscribe();
try {
await apiApp('/api/pwa/push/unsubscribe', {
method: 'POST',
body: JSON.stringify({ endpoint }),
});
} catch (e) {
// best-effort
}
}
setHasSubscription(false);
flash.show('info', 'Notifications désactivées');
} catch (e) {
console.error('[push] disable error', e);
setErrorDetail('Erreur lors de la désactivation : ' + (e.message || 'inconnue'));
flash.show('error', 'Erreur lors de la désactivation');
} finally {
setLoading(false);
}
};
const isActive = isLoggedIn && supportNotif && supportPush && !iosNeedsInstall
&& permission === 'granted' && hasSubscription;
const isDenied = supportNotif && permission === 'denied';
// V3.5 — Bug #3 : si l'utilisateur a refusé (permission denied), le toggle
// ne sert à rien (Chrome/Safari bloquent). On retire le toggle dans ce cas.
const canActivate = isLoggedIn && supportNotif && supportPush && !iosNeedsInstall
&& !isActive && !isDenied;
let statusTitle, statusSubtitle, statusBg, statusIconColor;
if (!isLoggedIn) {
statusTitle = 'Connexion requise';
statusSubtitle = "Connecte-toi avec ton numéro WhatsApp pour activer les notifications";
statusBg = '#FEF3C7'; statusIconColor = '#92400E';
} else if (iosNeedsInstall) {
statusTitle = 'Action requise sur iPhone';
statusSubtitle = "Ajoute Oveyo à ton écran d'accueil pour activer les notifications";
statusBg = '#FEF3C7'; statusIconColor = '#92400E';
} else if (!supportNotif || !supportPush) {
statusTitle = 'Non supporté';
statusSubtitle = 'Ton navigateur ne permet pas les notifications push';
statusBg = '#F1F4F9'; statusIconColor = '#6B7891';
} else if (isActive) {
statusTitle = 'Notifications activées';
statusSubtitle = 'Tu recevras les alertes de ton quartier';
statusBg = '#E8F5E9'; statusIconColor = '#15803D';
} else if (isDenied) {
statusTitle = 'Notifications refusées';
statusSubtitle = 'Tu les as refusées une fois — autorise-les dans les réglages navigateur';
statusBg = '#FEEEE3'; statusIconColor = '#F4632B';
} else {
statusTitle = 'Notifications désactivées';
statusSubtitle = 'Active pour ne rien rater des prix et alertes';
statusBg = '#F1F4F9'; statusIconColor = '#102A43';
}
return (
{iosNeedsInstall && Smartphone
?
: }
{statusTitle}
{statusSubtitle}
{iosNeedsInstall && (
Comment installer sur iPhone
Touche le bouton Partager en bas de Safari
Choisis Sur l'écran d'accueil
Ouvre Oveyo depuis l'icône installée
Reviens ici activer les notifications
)}
Tu seras alertée pour :
Confirmation d'un écart de prix dans ton quartier
Nouveau groupe d'achat près de chez toi
Pharmacie de garde change pour ce soir
Tu as gagné un nouveau badge
{/* V3.7 — Gros bouton clair au lieu d'un toggle ambigu */}
{isActive && (
{loading ? : }
{loading ? 'Désactivation...' : 'Désactiver les notifications'}
)}
{canActivate && !isActive && (
{loading ? : }
{loading ? 'Activation...' : 'Activer les notifications'}
)}
{isDenied && (
Comment réactiver les notifications :
Ouvre les réglages de ton navigateur
Va dans Site → app.oveyo.sn → Notifications
Choisis "Autoriser"
Reviens ici pour activer
)}
{!isLoggedIn && (
📱
Se connecter avec WhatsApp
)}
{errorDetail && (
Détail : {errorDetail}
)}
);
};
// ============================================================
// SHEET : RÉCOMPENSE ORANGE
// ============================================================
const RewardSheet = ({ open, onClose, data }) => {
const { Gift, Award, TrendingUp } = LuApp;
const stats = data?.stats || { nb_observations: 0, rank_in_neighborhood: null };
return (
500 Mo de DATA Orange
Offert chaque mois aux 100 meilleures contributrices
{stats.nb_observations}
Tes prix partagés
{stats.rank_in_neighborhood ? `#${stats.rank_in_neighborhood}` : '—'}
Ton rang
Comment gagner ?
Chaque prix partagé validé = +5 points
Signaler un abus confirmé = +10 points
Top 100 du mois reçoivent 500 Mo Orange
Le crédit data est envoyé directement sur ton numéro à la fin du mois. Tu seras notifiée par WhatsApp.
);
};
// ============================================================
// SHEET : DEVENIR AMBASSADEUR (CTA upgrade)
// ============================================================
// ============================================================
// SHEET : DEVENIR AMBASSADRICE — V3.5 vrai formulaire
// ============================================================
const BecomeAmbassadorSheet = ({ open, onClose, state, onOpenSheet }) => {
const { Sparkles, ShieldCheck, Award, Send, Loader2, Camera, Check, X, Lock } = LuApp;
const flash = ufApp();
const userPhone = state?.user?.phone;
const isLoggedIn = typeof userPhone === 'string' && userPhone.length >= 10;
const { compressImage: cI } = window.OveyoState;
const [realName, setRealName] = React.useState('');
const [motivation, setMotivation] = React.useState('');
const [photoFile, setPhotoFile] = React.useState(null);
const [photoPreview, setPhotoPreview] = React.useState(null);
const [submitting, setSubmitting] = React.useState(false);
const [done, setDone] = React.useState(false);
const photoInputRef = React.useRef(null);
// Reset à chaque réouverture
React.useEffect(() => {
if (!open) {
setRealName(''); setMotivation('');
setPhotoFile(null); setPhotoPreview(null);
setSubmitting(false); setDone(false);
}
}, [open]);
const handlePhotoSelect = async (file) => {
if (!file) return;
try {
const compressed = await cI(file, 1024, 0.85);
setPhotoFile(compressed);
setPhotoPreview(URL.createObjectURL(compressed));
} catch {
flash.show('error', "Impossible de lire la photo");
}
};
const submit = async () => {
if (!isLoggedIn) {
// sécurité — le bouton ne devrait pas être visible mais on garde-fou
flash.show('error', 'Connecte-toi avant de candidater');
return;
}
const name = realName.trim();
if (name.length < 3) { flash.show('error', 'Renseigne ton vrai nom et prénom'); return; }
if (name.length > 80) { flash.show('error', 'Nom trop long'); return; }
setSubmitting(true);
try {
const fd = new FormData();
fd.append('phone', userPhone);
fd.append('real_name', name);
if (motivation.trim()) fd.append('motivation', motivation.trim());
if (state?.user?.neighborhood_slug) fd.append('neighborhood_slug', state.user.neighborhood_slug);
if (photoFile) fd.append('photo', photoFile, 'ambassador_photo.jpg');
const r = await fetch('/api/pwa/ambassador/apply', { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok) {
flash.show('error', data.detail || `Erreur ${r.status}`);
setSubmitting(false);
return;
}
if (data.already_pending) {
flash.show('info', data.message || 'Candidature déjà en cours.');
} else {
flash.show('success', '✅ Candidature envoyée');
}
setDone(true);
} catch (e) {
flash.show('error', "Erreur d'envoi — réessaie");
setSubmitting(false);
}
};
// Vue confirmation
if (done) {
return (
Merci !
Notre équipe vérifie ton identité et te recontacte par WhatsApp sous 48h.
Fermer
);
}
// Pas connecté : invite à se connecter (avec intent pour rouvrir ce sheet après login)
if (!isLoggedIn) {
return (
Connexion requise
Connecte-toi avec ton numéro WhatsApp pour candidater au programme ambassadeur.
{
onClose();
setTimeout(() => onOpenSheet?.('login', { redirect: 'become-ambassador' }), 500);
}}
className="px-5 py-3 rounded-xl font-bold text-white"
style={{ background: '#F4632B' }}
>
Me connecter avec WhatsApp
);
}
// Vue formulaire
return (
Programme Ambassadeur
Pour les contributrices les plus engagées
Tu deviens visible publiquement
Vrai nom + photo de profil affichés sur tes prix partagés
Badge bleu vérifié ✓ visible par tout le monde
Validation manuelle. On vérifie ton identité (selfie ou CNI) sous 48h et on te répond par WhatsApp.
{/* Vrai nom et prénom */}
{/* Photo selfie/CNI (optionnelle mais recommandée) */}
Photo (selfie ou CNI)
photoInputRef.current?.click()}
className="mt-1.5 w-full rounded-xl flex items-center gap-3 p-3 text-left"
style={{ background: photoPreview ? '#E8F5E9' : '#F1F4F9' }}
>
{photoPreview ? (
<>
Photo prête
Touche pour changer
>
) : (
<>
Ajouter une photo
Recommandé pour valider plus vite
>
)}
handlePhotoSelect(e.target.files?.[0])}
/>
{/* Motivation libre */}
Pourquoi tu veux devenir ambassadrice ? (facultatif)
{submitting ? : }
{submitting ? 'Envoi…' : 'Envoyer ma candidature'}
);
};
// ============================================================
// SHEET : RÉCLAMER UNE BOUTIQUE (NINEA)
// ============================================================
const ClaimShopSheet = ({ open, onClose, data }) => {
const { Store, Send, Loader2, Check } = LuApp;
const flash = ufApp();
const [ninea, setNinea] = React.useState('');
const [ownerName, setOwnerName] = React.useState('');
const [phone, setPhone] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [done, setDone] = React.useState(false);
const shop = data?.shop || {};
React.useEffect(() => {
if (!open) {
setNinea(''); setOwnerName(''); setPhone(''); setLoading(false); setDone(false);
}
}, [open]);
const submit = async () => {
if (ninea.trim().length < 4) { flash.show('error', 'NINEA invalide'); return; }
if (ownerName.trim().length < 2) { flash.show('error', 'Nom invalide'); return; }
const cleanPhone = '221' + phone.replace(/\D/g, '');
if (cleanPhone.length < 11) { flash.show('error', 'Numéro WhatsApp invalide'); return; }
setLoading(true);
try {
await apiApp('/api/pwa/shop-claim', {
method: 'POST',
body: JSON.stringify({
shop_id: shop.id || null,
shop_name: shop.name || null,
ninea: ninea.trim(),
owner_name: ownerName.trim(),
phone: cleanPhone,
}),
});
setDone(true);
flash.show('success', '✅ Demande envoyée. On te contactera sous 48h.');
} catch (e) {
flash.show('error', "Erreur d'envoi");
} finally { setLoading(false); }
};
if (done) {
return (
Merci !
Notre équipe va vérifier ton NINEA et te recontacter par WhatsApp sous 48h pour finaliser ta réclamation.
Fermer
);
}
return (
{shop.name || 'Boutique'}
{shop.area || 'Quartier'}
Ton nom complet (propriétaire)
setOwnerName(e.target.value)}
placeholder="Modou Ndiaye"
className="mt-1.5 w-full p-3 rounded-xl text-sm outline-none"
style={{ background: '#F1F4F9' }}
/>
Vérification sous 48h. On contrôle ton NINEA dans le registre du commerce, puis on te contacte sur WhatsApp.
{loading ? : }
Envoyer ma réclamation
);
};
// ============================================================
// SHEET : SOS ORDONNANCE
// ============================================================
const SosOrdonnanceSheet = ({ open, onClose, state }) => {
const { Camera, Send, Loader2, Check, Cross } = LuApp;
const flash = ufApp();
const [photo, setPhoto] = React.useState(null);
const [photoPreview, setPhotoPreview] = React.useState(null);
const [phone, setPhone] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [done, setDone] = React.useState(false);
const inputRef = React.useRef(null);
React.useEffect(() => {
if (!open) {
setPhoto(null); setPhotoPreview(null); setLoading(false); setDone(false);
if (!state.user.phone) setPhone('');
} else if (state.user.phone) {
setPhone(state.user.phone.replace(/^221/, ''));
}
}, [open, state.user.phone]);
const handlePhoto = async (file) => {
if (!file) return;
const { compressImage } = window.OveyoState;
const compressed = await compressImage(file, 1200, 0.8);
setPhoto(compressed);
setPhotoPreview(URL.createObjectURL(compressed));
};
const submit = async () => {
if (!photo) { flash.show('error', 'Photo de l\'ordonnance requise'); return; }
const cleanPhone = phone.startsWith('221') ? phone : '221' + phone.replace(/\D/g, '');
if (cleanPhone.length < 11) { flash.show('error', 'Numéro WhatsApp invalide'); return; }
setLoading(true);
try {
const fd = new FormData();
fd.append('photo', photo, 'ordonnance.jpg');
fd.append('phone', cleanPhone);
const r = await fetch('/api/pwa/sos-prescription', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload failed');
setDone(true);
flash.show('success', '✅ Pharmacies de garde alertées');
} catch (e) {
flash.show('error', "Erreur d'envoi");
} finally { setLoading(false); }
};
if (done) {
return (
Pharmacies prévenues
Les pharmacies de garde reçoivent ta demande. Elles te contacteront sur WhatsApp si elles ont le médicament.
Fermer
);
}
return (
Photographie ton ordonnance. Les pharmacies de garde te diront qui a le médicament en stock.
inputRef.current?.click()}
className="w-full aspect-[4/3] max-h-64 rounded-2xl flex flex-col items-center justify-center gap-3 relative overflow-hidden"
style={{ background: photoPreview ? `url(${photoPreview}) center/cover` : 'linear-gradient(135deg, #102A43 0%, #1f3a5f 100%)' }}
>
{!photoPreview && (
<>
Photographier l'ordonnance
>
)}
handlePhoto(e.target.files?.[0])} />
{!state.user.phone && (
)}
{loading ? : }
Alerter les pharmacies de garde
);
};
// ============================================================
// SHEET : REJOINDRE GROUPE D'ACHAT
// ============================================================
const JoinGroupBuySheet = ({ open, onClose, data }) => {
const { Users, Send, Loader2, Check } = LuApp;
const flash = ufApp();
const [loading, setLoading] = React.useState(false);
const [phone, setPhone] = React.useState('');
const [done, setDone] = React.useState(false);
const group = data?.group || null;
React.useEffect(() => {
if (!open) { setPhone(''); setLoading(false); setDone(false); }
}, [open]);
const submit = async () => {
const cleanPhone = '221' + phone.replace(/\D/g, '');
if (cleanPhone.length < 11) { flash.show('error', 'Numéro WhatsApp invalide'); return; }
if (!group) return;
setLoading(true);
try {
const r = await apiApp(`/api/pwa/group-buys/${group.id}/join`, {
method: 'POST',
body: JSON.stringify({ phone: cleanPhone }),
});
setDone(true);
flash.show('success', `✅ Inscrit ! Vous êtes ${r.current_quantity} sur ${r.target_quantity}`);
} catch (e) {
flash.show('error', "Erreur d'inscription");
} finally { setLoading(false); }
};
if (!group) return null;
if (done) {
return (
À bientôt !
Tu seras alertée par WhatsApp dès que le groupe atteint sa cible. La livraison se fera à un point de retrait commun.
Fermer
);
}
return (
{group.product_name}
{group.current_quantity} / {group.target_quantity} voisins
{group.description}
Comment ça marche ?
Tu rejoins gratuitement et sans engagement
Quand le groupe est complet, on négocie un prix de gros
Livraison groupée à un point de retrait commun
Tu paies à la livraison (pas avant)
{loading ? : }
Rejoindre le groupe
);
};
// ============================================================
// SHOP DETAIL SHEET — fiche boutique cliquable (C2)
// ============================================================
const ShopDetailSheet = ({ open, onClose, data, state, onOpenSheet }) => {
const {
Loader2, MapPin, ShieldCheck, Store, Navigation, AlertTriangle,
Camera, Check, ChevronRight,
} = LuApp;
const flash = ufApp();
const shopId = data?.shop_id || null;
const [info, setInfo] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
// Charge les données quand le sheet s'ouvre
React.useEffect(() => {
if (!open || !shopId) {
setInfo(null); setError(null);
return;
}
let cancelled = false;
const load = async () => {
setLoading(true); setError(null);
try {
const r = await apiApp(`/api/pwa/shops/${shopId}`);
if (!cancelled) setInfo(r);
} catch (e) {
if (!cancelled) {
setError(e?.message === 'Boutique introuvable' ? 'Cette boutique n\'existe plus' : 'Impossible de charger la boutique');
}
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [open, shopId]);
// Format prix
const fmt = (n) => Number(n).toLocaleString('fr-FR') + ' F';
const shop = info?.shop || null;
const prices = info?.prices || [];
const observations = info?.observations || [];
const trust = info?.trust || null;
const isClaimed = info?.is_claimed === true;
// Distance si user géoloc dispo (Haversine simple)
const userPos = state?.user?.geolocation || null;
const distanceKm = (() => {
if (!shop || !shop.lat || !shop.lng || !userPos?.lat || !userPos?.lng) return null;
const R = 6371;
const toRad = (d) => d * Math.PI / 180;
const dLat = toRad(shop.lat - userPos.lat);
const dLng = toRad(shop.lng - userPos.lng);
const a = Math.sin(dLat/2) ** 2 +
Math.cos(toRad(userPos.lat)) * Math.cos(toRad(shop.lat)) *
Math.sin(dLng/2) ** 2;
return Number((2 * R * Math.asin(Math.sqrt(a))).toFixed(1));
})();
// Action handlers
const openWhatsApp = () => {
if (!shop?.whatsapp_phone) return;
const cleaned = String(shop.whatsapp_phone).replace(/\D/g, '');
window.open(`https://wa.me/${cleaned}`, '_blank', 'noopener');
};
const openItinerary = () => {
if (!shop?.lat || !shop?.lng) return;
const url = `https://www.google.com/maps/dir/?api=1&destination=${shop.lat},${shop.lng}`;
window.open(url, '_blank', 'noopener');
};
const goClaim = () => {
onClose();
// V3.5 — Bug #7 : 500ms pour ne pas chevaucher les animations de sheet.
setTimeout(() => onOpenSheet?.('claim-shop', { shop }), 500);
};
const goReportFraud = () => {
onClose();
setTimeout(() => onOpenSheet?.('report-fraud', { shop }), 500);
};
const goAddPhoto = () => {
flash.show('info', '📷 Upload photo bientôt disponible');
};
// Couleur du badge confiance
const trustBg = trust?.label === 'fiable' ? '#E8F5E9'
: trust?.label === 'à surveiller' ? '#FEF3C7'
: trust?.label === 'abusif' ? '#FEEEE3' : '#F1F4F9';
const trustColor = trust?.label === 'fiable' ? '#15803D'
: trust?.label === 'à surveiller' ? '#92400E'
: trust?.label === 'abusif' ? '#B91C1C' : '#6B7891';
const sheetTitle = shop?.name || 'Boutique';
return (
{loading && (
)}
{error && !loading && (
{error}
)}
{!loading && !error && shop && (
{/* Photo de façade ou placeholder */}
{shop.photo_url ? (
) : (
Ajouter une photo
Façade depuis la voie publique · modération avant publication
)}
{/* Header : nom + badges */}
{shop.name}
{shop.neighborhood_name || '—'}
{shop.city_name && shop.city_name !== shop.neighborhood_name && (
· {shop.city_name}
)}
{distanceKm != null && · {distanceKm} km }
{shop.verified ? (
Vérifiée
) : (
Non vérifiée
)}
{shop.address_text && (
{shop.address_text}
)}
{/* Indice de confiance */}
{trust && (
Indice de confiance
{trust.label === 'indéterminé'
? 'Pas assez de données'
: trust.label.charAt(0).toUpperCase() + trust.label.slice(1)}
{trust.in_norm_pct != null && (
{Math.round(trust.in_norm_pct)}%
prix dans la norme
)}
Calculé sur {trust.total_observations_30d} observation{trust.total_observations_30d > 1 ? 's' : ''} · 30 derniers jours
{trust.nb_abuse_30d > 0 && ` · ${trust.nb_abuse_30d} signalement${trust.nb_abuse_30d > 1 ? 's' : ''} abus`}
)}
{/* Prix actuels (7j) */}
Prix actuels · 7 derniers jours
{prices.length === 0 ? (
Aucun prix relevé récemment
) : (
{prices.map((p) => {
const isOver = Boolean(p.is_overpriced || p.is_abuse);
const isOOS = p.stock === 'rupture';
return (
{p.product_icon || '📦'}
{p.product_name}
{/* V4.1 — quantité observée (préférée à p.unit si remontée) */}
{p.quantity_label
? {p.quantity_label}
: (p.unit && {p.unit} )}
{p.price_official ? (
<>
·
DGCC
{fmt(p.price_official)}
>
) : (
<>· prix libre >
)}
{isOOS ? (
Rupture
) : (
<>
{fmt(p.price)}
{isOver && (
Abus
)}
>
)}
);
})}
)}
{/* Derniers signalements */}
{observations.length > 0 && (
Derniers signalements
{observations.map((o, idx) => {
const dt = o.created_at ? new Date(o.created_at.replace(' ', 'T') + 'Z') : null;
const dateStr = dt
? dt.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
: '—';
const obsAbuse = Boolean(o.is_overpriced || o.is_abuse);
return (
{o.product_icon || '📦'}
{o.product_name}
{fmt(o.price)}
{dateStr}
{obsAbuse && (
)}
);
})}
)}
{/* Boutons d'action */}
{/* Ligne 1 : WhatsApp + Itinéraire (côte à côte si les 2 dispos) */}
📱 WhatsApp
Itinéraire
{/* Réclamer si pas claimée */}
{!isClaimed && (
Tu es propriétaire ? Réclame cette boutique
)}
{/* Signaler un écart de prix */}
Signaler un écart de prix
{/* Ajouter photo (placeholder C3) */}
{!shop.photo_url && (
Ajouter une photo de la boutique 🚧
)}
)}
);
};
// ============================================================
// PRICE COMPARE SHEET — comparaison prix d'un produit
// ============================================================
const PriceCompareSheet = ({ open, onClose, data, state }) => {
const { Loader2, Search, MapPin, ChevronRight, AlertTriangle, Navigation, X, ShoppingBag } = LuApp;
const flash = ufApp();
const catalog = ucApp();
// Mode "picker" si pas de produit fourni, sinon affiche directement la compare
const initialSlug = data?.product_slug || null;
const [productSlug, setProductSlug] = React.useState(initialSlug);
const [search, setSearch] = React.useState('');
const [compareData, setCompareData] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const [useGeo, setUseGeo] = React.useState(false);
const [geoCoords, setGeoCoords] = React.useState(null);
const [geoLoading, setGeoLoading] = React.useState(false);
const [radiusKm, setRadiusKm] = React.useState(5);
React.useEffect(() => {
if (!open) {
setProductSlug(initialSlug);
setCompareData(null); setSearch(''); setError(null);
setUseGeo(false); setGeoCoords(null); setRadiusKm(5);
setGeoLoading(false);
} else {
setProductSlug(initialSlug);
}
}, [open]);
// Charge la comparaison quand productSlug change
React.useEffect(() => {
if (!open || !productSlug) return;
let cancelled = false;
const load = async () => {
setLoading(true); setError(null);
try {
const params = new URLSearchParams();
params.set('days', '7');
const nbSlug = state?.user?.neighborhood_slug;
if (nbSlug && !useGeo) params.set('neighborhood_slug', nbSlug);
if (useGeo && geoCoords) {
params.set('lat', String(geoCoords.lat));
params.set('lng', String(geoCoords.lng));
params.set('radius_km', String(radiusKm));
}
const r = await apiApp(`/api/pwa/products/${encodeURIComponent(productSlug)}/compare?${params.toString()}`);
if (!cancelled) setCompareData(r);
} catch (e) {
if (!cancelled) setError('Impossible de charger la comparaison');
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [open, productSlug, useGeo, geoCoords, radiusKm]);
const askGeolocation = () => {
if (!navigator.geolocation) {
flash.show('error', 'Géolocalisation non supportée par ton navigateur');
return;
}
setGeoLoading(true);
// Timeout côté JS pour ne pas rester bloqué éternellement
let resolved = false;
const fallbackTimer = setTimeout(() => {
if (resolved) return;
resolved = true;
setGeoLoading(false);
flash.show('error', 'Position trop longue à obtenir, réessaie');
}, 12000);
navigator.geolocation.getCurrentPosition(
(pos) => {
if (resolved) return;
resolved = true;
clearTimeout(fallbackTimer);
setGeoCoords({ lat: pos.coords.latitude, lng: pos.coords.longitude });
setUseGeo(true);
setGeoLoading(false);
flash.show('success', 'Position OK — recherche du moins cher');
},
(err) => {
if (resolved) return;
resolved = true;
clearTimeout(fallbackTimer);
setGeoLoading(false);
const msg = err.code === 1
? 'Autorise la géolocalisation dans ton navigateur'
: err.code === 2
? 'Position indisponible (réseau ou GPS)'
: 'Erreur de géolocalisation';
flash.show('error', msg);
},
// enableHighAccuracy:false → plus tolérant et plus rapide sur mobile
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 }
);
};
// ---------- mode picker ----------
if (!productSlug) {
const products = catalog?.products || [];
const filtered = search.trim()
? products.filter(p => {
const label = (p.name_fr || p.name || p.slug || '').toLowerCase();
return label.includes(search.trim().toLowerCase());
})
: products;
return (
setSearch(e.target.value)}
placeholder="Chercher un produit (riz, huile, sucre...)"
className="flex-1 bg-transparent text-sm outline-none"
autoFocus={false}
/>
{search && (
setSearch('')} aria-label="Effacer">
)}
{filtered.length === 0 ? (
Aucun produit trouvé
) : filtered.map((p) => {
const label = p.name_fr || p.name || p.slug;
const officialPrice = p.price_official != null ? p.price_official : p.official_price;
return (
setProductSlug(p.slug)}
className="w-full flex items-center gap-3 p-3 rounded-xl text-left active:opacity-70"
style={{ background: '#F8FAFC', border: '1px solid #EAEEF4' }}
>
{p.icon || '📦'}
{label}
{officialPrice ? (
DGCC
{Number(officialPrice).toLocaleString('fr-FR')} F
) : (
Prix libre du marché
)}
);
})}
);
}
// ---------- mode compare ----------
const stats = compareData?.stats || null;
const offers = compareData?.items || compareData?.offers || [];
const productName = compareData?.product?.name_fr || compareData?.product?.name || productSlug;
const officialPrice = stats?.official || null;
const fmt = (n) => Number(n).toLocaleString('fr-FR') + ' F';
return (
{/* Bouton retour picker */}
{ setProductSlug(null); setCompareData(null); }}
className="text-xs font-bold text-slate flex items-center gap-1"
>
← Changer de produit
{loading && (
)}
{error && !loading && (
{error}
)}
{!loading && !error && stats && (
<>
{/* Stats min/médiane/max */}
Min
{stats.min ? fmt(stats.min) : '—'}
Médiane
{stats.median ? fmt(stats.median) : '—'}
Max
{stats.max ? fmt(stats.max) : '—'}
{/* V4.4 — Visualisation graphique avec ligne DGCC toujours visible si prix officiel */}
{!!officialPrice && stats.min && stats.max && (() => {
// Calcul de l'échelle : on couvre du min citoyen (ou DGCC si plus bas)
// jusqu'au max citoyen + un peu de marge.
const minVal = Math.min(stats.min, officialPrice);
const maxVal = Math.max(stats.max, officialPrice);
const padding = (maxVal - minVal) * 0.12 || officialPrice * 0.1;
const scaleMin = Math.max(0, minVal - padding);
const scaleMax = maxVal + padding;
const range = scaleMax - scaleMin || 1;
const pct = (val) => Math.max(0, Math.min(100, ((val - scaleMin) / range) * 100));
const dgccPct = pct(officialPrice);
const minPct = pct(stats.min);
const medianPct = pct(stats.median || ((stats.min + stats.max) / 2));
const maxPct = pct(stats.max);
const isMaxOver = stats.max > officialPrice * 1.1;
const isMedianOver = stats.median && stats.median > officialPrice * 1.1;
return (
DGCC
Référence officielle
{fmt(officialPrice)}
{/* Barre horizontale */}
{/* Track de fond */}
{/* Ligne DGCC verticale (la référence) */}
{/* Min citoyen */}
{/* Médiane */}
{!!stats.median && (
)}
{/* Max */}
{/* Légende compacte */}
Min
Médiane
Max
DGCC
{isMaxOver && (
⚠️ Le prix max relevé dépasse le seuil d'abus DGCC ({fmt(Math.round(officialPrice * 1.1))})
)}
);
})()}
{!!officialPrice && (
Seuil abus DGCC (+10%) : {fmt(Math.round(officialPrice * 1.1))}
)}
{/* Toggle géoloc */}
{!useGeo ? (
{geoLoading ? (
<>
Localisation en cours...
>
) : (
<>
Trouver le moins cher près de moi
>
)}
) : (
Recherche dans un rayon de {radiusKm} km
{ setUseGeo(false); setGeoCoords(null); }}
className="ml-auto text-[11px] font-bold text-slate underline"
>Annuler
{[1, 5, 20].map((km) => (
setRadiusKm(km)}
className="py-2 rounded-xl text-xs font-bold"
style={{
background: radiusKm === km ? '#F4632B' : '#F1F4F9',
color: radiusKm === km ? 'white' : '#0F1F36',
}}
>
{km} km
))}
)}
{/* Liste des offres */}
{stats.count} offre{stats.count > 1 ? 's' : ''} sur 7 jours
{offers.length === 0 ? (
Aucune offre récente. Sois la première à signaler !
) : offers.map((o, idx) => {
const isOver = o.is_overpriced;
const isCheapest = idx === 0;
return (
{isOver &&
}
{o.shop_name || 'Boutique anonyme'}
{o.neighborhood_name || '—'}
{o.distance_km != null && (
· {o.distance_km.toFixed(1)} km
)}
{o.justification && (
"{o.justification}"
)}
{fmt(o.price)}
{isCheapest && !isOver && (
Moins cher
)}
{isOver && (
Abus
)}
);
})}
>
)}
);
};
// ============================================================
// APP ROOT
// ============================================================
const App = () => {
const [state, setState] = React.useState(lS);
const [activeTab, setActiveTab] = React.useState('home');
const [view, setView] = React.useState('main');
const [sheet, setSheet] = React.useState(null);
const [sheetData, setSheetData] = React.useState(null);
const online = uOn();
React.useEffect(() => { sS(state); }, [state]);
const openSheet = (name, data) => { setSheetData(data || null); setSheet(name); };
const closeSheet = () => { setSheet(null); setSheetData(null); };
const handleTabChange = (tab) => {
// V3.7 — Fermer tout sheet ouvert quand l'utilisateur change d'onglet.
setSheet(null);
setSheetData(null);
setActiveTab(tab);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleLoginSuccess = (userData) => {
// V3.5 — Bug #5 : on lit l'intention AVANT de fermer le sheet (closeSheet
// remet sheetData à null). Si le user a cliqué sur "Espace Commerçant"
// sans être connecté, on bascule directement en vue merchant après login,
// au lieu de le laisser sur la page Profil (où il devait recliquer).
const intent = sheetData?.redirect;
setState({
...state,
user: {
phone: userData.phone, pseudonym: userData.pseudonym,
neighborhood_slug: userData.neighborhood_slug,
language: state.user.language || 'fr',
geolocation: state.user.geolocation || null,
session_token: userData.session_token || null,
is_merchant: !!userData.is_merchant,
},
persona: { ...state.persona, ambassador_validated: !!userData.is_ambassador },
});
closeSheet();
if (intent === 'merchant') {
setView('merchant');
} else if (intent === 'become-ambassador') {
// après login depuis la CTA "Devenir Ambassadrice"
setTimeout(() => openSheet('become-ambassador'), 250);
}
};
// Bloc commun de tous les sheets — utilisé en main view ET en merchant view
const sheets = (
<>
>
);
if (view === 'merchant') {
return (
setView('main')}
onOpenSheet={openSheet}
/>
{sheets}
);
}
return (
{activeTab === 'home' &&
openSheet('push-info')} />}
{!online && (
📡 Hors ligne — tes prix seront partagés au retour du réseau
)}
{activeTab === 'home' && }
{activeTab === 'map' && }
{activeTab === 'report' && (
openSheet('login')} />
)}
{activeTab === 'pharmacy' && }
{activeTab === 'profile' && (
setView(v)} />
)}
{sheets}
);
};
const Root = () => ( );
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( );