spb/fabri-ka Public
Agrégateur de produits québécois — www.fabri-ka.com
HTML 57.9%
Python 18.6%
TypeScript 15.6%
CSS 7.8%
1import { useEffect, useMemo, useRef, useState } from 'react'2import { Link, useParams, useSearchParams } from 'react-router-dom'3import {4 fetchProducts,5 fetchStore,6 formatInt,7 formatPrice,8 formatRelativeDate,9 hostnameOf,10 ProductsResponse,11 StoreDetail as StoreDetailType,12} from '../api'13import EmptyState from '../components/EmptyState'14import {15 categoryIcon,16 IconExternal,17 IconFacebook,18 IconInstagram,19 IconMapPin,20 IconSearch,21} from '../components/Icons'22import OriginBadge from '../components/OriginBadge'23import Pagination from '../components/Pagination'24import ProductGrid from '../components/ProductGrid'25import Skeleton, { SkeletonGrid } from '../components/Skeleton'26import StoreLogo from '../components/StoreLogo'2728const PER_PAGE = 2429const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endroit.'3031const SORT_OPTIONS: { value: string; label: string }[] = [32 { value: 'recent', label: 'Plus récents' },33 { value: 'price_asc', label: 'Prix croissant' },34 { value: 'price_desc', label: 'Prix décroissant' },35 { value: 'title', label: 'Ordre alphabétique' },36]3738export default function StoreDetail() {39 const { id } = useParams<{ id: string }>()40 const [searchParams, setSearchParams] = useSearchParams()41 const [store, setStore] = useState<StoreDetailType | null>(null)42 const [storeError, setStoreError] = useState(false)43 const [products, setProducts] = useState<ProductsResponse | null>(null)44 const [productsLoading, setProductsLoading] = useState(true)45 const [coverReady, setCoverReady] = useState(false)4647 const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)48 const q = searchParams.get('q') ?? ''49 const sort = searchParams.get('tri') ?? 'recent'5051 const [search, setSearch] = useState(q)52 const debounceRef = useRef<number | undefined>(undefined)5354 useEffect(() => {55 setSearch(q)56 }, [q])5758 // ---- store -------------------------------------------------------------59 useEffect(() => {60 if (!id) return61 const controller = new AbortController()62 setStore(null)63 setStoreError(false)64 setCoverReady(false)65 fetchStore(id, controller.signal)66 .then(setStore)67 .catch((err: unknown) => {68 if (err instanceof DOMException && err.name === 'AbortError') return69 setStoreError(true)70 })71 window.scrollTo({ top: 0 })72 return () => controller.abort()73 }, [id])7475 // ---- SEO title ----------------------------------------------------------76 useEffect(() => {77 if (store) document.title = `${store.name} — Fabri-Ka`78 return () => {79 document.title = DEFAULT_TITLE80 }81 }, [store])8283 // ---- cover preload (never show a broken cover) --------------------------84 const coverUrl = store?.cover_url ?? null85 useEffect(() => {86 if (!coverUrl) return87 let cancelled = false88 const img = new Image()89 img.onload = () => {90 if (!cancelled) setCoverReady(true)91 }92 img.src = coverUrl93 return () => {94 cancelled = true95 }96 }, [coverUrl])9798 // ---- products (in-store search + sort + pagination) ---------------------99 useEffect(() => {100 if (!id) return101 const controller = new AbortController()102 setProductsLoading(true)103 fetchProducts(104 {105 store: id,106 q: q || undefined,107 sort,108 page,109 per_page: PER_PAGE,110 },111 controller.signal112 )113 .then((res) => {114 setProducts(res)115 setProductsLoading(false)116 })117 .catch((err: unknown) => {118 if (err instanceof DOMException && err.name === 'AbortError') return119 setProducts(null)120 setProductsLoading(false)121 })122 return () => controller.abort()123 }, [id, q, sort, page])124125 const updateParams = useMemo(126 () =>127 (patch: { q?: string; sort?: string; page?: number }) => {128 setSearchParams(129 (prev) => {130 const next = new URLSearchParams(prev)131 const query = patch.q ?? q132 const tri = patch.sort ?? sort133 const p = patch.page ?? 1134 if (query) next.set('q', query)135 else next.delete('q')136 if (tri && tri !== 'recent') next.set('tri', tri)137 else next.delete('tri')138 if (p > 1) next.set('page', String(p))139 else next.delete('page')140 return next141 },142 { replace: patch.page === undefined }143 )144 },145 [setSearchParams, q, sort]146 )147148 function onSearchInput(value: string) {149 setSearch(value)150 window.clearTimeout(debounceRef.current)151 debounceRef.current = window.setTimeout(() => updateParams({ q: value }), 300)152 }153154 function goToPage(p: number) {155 updateParams({ page: p })156 document157 .getElementById('store-catalogue')158 ?.scrollIntoView({ behavior: 'smooth', block: 'start' })159 }160161 if (storeError) {162 return (163 <div className="page">164 <EmptyState message="Boutique introuvable.">165 <Link className="btn btn-secondary" to="/boutiques">166 Retour aux boutiques167 </Link>168 </EmptyState>169 </div>170 )171 }172173 const socials =174 store?.socials && !Array.isArray(store.socials) ? store.socials : {}175 const instagram = socials['instagram']176 const facebook = socials['facebook']177 const stats = store?.product_stats178 const location = store179 ? [store.city, store.region].filter(Boolean).join(', ') || 'Québec'180 : ''181 const lastSyncRel = formatRelativeDate(store?.last_sync ?? null)182 const priceRange =183 stats && stats.price_min !== null && stats.price_max !== null184 ? stats.price_min === stats.price_max185 ? formatPrice(stats.price_min)186 : `${formatPrice(stats.price_min)} – ${formatPrice(stats.price_max)}`187 : null188 const categories = (store?.category_breakdown ?? []).filter(189 (c): c is { key: string; n: number; label: string } =>190 c.key !== null && c.key !== '' && c.n > 0191 )192 const similar = store?.similar ?? []193 const hasCover = Boolean(coverUrl && coverReady)194195 return (196 <div className="page page-store-detail">197 <nav className="breadcrumb" aria-label="Fil d'Ariane">198 <Link to="/boutiques">Boutiques</Link>199 <span aria-hidden="true">/</span>200 <span>{store?.name ?? '…'}</span>201 </nav>202203 {store ? (204 <>205 {/* ---- Hero ------------------------------------------------- */}206 <header className={hasCover ? 'store-hero store-hero-covered' : 'store-hero'}>207 <div className="store-hero-cover" aria-hidden="true">208 {hasCover && (209 <div210 className="store-hero-cover-img"211 style={{ backgroundImage: `url("${coverUrl}")` }}212 />213 )}214 <div className="store-hero-scrim" />215 </div>216 <div className="store-hero-body">217 <StoreLogo218 storeId={store.id}219 name={store.name}220 logoUrl={store.logo_url}221 size="lg"222 ring223 className="store-hero-logo"224 />225 <h1 className="store-hero-name">{store.name}</h1>226 <div className="store-hero-chips">227 <span className="store-hero-chip">228 <IconMapPin size={14} />229 {location}230 </span>231 <OriginBadge origin={store.origin_class} withLabel />232 </div>233 <div className="store-hero-actions">234 <a235 className="btn btn-primary store-hero-cta"236 href={store.url}237 target="_blank"238 rel="noopener noreferrer"239 >240 Visiter la boutique <IconExternal size={16} />241 </a>242 {instagram && (243 <a244 className="store-social-link"245 href={instagram}246 target="_blank"247 rel="noopener noreferrer"248 aria-label={`Instagram de ${store.name}`}249 title="Instagram"250 >251 <IconInstagram size={19} />252 </a>253 )}254 {facebook && (255 <a256 className="store-social-link"257 href={facebook}258 target="_blank"259 rel="noopener noreferrer"260 aria-label={`Facebook de ${store.name}`}261 title="Facebook"262 >263 <IconFacebook size={19} />264 </a>265 )}266 <span className="store-hero-domain">{hostnameOf(store.url)}</span>267 </div>268 </div>269 </header>270271 {/* ---- About ------------------------------------------------ */}272 {(store.description_meta || store.origin_evidence) && (273 <section className="store-about">274 {store.description_meta && (275 <p className="store-about-text">{store.description_meta}</p>276 )}277 {store.origin_evidence && (278 <blockquote className="store-evidence">279 « {store.origin_evidence} »280 </blockquote>281 )}282 </section>283 )}284285 {/* ---- Stats band -------------------------------------------- */}286 <section className="store-stats" aria-label="Statistiques de la boutique">287 <div className="store-stat-tile">288 <span className="store-stat-value">289 {formatInt(stats?.n ?? store.product_count)}290 </span>291 <span className="store-stat-label">292 Produit{(stats?.n ?? store.product_count) === 1 ? '' : 's'} en ligne293 </span>294 </div>295 {priceRange && (296 <div className="store-stat-tile">297 <span className="store-stat-value store-stat-value-sm">298 {priceRange}299 </span>300 <span className="store-stat-label">Fourchette de prix</span>301 </div>302 )}303 <div className="store-stat-tile">304 <span className="store-stat-value store-stat-value-sm">305 {store.region || 'Québec'}306 </span>307 <span className="store-stat-label">308 {store.platform ? `Région · ${store.platform}` : 'Région'}309 </span>310 </div>311 {lastSyncRel && (312 <div className="store-stat-tile">313 <span className="store-stat-value store-stat-value-sm">314 {lastSyncRel}315 </span>316 <span className="store-stat-label">Dernière synchro</span>317 </div>318 )}319 </section>320321 {/* ---- Category chips ---------------------------------------- */}322 {categories.length > 0 && (323 <section className="store-categories" aria-label="Catégories de la boutique">324 <div className="category-chips">325 {categories.map((c) => (326 <Link327 key={c.key}328 className="chip"329 to={`/produits?store=${encodeURIComponent(store.id)}&category=${encodeURIComponent(c.key)}`}330 >331 <span className="chip-icon">{categoryIcon(c.label)}</span>332 {c.label}333 <span className="chip-count">{formatInt(c.n)}</span>334 </Link>335 ))}336 </div>337 </section>338 )}339 </>340 ) : (341 <div className="store-hero">342 <div className="store-hero-cover" aria-hidden="true">343 <div className="store-hero-scrim" />344 </div>345 <div className="store-hero-body">346 <Skeleton height="72px" width="72px" radius="50%" />347 <Skeleton height="2.2rem" width="55%" />348 <Skeleton height="1rem" width="35%" />349 </div>350 </div>351 )}352353 {/* ---- Catalogue ------------------------------------------------ */}354 <section className="store-detail-products" id="store-catalogue">355 <div className="section-header">356 <h2>357 Catalogue358 {products && !productsLoading && <> ({formatInt(products.total)})</>}359 </h2>360 </div>361 <div className="store-catalogue-toolbar">362 <div className="store-catalogue-search">363 <span className="searchbar-icon">364 <IconSearch size={17} />365 </span>366 <input367 type="search"368 value={search}369 onChange={(e) => onSearchInput(e.target.value)}370 placeholder="Chercher dans la boutique…"371 aria-label="Chercher dans la boutique"372 />373 </div>374 <select375 value={sort}376 onChange={(e) => updateParams({ sort: e.target.value })}377 aria-label="Trier les produits"378 >379 {SORT_OPTIONS.map((o) => (380 <option key={o.value} value={o.value}>381 {o.label}382 </option>383 ))}384 </select>385 </div>386 {productsLoading ? (387 <SkeletonGrid count={12} />388 ) : (389 <>390 <ProductGrid391 products={products?.items ?? []}392 emptyMessage={393 q394 ? 'Aucun produit ne correspond à cette recherche dans la boutique'395 : 'Aucun produit trouvé pour cette boutique'396 }397 />398 {products && (399 <Pagination400 page={products.page}401 perPage={products.per_page}402 total={products.total}403 onPageChange={goToPage}404 />405 )}406 </>407 )}408 </section>409410 {/* ---- Similar stores rail --------------------------------------- */}411 {similar.length > 0 && (412 <section className="rail store-similar">413 <header className="section-header">414 <h2>Boutiques similaires</h2>415 </header>416 <div className="rail-track">417 {similar.map((s) => (418 <Link419 key={s.id}420 to={`/boutiques/${encodeURIComponent(s.id)}`}421 className="card store-mini-card"422 >423 <StoreLogo424 storeId={s.id}425 name={s.name}426 logoUrl={s.logo_url}427 size="sm"428 />429 <span className="store-mini-body">430 <span className="store-mini-name">{s.name}</span>431 <span className="store-mini-meta">432 {s.region || 'Québec'} · {formatInt(s.product_count)} produit433 {s.product_count === 1 ? '' : 's'}434 </span>435 </span>436 </Link>437 ))}438 </div>439 </section>440 )}441442 {/* ---- Mobile sticky CTA (safe-area aware) ------------------------ */}443 {store && (444 <div className="store-cta-bar">445 <div className="store-cta-id">446 <StoreLogo447 storeId={store.id}448 name={store.name}449 logoUrl={store.logo_url}450 size="sm"451 />452 <span className="store-cta-name">{store.name}</span>453 </div>454 <a455 className="btn btn-primary store-cta-btn"456 href={store.url}457 target="_blank"458 rel="noopener noreferrer"459 >460 Visiter la boutique <IconExternal size={15} />461 </a>462 </div>463 )}464 </div>465 )466}467