// ----------------------------------------------------------------------------- // Food-Ka — Agrégateur de produits d'épicerie (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Product.tsx : fiche produit — galerie packshot, bloc prix (solde, // prix unitaire), description, étiquettes, historique de prix et // « Comparer les prix » (même produit chez les autres bannières). // Mobile-first : galerie → prix → comparaison ; CTA source sticky en bas. // ----------------------------------------------------------------------------- import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Product, ProductDetail, discountPct, fetchProduct, fetchSources, fmtPrice, fmtTs, registerSourceNames, sourceName, } from "../api"; import SourceLogo from "../components/SourceLogo"; const NBSP = " "; // --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran ----- function Galerie({ images, titre }: { images: string[]; titre: string }) { const [idx, setIdx] = useState(0); const [zoom, setZoom] = useState(false); const track = useRef(null); const onScroll = () => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }; const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" }); if (images.length === 0) return
🛒
; return ( <>
{images.map((u, i) => ( {`${titre} setZoom(true)} /> ))}
{images.length > 1 && ( {idx + 1}/{images.length} )} {idx > 0 && ( )} {idx < images.length - 1 && ( )}
{images.length > 1 && (
{images.map((u, i) => ( ))}
)} {zoom && (
setZoom(false)} role="dialog" aria-label="Image agrandie">
)} ); } // --- Rangée de comparaison inter-bannières ----------------------------------- function CompareRow({ p, best }: { p: Product; best: boolean }) { const pct = discountPct(p); const img = p.images && p.images.length > 0 ? p.images[0] : null; return (
  • {img ? : 🛒} {p.name} {[p.brand, p.size_label].filter(Boolean).join(" · ")} {fmtPrice(p.price, p.price_label)} {p.on_sale && p.regular_price != null && ( {fmtPrice(p.regular_price)} )} {pct != null && −{pct} %} {best && meilleur prix} {p.unit_price_label && {p.unit_price_label}}
  • ); } export default function ProductPage() { const { uid } = useParams<{ uid: string }>(); const [p, setP] = useState(null); const [error, setError] = useState(null); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); if (!uid) return; setP(null); setError(null); fetchProduct(uid).then(setP).catch((e) => setError(String(e))); window.scrollTo(0, 0); }, [uid]); if (error) return (
    ⚠️

    Produit introuvable

    {error}

    Retour aux produits
    ); if (!p) return (
    ); const pct = discountPct(p); const updated = p.updated_at ? new Date(p.updated_at * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" }) : null; // chips clés const chips: string[] = []; if (p.size_label) chips.push(p.size_label); if (p.category) chips.push(p.category); if (p.on_sale) chips.push(pct != null ? `Solde −${pct}${NBSP}%` : "En solde"); if (p.in_stock === true) chips.push("En stock"); if (p.in_stock === false) chips.push("Rupture de stock"); // comparaison triée du moins cher au plus cher (prix inconnus à la fin) const compare = [...(p.compare ?? [])].sort((a, b) => { if (a.price == null) return 1; if (b.price == null) return -1; return a.price - b.price; }); const bestPrice = compare.find((c) => c.price != null)?.price ?? null; const hist = (p.price_history ?? []).filter((h) => h.price != null); const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price ? { de: hist[1].price!, a: hist[0].price! } : null; return (
    {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}

    Description

    {p.description ?

    {p.description}

    :

    La bannière ne fournit pas de description pour ce produit.

    } {p.keywords.length > 0 && (
    {p.keywords.map((k) => ( {k} ))}
    )}

    Détails pratiques

    Bannière
    {sourceName(p.source)}
    {p.brand && (
    Marque
    {p.brand}
    )} {p.size_label && (
    Format
    {p.size_label}
    )} {p.category_raw && (
    Rayon (source)
    {p.category_raw}
    )} {p.price_label && (
    Prix affiché
    {p.price_label}
    )} {updated && (
    Synchronisé
    {updated}
    )}
    {baissePrix && (
    {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "} {fmtPrice(baissePrix.de)} à {fmtPrice(baissePrix.a)}
    )} {hist.length > 1 && (
    Historique de prix ({hist.length} relevés)
      {hist.map((h) => (
    • {fmtTs(h.ts)} {fmtPrice(h.price)}
    • ))}
    )}
    {/* ------- colonne droite (desktop) : prix, comparaison --------------- */}
    {fmtPrice(p.price, p.price_label)} {p.on_sale && p.regular_price != null && ( {fmtPrice(p.regular_price)} )}
    {p.on_sale && (
    🔥 En solde{pct != null && <> — épargnez {pct}{NBSP}%}
    )} {p.unit_price_label &&
    {p.unit_price_label}
    }

    {p.name}

    {[p.brand, p.size_label].filter(Boolean).join(" · ")}
    {chips.map((c) => {c})}
    Voir chez {sourceName(p.source)} ↗

    Prix affiché par la bannière lors de la dernière synchronisation — vérifiez en magasin ou sur la fiche originale.

    Comparer les prix

    {compare.length > 0 ? ( <>

    Produits similaires chez les autres bannières, du moins cher au plus cher.

      {compare.map((c) => ( ))}
    ) : (

    Aucun produit comparable trouvé chez les autres bannières pour l'instant.

    )}
    {updated && <>Dernière synchronisation : {updated}. } Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à la page produit originale.
    {/* CTA sticky mobile — toujours visible */}
    {fmtPrice(p.price, p.price_label)} {p.unit_price_label && {p.unit_price_label}} Voir chez {sourceName(p.source)} ↗
    ); }