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