/**
* Oveyo PWA V8.0.1 — Refonte design page-share
* ===============================================
*
* Corrections V8.0.1 :
* - Icônes Lucide blanches dans boxes plus petites (40×40, pas 56×56)
* - Safe-area-inset-top pour ne pas être collé sous le status bar iOS
* - Couleurs cohérentes avec page-home (navy pour bouton primary)
* - Photos/picker : design uniforme bg-white + border #EAEEF4
* - Pas d'emoji rendus comme icônes (l'emoji 🏪 affiche un logo Apple coloré
* qui ne correspond pas au DA Oveyo) → on utilise les icônes Lucide
* - Espacement cohérent (px-5, gap-3)
* - Plus d'aération sur les formulaires
*
* Composants exposés sur window :
* - SharePicker
* - QuartierForm, EnseigneForm, StationForm, PharmacieForm
*/
// ============================================================
// Helper : safe-area-top pour ne pas être collé au status bar
// ============================================================
const SAFE_TOP = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
};
// ============================================================
// SharePicker — page principale "Tu signales quoi ?"
// ============================================================
const SharePicker = ({ state, setState, setActiveTab, onLoginRequired }) => {
const { ArrowLeft, ChevronRight, Store, Fuel, Pill, ShoppingBasket, Camera } = window.LucideReact || {};
// Fix 0.10 — On NE force PLUS la modal Login au mount du SharePicker.
// L'utilisateur peut explorer les 4 catégories sans être loggé. Les
// sous-formulaires (Quartier/Enseigne/Station/Pharmacie) demanderont
// l'auth au moment de submit. Cadenas visuel reste sur les boutons.
const isLoggedIn = !!state?.user?.session_token;
const [categories, setCategories] = React.useState([]);
const [selectedCategory, setSelectedCategory] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
(async () => {
try {
const r = await fetch('/api/pwa/categories');
if (r.ok) {
const d = await r.json();
setCategories(d.categories || []);
}
} catch (e) { /* silent */ }
finally { setLoading(false); }
})();
}, []);
// Mapping slug → composant Lucide (icône blanche dans une box colorée)
const ICON_FOR = {
quartier: ShoppingBasket,
enseigne: Store,
station: Fuel,
pharmacie: Pill,
};
if (selectedCategory) {
const props = {
state,
setState,
setActiveTab,
onLoginRequired,
onBack: () => setSelectedCategory(null),
category: selectedCategory,
};
if (selectedCategory.slug === 'quartier' && window.QuartierForm) return ;
if (selectedCategory.slug === 'enseigne' && window.EnseigneForm) return ;
if (selectedCategory.slug === 'station' && window.StationForm) return ;
if (selectedCategory.slug === 'pharmacie'&& window.PharmacieForm) return ;
/* V8.1-PHOTO-ENABLED V8.1 : routing vers PhotoForm */
if (selectedCategory.slug === 'photo' && window.PhotoForm) return ;
return (
Formulaire en cours de chargement…
);
}
return (
{/* V8.0.2-FIXES V8.0.2 : intitulé adouci + plus d'espacement */}
Que veux-tu partager ?
Choisis le type de prix que tu souhaites partager
{loading && (
Chargement…
)}
{!loading && categories.length === 0 && (
Impossible de charger les catégories. Réessaie plus tard.
)}
{categories.map((cat) => {
const Icon = ICON_FOR[cat.slug];
return (
);
})}
{/* V8.1-PHOTO-ENABLED V8.1 : Photo activée — extract multi-produits via Claude Vision */}
Tes partages aident toute la communauté à payer le juste prix au Sénégal 🇸🇳
);
};
// ============================================================
// Helpers communs aux 4 formulaires
// ============================================================
const FormHeader = ({ onBack, icon: Icon, title, color, step, totalSteps, subtitle }) => {
const { ArrowLeft } = window.LucideReact || {};
return (
<>
{Icon && }
{title}
{step && totalSteps && (
Étape {step}/{totalSteps}{subtitle ? ` · ${subtitle}` : ''}
)}
>
);
};
// ============================================================
// QUARTIER — wrapper du ReportPage existant
// ============================================================
const QuartierForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => {
const { ArrowLeft } = window.LucideReact || {};
React.useEffect(() => {
setState((s) => ({
...s,
_share_context: { category_slug: 'quartier', branch_id: null },
}));
}, []);
const ReportPage = window.ReportPage;
if (!ReportPage) {
return Formulaire en cours de chargement…
;
}
return (
);
};
// ============================================================
// ENSEIGNE — 4 étapes : brand → branch → product → price
// ============================================================
const EnseigneForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => {
const { ChevronRight, Store, Plus, MapPin, Navigation } = window.LucideReact || {};
const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} };
const [step, setStep] = React.useState('brand');
const [brands, setBrands] = React.useState([]);
const [branches, setBranches] = React.useState([]);
const [products, setProducts] = React.useState([]);
const [selectedBrand, setSelectedBrand] = React.useState(null);
const [selectedBranch, setSelectedBranch] = React.useState(null);
const [selectedProduct, setSelectedProduct] = React.useState(null);
const [price, setPrice] = React.useState('');
const [available, setAvailable] = React.useState(true);
const [submitting, setSubmitting] = React.useState(false);
const [quantity, setQuantity] = React.useState(''); // Sprint 3 - quantite libre
const [showGpsPrompt, setShowGpsPrompt] = React.useState(false);
// V9.2.4 — Catalogue ouvert dans flow Enseigne
const [pendingNewProduct, setPendingNewProduct] = React.useState(null);
const [newProductName, setNewProductName] = React.useState('');
const [newProductCategory, setNewProductCategory] = React.useState('alimentaire');
const [gpsPromptDone, setGpsPromptDone] = React.useState(false);
const [showCreateBranch, setShowCreateBranch] = React.useState(false);
const [newBranchName, setNewBranchName] = React.useState('');
// V10.7.0 — État Localisation : quartier + GPS
const [neighborhood, setNeighborhood] = React.useState('');
const [gettingGps, setGettingGps] = React.useState(false);
// V10.7.0 — Pré-remplit le quartier depuis la succursale sélectionnée
React.useEffect(() => {
if (selectedBranch?.neighborhood_name) {
setNeighborhood(selectedBranch.neighborhood_name);
}
}, [selectedBranch]);
// V10.7.0 — Bouton GPS : récupère la géolocalisation
const askGps = () => {
if (!navigator.geolocation) {
flash.show('error', 'Géolocalisation non supportée');
return;
}
setGettingGps(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setState((s) => ({
...s,
user: {
...s.user,
geolocation: { lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() },
},
}));
setGettingGps(false);
flash.show('success', 'Position OK');
},
(err) => {
setGettingGps(false);
const msg = err.code === 1 ? 'Autorise la géolocalisation dans Réglages'
: err.code === 2 ? 'Position indisponible'
: 'Erreur de géolocalisation';
flash.show('error', msg);
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 0 }
);
};
const isLoggedIn = !!state.user?.session_token;
React.useEffect(() => {
fetch('/api/pwa/brands?category=enseigne')
.then((r) => r.json())
.then((d) => {
// Filtrer les brands non pertinents pour le picker
const filtered = (d.brands || []).filter((b) =>
!['sococim', 'peyrissac'].includes(b.slug)
);
setBrands(filtered);
})
.catch(() => setBrands([]));
}, []);
React.useEffect(() => {
if (!selectedBrand) return;
fetch(`/api/pwa/branches?brand_id=${selectedBrand.id}`)
.then((r) => r.json())
.then((d) => setBranches(d.branches || []))
.catch(() => setBranches([]));
}, [selectedBrand]);
React.useEffect(() => {
if (step !== 'product' || products.length > 0) return;
fetch('/api/pwa/catalog')
.then((r) => r.json())
.then((d) => {
const allProducts = d.products || [];
// V9.1.1 — Boutique : exclut energie sauf gaz 2,7kg et 6kg (vendus en quartier)
const BOUTIQUE_GAZ_SLUGS = ['gaz-2-7kg', 'gaz'];
const filtered = allProducts.filter(p =>
p.category_slug !== 'energie' || BOUTIQUE_GAZ_SLUGS.includes(p.slug)
);
setProducts(filtered);
})
.catch(() => setProducts([]));
}, [step]);
const createBranchInline = async () => {
if (!newBranchName.trim()) return;
if (!isLoggedIn) { onLoginRequired?.(); return; }
try {
const userGeo = state.user?.geolocation || null;
const r = await fetch('/api/pwa/branches', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.user.session_token },
body: JSON.stringify({
brand_id: selectedBrand.id,
name: newBranchName.trim(),
lat: userGeo?.lat || null,
lng: userGeo?.lng || null,
}),
});
const d = await r.json();
if (d.ok && d.branch_id) {
const b = { id: d.branch_id, name: newBranchName.trim(), verified: false, source: 'crowdsource' };
setBranches((p) => [b, ...p]);
setSelectedBranch(b);
setShowCreateBranch(false);
setNewBranchName('');
setStep('product');
flash.show('success', 'Succursale ajoutée — sera vérifiée');
} else {
flash.show('error', 'Impossible de créer la succursale');
}
} catch (e) { flash.show('error', 'Erreur réseau'); }
};
const submitObservation = async () => {
if (!isLoggedIn) { onLoginRequired?.(); return; }
const priceInt = parseInt(price, 10);
if (!priceInt || priceInt < 1) { flash.show('error', 'Saisis un prix valide'); return; }
// Sprint 3 - GPS pressant : si pas de GPS et pas encore demande, ouvrir la sheet
if (!state.user?.geolocation && !gpsPromptDone) {
setShowGpsPrompt(true);
return;
}
setSubmitting(true);
try {
const userGeo = state.user?.geolocation || null;
// V10.7.0 — priorité au quartier saisi/auto-rempli, fallback sur user
const ngName = neighborhood.trim() || state.user.neighborhood_slug || null;
const payload = {
product_id: selectedProduct?.id || null,
price: priceInt,
available: available ? 1 : 0,
is_abuse: 0,
shop_name: selectedBranch?.name || selectedBrand.name,
reported_by: state.user.phone || 'pwa-anonymous',
neighborhood_name: ngName,
category_slug: 'enseigne',
brand_id: selectedBrand?.id || null,
branch_id: selectedBranch?.id || null,
shop_lat: userGeo?.lat || null,
shop_lng: userGeo?.lng || null,
quantity_label: quantity.trim() || null,
};
// V9.2.4 — Si nouveau produit : route vers /api/pwa/product-submission
const isNewProduct = !!pendingNewProduct;
const endpoint = isNewProduct ? '/api/pwa/product-submission' : '/api/pwa/observation';
const submissionPayload = isNewProduct ? {
proposed_name: pendingNewProduct.name,
proposed_category_slug: pendingNewProduct.category_slug,
price: priceInt,
shop_name_raw: selectedBranch?.name || selectedBrand.name,
neighborhood_name_raw: ngName,
brand_id: selectedBrand?.id || null,
branch_id: selectedBranch?.id || null,
shop_lat: userGeo?.lat ?? null,
shop_lng: userGeo?.lng ?? null,
} : payload;
const r = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (state?.user?.session_token || ''),
},
body: JSON.stringify(submissionPayload),
});
if (r.ok) {
if (isNewProduct) {
flash.show('success', 'Produit soumis ! On l\'ajoute après vérification.');
} else {
flash.show('success', `Prix ${selectedBrand.name} enregistré !`);
}
setActiveTab?.('home');
} else {
// V8.6.1.2-ERR-DETAIL-ENS : lire detail backend pour message clair
let msg = "Erreur lors de l'envoi";
try {
const errBody = await r.json();
if (typeof errBody?.detail === 'string') msg = errBody.detail;
else if (Array.isArray(errBody?.detail) && errBody.detail[0]?.msg) {
const f = errBody.detail[0];
const field = Array.isArray(f.loc) ? f.loc[f.loc.length - 1] : '';
msg = field ? `${field}: ${f.msg}` : f.msg;
}
} catch {}
flash.show('error', msg);
}
} catch (e) { flash.show('error', 'Erreur réseau'); }
finally { setSubmitting(false); }
};
const goBack = () => {
// V9.2.4 — depuis 'price' avec nouveau produit, retour au form newProduct
if (step === 'price' && pendingNewProduct) setStep('newProduct');
else if (step === 'price') setStep('product');
else if (step === 'newProduct') setStep('product');
else if (step === 'product') setStep('branch');
else if (step === 'branch') setStep('brand');
else onBack();
};
const stepNum = step === 'brand' ? 1 : step === 'branch' ? 2 : step === 'product' ? 3 : 4;
return (
{/* Step 1 — pick brand : pas d'icône emoji moche, juste la couleur de la marque */}
{step === 'brand' && (
Quelle enseigne ?
{brands.map((b) => (
))}
)}
{/* Step 2 — pick branch */}
{step === 'branch' && (
Quelle succursale {selectedBrand.name} ?
{branches.map((br) => (
))}
{!showCreateBranch ? (
) : (
Ex : {selectedBrand.name} Yoff
setNewBranchName(e.target.value)}
placeholder={`Nom de la succursale ${selectedBrand.name}`}
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }}
autoFocus
/>
)}
)}
{/* Step 3 — pick product */}
{step === 'product' && (
Quel produit ?
{/* V9.2.4 — Bouton catalogue ouvert */}
{products
.filter((p) => !['super', 'gasoil', 'essence_ord', 'essence_pirogue', 'petrole_lampant', 'gaz_2_7kg', 'gaz_9kg', 'gaz_12_5kg', 'gaz_38kg'].includes(p.slug))
.map((p) => (
))}
)}
{/* V9.2.4 — Step newProduct : saisie produit non listé (Enseigne) */}
{step === 'newProduct' && (
Nouveau produit chez {selectedBrand?.name || 'l\'enseigne'}
Décris-le, on l'ajoute après vérification.
setNewProductName(e.target.value)}
placeholder="Ex: Détergent OMO 1kg"
maxLength={200}
className="w-full mt-2 px-4 py-3 rounded-xl text-sm outline-none"
style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }}
autoFocus
/>
{[
{ slug: 'alimentaire', label: '🛒 Alimentaire' },
{ slug: 'legumes', label: '🥬 Légumes & Fruits' },
{ slug: 'boulangerie', label: '🍞 Boulangerie' },
{ slug: 'hygiene', label: '🧴 Hygiène' },
{ slug: 'bebe', label: '👶 Bébé' },
{ slug: 'medicaments', label: '💊 Médicaments' },
].map((cat) => (
))}
)}
{/* Step 4 — saisir prix */}
{step === 'price' && (selectedProduct || pendingNewProduct) && (
)}
{/* Sprint 3 - GPS pressant */}
{window.OveyoSheets?.GpsPrompt && (
setShowGpsPrompt(false)}
onActivated={(coords) => {
setState((s) => ({ ...s, user: { ...s.user, geolocation: { ...coords, ts: Date.now() } } }));
setShowGpsPrompt(false);
setGpsPromptDone(true);
setTimeout(() => submitObservation(), 100);
}}
onContinueWithoutGps={() => {
setShowGpsPrompt(false);
setGpsPromptDone(true);
setTimeout(() => submitObservation(), 100);
}}
/>
)}
);
};
// ============================================================
// STATION — flow station avec auto-check CRSE
// ============================================================
const StationForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => {
const { ChevronRight, Fuel, Plus, AlertTriangle, Check, MapPin, Navigation } = window.LucideReact || {};
const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} };
const [step, setStep] = React.useState('brand');
const [brands, setBrands] = React.useState([]);
const [branches, setBranches] = React.useState([]);
const [stationProducts, setStationProducts] = React.useState([]);
// V9.2.5 — Catalogue ouvert dans flow Station (catégorie auto: 'energie')
const [pendingNewProduct, setPendingNewProduct] = React.useState(null);
const [newProductName, setNewProductName] = React.useState('');
const [selectedBrand, setSelectedBrand] = React.useState(null);
const [selectedBranch, setSelectedBranch] = React.useState(null);
const [selectedProduct, setSelectedProduct] = React.useState(null);
const [price, setPrice] = React.useState('');
const [crseCheck, setCrseCheck] = React.useState(null);
const [submitting, setSubmitting] = React.useState(false);
const [showGpsPrompt, setShowGpsPrompt] = React.useState(false); // Sprint 3 - GPS
const [gpsPromptDone, setGpsPromptDone] = React.useState(false); // Sprint 3 - flag
const [available, setAvailable] = React.useState(true);
const [newBranchName, setNewBranchName] = React.useState('');
const [showCreateBranch, setShowCreateBranch] = React.useState(false);
// V10.7.0 — Localisation
const [neighborhood, setNeighborhood] = React.useState('');
const [gettingGps, setGettingGps] = React.useState(false);
React.useEffect(() => {
if (selectedBranch?.neighborhood_name) {
setNeighborhood(selectedBranch.neighborhood_name);
}
}, [selectedBranch]);
const askGps = () => {
if (!navigator.geolocation) {
flash.show('error', 'Géolocalisation non supportée');
return;
}
setGettingGps(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setState((s) => ({
...s,
user: {
...s.user,
geolocation: { lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() },
},
}));
setGettingGps(false);
flash.show('success', 'Position OK');
},
(err) => {
setGettingGps(false);
const msg = err.code === 1 ? 'Autorise la géolocalisation dans Réglages'
: err.code === 2 ? 'Position indisponible'
: 'Erreur de géolocalisation';
flash.show('error', msg);
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 0 }
);
};
const isLoggedIn = !!state.user?.session_token;
React.useEffect(() => {
fetch('/api/pwa/brands?category=station')
.then((r) => r.json())
.then((d) => setBrands(d.brands || []))
.catch(() => setBrands([]));
}, []);
React.useEffect(() => {
if (!selectedBrand) return;
fetch(`/api/pwa/branches?brand_id=${selectedBrand.id}`)
.then((r) => r.json())
.then((d) => setBranches(d.branches || []))
.catch(() => setBranches([]));
}, [selectedBrand]);
React.useEffect(() => {
if (step !== 'product' || stationProducts.length > 0) return;
fetch('/api/pwa/crse/active')
.then((r) => r.json())
.then((d) => setStationProducts(d.prices || []))
.catch(() => setStationProducts([]));
}, [step]);
React.useEffect(() => {
if (!selectedProduct || !price || parseInt(price, 10) < 1) {
setCrseCheck(null);
return;
}
const t = setTimeout(() => {
fetch('/api/pwa/crse/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_slug: selectedProduct.product_slug,
observed_price: parseInt(price, 10),
}),
})
.then((r) => r.json())
.then((d) => setCrseCheck(d))
.catch(() => setCrseCheck(null));
}, 400);
return () => clearTimeout(t);
}, [price, selectedProduct]);
const createBranchInline = async () => {
if (!newBranchName.trim()) return;
if (!isLoggedIn) { onLoginRequired?.(); return; }
try {
const userGeo = state.user?.geolocation || null;
const r = await fetch('/api/pwa/branches', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.user.session_token },
body: JSON.stringify({
brand_id: selectedBrand.id,
name: newBranchName.trim(),
lat: userGeo?.lat || null,
lng: userGeo?.lng || null,
}),
});
const d = await r.json();
if (d.ok && d.branch_id) {
const b = { id: d.branch_id, name: newBranchName.trim(), verified: false };
setBranches((p) => [b, ...p]);
setSelectedBranch(b);
setShowCreateBranch(false);
setNewBranchName('');
setStep('product');
}
} catch (e) { flash.show('error', 'Erreur réseau'); }
};
const submitObservation = async () => {
if (!isLoggedIn) { onLoginRequired?.(); return; }
const priceInt = parseInt(price, 10);
if (!priceInt || priceInt < 1) { flash.show('error', 'Saisis un prix valide'); return; }
// Sprint 3 - GPS pressant
if (!state.user?.geolocation && !gpsPromptDone) {
setShowGpsPrompt(true);
return;
}
setSubmitting(true);
try {
const userGeo = state.user?.geolocation || null;
const isAbuse = crseCheck && crseCheck.compliance === 'above' ? 1 : 0;
// Sprint 3 - Conformite CRSE: 1=conforme, 0=non conforme, null=pas de prix CRSE
let crseCompliant = null;
if (crseCheck && crseCheck.has_crse) {
crseCompliant = crseCheck.compliance === 'above' ? 0 : 1;
}
// V10.7.0 — Quartier saisi prioritaire
const ngName = neighborhood.trim() || state.user.neighborhood_slug || null;
const payload = {
product_id: selectedProduct?.product_id || null,
price: priceInt,
available: available ? 1 : 0,
is_abuse: isAbuse,
crse_compliant: crseCompliant,
shop_name: selectedBranch?.name || selectedBrand.name,
reported_by: state.user.phone || 'pwa-anonymous',
neighborhood_name: ngName,
category_slug: 'station',
brand_id: selectedBrand?.id || null,
branch_id: selectedBranch?.id || null,
shop_lat: userGeo?.lat || null,
shop_lng: userGeo?.lng || null,
};
// V9.2.5 — Si nouveau produit Station : route vers /api/pwa/product-submission
const isNewProduct = !!pendingNewProduct;
const endpoint = isNewProduct ? '/api/pwa/product-submission' : '/api/pwa/observation';
const submissionPayload = isNewProduct ? {
proposed_name: pendingNewProduct.name,
proposed_category_slug: 'energie',
price: priceInt,
shop_name_raw: selectedBranch?.name || selectedBrand.name,
neighborhood_name_raw: ngName,
brand_id: selectedBrand?.id || null,
branch_id: selectedBranch?.id || null,
shop_lat: userGeo?.lat ?? null,
shop_lng: userGeo?.lng ?? null,
} : payload;
const r = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (state?.user?.session_token || ''),
},
body: JSON.stringify(submissionPayload),
});
if (r.ok) {
if (isNewProduct) {
flash.show('success', 'Produit soumis ! On l\'ajoute après vérification.');
} else {
flash.show('success', `Prix ${selectedBrand.name} enregistré !`);
}
setActiveTab?.('home');
} else {
// V8.6.1.2-ERR-DETAIL-STA : lire detail backend pour message clair
let msg = "Erreur lors de l'envoi";
try {
const errBody = await r.json();
if (typeof errBody?.detail === 'string') msg = errBody.detail;
else if (Array.isArray(errBody?.detail) && errBody.detail[0]?.msg) {
const f = errBody.detail[0];
const field = Array.isArray(f.loc) ? f.loc[f.loc.length - 1] : '';
msg = field ? `${field}: ${f.msg}` : f.msg;
}
} catch {}
flash.show('error', msg);
}
} catch (e) { flash.show('error', 'Erreur réseau'); }
finally { setSubmitting(false); }
};
const goBack = () => {
// V9.2.4 — depuis 'price' avec nouveau produit, retour au form newProduct
if (step === 'price' && pendingNewProduct) setStep('newProduct');
else if (step === 'price') setStep('product');
else if (step === 'newProduct') setStep('product');
else if (step === 'product') setStep('branch');
else if (step === 'branch') setStep('brand');
else onBack();
};
const stepNum = step === 'brand' ? 1 : step === 'branch' ? 2 : step === 'product' ? 3 : 4;
return (
{step === 'brand' && (
Quelle station ?
{brands.map((b) => (
))}
)}
{step === 'branch' && (
Quelle station {selectedBrand.name} ?
{branches.map((br) => (
))}
{!showCreateBranch ? (
) : (
)}
)}
{step === 'product' && (
Quel produit ?
{/* V9.2.5 — Bouton catalogue ouvert (Station) */}
{stationProducts.map((p) => (
))}
)}
{/* V9.2.5 — Step newProduct : saisie produit non listé (Station, catégorie forcée 'energie') */}
{step === 'newProduct' && (
)}
{step === 'price' && (selectedProduct || pendingNewProduct) && (
Prix observé pour {pendingNewProduct ? pendingNewProduct.name : selectedProduct.product_name}
setPrice(e.target.value)}
placeholder={selectedProduct?.price ? `CRSE : ${selectedProduct.price} F` : 'Ex: 800'}
className="w-full mt-2 px-4 py-3 rounded-xl text-lg font-bold outline-none"
style={{ background: '#F1F4F9', border: '1px solid #EAEEF4' }}
autoFocus
/>
{crseCheck && crseCheck.has_crse && (
{crseCheck.compliance === 'above' ? (
) : crseCheck.compliance === 'below' ? (
) : (
)}
)}
{/* V10.7.0 — Bloc Localisation : quartier + GPS pour la station */}
)}
{/* Sprint 3 - GPS pressant */}
{window.OveyoSheets?.GpsPrompt && (
setShowGpsPrompt(false)}
onActivated={(coords) => {
setState((s) => ({ ...s, user: { ...s.user, geolocation: { ...coords, ts: Date.now() } } }));
setShowGpsPrompt(false);
setGpsPromptDone(true);
setTimeout(() => submitObservation(), 100);
}}
onContinueWithoutGps={() => {
setShowGpsPrompt(false);
setGpsPromptDone(true);
setTimeout(() => submitObservation(), 100);
}}
/>
)}
);
};
// ============================================================
// PHARMACIE — formulaire simple, design cohérent
// ============================================================
const PharmacieForm = ({ state, setState, setActiveTab, onLoginRequired, onBack, category }) => {
const { Pill } = window.LucideReact || {};
const flash = window.OveyoUI?.useFlash ? window.OveyoUI.useFlash() : { show: () => {} };
const [pharmacyName, setPharmacyName] = React.useState('');
const [medName, setMedName] = React.useState('');
const [price, setPrice] = React.useState('');
const [available, setAvailable] = React.useState(true);
const [submitting, setSubmitting] = React.useState(false);
// V8.6.1.2-PHARMACY-NEIGHBORHOOD : champ Quartier (texte libre)
const [neighborhoodInput, setNeighborhoodInput] = React.useState(state?.user?.neighborhood_slug || '');
// V10.7.0 — GPS button state
const [gettingGpsPharma, setGettingGpsPharma] = React.useState(false);
const askGpsPharma = () => {
if (!navigator.geolocation) {
flash.show('error', 'Géolocalisation non supportée');
return;
}
setGettingGpsPharma(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setState((s) => ({
...s,
user: {
...s.user,
geolocation: { lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() },
},
}));
setGettingGpsPharma(false);
flash.show('success', 'Position OK');
},
(err) => {
setGettingGpsPharma(false);
const msg = err.code === 1 ? 'Autorise la géolocalisation dans Réglages'
: err.code === 2 ? 'Position indisponible'
: 'Erreur de géolocalisation';
flash.show('error', msg);
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 0 }
);
};
const isLoggedIn = !!state.user?.session_token;
const submit = async () => {
if (!isLoggedIn) { onLoginRequired?.(); return; }
if (!pharmacyName.trim() || !medName.trim() || !parseInt(price, 10)) {
flash.show('error', 'Remplis tous les champs');
return;
}
setSubmitting(true);
try {
const userGeo = state.user?.geolocation || null;
// V8.6.1.2-PHARMACY-NEIGHBORHOOD : quartier saisi prioritaire sur profil
const ngName = neighborhoodInput && neighborhoodInput.trim()
? neighborhoodInput.trim()
: (state.user?.neighborhood_slug || null);
const payload = {
product_id: 118, // Sprint 2 - produit 'Medicament' generique (slug: medicament-generique). Le vrai nom du medicament est dans pharmacy_drug.
price: parseInt(price, 10),
available: available ? 1 : 0,
is_abuse: 0,
shop_name: `${pharmacyName.trim()} — ${medName.trim()}`,
reported_by: state.user.phone || 'pwa-anonymous',
neighborhood_name: ngName,
category_slug: 'pharmacie',
shop_lat: userGeo?.lat || null,
shop_lng: userGeo?.lng || null,
pharmacy_drug: medName.trim(),
};
const r = await fetch('/api/pwa/observation', {
method: 'POST',
// V8.6.2-FETCH-AUTH V8.6.2 : ajoute Bearer token (backend exige session)
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (state?.user?.session_token || ''),
},
body: JSON.stringify(payload),
});
if (r.ok) {
flash.show('success', 'Prix médicament enregistré');
setActiveTab?.('home');
} else {
// V8.6.1.2-ERR-DETAIL-PHA : lire detail backend pour message clair
let msg = "Erreur lors de l'envoi";
try {
const errBody = await r.json();
if (typeof errBody?.detail === 'string') msg = errBody.detail;
else if (Array.isArray(errBody?.detail) && errBody.detail[0]?.msg) {
const f = errBody.detail[0];
const field = Array.isArray(f.loc) ? f.loc[f.loc.length - 1] : '';
msg = field ? `${field}: ${f.msg}` : f.msg;
}
} catch {}
flash.show('error', msg);
}
} catch (e) { flash.show('error', 'Erreur réseau'); }
finally { setSubmitting(false); }
};
return (
Partage un prix de médicament
);
};
// Export
window.SharePicker = SharePicker;
window.QuartierForm = QuartierForm;
window.EnseigneForm = EnseigneForm;
window.StationForm = StationForm;
window.PharmacieForm = PharmacieForm;