SPB Git

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