/**
* Oveyo PWA — Page Producteur (V9.4)
*
* Composants :
* - AccountTypeChoice : écran de choix initial (Boutique / Producteur / Coopérative)
* - ProducerRegisterForm : formulaire d'inscription producteur
* - ProducerDashboard : accueil producteur (profil + annonces)
* - NewListingForm : formulaire de publication d'annonce
* - ProducerView : composant routeur principal (orchestration)
*
* Exposé : window.OveyoPages.ProducerView
*/
const LuPr = window.LucideReact || {};
const { Card: Cpr, Spinner: Sppr, EmptyState: ESpr, useFlash: uFpr, Sheet: ShPr } = window.OveyoUI;
const { api: apiPr } = window.OveyoState;
// ============================================================
// LISTE DES CULTURES (synchro avec backend producer_crops.py)
// ============================================================
const CROPS_BY_CATEGORY = {
cereales: { label: 'Céréales', icon: '🌾', items: ['riz', 'mil', 'mais', 'sorgho', 'fonio'] },
legumes: { label: 'Légumes', icon: '🥬', items: ['oignon', 'pomme-terre', 'tomate', 'carotte', 'chou', 'aubergine', 'navet', 'manioc', 'patate-douce', 'gombo', 'piment'] },
fruits: { label: 'Fruits', icon: '🥭', items: ['mangue', 'papaye', 'banane', 'pasteque', 'melon', 'citron'] },
legumineuses: { label: 'Légumineuses', icon: '🫘', items: ['arachide', 'niebe'] },
elevage: { label: 'Élevage', icon: '🐄', items: ['boeuf', 'mouton', 'volaille', 'oeuf', 'lait'] },
peche: { label: 'Pêche', icon: '🐟', items: ['poisson', 'poisson-fume', 'crevette'] },
};
const CROP_LABELS = {
riz: 'Riz', mil: 'Mil', mais: 'Maïs', sorgho: 'Sorgho', fonio: 'Fonio',
oignon: 'Oignon', 'pomme-terre': 'Pomme de terre', tomate: 'Tomate',
carotte: 'Carotte', chou: 'Chou', aubergine: 'Aubergine', navet: 'Navet',
manioc: 'Manioc', 'patate-douce': 'Patate douce', gombo: 'Gombo', piment: 'Piment',
mangue: 'Mangue', papaye: 'Papaye', banane: 'Banane', pasteque: 'Pastèque',
melon: 'Melon', citron: 'Citron',
arachide: 'Arachide', niebe: 'Niébé',
boeuf: 'Bétail', mouton: 'Mouton', volaille: 'Volaille', oeuf: 'Œufs', lait: 'Lait',
poisson: 'Poisson frais', 'poisson-fume': 'Poisson fumé', crevette: 'Crevette',
};
// ============================================================
// AccountTypeChoice — Écran initial : quel type de compte ?
// ============================================================
const AccountTypeChoice = ({ onSelect, onBack }) => {
const { ChevronLeft, Store, Wheat, Users } = LuPr;
const types = [
{ id: 'shop', icon: Store, color: '#F4632B', bgColor: '#FFF1E9',
title: 'Boutique',
desc: 'Tu tiens une boutique de quartier, station-service ou enseigne.' },
{ id: 'producer', icon: Wheat, color: '#15803D', bgColor: '#E8F5E9',
title: 'Producteur',
desc: 'Tu cultives, élèves ou produis pour vendre en gros.' },
{ id: 'cooperative', icon: Users, color: '#7C3AED', bgColor: '#F3EBFF',
title: 'Coopérative',
desc: 'Vous êtes plusieurs producteurs regroupés.' },
];
return (
{/* Header */}
Espace Pro
Choisis ton type de compte
{/* Hero */}
Bienvenue 👋
Pour activer ton espace, dis-nous quel type d'acteur tu es. Tu pourras toujours ajouter d'autres profils plus tard.
{/* Cartes */}
{types.map((t) => {
const Icon = t.icon;
return (
);
})}
{/* Footer note */}
💡 Ton inscription est gratuite. Ton profil sera vérifié par notre équipe sous 48h pour activer le badge ✓.
);
};
// ============================================================
// CropPicker — Multi-select avec accordéon par catégorie + "Autre"
// ============================================================
const CropPicker = ({ value, onChange }) => {
// value: { slugs: [...], others: [...] }
const [expanded, setExpanded] = React.useState(null);
const [otherInput, setOtherInput] = React.useState('');
const { Plus, X } = LuPr;
const slugs = value?.slugs || [];
const others = value?.others || [];
const toggleSlug = (slug) => {
if (slugs.includes(slug)) {
onChange({ slugs: slugs.filter((s) => s !== slug), others });
} else {
onChange({ slugs: [...slugs, slug], others });
}
};
const addOther = () => {
const t = otherInput.trim();
if (!t || others.includes(t)) return;
onChange({ slugs, others: [...others, t] });
setOtherInput('');
};
const removeOther = (t) => {
onChange({ slugs, others: others.filter((o) => o !== t) });
};
const totalSelected = slugs.length + others.length;
return (
Tes productions
{totalSelected > 0 ? `${totalSelected} sélectionné(s)` : 'Aucune sélection'}
{/* Catégories */}
{Object.entries(CROPS_BY_CATEGORY).map(([catSlug, cat]) => (
{expanded === catSlug && (
{cat.items.map((slug) => {
const sel = slugs.includes(slug);
return (
);
})}
)}
))}
{/* Section "Autre" */}
Autre (texte libre)
{others.length > 0 && (
{others.map((t) => (
{t}
))}
)}
);
};
// ============================================================
// ProducerRegisterForm — Formulaire d'inscription producteur
// ============================================================
const ProducerRegisterForm = ({ token, accountType, onSuccess, onBack }) => {
const { ChevronLeft, MapPin, Loader2, Wheat, Users } = LuPr;
const flash = uFpr();
const [busy, setBusy] = React.useState(false);
const [form, setForm] = React.useState({
name: '',
region: '',
village: '',
lat: 14.6928, // Dakar par défaut, l'utilisateur peut changer
lng: -17.4467,
crops: { slugs: [], others: [] },
surface_hectares: '',
description: '',
});
const [gpsLoading, setGpsLoading] = React.useState(false);
const setField = (key, val) => setForm({ ...form, [key]: val });
const detectGPS = () => {
if (!navigator.geolocation) {
flash.show('error', 'GPS non disponible sur ce téléphone');
return;
}
setGpsLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setForm((f) => ({ ...f, lat: pos.coords.latitude, lng: pos.coords.longitude }));
setGpsLoading(false);
flash.show('success', 'Position détectée');
},
() => {
setGpsLoading(false);
flash.show('error', 'Impossible de détecter la position');
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
const submit = async () => {
// Validation
if (form.name.trim().length < 3) {
flash.show('error', 'Le nom doit faire au moins 3 caractères');
return;
}
if (form.region.trim().length < 2) {
flash.show('error', 'Indique ta région');
return;
}
if (form.crops.slugs.length + form.crops.others.length === 0) {
flash.show('error', 'Sélectionne au moins une production');
return;
}
setBusy(true);
try {
const allCrops = [...form.crops.slugs, ...form.crops.others];
const payload = {
name: form.name.trim(),
region: form.region.trim(),
village: form.village.trim() || null,
lat: form.lat,
lng: form.lng,
crops: allCrops,
surface_hectares: form.surface_hectares ? parseFloat(form.surface_hectares) : null,
description: form.description.trim() || null,
account_type: accountType,
};
const res = await apiPr('/api/pwa/merchant/producer/register', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: JSON.stringify(payload),
});
flash.show('success', 'Inscription réussie ✓');
if (onSuccess) onSuccess(res);
} catch (e) {
const msg = String(e);
if (msg.match(/409/)) {
flash.show('error', 'Tu as déjà un profil — recharge la page');
} else {
flash.show('error', 'Erreur lors de l\'inscription');
}
} finally {
setBusy(false);
}
};
const Icon = accountType === 'cooperative' ? Users : Wheat;
const accentColor = accountType === 'cooperative' ? '#7C3AED' : '#15803D';
const accentBg = accountType === 'cooperative' ? '#F3EBFF' : '#E8F5E9';
return (
{/* Header */}
{accountType === 'cooperative' ? 'Inscription Coopérative' : 'Inscription Producteur'}
{/* Nom */}
setField('name', e.target.value)}
placeholder={accountType === 'cooperative' ? "Ex: Coopérative Niayes" : "Ex: Ferme Diop & Fils"}
maxLength={100}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Région */}
setField('region', e.target.value)}
placeholder="Ex: Diourbel, Kaolack, Saint-Louis..."
maxLength={80}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Village */}
setField('village', e.target.value)}
placeholder="Ex: Touba Mosquée, Mboro..."
maxLength={80}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* GPS */}
{/* Cultures */}
setField('crops', v)}
/>
{/* Surface */}
setField('surface_hectares', e.target.value)}
placeholder="Ex: 5.5"
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Description */}
{/* Submit */}
Ton profil sera vérifié par notre équipe sous 48h.
Le badge ✓ sera ajouté après validation.
);
};
// ============================================================
// NewListingForm — Formulaire de création d'annonce
// ============================================================
const NewListingForm = ({ token, accentColor, onSuccess, onCancel }) => {
const { ChevronLeft, Loader2, Package } = LuPr;
const flash = uFpr();
const [busy, setBusy] = React.useState(false);
const [form, setForm] = React.useState({
product_name: '',
variety: '',
unit: 'sac',
unit_weight_kg: 25,
price_per_unit: '',
quantity_available: '',
quantity_min_order: 1,
pickup_location: '',
available_from: '',
available_to: '',
delivery_possible: false,
delivery_max_km: '',
delivery_extra_cost: 0,
caution_percent: 20,
notes: '',
});
const setField = (key, val) => setForm({ ...form, [key]: val });
const submit = async () => {
if (form.product_name.trim().length < 2) {
flash.show('error', 'Indique le nom du produit');
return;
}
if (!form.price_per_unit || parseFloat(form.price_per_unit) <= 0) {
flash.show('error', 'Indique le prix');
return;
}
if (!form.quantity_available || parseFloat(form.quantity_available) <= 0) {
flash.show('error', 'Indique la quantité disponible');
return;
}
setBusy(true);
try {
const payload = {
product_name: form.product_name.trim(),
variety: form.variety.trim() || null,
unit: form.unit,
unit_weight_kg: form.unit_weight_kg ? parseFloat(form.unit_weight_kg) : null,
price_per_unit: parseFloat(form.price_per_unit),
quantity_available: parseFloat(form.quantity_available),
quantity_min_order: parseFloat(form.quantity_min_order) || 1,
pickup_location: form.pickup_location.trim() || null,
available_from: form.available_from || null,
available_to: form.available_to || null,
delivery_possible: form.delivery_possible,
delivery_max_km: form.delivery_max_km ? parseInt(form.delivery_max_km) : null,
delivery_extra_cost: parseFloat(form.delivery_extra_cost) || 0,
caution_percent: parseFloat(form.caution_percent) || 20,
notes: form.notes.trim() || null,
};
const res = await apiPr('/api/pwa/merchant/producer/listings', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: JSON.stringify(payload),
});
flash.show('success', 'Annonce publiée ✓');
if (onSuccess) onSuccess(res);
} catch (e) {
flash.show('error', 'Erreur lors de la publication');
} finally {
setBusy(false);
}
};
return (
{/* Header */}
Nouvelle annonce
Publier une disponibilité
{/* Produit */}
setField('product_name', e.target.value)}
placeholder="Ex: Oignon, Riz, Tomate, Mil..."
maxLength={80}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Variété */}
setField('variety', e.target.value)}
placeholder="Ex: Galmi, Bombay, Roma..."
maxLength={60}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Unité + poids */}
{form.unit !== 'kg' && (
setField('unit_weight_kg', e.target.value)}
placeholder="25"
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
)}
{/* Prix + Quantité dispo */}
{/* Min order */}
setField('quantity_min_order', e.target.value)}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Lieu retrait */}
setField('pickup_location', e.target.value)}
placeholder="Ex: Champ à 3km de Touba"
maxLength={200}
className="w-full px-3 py-2.5 rounded-xl text-sm"
style={{ background: '#FFFFFF', border: '1px solid #EAEEF4' }}
/>
{/* Dates */}
{/* Livraison */}
{/* Caution */}
{/* Notes */}
);
};
// ============================================================
// ProducerDashboard — Vue principale producteur (profil + annonces)
// ============================================================
const ProducerDashboard = ({ state, token, onBack, onLogout }) => {
const { ChevronLeft, Plus, MapPin, Package, Eye, Users2, Trash2, Loader2, Wheat, ShieldCheck } = LuPr;
const flash = uFpr();
const [profile, setProfile] = React.useState(null);
const [listings, setListings] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [showNewForm, setShowNewForm] = React.useState(false);
const isCoop = profile?.shop_type === 'cooperative';
const accentColor = isCoop ? '#7C3AED' : '#15803D';
const accentBg = isCoop ? '#F3EBFF' : '#E8F5E9';
const fetchData = React.useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [pRes, lRes] = await Promise.all([
apiPr('/api/pwa/merchant/producer/profile', {
headers: { Authorization: 'Bearer ' + token },
}),
apiPr('/api/pwa/merchant/producer/listings?status=active', {
headers: { Authorization: 'Bearer ' + token },
}),
]);
setProfile(pRes);
setListings(lRes.listings || []);
} catch (e) {
console.error('Producer dashboard fetch:', e);
} finally {
setLoading(false);
}
}, [token]);
React.useEffect(() => { fetchData(); }, [fetchData]);
const deleteListing = async (id) => {
if (!confirm('Supprimer cette annonce ?')) return;
try {
await apiPr(`/api/pwa/merchant/producer/listings/${id}`, {
method: 'DELETE',
headers: { Authorization: 'Bearer ' + token },
});
flash.show('success', 'Annonce supprimée');
fetchData();
} catch {
flash.show('error', 'Erreur');
}
};
if (showNewForm) {
return (
{ setShowNewForm(false); fetchData(); }}
onCancel={() => setShowNewForm(false)}
/>
);
}
if (loading) {
return (
);
}
if (!profile) {
return (
Profil introuvable
);
}
const cropsCount = (profile.crops?.slugs?.length || 0) + (profile.crops?.others?.length || 0);
return (
{/* Header */}
{profile.name}
{profile.verified && }
{isCoop ? 'Coopérative' : 'Producteur'} · {profile.region}
{profile.village ? ` · ${profile.village}` : ''}
{/* Badge vérif */}
{!profile.verified && (
⏳ Profil en cours de vérification (48h max). Tu peux déjà publier tes annonces.
)}
{/* Stats */}
{listings.length}
Annonces
{listings.reduce((s, l) => s + (l.views_count || 0), 0)}
Vues
{/* Bouton nouvelle annonce */}
{/* Liste annonces */}
Mes annonces actives
{listings.length === 0 ? (
Aucune annonce
Publie ta première disponibilité ci-dessus
) : (
{listings.map((l) => (
{l.product_name}
{l.variety ? · {l.variety} : null}
{l.quantity_remaining}/{l.quantity_available} {l.unit}(s) · {Math.round(l.price_per_unit).toLocaleString('fr-FR')} F/{l.unit}
{l.pickup_location && (
{l.pickup_location}
)}
{l.views_count || 0}
{l.contact_clicks || 0} contacts
{l.available_to && (
jusqu'au {l.available_to}
)}
))}
)}
);
};
// ============================================================
// ProducerView — Routeur principal (orchestre les écrans)
// ============================================================
const ProducerHubView = ({ state, setState, onBack, onOpenSheet }) => {
const flash = uFpr();
const token = state.user?.session_token;
const [step, setStep] = React.useState('loading'); // loading | choice | register | dashboard
const [chosenType, setChosenType] = React.useState(null);
const checkAccountType = React.useCallback(async () => {
if (!token) {
setStep('not_logged_in');
return;
}
try {
const r = await apiPr('/api/pwa/merchant/account-type', {
headers: { Authorization: 'Bearer ' + token },
});
const t = r.account_type;
if (t === 'producer' || t === 'cooperative') {
setStep('dashboard');
} else if (t === 'shop') {
// Pas notre vue — laisse le MerchantView gérer
setStep('shop_redirect');
if (onBack) onBack();
} else {
// null → choix initial
setStep('choice');
}
} catch (e) {
console.error('Account type fetch:', e);
setStep('error');
}
}, [token, onBack]);
React.useEffect(() => { checkAccountType(); }, [checkAccountType]);
if (step === 'loading') {
return (
);
}
if (step === 'not_logged_in') {
return (
Connecte-toi d'abord
L'espace pro nécessite une session active
);
}
if (step === 'error') {
return (
Erreur — recharge la page
);
}
if (step === 'choice') {
return (
{
if (type === 'shop') {
// Pour les boutiques : retour pour utiliser le flow merchant_me classique
flash.show('info', 'Pour les boutiques, réclame ta boutique depuis la Carte');
onBack();
return;
}
setChosenType(type);
setStep('register');
}}
/>
);
}
if (step === 'register') {
return (
setStep('choice')}
onSuccess={() => setStep('dashboard')}
/>
);
}
if (step === 'dashboard') {
return (
);
}
return null;
};
// ============================================================
// EXPORT — expose au reste de l'app
// ============================================================
window.OveyoPages = window.OveyoPages || {};
window.OveyoPages.ProducerHubView = ProducerHubView;
// ============================================================
// V9.4 Phase 1 — PRODUCTEUR CÔTÉ ACHETEUR
// ============================================================
// Composants ajoutés :
// - ListingCard : carte d'annonce réutilisable
// - ListingDetailSheet : sheet plein écran au clic d'une annonce
// - ProducerAcheteurPage : écran principal de l'onglet "Producteur"
// - ProducerView : routeur (acheteur si onglet, hub si Espace Pro)
// ============================================================
// Helpers d'affichage
const _formatCFA = (n) => {
if (!n && n !== 0) return '—';
return Math.round(n).toLocaleString('fr-FR') + ' F';
};
const _formatDistance = (lat1, lng1, lat2, lng2) => {
if (!lat1 || !lng1 || !lat2 || !lng2) return null;
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return Math.round(R * c);
};
// Icônes selon produit (best effort)
const _productIcon = (name) => {
if (!name) return '📦';
const n = name.toLowerCase();
if (n.includes('oignon')) return '🧅';
if (n.includes('tomate')) return '🍅';
if (n.includes('riz')) return '🌾';
if (n.includes('mil') || n.includes('sorgho')) return '🌾';
if (n.includes('mais')) return '🌽';
if (n.includes('mangue')) return '🥭';
if (n.includes('papaye')) return '🥭';
if (n.includes('banane')) return '🍌';
if (n.includes('pasteq') || n.includes('pastèq') || n.includes('melon')) return '🍉';
if (n.includes('citron')) return '🍋';
if (n.includes('carotte')) return '🥕';
if (n.includes('pomme de terre') || n.includes('patate')) return '🥔';
if (n.includes('chou')) return '🥬';
if (n.includes('piment')) return '🌶️';
if (n.includes('arachide') || n.includes('niebe') || n.includes('niébé')) return '🥜';
if (n.includes('boeuf') || n.includes('bœuf') || n.includes('mouton')) return '🐄';
if (n.includes('volaille') || n.includes('oeuf')) return '🐔';
if (n.includes('poisson') || n.includes('crevette')) return '🐟';
if (n.includes('lait')) return '🥛';
return '📦';
};
// ============================================================
// ListingCard — Carte d'annonce (verticale, format liste)
// ============================================================
const ListingCard = ({ listing, onOpen, userLat, userLng }) => {
const { MapPin, ShieldCheck, Package, Truck } = LuPr;
const distance = _formatDistance(userLat, userLng, listing.pickup_lat || null, listing.pickup_lng || null);
const icon = _productIcon(listing.product_name);
return (
);
};
// ============================================================
// ListingCardCompact — Variante horizontale (pour scroll horizontal)
// ============================================================
const ListingCardCompact = ({ listing, onOpen }) => {
const { ShieldCheck } = LuPr;
const icon = _productIcon(listing.product_name);
return (
);
};
// ============================================================
// ListingDetailSheet — Détail d'une annonce (plein écran)
// ============================================================
const ListingDetailSheet = ({ listing, open, onClose, token }) => {
const { MapPin, ShieldCheck, Package, Truck, MessageCircle, Calendar, Loader2 } = LuPr;
const flash = uFpr();
const [detail, setDetail] = React.useState(null);
const [contactLoading, setContactLoading] = React.useState(false);
// Fetch détail enrichi quand le sheet s'ouvre
React.useEffect(() => {
if (!open || !listing) {
setDetail(null);
return;
}
apiPr(`/api/pwa/listings/${listing.id}`)
.then((r) => setDetail(r))
.catch(() => setDetail(listing)); // fallback sur ce qu'on a déjà
}, [open, listing]);
const requestContact = async () => {
if (contactLoading || !listing) return;
setContactLoading(true);
try {
const r = await apiPr(`/api/pwa/listings/${listing.id}/contact`, { method: 'POST' });
if (r.wa_me_link) {
window.open(r.wa_me_link, '_blank', 'noopener');
} else {
flash.show('error', "Numéro de contact indisponible");
}
} catch (e) {
flash.show('error', "Impossible de récupérer le contact");
} finally {
setContactLoading(false);
}
};
if (!open || !listing) return null;
const l = detail || listing;
const icon = _productIcon(l.product_name);
return (
{/* Icône produit en grand */}
{icon}
{/* Prix */}
Prix
{_formatCFA(l.price_per_unit)}
/{l.unit}
{l.unit_weight_kg && (
1 {l.unit} = {l.unit_weight_kg} kg
{' · '}{_formatCFA(l.price_per_unit / l.unit_weight_kg)}/kg
)}
{l.quantity_min_order > 1 && (
Commande minimum : {l.quantity_min_order} {l.unit}(s)
)}
{/* Producteur */}
Producteur
{l.producer_name}
{l.producer_verified &&
}
{l.producer_region}
{l.producer_village ? ` · ${l.producer_village}` : ''}
{l.producer_description && (
{l.producer_description}
)}
{/* Disponibilité */}
Disponibilité
{l.quantity_remaining || l.quantity_available}
{l.unit}(s) disponibles
{(l.available_from || l.available_to) && (
{l.available_from && `Du ${l.available_from}`}
{l.available_from && l.available_to && ' '}
{l.available_to && `au ${l.available_to}`}
)}
{/* Livraison */}
{l.delivery_possible && (
Livraison possible
{l.delivery_max_km && `Distance max : ${l.delivery_max_km} km`}
{l.delivery_extra_cost > 0 && ` · Frais : ${_formatCFA(l.delivery_extra_cost)}`}
)}
{/* Lieu retrait */}
{l.pickup_location && (
Lieu de retrait
{l.pickup_location}
)}
{/* Notes du producteur */}
{l.notes && (
Notes du producteur
{l.notes}
)}
{/* Caution info */}
{l.caution_percent > 0 && (
💳 Pour précommander, une caution de {l.caution_percent}% sera demandée
(système de paiement en cours d'intégration).
)}
{/* Actions (intégrées au flux, pas en sticky) */}
);
};
// ============================================================
// ProducerAcheteurPage — Écran principal de l'onglet "Producteur"
// ============================================================
const ProducerAcheteurPage = ({ state, setState, onBack, onOpenSheet, isProducer }) => {
const { Search, MapPin, Wheat, X, Filter, ChevronRight } = LuPr;
const [listings, setListings] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [searchQuery, setSearchQuery] = React.useState('');
const [regionFilter, setRegionFilter] = React.useState('');
const [categoryFilter, setCategoryFilter] = React.useState('');
const [filterOpen, setFilterOpen] = React.useState(false);
const [selectedListing, setSelectedListing] = React.useState(null);
const userLat = state.user?.lat || null;
const userLng = state.user?.lng || null;
const token = state.user?.session_token || null;
// Fetch listings
const fetchListings = React.useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (searchQuery.trim()) params.append('product', searchQuery.trim());
if (regionFilter.trim()) params.append('region', regionFilter.trim());
params.append('limit', '50');
const r = await apiPr(`/api/pwa/listings?${params.toString()}`);
setListings(r.listings || []);
} catch (e) {
console.error('Listings fetch error:', e);
setListings([]);
} finally {
setLoading(false);
}
}, [searchQuery, regionFilter]);
React.useEffect(() => { fetchListings(); }, [fetchListings]);
// Calcul des annonces "près de chez toi" (tri par distance si user a un GPS)
const listingsNearby = React.useMemo(() => {
if (!userLat || !userLng) return listings.slice(0, 3);
return [...listings]
.map((l) => ({
...l,
_distance: _formatDistance(userLat, userLng, l.pickup_lat || null, l.pickup_lng || null),
}))
.filter((l) => l._distance !== null)
.sort((a, b) => a._distance - b._distance)
.slice(0, 5);
}, [listings, userLat, userLng]);
// Header réutilisé depuis page-others
const HeaderC = window.OveyoPages?.Header;
return (
{/* Header gradient (réutilise composant Oveyo) */}
{HeaderC ? (
) : (
// Fallback minimal si Header pas dispo
Espace Commerçant
🌾 Producteurs
)}
{/* Bandeau Cas C — si user est producteur */}
{isProducer && (
Tu es producteur
Gère tes annonces depuis l'Espace pro
)}
{/* Recherche */}
{/* Filtre région — select natif */}
{/* Chevron custom (appearance-none cache celui par défaut) */}
{/* Loading */}
{loading && (
)}
{/* Empty */}
{!loading && listings.length === 0 && (
Aucune annonce
{searchQuery || regionFilter ? 'Essaie d\'élargir tes critères' : 'Reviens bientôt — les producteurs arrivent !'}
)}
{/* Section "Près de chez toi" (si on a GPS et annonces) */}
{!loading && listingsNearby.length > 0 && userLat && (
Près de chez toi
{listingsNearby.map((l) => (
))}
)}
{/* Section "Toutes les annonces" */}
{!loading && listings.length > 0 && (
Toutes les annonces
{listings.length} résultat(s)
{listings.map((l) => (
))}
)}
{/* Sheet détail annonce */}
setSelectedListing(null)}
token={token}
/>
);
};
// ============================================================
// ProducerView — Routeur final
// Si appelé depuis l'onglet bottom (default) → ProducerAcheteurPage
// Si appelé depuis Espace Pro → ProducerHubView (dashboard / inscription)
// ============================================================
const ProducerView = ({ state, setState, onBack, onOpenSheet, mode }) => {
const token = state.user?.session_token;
const [isProducer, setIsProducer] = React.useState(false);
// On vérifie le type de compte juste pour afficher le bandeau "Tu es producteur"
React.useEffect(() => {
if (!token) { setIsProducer(false); return; }
apiPr('/api/pwa/merchant/account-type', {
headers: { Authorization: 'Bearer ' + token },
}).then((r) => {
setIsProducer(r.account_type === 'producer' || r.account_type === 'cooperative');
}).catch(() => setIsProducer(false));
}, [token]);
// Mode hub (Espace Pro) → utilise l'ancien composant
if (mode === 'hub') {
return ;
}
// Mode acheteur par défaut (onglet bottom)
return (
);
};
// ============================================================
// EXPORTS finaux
// ============================================================
window.OveyoPages = window.OveyoPages || {};
window.OveyoPages.ProducerView = ProducerView;
window.OveyoPages.ProducerAcheteurPage = ProducerAcheteurPage;
window.OveyoPages.ListingDetailSheet = ListingDetailSheet;