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%
15.5 KB · 467 lines tsx
Raw Blame History
1import { useEffect, useMemo, useRef, useState } from 'react'2import { Link, useParams, useSearchParams } from 'react-router-dom'3import {4  fetchProducts,5  fetchStore,6  formatInt,7  formatPrice,8  formatRelativeDate,9  hostnameOf,10  ProductsResponse,11  StoreDetail as StoreDetailType,12} from '../api'13import EmptyState from '../components/EmptyState'14import {15  categoryIcon,16  IconExternal,17  IconFacebook,18  IconInstagram,19  IconMapPin,20  IconSearch,21} from '../components/Icons'22import OriginBadge from '../components/OriginBadge'23import Pagination from '../components/Pagination'24import ProductGrid from '../components/ProductGrid'25import Skeleton, { SkeletonGrid } from '../components/Skeleton'26import StoreLogo from '../components/StoreLogo'2728const PER_PAGE = 2429const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endroit.'3031const SORT_OPTIONS: { value: string; label: string }[] = [32  { value: 'recent', label: 'Plus récents' },33  { value: 'price_asc', label: 'Prix croissant' },34  { value: 'price_desc', label: 'Prix décroissant' },35  { value: 'title', label: 'Ordre alphabétique' },36]3738export default function StoreDetail() {39  const { id } = useParams<{ id: string }>()40  const [searchParams, setSearchParams] = useSearchParams()41  const [store, setStore] = useState<StoreDetailType | null>(null)42  const [storeError, setStoreError] = useState(false)43  const [products, setProducts] = useState<ProductsResponse | null>(null)44  const [productsLoading, setProductsLoading] = useState(true)45  const [coverReady, setCoverReady] = useState(false)4647  const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)48  const q = searchParams.get('q') ?? ''49  const sort = searchParams.get('tri') ?? 'recent'5051  const [search, setSearch] = useState(q)52  const debounceRef = useRef<number | undefined>(undefined)5354  useEffect(() => {55    setSearch(q)56  }, [q])5758  // ---- store -------------------------------------------------------------59  useEffect(() => {60    if (!id) return61    const controller = new AbortController()62    setStore(null)63    setStoreError(false)64    setCoverReady(false)65    fetchStore(id, controller.signal)66      .then(setStore)67      .catch((err: unknown) => {68        if (err instanceof DOMException && err.name === 'AbortError') return69        setStoreError(true)70      })71    window.scrollTo({ top: 0 })72    return () => controller.abort()73  }, [id])7475  // ---- SEO title ----------------------------------------------------------76  useEffect(() => {77    if (store) document.title = `${store.name} — Fabri-Ka`78    return () => {79      document.title = DEFAULT_TITLE80    }81  }, [store])8283  // ---- cover preload (never show a broken cover) --------------------------84  const coverUrl = store?.cover_url ?? null85  useEffect(() => {86    if (!coverUrl) return87    let cancelled = false88    const img = new Image()89    img.onload = () => {90      if (!cancelled) setCoverReady(true)91    }92    img.src = coverUrl93    return () => {94      cancelled = true95    }96  }, [coverUrl])9798  // ---- products (in-store search + sort + pagination) ---------------------99  useEffect(() => {100    if (!id) return101    const controller = new AbortController()102    setProductsLoading(true)103    fetchProducts(104      {105        store: id,106        q: q || undefined,107        sort,108        page,109        per_page: PER_PAGE,110      },111      controller.signal112    )113      .then((res) => {114        setProducts(res)115        setProductsLoading(false)116      })117      .catch((err: unknown) => {118        if (err instanceof DOMException && err.name === 'AbortError') return119        setProducts(null)120        setProductsLoading(false)121      })122    return () => controller.abort()123  }, [id, q, sort, page])124125  const updateParams = useMemo(126    () =>127      (patch: { q?: string; sort?: string; page?: number }) => {128        setSearchParams(129          (prev) => {130            const next = new URLSearchParams(prev)131            const query = patch.q ?? q132            const tri = patch.sort ?? sort133            const p = patch.page ?? 1134            if (query) next.set('q', query)135            else next.delete('q')136            if (tri && tri !== 'recent') next.set('tri', tri)137            else next.delete('tri')138            if (p > 1) next.set('page', String(p))139            else next.delete('page')140            return next141          },142          { replace: patch.page === undefined }143        )144      },145    [setSearchParams, q, sort]146  )147148  function onSearchInput(value: string) {149    setSearch(value)150    window.clearTimeout(debounceRef.current)151    debounceRef.current = window.setTimeout(() => updateParams({ q: value }), 300)152  }153154  function goToPage(p: number) {155    updateParams({ page: p })156    document157      .getElementById('store-catalogue')158      ?.scrollIntoView({ behavior: 'smooth', block: 'start' })159  }160161  if (storeError) {162    return (163      <div className="page">164        <EmptyState message="Boutique introuvable.">165          <Link className="btn btn-secondary" to="/boutiques">166            Retour aux boutiques167          </Link>168        </EmptyState>169      </div>170    )171  }172173  const socials =174    store?.socials && !Array.isArray(store.socials) ? store.socials : {}175  const instagram = socials['instagram']176  const facebook = socials['facebook']177  const stats = store?.product_stats178  const location = store179    ? [store.city, store.region].filter(Boolean).join(', ') || 'Québec'180    : ''181  const lastSyncRel = formatRelativeDate(store?.last_sync ?? null)182  const priceRange =183    stats && stats.price_min !== null && stats.price_max !== null184      ? stats.price_min === stats.price_max185        ? formatPrice(stats.price_min)186        : `${formatPrice(stats.price_min)} – ${formatPrice(stats.price_max)}`187      : null188  const categories = (store?.category_breakdown ?? []).filter(189    (c): c is { key: string; n: number; label: string } =>190      c.key !== null && c.key !== '' && c.n > 0191  )192  const similar = store?.similar ?? []193  const hasCover = Boolean(coverUrl && coverReady)194195  return (196    <div className="page page-store-detail">197      <nav className="breadcrumb" aria-label="Fil d'Ariane">198        <Link to="/boutiques">Boutiques</Link>199        <span aria-hidden="true">/</span>200        <span>{store?.name ?? '…'}</span>201      </nav>202203      {store ? (204        <>205          {/* ---- Hero ------------------------------------------------- */}206          <header className={hasCover ? 'store-hero store-hero-covered' : 'store-hero'}>207            <div className="store-hero-cover" aria-hidden="true">208              {hasCover && (209                <div210                  className="store-hero-cover-img"211                  style={{ backgroundImage: `url("${coverUrl}")` }}212                />213              )}214              <div className="store-hero-scrim" />215            </div>216            <div className="store-hero-body">217              <StoreLogo218                storeId={store.id}219                name={store.name}220                logoUrl={store.logo_url}221                size="lg"222                ring223                className="store-hero-logo"224              />225              <h1 className="store-hero-name">{store.name}</h1>226              <div className="store-hero-chips">227                <span className="store-hero-chip">228                  <IconMapPin size={14} />229                  {location}230                </span>231                <OriginBadge origin={store.origin_class} withLabel />232              </div>233              <div className="store-hero-actions">234                <a235                  className="btn btn-primary store-hero-cta"236                  href={store.url}237                  target="_blank"238                  rel="noopener noreferrer"239                >240                  Visiter la boutique <IconExternal size={16} />241                </a>242                {instagram && (243                  <a244                    className="store-social-link"245                    href={instagram}246                    target="_blank"247                    rel="noopener noreferrer"248                    aria-label={`Instagram de ${store.name}`}249                    title="Instagram"250                  >251                    <IconInstagram size={19} />252                  </a>253                )}254                {facebook && (255                  <a256                    className="store-social-link"257                    href={facebook}258                    target="_blank"259                    rel="noopener noreferrer"260                    aria-label={`Facebook de ${store.name}`}261                    title="Facebook"262                  >263                    <IconFacebook size={19} />264                  </a>265                )}266                <span className="store-hero-domain">{hostnameOf(store.url)}</span>267              </div>268            </div>269          </header>270271          {/* ---- About ------------------------------------------------ */}272          {(store.description_meta || store.origin_evidence) && (273            <section className="store-about">274              {store.description_meta && (275                <p className="store-about-text">{store.description_meta}</p>276              )}277              {store.origin_evidence && (278                <blockquote className="store-evidence">279                  « {store.origin_evidence} »280                </blockquote>281              )}282            </section>283          )}284285          {/* ---- Stats band -------------------------------------------- */}286          <section className="store-stats" aria-label="Statistiques de la boutique">287            <div className="store-stat-tile">288              <span className="store-stat-value">289                {formatInt(stats?.n ?? store.product_count)}290              </span>291              <span className="store-stat-label">292                Produit{(stats?.n ?? store.product_count) === 1 ? '' : 's'} en ligne293              </span>294            </div>295            {priceRange && (296              <div className="store-stat-tile">297                <span className="store-stat-value store-stat-value-sm">298                  {priceRange}299                </span>300                <span className="store-stat-label">Fourchette de prix</span>301              </div>302            )}303            <div className="store-stat-tile">304              <span className="store-stat-value store-stat-value-sm">305                {store.region || 'Québec'}306              </span>307              <span className="store-stat-label">308                {store.platform ? `Région · ${store.platform}` : 'Région'}309              </span>310            </div>311            {lastSyncRel && (312              <div className="store-stat-tile">313                <span className="store-stat-value store-stat-value-sm">314                  {lastSyncRel}315                </span>316                <span className="store-stat-label">Dernière synchro</span>317              </div>318            )}319          </section>320321          {/* ---- Category chips ---------------------------------------- */}322          {categories.length > 0 && (323            <section className="store-categories" aria-label="Catégories de la boutique">324              <div className="category-chips">325                {categories.map((c) => (326                  <Link327                    key={c.key}328                    className="chip"329                    to={`/produits?store=${encodeURIComponent(store.id)}&category=${encodeURIComponent(c.key)}`}330                  >331                    <span className="chip-icon">{categoryIcon(c.label)}</span>332                    {c.label}333                    <span className="chip-count">{formatInt(c.n)}</span>334                  </Link>335                ))}336              </div>337            </section>338          )}339        </>340      ) : (341        <div className="store-hero">342          <div className="store-hero-cover" aria-hidden="true">343            <div className="store-hero-scrim" />344          </div>345          <div className="store-hero-body">346            <Skeleton height="72px" width="72px" radius="50%" />347            <Skeleton height="2.2rem" width="55%" />348            <Skeleton height="1rem" width="35%" />349          </div>350        </div>351      )}352353      {/* ---- Catalogue ------------------------------------------------ */}354      <section className="store-detail-products" id="store-catalogue">355        <div className="section-header">356          <h2>357            Catalogue358            {products && !productsLoading && <> ({formatInt(products.total)})</>}359          </h2>360        </div>361        <div className="store-catalogue-toolbar">362          <div className="store-catalogue-search">363            <span className="searchbar-icon">364              <IconSearch size={17} />365            </span>366            <input367              type="search"368              value={search}369              onChange={(e) => onSearchInput(e.target.value)}370              placeholder="Chercher dans la boutique…"371              aria-label="Chercher dans la boutique"372            />373          </div>374          <select375            value={sort}376            onChange={(e) => updateParams({ sort: e.target.value })}377            aria-label="Trier les produits"378          >379            {SORT_OPTIONS.map((o) => (380              <option key={o.value} value={o.value}>381                {o.label}382              </option>383            ))}384          </select>385        </div>386        {productsLoading ? (387          <SkeletonGrid count={12} />388        ) : (389          <>390            <ProductGrid391              products={products?.items ?? []}392              emptyMessage={393                q394                  ? 'Aucun produit ne correspond à cette recherche dans la boutique'395                  : 'Aucun produit trouvé pour cette boutique'396              }397            />398            {products && (399              <Pagination400                page={products.page}401                perPage={products.per_page}402                total={products.total}403                onPageChange={goToPage}404              />405            )}406          </>407        )}408      </section>409410      {/* ---- Similar stores rail --------------------------------------- */}411      {similar.length > 0 && (412        <section className="rail store-similar">413          <header className="section-header">414            <h2>Boutiques similaires</h2>415          </header>416          <div className="rail-track">417            {similar.map((s) => (418              <Link419                key={s.id}420                to={`/boutiques/${encodeURIComponent(s.id)}`}421                className="card store-mini-card"422              >423                <StoreLogo424                  storeId={s.id}425                  name={s.name}426                  logoUrl={s.logo_url}427                  size="sm"428                />429                <span className="store-mini-body">430                  <span className="store-mini-name">{s.name}</span>431                  <span className="store-mini-meta">432                    {s.region || 'Québec'} · {formatInt(s.product_count)} produit433                    {s.product_count === 1 ? '' : 's'}434                  </span>435                </span>436              </Link>437            ))}438          </div>439        </section>440      )}441442      {/* ---- Mobile sticky CTA (safe-area aware) ------------------------ */}443      {store && (444        <div className="store-cta-bar">445          <div className="store-cta-id">446            <StoreLogo447              storeId={store.id}448              name={store.name}449              logoUrl={store.logo_url}450              size="sm"451            />452            <span className="store-cta-name">{store.name}</span>453          </div>454          <a455            className="btn btn-primary store-cta-btn"456            href={store.url}457            target="_blank"458            rel="noopener noreferrer"459          >460            Visiter la boutique <IconExternal size={15} />461          </a>462        </div>463      )}464    </div>465  )466}467