/**
* 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'}
))}
)}
);
};
// ============================================================
// 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.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 ? (
) : (
)}
))
) : (
✓ Aucune alerte client en attente
)}
{/* Mes prix */}
Mes prix affichés
{prices.length === 0 ? (
Aucun prix défini. Ajoute tes prix officiels pour montrer ta transparence.
) : (
{prices.map((p) => {
const overpriced = p.price_official && p.price > p.price_official * 1.1;
const justified = !!p.justification;
return (
{p.icon} {p.name_fr}
{p.unit}
{p.price_official && (
<> · DGCC {p.price_official} F>
)}
{p.justification && (
"{p.justification}"
)}
{overpriced && !justified && (
NON JUSTIFIÉ
)}
{overpriced && justified && (
JUSTIFIÉ
)}
{p.price} F
);
})}
)}
{/* État du stock */}
État du stock aujourd'hui
{stock.length === 0 ? (
Aucun produit suivi. Marque la dispo de tes produits pour aider tes clients.
) : (
{stock.map((s) => (
{s.icon}
{s.name_fr}
))}
)}
{/* Stats 30j */}
{stats.nb_observations_30d || 0}
Signalements 30j
0 ? '#F4632B' : '#15803D' }}>
{stats.nb_abuse_signals || 0}
Alertes abus
{/* Modal édition prix (simple inline) */}
{editPrice && (
submitPrice(myShop.id)}
busy={busy && busy.startsWith('price-')}
/>
)}
);
};
// Header partagé
const Header = ({ onBack, title, subtitle }) => {
const { ChevronLeft } = Lu4;
return (
Espace Commerçant
{title}
{subtitle &&
{subtitle}
}
);
};
// Modal édition prix
const PriceEditModal = ({ editPrice, setEditPrice, prices, onSubmit, busy }) => {
const { X, Loader2 } = Lu4;
const { useCatalog } = window.OveyoState;
const { products } = useCatalog();
const isNew = !editPrice.product_id;
const product = isNew ? null : products.find(p => p.id === editPrice.product_id);
// Pour le mode "ajouter", offrir la liste des produits non encore tarifés
const tariffedIds = new Set(prices.map(p => p.product_id));
const candidateProducts = products.filter(p => !tariffedIds.has(p.id));
return (
setEditPrice(null)}
>
e.stopPropagation()}
>
{isNew ? 'Ajouter un prix' : `Modifier ${product?.name_fr || ''}`}
{isNew && (
)}
setEditPrice({ ...editPrice, price: e.target.value })}
placeholder="Ex: 750"
className="mt-1.5 w-full p-3 rounded-xl text-base outline-none font-display font-bold"
style={{ background: '#F1F4F9' }}
min={1}
max={10000000}
inputMode="numeric"
/>
);
};
// Helper local
const formatRelativeMerchant = (iso) => {
if (!iso) return '';
try {
const d = new Date(iso + 'Z');
const m = Math.floor((Date.now() - d.getTime()) / 60000);
if (m < 1) return "à l'instant";
if (m < 60) return `il y a ${m} min`;
if (m < 1440) return `il y a ${Math.floor(m/60)} h`;
return `il y a ${Math.floor(m/1440)} j`;
} catch { return ''; }
};
window.OveyoPages = window.OveyoPages || {};
window.OveyoPages.PharmacyPage = PharmacyPage;
window.OveyoPages.ProfilePage = ProfilePage;
window.OveyoPages.MerchantView = MerchantView;