spb/fabri-ka Public
Agrégateur de produits québécois — www.fabri-ka.com
HTML 57%
Python 19.9%
TypeScript 15.3%
CSS 7.7%
1import { useEffect, useRef, useState } from 'react'2import { Facets, formatInt, ORIGIN_KEYS, ORIGIN_LABELS } from '../api'34export interface FilterValues {5 q: string6 category: string7 region: string8 origins: string[]9 priceMin: string10 priceMax: string11 sort: string12}1314interface BodyProps {15 facets: Facets | null16 values: FilterValues17 onChange: (patch: Partial<FilterValues>) => void18 /** Prefix for input ids so desktop + sheet instances don't collide. */19 idPrefix?: string20}2122interface Props extends BodyProps {23 onReset: () => void24}2526const SORT_OPTIONS: { value: string; label: string }[] = [27 { value: 'recent', label: 'Plus récents' },28 { value: 'price_asc', label: 'Prix croissant' },29 { value: 'price_desc', label: 'Prix décroissant' },30 { value: 'title', label: 'Titre (A–Z)' },31]3233export function countActiveFilters(values: FilterValues): number {34 return (35 (values.q ? 1 : 0) +36 (values.category ? 1 : 0) +37 (values.region ? 1 : 0) +38 values.origins.length +39 (values.priceMin ? 1 : 0) +40 (values.priceMax ? 1 : 0)41 )42}4344/**45 * The filter controls themselves — rendered inside the desktop sidebar46 * and inside the mobile bottom sheet.47 */48export function FiltersBody({ facets, values, onChange, idPrefix = '' }: BodyProps) {49 const [search, setSearch] = useState(values.q)50 const debounceRef = useRef<number | undefined>(undefined)51 const firstRender = useRef(true)5253 // Keep local search in sync when URL changes externally (back button, chips…)54 useEffect(() => {55 setSearch(values.q)56 }, [values.q])5758 // Debounced propagation of the search input (300 ms)59 useEffect(() => {60 if (firstRender.current) {61 firstRender.current = false62 return63 }64 window.clearTimeout(debounceRef.current)65 debounceRef.current = window.setTimeout(() => {66 if (search !== values.q) onChange({ q: search })67 }, 300)68 return () => window.clearTimeout(debounceRef.current)69 // eslint-disable-next-line react-hooks/exhaustive-deps70 }, [search])7172 function toggleOrigin(key: string) {73 const next = values.origins.includes(key)74 ? values.origins.filter((o) => o !== key)75 : [...values.origins, key]76 onChange({ origins: next })77 }7879 return (80 <div className="filters-body">81 <div className="filter-group">82 <label className="filter-label" htmlFor={`${idPrefix}filter-search`}>83 Recherche84 </label>85 <input86 id={`${idPrefix}filter-search`}87 type="search"88 value={search}89 onChange={(e) => setSearch(e.target.value)}90 placeholder="Mot-clé…"91 />92 </div>9394 <div className="filter-group">95 <span className="filter-label">Catégories</span>96 <ul className="filter-category-list">97 <li>98 <button99 type="button"100 className={values.category === '' ? 'active' : ''}101 onClick={() => onChange({ category: '' })}102 >103 Toutes104 </button>105 </li>106 {(facets?.categories ?? []).map((c) => (107 <li key={c.key}>108 <button109 type="button"110 className={values.category === c.key ? 'active' : ''}111 onClick={() =>112 onChange({113 category: values.category === c.key ? '' : c.key,114 })115 }116 >117 <span>{c.label}</span>118 <span className="filter-count">{formatInt(c.n)}</span>119 </button>120 </li>121 ))}122 </ul>123 </div>124125 <div className="filter-group">126 <label className="filter-label" htmlFor={`${idPrefix}filter-region`}>127 Région128 </label>129 <select130 id={`${idPrefix}filter-region`}131 value={values.region}132 onChange={(e) => onChange({ region: e.target.value })}133 >134 <option value="">Toutes les régions</option>135 {(facets?.all_regions ?? []).map((r) => (136 <option key={r} value={r}>137 {r}138 </option>139 ))}140 </select>141 </div>142143 <div className="filter-group">144 <span className="filter-label">Origine</span>145 {ORIGIN_KEYS.map((key) => (146 <label className="filter-checkbox" key={key}>147 <input148 type="checkbox"149 checked={values.origins.includes(key)}150 onChange={() => toggleOrigin(key)}151 />152 <span className={`origin-badge origin-${key}`}>153 <span className="origin-badge-letter">{key}</span>154 </span>155 <span>{ORIGIN_LABELS[key]}</span>156 </label>157 ))}158 </div>159160 <div className="filter-group">161 <span className="filter-label">Prix</span>162 <div className="filter-price-row">163 <input164 type="number"165 min="0"166 inputMode="decimal"167 placeholder="Min"168 aria-label="Prix minimum"169 value={values.priceMin}170 onChange={(e) => onChange({ priceMin: e.target.value })}171 />172 <span aria-hidden="true">–</span>173 <input174 type="number"175 min="0"176 inputMode="decimal"177 placeholder="Max"178 aria-label="Prix maximum"179 value={values.priceMax}180 onChange={(e) => onChange({ priceMax: e.target.value })}181 />182 </div>183 </div>184185 <div className="filter-group">186 <label className="filter-label" htmlFor={`${idPrefix}filter-sort`}>187 Trier par188 </label>189 <select190 id={`${idPrefix}filter-sort`}191 value={values.sort || 'recent'}192 onChange={(e) => onChange({ sort: e.target.value })}193 >194 {SORT_OPTIONS.map((o) => (195 <option key={o.value} value={o.value}>196 {o.label}197 </option>198 ))}199 </select>200 </div>201 </div>202 )203}204205/**206 * Desktop sidebar (hidden on mobile — the bottom sheet takes over there).207 */208export default function Filters({ facets, values, onChange, onReset }: Props) {209 const activeCount = countActiveFilters(values)210 return (211 <aside className="filters" aria-label="Filtres">212 <div className="filters-title-row">213 <span className="filters-title">Filtres</span>214 {activeCount > 0 && (215 <button type="button" className="filters-reset-link" onClick={onReset}>216 Réinitialiser ({activeCount})217 </button>218 )}219 </div>220 <FiltersBody221 facets={facets}222 values={values}223 onChange={onChange}224 idPrefix="side-"225 />226 </aside>227 )228}229