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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'2import { useSearchParams } from 'react-router-dom'3import {4 Facets,5 fetchFacets,6 fetchProducts,7 formatInt,8 ProductsResponse,9} from '../api'10import EmptyState from '../components/EmptyState'11import Filters, {12 countActiveFilters,13 FilterValues,14} from '../components/Filters'15import FilterSheet from '../components/FilterSheet'16import { IconFilter, IconSearch } from '../components/Icons'17import Pagination from '../components/Pagination'18import ProductGrid from '../components/ProductGrid'19import { SkeletonGrid } from '../components/Skeleton'2021const PER_PAGE = 242223export default function Catalog() {24 const [searchParams, setSearchParams] = useSearchParams()25 const [facets, setFacets] = useState<Facets | null>(null)26 const [data, setData] = useState<ProductsResponse | null>(null)27 const [loading, setLoading] = useState(true)28 const [error, setError] = useState<string | null>(null)29 const [sheetOpen, setSheetOpen] = useState(false)30 const [shrunk, setShrunk] = useState(false)3132 const values: FilterValues = useMemo(33 () => ({34 q: searchParams.get('q') ?? '',35 category: searchParams.get('category') ?? '',36 region: searchParams.get('region') ?? '',37 origins: (searchParams.get('origin') ?? '')38 .split(',')39 .filter((o) => o.length > 0),40 priceMin: searchParams.get('price_min') ?? '',41 priceMax: searchParams.get('price_max') ?? '',42 sort: searchParams.get('sort') ?? 'recent',43 }),44 [searchParams]45 )46 const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)47 const activeCount = countActiveFilters(values)4849 // Mobile sticky search — local state, debounced to the URL.50 const [search, setSearch] = useState(values.q)51 const debounceRef = useRef<number | undefined>(undefined)52 useEffect(() => {53 setSearch(values.q)54 }, [values.q])5556 // Shrink the sticky search bar once the page is scrolled.57 useEffect(() => {58 const onScroll = () => setShrunk(window.scrollY > 24)59 onScroll()60 window.addEventListener('scroll', onScroll, { passive: true })61 return () => window.removeEventListener('scroll', onScroll)62 }, [])6364 useEffect(() => {65 const controller = new AbortController()66 fetchFacets(controller.signal)67 .then(setFacets)68 .catch(() => {})69 return () => controller.abort()70 }, [])7172 useEffect(() => {73 const controller = new AbortController()74 setLoading(true)75 setError(null)76 fetchProducts(77 {78 q: values.q || undefined,79 category: values.category || undefined,80 region: values.region || undefined,81 origin: values.origins.length > 0 ? values.origins.join(',') : undefined,82 price_min: values.priceMin || undefined,83 price_max: values.priceMax || undefined,84 sort: values.sort || 'recent',85 page,86 per_page: PER_PAGE,87 },88 controller.signal89 )90 .then((res) => {91 setData(res)92 setLoading(false)93 })94 .catch((err: unknown) => {95 if (err instanceof DOMException && err.name === 'AbortError') return96 setError('Impossible de charger les produits. Réessayez plus tard.')97 setLoading(false)98 })99 return () => controller.abort()100 }, [values, page])101102 const applyPatch = useCallback(103 (patch: Partial<FilterValues>, resetPage = true) => {104 setSearchParams(105 (prev) => {106 const next = new URLSearchParams(prev)107 const merged = { ...values, ...patch }108 const setOrDelete = (key: string, v: string) => {109 if (v) next.set(key, v)110 else next.delete(key)111 }112 setOrDelete('q', merged.q)113 setOrDelete('category', merged.category)114 setOrDelete('region', merged.region)115 setOrDelete('origin', merged.origins.join(','))116 setOrDelete('price_min', merged.priceMin)117 setOrDelete('price_max', merged.priceMax)118 setOrDelete('sort', merged.sort !== 'recent' ? merged.sort : '')119 if (resetPage) next.delete('page')120 return next121 },122 { replace: true }123 )124 },125 [setSearchParams, values]126 )127128 const resetFilters = useCallback(() => {129 setSearchParams(new URLSearchParams(), { replace: true })130 }, [setSearchParams])131132 const goToPage = useCallback(133 (p: number) => {134 setSearchParams((prev) => {135 const next = new URLSearchParams(prev)136 if (p > 1) next.set('page', String(p))137 else next.delete('page')138 return next139 })140 window.scrollTo({ top: 0, behavior: 'smooth' })141 },142 [setSearchParams]143 )144145 function onMobileSearch(value: string) {146 setSearch(value)147 window.clearTimeout(debounceRef.current)148 debounceRef.current = window.setTimeout(() => {149 applyPatch({ q: value })150 }, 300)151 }152153 return (154 <div className="page catalog">155 {/* Mobile sticky search (shrinks on scroll) */}156 <div157 className={158 shrunk ? 'catalog-searchbar catalog-searchbar-shrunk' : 'catalog-searchbar'159 }160 >161 <div className="catalog-searchbar-field">162 <span className="searchbar-icon" aria-hidden="true">163 <IconSearch size={17} />164 </span>165 <input166 type="search"167 value={search}168 onChange={(e) => onMobileSearch(e.target.value)}169 placeholder="Rechercher un produit…"170 aria-label="Rechercher un produit"171 enterKeyHint="search"172 />173 </div>174 </div>175176 <div className="catalog-layout">177 <Filters178 facets={facets}179 values={values}180 onChange={applyPatch}181 onReset={resetFilters}182 />183 <div className="catalog-main">184 <div className="catalog-header">185 <h1>Produits</h1>186 {data && !loading && (187 <p className="catalog-count">188 {formatInt(data.total)} produit{data.total === 1 ? '' : 's'}189 </p>190 )}191 </div>192 {loading ? (193 <SkeletonGrid count={PER_PAGE} />194 ) : error ? (195 <EmptyState variant="error" message={error}>196 <button197 type="button"198 className="btn btn-secondary"199 onClick={() => applyPatch({})}200 >201 Réessayer202 </button>203 </EmptyState>204 ) : (205 <>206 <ProductGrid207 products={data?.items ?? []}208 onReset={resetFilters}209 />210 {data && (211 <Pagination212 page={data.page}213 perPage={data.per_page}214 total={data.total}215 onPageChange={goToPage}216 />217 )}218 </>219 )}220 </div>221 </div>222223 {/* Mobile filter pill (above the tab bar) */}224 <button225 type="button"226 className="filter-fab"227 onClick={() => setSheetOpen(true)}228 aria-haspopup="dialog"229 >230 <IconFilter size={18} />231 Filtres232 {activeCount > 0 && (233 <span className="filter-fab-badge">{activeCount}</span>234 )}235 </button>236237 <FilterSheet238 open={sheetOpen}239 facets={facets}240 values={values}241 total={data ? data.total : null}242 onChange={applyPatch}243 onReset={resetFilters}244 onClose={() => setSheetOpen(false)}245 />246 </div>247 )248}249