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 { useSearchParams } from 'react-router-dom'3import {4 Facets,5 fetchFacets,6 fetchStores,7 formatInt,8 StoresResponse,9} from '../api'10import EmptyState from '../components/EmptyState'11import Skeleton from '../components/Skeleton'12import StoreCard from '../components/StoreCard'1314export default function Stores() {15 const [searchParams, setSearchParams] = useSearchParams()16 const [facets, setFacets] = useState<Facets | null>(null)17 const [data, setData] = useState<StoresResponse | null>(null)18 const [loading, setLoading] = useState(true)19 const [error, setError] = useState(false)2021 const region = searchParams.get('region') ?? ''22 const q = searchParams.get('q') ?? ''23 const withProducts = searchParams.get('avec_produits') !== '0'2425 const [search, setSearch] = useState(q)26 const debounceRef = useRef<number | undefined>(undefined)2728 useEffect(() => {29 setSearch(q)30 }, [q])3132 useEffect(() => {33 const controller = new AbortController()34 fetchFacets(controller.signal)35 .then(setFacets)36 .catch(() => {})37 return () => controller.abort()38 }, [])3940 useEffect(() => {41 const controller = new AbortController()42 setLoading(true)43 setError(false)44 fetchStores(45 {46 region: region || undefined,47 q: q || undefined,48 with_products: withProducts,49 },50 controller.signal51 )52 .then((res) => {53 setData(res)54 setLoading(false)55 })56 .catch((err: unknown) => {57 if (err instanceof DOMException && err.name === 'AbortError') return58 setError(true)59 setLoading(false)60 })61 return () => controller.abort()62 }, [region, q, withProducts])6364 const update = useMemo(65 () =>66 (patch: { region?: string; q?: string; withProducts?: boolean }) => {67 setSearchParams(68 (prev) => {69 const next = new URLSearchParams(prev)70 const r = patch.region ?? region71 const query = patch.q ?? q72 const wp = patch.withProducts ?? withProducts73 if (r) next.set('region', r)74 else next.delete('region')75 if (query) next.set('q', query)76 else next.delete('q')77 if (!wp) next.set('avec_produits', '0')78 else next.delete('avec_produits')79 return next80 },81 { replace: true }82 )83 },84 [setSearchParams, region, q, withProducts]85 )8687 function onSearchInput(value: string) {88 setSearch(value)89 window.clearTimeout(debounceRef.current)90 debounceRef.current = window.setTimeout(() => update({ q: value }), 300)91 }9293 // Most stocked boutiques first.94 const sortedStores = useMemo(95 () =>96 [...(data?.items ?? [])].sort(97 (a, b) => b.product_count - a.product_count98 ),99 [data]100 )101102 return (103 <div className="page stores">104 <div className="page-header">105 <h1>Boutiques</h1>106 {data && !loading && (107 <p className="catalog-count">108 {formatInt(data.total)} boutique{data.total === 1 ? '' : 's'}109 </p>110 )}111 </div>112113 <div className="stores-toolbar">114 <input115 type="search"116 value={search}117 onChange={(e) => onSearchInput(e.target.value)}118 placeholder="Rechercher une boutique…"119 aria-label="Rechercher une boutique"120 />121 <select122 value={region}123 onChange={(e) => update({ region: e.target.value })}124 aria-label="Filtrer par région"125 >126 <option value="">Toutes les régions</option>127 {(facets?.all_regions ?? []).map((r) => (128 <option key={r} value={r}>129 {r}130 </option>131 ))}132 </select>133 <label className="toggle">134 <input135 type="checkbox"136 checked={withProducts}137 onChange={(e) => update({ withProducts: e.target.checked })}138 />139 <span>Avec produits</span>140 </label>141 </div>142143 {loading ? (144 <div className="store-grid">145 {Array.from({ length: 8 }, (_, i) => (146 <div className="card store-card" key={i}>147 <div className="store-card-top">148 <Skeleton height="44px" width="44px" radius="50%" />149 <div className="store-card-heading">150 <Skeleton height="1.3rem" width="70%" />151 </div>152 </div>153 <Skeleton height="0.9rem" width="50%" />154 <Skeleton height="0.9rem" width="60%" />155 </div>156 ))}157 </div>158 ) : error ? (159 <EmptyState160 variant="error"161 message="Impossible de charger les boutiques. Réessayez plus tard."162 />163 ) : sortedStores.length === 0 ? (164 <EmptyState message="Aucune boutique trouvée">165 <button166 type="button"167 className="btn btn-secondary"168 onClick={() => setSearchParams(new URLSearchParams(), { replace: true })}169 >170 Réinitialiser les filtres171 </button>172 </EmptyState>173 ) : (174 <div className="store-grid">175 {sortedStores.map((s) => (176 <StoreCard key={s.id} store={s} />177 ))}178 </div>179 )}180 </div>181 )182}183