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%
10.8 KB · 356 lines tsx
Raw Blame History
1import { UIEvent, useEffect, useRef, useState } from 'react'2import { Link, useParams } from 'react-router-dom'3import {4  fetchProduct,5  formatPrice,6  ORIGIN_LABELS,7  ProductDetail as ProductDetailType,8} from '../api'9import EmptyState from '../components/EmptyState'10import { IconExternal, IconLeaf } from '../components/Icons'11import OriginBadge from '../components/OriginBadge'12import ProductCard from '../components/ProductCard'13import Skeleton from '../components/Skeleton'14import StoreLogo from '../components/StoreLogo'1516const DESCRIPTION_CLAMP = 2801718export default function ProductDetail() {19  const { uid } = useParams<{ uid: string }>()20  const [product, setProduct] = useState<ProductDetailType | null>(null)21  const [error, setError] = useState(false)22  const [activeImage, setActiveImage] = useState(0)23  const [descExpanded, setDescExpanded] = useState(false)24  const trackRef = useRef<HTMLDivElement>(null)2526  useEffect(() => {27    if (!uid) return28    const controller = new AbortController()29    setProduct(null)30    setError(false)31    setActiveImage(0)32    setDescExpanded(false)33    fetchProduct(uid, controller.signal)34      .then(setProduct)35      .catch((err: unknown) => {36        if (err instanceof DOMException && err.name === 'AbortError') return37        setError(true)38      })39    window.scrollTo({ top: 0 })40    return () => controller.abort()41  }, [uid])4243  function onTrackScroll(e: UIEvent<HTMLDivElement>) {44    const el = e.currentTarget45    if (el.clientWidth === 0) return46    const idx = Math.round(el.scrollLeft / el.clientWidth)47    if (idx !== activeImage) setActiveImage(idx)48  }4950  function goToImage(i: number) {51    const el = trackRef.current52    if (!el) return53    el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })54    setActiveImage(i)55  }5657  if (error) {58    return (59      <div className="page">60        <EmptyState message="Produit introuvable.">61          <Link className="btn btn-secondary" to="/produits">62            Retour aux produits63          </Link>64        </EmptyState>65      </div>66    )67  }6869  if (!product) {70    return (71      <div className="page">72        <div className="product-detail">73          <div className="product-detail-gallery">74            <Skeleton height="auto" radius="8px" className="skeleton-square" />75          </div>76          <div className="product-detail-info">77            <Skeleton height="2.2rem" width="80%" />78            <Skeleton height="1.6rem" width="30%" />79            <Skeleton height="1rem" width="100%" />80            <Skeleton height="1rem" width="90%" />81            <Skeleton height="3rem" width="60%" radius="8px" />82          </div>83        </div>84      </div>85    )86  }8788  const images = product.images89  const hasDiscount =90    product.compare_at_price !== null &&91    product.price !== null &&92    product.compare_at_price > product.price93  const originLabel = ORIGIN_LABELS[product.origin_class] ?? ''94  const description = product.description ?? ''95  const descIsLong = description.length > DESCRIPTION_CLAMP9697  return (98    <div className="page page-product-detail">99      <nav className="breadcrumb" aria-label="Fil d'Ariane">100        <Link to="/produits">Produits</Link>101        {product.category && (102          <>103            <span aria-hidden="true">/</span>104            <Link to={`/produits?category=${encodeURIComponent(product.category)}`}>105              {product.category}106            </Link>107          </>108        )}109      </nav>110111      <div className="product-detail">112        <div className="product-detail-gallery">113          {images.length > 0 ? (114            <>115              <div116                className="pd-track"117                ref={trackRef}118                onScroll={onTrackScroll}119                aria-label={`Images du produit (${images.length})`}120              >121                {images.map((img, i) => (122                  <div className="pd-slide" key={`${img}-${i}`}>123                    <GalleryImage124                      src={img}125                      alt={i === 0 ? product.title : ''}126                      eager={i === 0}127                    />128                  </div>129                ))}130              </div>131              {images.length > 1 && (132                <>133                  <div className="pd-dots" aria-hidden="true">134                    {images.map((_, i) => (135                      <span136                        key={i}137                        className={i === activeImage ? 'pd-dot pd-dot-active' : 'pd-dot'}138                      />139                    ))}140                  </div>141                  <div className="product-detail-thumbs">142                    {images.map((img, i) => (143                      <button144                        key={`${img}-${i}`}145                        type="button"146                        className={i === activeImage ? 'thumb thumb-active' : 'thumb'}147                        onClick={() => goToImage(i)}148                        aria-label={`Image ${i + 1}`}149                      >150                        <img src={img} alt="" loading="lazy" />151                      </button>152                    ))}153                  </div>154                </>155              )}156            </>157          ) : (158            <div className="pd-track">159              <div className="pd-slide">160                <div className="image-placeholder" aria-hidden="true">161                  <IconLeaf size={44} />162                </div>163              </div>164            </div>165          )}166        </div>167168        <div className="product-detail-info">169          <div className="product-detail-badges">170            <OriginBadge origin={product.origin_class} withLabel />171            {!product.available && (172              <span className="unavailable-badge">Non disponible</span>173            )}174          </div>175          <h1 className="product-detail-title">{product.title}</h1>176177          <div className="product-detail-price">178            {product.price !== null ? (179              <span className="price-chip price-chip-lg">180                {formatPrice(product.price)}181                {product.price_max !== null &&182                  product.price_max > product.price &&183                  ` – ${formatPrice(product.price_max)}`}184              </span>185            ) : (186              <span className="price-chip price-chip-muted">187                Prix affiché en boutique188              </span>189            )}190            {hasDiscount && (191              <s className="price-compare">192                {formatPrice(product.compare_at_price)}193              </s>194            )}195          </div>196197          {description && (198            <div className="product-detail-description-wrap">199              <p200                className={201                  descIsLong && !descExpanded202                    ? 'product-detail-description product-detail-description-clamped'203                    : 'product-detail-description'204                }205              >206                {description}207              </p>208              {descIsLong && (209                <button210                  type="button"211                  className="desc-toggle"212                  onClick={() => setDescExpanded((v) => !v)}213                  aria-expanded={descExpanded}214                >215                  {descExpanded ? 'Réduire' : 'Lire la suite'}216                </button>217              )}218            </div>219          )}220221          <dl className="product-detail-meta">222            {product.vendor && (223              <div>224                <dt>Marque</dt>225                <dd>{product.vendor}</dd>226              </div>227            )}228            {product.category && (229              <div>230                <dt>Catégorie</dt>231                <dd>{product.category}</dd>232              </div>233            )}234            {product.product_type && (235              <div>236                <dt>Type</dt>237                <dd>{product.product_type}</dd>238              </div>239            )}240            {originLabel && (241              <div>242                <dt>Origine</dt>243                <dd>{originLabel}</dd>244              </div>245            )}246          </dl>247248          {product.tags.length > 0 && (249            <div className="tag-list">250              {product.tags.map((t) => (251                <span className="tag" key={t}>252                  {t}253                </span>254              ))}255            </div>256          )}257258          <a259            className="btn btn-primary btn-cta"260            href={product.url}261            target="_blank"262            rel="noopener noreferrer"263          >264            Voir chez {product.store_name} <IconExternal size={16} />265          </a>266267          <div className="card store-info-card">268            <span className="filter-label">Boutique</span>269            <div className="store-info-row">270              <StoreLogo271                storeId={product.store_id}272                name={product.store_name}273                size="sm"274              />275              <div className="store-info-text">276                <Link277                  to={`/boutiques/${encodeURIComponent(product.store_id)}`}278                  className="store-info-name"279                >280                  {product.store_name}281                </Link>282                <p className="store-info-location">283                  {[product.store_city, product.store_region]284                    .filter(Boolean)285                    .join(', ') || 'Québec'}286                </p>287              </div>288            </div>289          </div>290        </div>291      </div>292293      {product.related.length > 0 && (294        <section className="rail">295          <header className="section-header">296            <h2>Produits similaires</h2>297          </header>298          <div className="rail-track">299            {product.related.map((p) => (300              <div className="rail-item" key={p.uid}>301                <ProductCard product={p} />302              </div>303            ))}304          </div>305        </section>306      )}307308      {/* Mobile sticky CTA (safe-area aware) */}309      <div className="pd-cta-bar">310        <div className="pd-cta-price">311          {product.price !== null ? (312            <span className="price-chip">{formatPrice(product.price)}</span>313          ) : (314            <span className="price-chip price-chip-muted">Prix en boutique</span>315          )}316        </div>317        <a318          className="btn btn-primary pd-cta-btn"319          href={product.url}320          target="_blank"321          rel="noopener noreferrer"322        >323          Voir chez {product.store_name} <IconExternal size={15} />324        </a>325      </div>326    </div>327  )328}329330function GalleryImage({331  src,332  alt,333  eager,334}: {335  src: string336  alt: string337  eager?: boolean338}) {339  const [failed, setFailed] = useState(false)340  if (failed) {341    return (342      <div className="image-placeholder" aria-hidden="true">343        <IconLeaf size={44} />344      </div>345    )346  }347  return (348    <img349      src={src}350      alt={alt}351      loading={eager ? 'eager' : 'lazy'}352      onError={() => setFailed(true)}353    />354  )355}356