/** * Oveyo PWA — Pages : Pharmacies de garde, Profil, Espace Commerçant */ const Lu4 = window.LucideReact || {}; const { Card: C4, Spinner: Sp4, EmptyState: ES4, useFlash: uF4 } = window.OveyoUI; const { usePharmaciesOnDuty: uPh, useAmbassador: uAmb, api: api4, } = window.OveyoState; // ============================================================ // PAGE PHARMACIES // ============================================================ const PharmacyPage = ({ state, onOpenSheet }) => { const { Cross, Camera, ChevronRight, Phone, Navigation, Clock, Pill } = Lu4; const { pharmacies, loading } = uPh(); return (

De garde ce soir

Mis à jour il y a peu
{/* SOS Ordonnance — feature avancée */} {/* Liste pharmacies */} {loading ? (
) : pharmacies.length === 0 ? ( ) : (
{pharmacies.map((p, i) => (
{p.name}
{p.neighborhood_name || ''} {p.distance && `· ${p.distance}`}
{p.duty_label || 'Garde 24h'}
{p.phone && ( Appeler )} {p.lat && p.lng && ( Itinéraire )}
))}
)}
); }; // ============================================================ // PAGE PROFIL // ============================================================ const ProfilePage = ({ state, setState, onOpenSheet, onSwitchView }) => { const { Award, Gift, Eye, ChevronRight, Sparkles, Store, Shield, Bell, LogOut, WifiOff, Lock, BellRing, } = Lu4; const { data: ambData } = uAmb(state.user.phone); const flash = uF4(); // Pas connectée — invitation if (!state.user.phone) { return (

Mon compte

Connecte-toi pour gagner des points et débloquer ton espace ambassadeur

Devenir Ambassadrice

Gagne des points à chaque prix partagé, débloque des badges, et reçois des récompenses Orange.

{/* Liens secondaires */}
); } // Connectée const stats = ambData?.stats || { nb_observations: 0, nb_badges: 0, points: 0, rank_in_neighborhood: null }; const badges = ambData?.badges || []; const recent = ambData?.recent || []; const userInfo = ambData?.user || null; const isAmbassador = userInfo?.is_ambassador; const logout = async () => { if (!confirm('Te déconnecter ?')) return; // Révocation server-side (best-effort, ne bloque pas si réseau KO) const token = state.user?.session_token; if (token) { try { await fetch('/api/pwa/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, }); } catch (_) { /* offline → on clear quand même côté client */ } } setState({ ...state, user: { phone: null, pseudonym: null, neighborhood_slug: null, language: 'fr', session_token: null, is_merchant: false, }, }); flash.show('info', 'Tu es déconnectée'); }; // Multi-villes : afficher quartier + ville si hors Dakar const neighborhoodLabel = userInfo?.neighborhood_name || (state.user.neighborhood_slug ? state.user.neighborhood_slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) : 'Dakar'); const cityName = userInfo?.city_name || null; const showCity = cityName && cityName.toLowerCase() !== 'dakar'; const profileSubtitle = showCity ? `Pseudonyme actif · ${cityName}` : (userInfo?.neighborhood_name ? `Pseudonyme actif · ${userInfo.neighborhood_name}` : 'Pseudonyme actif'); return (
{/* Carte profil */}
{(state.user.pseudonym || 'O').charAt(0).toUpperCase()}
{state.user.pseudonym || 'Anonyme'}
{profileSubtitle}
{stats.nb_observations}
Signalements
{stats.points}
Points
{stats.rank_in_neighborhood ? `#${stats.rank_in_neighborhood}` : '—'}
Quartier
{/* Identité publique + sub-CTA Devenir Ambassadeur */}
Identité publique
Tu apparais comme « {state.user.pseudonym || 'Anonyme'} de {neighborhoodLabel} »
Tes signalements restent 100% anonymes. Aucun numéro n'est partagé.
{!isAmbassador && (
Devenir Ambassadeur
Vrai nom + photo + badge vérifié ✓
)}
{/* Espace Commerçant */} {/* Carte Ambassadeur */}
Ambassadeur
{stats.nb_observations} prix partagé{stats.nb_observations > 1 ? 's' : ''} {' · '} {stats.nb_badges} badge{stats.nb_badges > 1 ? 's' : ''}
{/* Récompense Orange — cliquable */} {/* Liste : Mes prix partagés / Mes badges / Mode hors-ligne */} {/* Notifications + déconnexion */}
); }; // ============================================================ // VUE COMMERÇANT // ============================================================ // ============================================================ // VUE COMMERÇANT — V3 live (sessions, actions réelles) // ============================================================ const MerchantView = ({ state, setState, onBack, onOpenSheet }) => { const { ChevronLeft, AlertTriangle, Edit3, ShieldCheck, Eye, Store, Loader2, Lock, RefreshCw, Check, X, } = Lu4; const flash = uF4(); const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [busy, setBusy] = React.useState(null); // id de l'action en cours const [justifyFor, setJustifyFor] = React.useState(null); // alert id const [justifyText, setJustifyText] = React.useState(''); const [editPrice, setEditPrice] = React.useState(null); // {product_id, price, justification} const token = state.user?.session_token; const fetchMe = React.useCallback(async () => { if (!token) { setLoading(false); setError('login'); return; } setLoading(true); // V3.8 P3 — important : reset l'erreur AVANT le fetch. // Sans ça, après login, fetchMe est rappelé mais on garde error='login' // pendant le fetch → l'écran "Connexion requise" reste affiché. setError(null); try { const r = await api4('/api/pwa/merchant/me', { headers: { Authorization: 'Bearer ' + token }, }); setData(r); setError(null); } catch (e) { if (String(e).match(/401|403/)) { setError('login'); } else { setError('network'); } } finally { setLoading(false); } }, [token]); React.useEffect(() => { fetchMe(); }, [fetchMe]); const authedFetch = async (url, options = {}) => { return api4(url, { ...options, headers: { ...(options.headers || {}), Authorization: 'Bearer ' + token, }, }); }; // --- Actions --- const dismissAlert = async (alertId) => { setBusy('dismiss-' + alertId); try { await authedFetch(`/api/pwa/merchant/alerts/${alertId}/dismiss`, { method: 'POST' }); flash.show('success', 'Alerte fermée'); await fetchMe(); } catch (e) { flash.show('error', 'Erreur — réessaie'); } finally { setBusy(null); } }; const submitJustification = async (alertId) => { if (justifyText.trim().length < 10) { flash.show('error', 'Justification trop courte (10 caractères min)'); return; } setBusy('justify-' + alertId); try { await authedFetch(`/api/pwa/merchant/alerts/${alertId}/justify`, { method: 'POST', body: JSON.stringify({ justification: justifyText.trim() }), }); flash.show('success', 'Justification envoyée'); setJustifyFor(null); setJustifyText(''); await fetchMe(); } catch { flash.show('error', 'Erreur — réessaie'); } finally { setBusy(null); } }; const toggleStock = async (shopId, productId, available) => { setBusy(`stock-${shopId}-${productId}`); try { await authedFetch(`/api/pwa/merchant/shops/${shopId}/stock`, { method: 'POST', body: JSON.stringify({ product_id: productId, available }), }); flash.show('success', available ? 'Marqué disponible' : 'Marqué en rupture'); await fetchMe(); } catch { flash.show('error', 'Erreur'); } finally { setBusy(null); } }; const submitPrice = async (shopId) => { if (!editPrice) return; const p = parseInt(editPrice.price, 10); if (!p || p < 1) { flash.show('error', 'Prix invalide'); return; } setBusy(`price-${editPrice.product_id}`); try { await authedFetch(`/api/pwa/merchant/shops/${shopId}/prices`, { method: 'POST', body: JSON.stringify({ product_id: editPrice.product_id, price: p, justification: editPrice.justification?.trim() || null, }), }); flash.show('success', 'Prix mis à jour'); setEditPrice(null); await fetchMe(); } catch (e) { const msg = String(e.message || e); if (msg.includes('Justification obligatoire')) { flash.show('error', 'Justification obligatoire si prix > 110% officiel'); } else { flash.show('error', 'Erreur — réessaie'); } } finally { setBusy(null); } }; // --- Rendus états spéciaux --- // Pas de session : invite à se connecter if (error === 'login') { return (

Connexion requise

L'Espace Commerçant nécessite une connexion vérifiée par WhatsApp. Tes actions sont signées et tracées pour la sécurité.

); } if (loading) { return (
); } if (error === 'network') { return (
); } // Pas encore de boutique vérifiée if (data && data.shops.length === 0) { return (

Réclame ta boutique

Trouve ta boutique sur la Carte et clique sur "Réclame cette boutique". Notre équipe vérifie ton NINEA sous 48h, puis tu peux gérer ton espace ici.

); } // --- Vue principale, données live --- const myShop = data.shops[0]; const alerts = data.alerts || []; const prices = data.prices || []; const stock = data.stock || []; const stats = data.stats || {}; return (
Vérifié {data.shops.length > 1 && ( {data.shops.length} boutiques )}
{/* Alertes clients (live) */} {alerts.length > 0 ? ( alerts.map((a) => (
Alerte client · {formatRelativeMerchant(a.created_at)}
{a.customer_message || 'Prix signalé'}
{a.product_name && (
{a.icon} {a.product_name} {a.price_official ? ` · officiel ${a.price_official} F` : ''}
)}
{justifyFor === a.id ? (