/** * Oveyo PWA — State management + API client + hooks personnalisés */ const API_BASE = ''; const STORAGE_KEY = 'oveyo:state:v2'; // ============================================================ // STATE PERSISTENT (localStorage) // ============================================================ const defaultState = () => ({ user: { phone: null, pseudonym: null, neighborhood_slug: null, language: 'fr', geolocation: null }, persona: { ambassador_validated: false, merchant_validated: false }, pwa: { install_dismissed: false, push_subscribed: false }, ambassador_level: 'apprenti', // apprenti / confirmé / maître }); const loadState = () => { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? { ...defaultState(), ...JSON.parse(raw) } : defaultState(); } catch { return defaultState(); } }; const saveState = (state) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }; // ============================================================ // API CLIENT // ============================================================ const api = async (endpoint, options = {}) => { const url = `${API_BASE}${endpoint}`; const opts = { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }; const response = await fetch(url, opts); if (!response.ok) { const err = new Error(`HTTP ${response.status}`); err.status = response.status; try { err.body = await response.json(); } catch {} throw err; } return response.json(); }; // ============================================================ // HOOK : usePrices (consultation des prix) // ============================================================ const usePrices = (neighborhoodSlug = '', days = 7) => { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); React.useEffect(() => { let cancelled = false; setLoading(true); setError(null); const params = new URLSearchParams({ days: String(days) }); if (neighborhoodSlug) params.set('neighborhood_slug', neighborhoodSlug); api(`/api/pwa/prices?${params}`) .then((d) => { if (!cancelled) setData(d); }) .catch((e) => { if (!cancelled) setError(e); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [neighborhoodSlug, days]); return { data, loading, error }; }; // ============================================================ // HOOK : useCatalog (produits + quartiers, cache long) // ============================================================ const useCatalog = () => { const [catalog, setCatalog] = React.useState({ products: [], neighborhoods: [] }); const [loading, setLoading] = React.useState(true); React.useEffect(() => { api('/api/pwa/catalog') .then((d) => setCatalog(d)) .finally(() => setLoading(false)); }, []); return { ...catalog, loading }; }; // ============================================================ // HOOK : usePharmaciesOnDuty // ============================================================ const usePharmaciesOnDuty = () => { const [pharmacies, setPharmacies] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { api('/api/pwa/pharmacies/on-duty') .then((d) => setPharmacies(d.pharmacies || [])) .catch(() => setPharmacies([])) .finally(() => setLoading(false)); }, []); return { pharmacies, loading }; }; // ============================================================ // HOOK : useShops (boutiques pour un produit donné, filtrable) // ============================================================ const useShops = (productSlug = 'sucre', neighborhoodSlug = '') => { const [shops, setShops] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { let cancelled = false; setLoading(true); const params = new URLSearchParams({ product_slug: productSlug }); if (neighborhoodSlug) params.set('neighborhood_slug', neighborhoodSlug); api(`/api/pwa/shops?${params}`) .then((d) => { if (!cancelled) setShops(d.shops || []); }) .catch(() => { if (!cancelled) setShops([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [productSlug, neighborhoodSlug]); return { shops, loading }; }; // ============================================================ // HOOK : usePulseFeed (activité récente du quartier) // ============================================================ const usePulseFeed = (neighborhoodSlug = '') => { const [feed, setFeed] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { const params = new URLSearchParams(); if (neighborhoodSlug) params.set('neighborhood_slug', neighborhoodSlug); api(`/api/pwa/pulse?${params}`) .then((d) => setFeed(d.events || [])) .catch(() => setFeed([])) .finally(() => setLoading(false)); }, [neighborhoodSlug]); return { feed, loading }; }; // ============================================================ // HOOK : useAmbassador (espace personnel) // ============================================================ const useAmbassador = (phone) => { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!phone) { setLoading(false); return; } api(`/api/pwa/ambassador/me?phone=${encodeURIComponent(phone)}`) .then((d) => setData(d)) .catch(() => setData(null)) .finally(() => setLoading(false)); }, [phone]); return { data, loading }; }; // ============================================================ // HOOK : useTrustIndex (indice de confiance quartier) // ============================================================ const useTrustIndex = (neighborhoodSlug = '') => { const [data, setData] = React.useState(null); React.useEffect(() => { const params = new URLSearchParams(); if (neighborhoodSlug) params.set('neighborhood_slug', neighborhoodSlug); api(`/api/pwa/trust-index?${params}`) .then((d) => setData(d)) .catch(() => setData(null)); }, [neighborhoodSlug]); return data; }; // ============================================================ // HOOK : useGroupBuys (groupes d'achat ouverts) // ============================================================ const useGroupBuys = (neighborhoodSlug = '') => { const [groups, setGroups] = React.useState([]); React.useEffect(() => { const params = new URLSearchParams(); if (neighborhoodSlug) params.set('neighborhood_slug', neighborhoodSlug); api(`/api/pwa/group-buys?${params}`) .then((d) => setGroups(d.groups || [])) .catch(() => setGroups([])); }, [neighborhoodSlug]); return groups; }; // ============================================================ // HOOK : useGeolocation // V3.5 — Bug #2 : enableHighAccuracy=false (plus rapide sur PWA iOS), // timeout JS de fallback à 12s pour ne jamais rester bloqué silencieusement, // messages d'erreur traduits selon le code (PERMISSION_DENIED, etc.). // ============================================================ const useGeolocation = () => { const [position, setPosition] = React.useState(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(false); const fallbackTimerRef = React.useRef(null); const detect = React.useCallback(() => { if (!navigator.geolocation) { setError('Géolocalisation non disponible sur ce navigateur'); return; } setError(null); setLoading(true); // Garde-fou : si le navigateur ne rappelle JAMAIS (cas iOS standalone parfois) if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current); fallbackTimerRef.current = setTimeout(() => { setLoading(false); setError('Délai dépassé — vérifie que la localisation est autorisée pour Oveyo dans les Réglages.'); }, 12000); navigator.geolocation.getCurrentPosition( (pos) => { if (fallbackTimerRef.current) { clearTimeout(fallbackTimerRef.current); fallbackTimerRef.current = null; } setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setLoading(false); }, (err) => { if (fallbackTimerRef.current) { clearTimeout(fallbackTimerRef.current); fallbackTimerRef.current = null; } let msg = err.message || 'Erreur GPS'; if (err.code === 1) msg = 'Permission refusée — autorise la localisation pour Oveyo dans Réglages → Safari → Localisation.'; else if (err.code === 2) msg = 'Position indisponible — essaie en extérieur ou réessaie dans un instant.'; else if (err.code === 3) msg = 'Délai dépassé — réessaie.'; setError(msg); setLoading(false); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 } ); }, []); return { position, error, loading, detect }; }; // ============================================================ // HOOK : useOnlineStatus // ============================================================ const useOnlineStatus = () => { const [online, setOnline] = React.useState(navigator.onLine); React.useEffect(() => { const on = () => setOnline(true); const off = () => setOnline(false); window.addEventListener('online', on); window.addEventListener('offline', off); return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); }; }, []); return online; }; // ============================================================ // UTILS — formatage // ============================================================ const formatRelative = (isoDate) => { if (!isoDate) return ''; const d = new Date(isoDate); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return "à l'instant"; if (diff < 3600) return `${Math.floor(diff / 60)} min`; if (diff < 86400) return `${Math.floor(diff / 3600)} h`; if (diff < 86400 * 7) return `${Math.floor(diff / 86400)} j`; return d.toLocaleDateString('fr-FR'); }; const formatPhone = (phone) => { if (!phone) return ''; const clean = String(phone).replace(/\D/g, ''); if (clean.startsWith('221') && clean.length === 12) { return `+221 ${clean.slice(3, 5)} ${clean.slice(5, 8)} ${clean.slice(8, 10)} ${clean.slice(10)}`; } return phone; }; const compressImage = (file, maxDim = 1024, quality = 0.82) => { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { let { width, height } = img; if (width > maxDim || height > maxDim) { const scale = maxDim / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d').drawImage(img, 0, 0, width, height); canvas.toBlob((blob) => resolve(blob), 'image/jpeg', quality); URL.revokeObjectURL(url); }; img.onerror = reject; img.src = url; }); }; // Export globaux window.OveyoState = { loadState, saveState, defaultState, api, usePrices, useCatalog, usePharmaciesOnDuty, useShops, usePulseFeed, useAmbassador, useTrustIndex, useGroupBuys, useGeolocation, useOnlineStatus, formatRelative, formatPhone, compressImage, };