SPB Git

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%
6.6 KB · 229 lines tsx
Raw Blame History
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