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%
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