import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams, useSearchParams } from 'react-router-dom' import { fetchProducts, fetchStore, formatInt, formatPrice, formatRelativeDate, hostnameOf, ProductsResponse, StoreDetail as StoreDetailType, } from '../api' import EmptyState from '../components/EmptyState' import { categoryIcon, IconExternal, IconFacebook, IconInstagram, IconMapPin, IconSearch, } from '../components/Icons' import OriginBadge from '../components/OriginBadge' import Pagination from '../components/Pagination' import ProductGrid from '../components/ProductGrid' import Skeleton, { SkeletonGrid } from '../components/Skeleton' import StoreLogo from '../components/StoreLogo' const PER_PAGE = 24 const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endroit.' const SORT_OPTIONS: { value: string; label: string }[] = [ { value: 'recent', label: 'Plus récents' }, { value: 'price_asc', label: 'Prix croissant' }, { value: 'price_desc', label: 'Prix décroissant' }, { value: 'title', label: 'Ordre alphabétique' }, ] export default function StoreDetail() { const { id } = useParams<{ id: string }>() const [searchParams, setSearchParams] = useSearchParams() const [store, setStore] = useState(null) const [storeError, setStoreError] = useState(false) const [products, setProducts] = useState(null) const [productsLoading, setProductsLoading] = useState(true) const [coverReady, setCoverReady] = useState(false) const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) const q = searchParams.get('q') ?? '' const sort = searchParams.get('tri') ?? 'recent' const [search, setSearch] = useState(q) const debounceRef = useRef(undefined) useEffect(() => { setSearch(q) }, [q]) // ---- store ------------------------------------------------------------- useEffect(() => { if (!id) return const controller = new AbortController() setStore(null) setStoreError(false) setCoverReady(false) fetchStore(id, controller.signal) .then(setStore) .catch((err: unknown) => { if (err instanceof DOMException && err.name === 'AbortError') return setStoreError(true) }) window.scrollTo({ top: 0 }) return () => controller.abort() }, [id]) // ---- SEO title ---------------------------------------------------------- useEffect(() => { if (store) document.title = `${store.name} — Fabri-Ka` return () => { document.title = DEFAULT_TITLE } }, [store]) // ---- cover preload (never show a broken cover) -------------------------- const coverUrl = store?.cover_url ?? null useEffect(() => { if (!coverUrl) return let cancelled = false const img = new Image() img.onload = () => { if (!cancelled) setCoverReady(true) } img.src = coverUrl return () => { cancelled = true } }, [coverUrl]) // ---- products (in-store search + sort + pagination) --------------------- useEffect(() => { if (!id) return const controller = new AbortController() setProductsLoading(true) fetchProducts( { store: id, q: q || undefined, sort, page, per_page: PER_PAGE, }, controller.signal ) .then((res) => { setProducts(res) setProductsLoading(false) }) .catch((err: unknown) => { if (err instanceof DOMException && err.name === 'AbortError') return setProducts(null) setProductsLoading(false) }) return () => controller.abort() }, [id, q, sort, page]) const updateParams = useMemo( () => (patch: { q?: string; sort?: string; page?: number }) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev) const query = patch.q ?? q const tri = patch.sort ?? sort const p = patch.page ?? 1 if (query) next.set('q', query) else next.delete('q') if (tri && tri !== 'recent') next.set('tri', tri) else next.delete('tri') if (p > 1) next.set('page', String(p)) else next.delete('page') return next }, { replace: patch.page === undefined } ) }, [setSearchParams, q, sort] ) function onSearchInput(value: string) { setSearch(value) window.clearTimeout(debounceRef.current) debounceRef.current = window.setTimeout(() => updateParams({ q: value }), 300) } function goToPage(p: number) { updateParams({ page: p }) document .getElementById('store-catalogue') ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } if (storeError) { return (
Retour aux boutiques
) } const socials = store?.socials && !Array.isArray(store.socials) ? store.socials : {} const instagram = socials['instagram'] const facebook = socials['facebook'] const stats = store?.product_stats const location = store ? [store.city, store.region].filter(Boolean).join(', ') || 'Québec' : '' const lastSyncRel = formatRelativeDate(store?.last_sync ?? null) const priceRange = stats && stats.price_min !== null && stats.price_max !== null ? stats.price_min === stats.price_max ? formatPrice(stats.price_min) : `${formatPrice(stats.price_min)} – ${formatPrice(stats.price_max)}` : null const categories = (store?.category_breakdown ?? []).filter( (c): c is { key: string; n: number; label: string } => c.key !== null && c.key !== '' && c.n > 0 ) const similar = store?.similar ?? [] const hasCover = Boolean(coverUrl && coverReady) return (
{store ? ( <> {/* ---- Hero ------------------------------------------------- */}
{/* ---- About ------------------------------------------------ */} {(store.description_meta || store.origin_evidence) && (
{store.description_meta && (

{store.description_meta}

)} {store.origin_evidence && (
« {store.origin_evidence} »
)}
)} {/* ---- Stats band -------------------------------------------- */}
{formatInt(stats?.n ?? store.product_count)} Produit{(stats?.n ?? store.product_count) === 1 ? '' : 's'} en ligne
{priceRange && (
{priceRange} Fourchette de prix
)}
{store.region || 'Québec'} {store.platform ? `Région · ${store.platform}` : 'Région'}
{lastSyncRel && (
{lastSyncRel} Dernière synchro
)}
{/* ---- Category chips ---------------------------------------- */} {categories.length > 0 && (
{categories.map((c) => ( {categoryIcon(c.label)} {c.label} {formatInt(c.n)} ))}
)} ) : (
)} {/* ---- Catalogue ------------------------------------------------ */}

Catalogue {products && !productsLoading && <> ({formatInt(products.total)})}

onSearchInput(e.target.value)} placeholder="Chercher dans la boutique…" aria-label="Chercher dans la boutique" />
{productsLoading ? ( ) : ( <> {products && ( )} )}
{/* ---- Similar stores rail --------------------------------------- */} {similar.length > 0 && (

Boutiques similaires

{similar.map((s) => ( {s.name} {s.region || 'Québec'} · {formatInt(s.product_count)} produit {s.product_count === 1 ? '' : 's'} ))}
)} {/* ---- Mobile sticky CTA (safe-area aware) ------------------------ */} {store && ( )}
) }