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 { useEffect, useMemo, useState } from 'react'2import { Link } from 'react-router-dom'3import {4 ExtendedStats,5 fetchExtendedStats,6 formatInt,7 formatPrice,8 formatPriceCompact,9 GrowthPoint,10 ORIGIN_KEYS,11 ORIGIN_LABELS,12} from '../api'13import CountUp from '../components/CountUp'14import EmptyState from '../components/EmptyState'15import { IconDownload } from '../components/Icons'16import OriginBadge from '../components/OriginBadge'17import Skeleton from '../components/Skeleton'18import StoreLogo from '../components/StoreLogo'1920const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endroit.'2122// ---------------------------------------------------------------------------23// Formatting helpers local to the stats page24// ---------------------------------------------------------------------------2526const percentFormatter = new Intl.NumberFormat('fr-CA', {27 style: 'percent',28 maximumFractionDigits: 1,29})3031function formatShare(part: number, total: number): string {32 if (!total) return ''33 return percentFormatter.format(part / total)34}3536const dayFormatter = new Intl.DateTimeFormat('fr-CA', {37 day: 'numeric',38 month: 'short',39})4041function formatDay(day: string): string {42 const d = new Date(`${day}T00:00:00`)43 return Number.isNaN(d.getTime()) ? day : dayFormatter.format(d)44}4546const BUCKET_LABELS: Record<string, string> = {47 '0-10': 'Moins de 10 $',48 '10-25': '10 – 25 $',49 '25-50': '25 – 50 $',50 '50-100': '50 – 100 $',51 '100-250': '100 – 250 $',52 '250-1000': '250 – 1 000 $',53 '1000+': '1 000 $ et plus',54}5556const REPORT_URL = '/api/report.pdf'57const REPORT_HINT = 'Rapport de marché complet — PDF, mise à jour en continu'5859// Availability keys → French label + CSS slug (pine / muted / border)60const AVAILABILITY_META: Record<61 string,62 { label: string; slug: string; order: number }63> = {64 'en stock': { label: 'En stock', slug: 'stock', order: 0 },65 rupture: { label: 'En rupture', slug: 'rupture', order: 1 },66 inconnu: { label: 'Inconnu', slug: 'inconnu', order: 2 },67}6869// ---------------------------------------------------------------------------70// Growth — hand-rolled SVG area chart (terracotta line on sand fill)71// ---------------------------------------------------------------------------7273function GrowthChart({ points }: { points: GrowthPoint[] }) {74 // API returns the last 30 days DESC → plot ASC.75 const asc = [...points].sort((a, b) => a.day.localeCompare(b.day))76 const W = 64077 const H = 20078 const PAD_X = 879 const PAD_TOP = 2680 const PAD_BOTTOM = 2681 const innerW = W - PAD_X * 282 const innerH = H - PAD_TOP - PAD_BOTTOM83 const baseline = H - PAD_BOTTOM8485 const max = Math.max(...asc.map((p) => p.n), 1)86 const min = Math.min(...asc.map((p) => p.n))87 const x = (i: number) =>88 PAD_X + (asc.length > 1 ? (i * innerW) / (asc.length - 1) : innerW / 2)89 const y = (n: number) => PAD_TOP + (1 - n / max) * innerH9091 const line = asc92 .map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.n).toFixed(1)}`)93 .join(' ')94 const area = `${line} L${x(asc.length - 1).toFixed(1)},${baseline} L${x(0).toFixed(1)},${baseline} Z`9596 const maxIdx = asc.findIndex((p) => p.n === max)97 const minIdx = asc.findIndex((p) => p.n === min)98 const clampX = (v: number) => Math.min(W - 30, Math.max(30, v))99100 return (101 <figure className="stats-growth-figure">102 <svg103 viewBox={`0 0 ${W} ${H}`}104 className="stats-growth-svg"105 role="img"106 aria-label={`Produits ajoutés par jour, du ${formatDay(asc[0].day)} au ${formatDay(asc[asc.length - 1].day)}. Maximum ${formatInt(max)}, minimum ${formatInt(min)}.`}107 >108 <line109 x1={PAD_X}110 y1={baseline}111 x2={W - PAD_X}112 y2={baseline}113 className="stats-growth-baseline"114 />115 <path d={area} className="stats-growth-area" />116 <path d={line} className="stats-growth-line" />117118 {/* min/max direct labels only — no axis machinery */}119 <circle cx={x(maxIdx)} cy={y(max)} r={3.5} className="stats-growth-dot" />120 <text121 x={clampX(x(maxIdx))}122 y={y(max) - 9}123 textAnchor="middle"124 className="stats-growth-label"125 >126 {formatInt(max)}127 </text>128 {minIdx !== maxIdx && (129 <>130 <circle131 cx={x(minIdx)}132 cy={y(min)}133 r={3}134 className="stats-growth-dot stats-growth-dot-min"135 />136 <text137 x={clampX(x(minIdx))}138 y={Math.min(y(min) + 16, baseline - 4)}139 textAnchor="middle"140 className="stats-growth-label"141 >142 {formatInt(min)}143 </text>144 </>145 )}146147 <text x={PAD_X} y={H - 8} className="stats-growth-axis">148 {formatDay(asc[0].day)}149 </text>150 <text x={W - PAD_X} y={H - 8} textAnchor="end" className="stats-growth-axis">151 {formatDay(asc[asc.length - 1].day)}152 </text>153154 {/* hover layer: one generous hit target per point */}155 {asc.map((p, i) => (156 <circle key={p.day} cx={x(i)} cy={y(p.n)} r={9} fill="transparent">157 <title>{`${formatDay(p.day)} — ${formatInt(p.n)} produit${p.n > 1 ? 's' : ''}`}</title>158 </circle>159 ))}160 </svg>161 </figure>162 )163}164165// ---------------------------------------------------------------------------166// Page167// ---------------------------------------------------------------------------168169export default function Stats() {170 const [stats, setStats] = useState<ExtendedStats | null>(null)171 const [error, setError] = useState(false)172173 useEffect(() => {174 const controller = new AbortController()175 fetchExtendedStats(controller.signal)176 .then(setStats)177 .catch((err: unknown) => {178 if (err instanceof DOMException && err.name === 'AbortError') return179 setError(true)180 })181 window.scrollTo({ top: 0 })182 return () => controller.abort()183 }, [])184185 useEffect(() => {186 document.title = 'Statistiques — Fabri-Ka'187 return () => {188 document.title = DEFAULT_TITLE189 }190 }, [])191192 const generatedOn = useMemo(193 () =>194 new Intl.DateTimeFormat('fr-CA', { dateStyle: 'long' }).format(new Date()),195 []196 )197198 if (error) {199 return (200 <div className="page stats">201 <EmptyState202 variant="error"203 title="Statistiques indisponibles"204 message="Impossible de charger les statistiques pour le moment. Réessayez dans quelques instants."205 >206 <Link className="btn btn-secondary" to="/">207 Retour à l'accueil208 </Link>209 </EmptyState>210 </div>211 )212 }213214 const totals = stats?.totals215 const bucketMax = stats216 ? Math.max(...stats.price_buckets.map((b) => b.n), 1)217 : 1218 const regionMax = stats219 ? Math.max(...stats.by_region.map((r) => r.products), 1)220 : 1221 // fixed A→E order (identity colors follow the entity, never its rank)222 const originRows = stats223 ? ORIGIN_KEYS.flatMap((key) => {224 const stat = stats.by_origin.find((o) => o.key === key)225 return stat && (stat.products > 0 || stat.stores > 0)226 ? [{ key, stat }]227 : []228 })229 : []230 const originTotal = originRows.reduce((sum, r) => sum + r.stat.products, 0)231 const catMax = stats232 ? Math.max(...stats.by_category.map((c) => c.products), 1)233 : 1234235 const availabilityRows = stats236 ? [...stats.availability]237 .filter((a) => AVAILABILITY_META[a.key])238 .sort(239 (a, b) => AVAILABILITY_META[a.key].order - AVAILABILITY_META[b.key].order240 )241 : []242 const availabilityTotal = availabilityRows.reduce((sum, a) => sum + a.n, 0)243244 const coverageMeters =245 stats && totals246 ? [247 {248 label: 'Produits avec image',249 value: stats.coverage.with_image,250 total: totals.products,251 },252 {253 label: 'Produits avec description',254 value: stats.coverage.with_desc,255 total: totals.products,256 },257 {258 label: 'Boutiques avec logo',259 value: stats.coverage.with_logo,260 total: totals.stores_registry,261 },262 {263 label: 'Boutiques géolocalisées',264 value: stats.coverage.with_region,265 total: totals.stores_registry,266 },267 ].map((m) => ({ ...m, pct: m.total ? m.value / m.total : 0 }))268 : []269270 return (271 <div className="page stats">272 {/* 1 — editorial header */}273 <section className="stats-hero">274 <p className="hero-eyebrow">Statistiques</p>275 <h1 className="stats-hero-title">276 Le Québec qui vend en ligne, <em>en chiffres.</em>277 </h1>278 <p className="stats-hero-sub">279 Portrait généré à partir des catalogues publics agrégés par Fabri-Ka —280 données au {generatedOn}, recalculées en continu. Chaque chiffre est281 vivant : il se met à jour à mesure que les boutiques se synchronisent.282 Pour le détail complet, téléchargez le rapport de marché.283 </p>284 <div className="stats-hero-actions">285 <a286 className="btn btn-primary stats-report-btn"287 href={REPORT_URL}288 download289 >290 <IconDownload size={18} />291 Télécharger le rapport PDF292 </a>293 <span className="stats-report-hint">{REPORT_HINT}</span>294 </div>295 </section>296297 {/* 2 — KPI tiles */}298 <section className="stats-kpis" aria-label="Chiffres clés">299 {totals ? (300 <>301 <div className="stats-kpi">302 <span className="stats-kpi-value">303 <CountUp value={totals.products} />304 </span>305 <span className="stats-kpi-label">Produits</span>306 <span className="stats-kpi-sub">307 {formatInt(totals.products_priced)} avec prix affiché308 </span>309 </div>310 <div className="stats-kpi">311 <span className="stats-kpi-value">312 <CountUp value={totals.stores_live} />313 </span>314 <span className="stats-kpi-label">Boutiques actives</span>315 <span className="stats-kpi-sub">316 {formatShare(totals.stores_live, totals.stores_registry) ||317 '—'}{' '}318 du registre319 </span>320 </div>321 <div className="stats-kpi">322 <span className="stats-kpi-value">323 <CountUp value={totals.stores_registry} />324 </span>325 <span className="stats-kpi-label">Boutiques au registre</span>326 <span className="stats-kpi-sub">boutiques suivies</span>327 </div>328 <div className="stats-kpi">329 <span className="stats-kpi-value">330 <CountUp value={totals.regions} />331 </span>332 <span className="stats-kpi-label">Régions</span>333 <span className="stats-kpi-sub">du Québec couvertes</span>334 </div>335 <div className="stats-kpi">336 <span className="stats-kpi-value">337 {formatPrice(totals.price_median) || '—'}338 </span>339 <span className="stats-kpi-label">Prix médian</span>340 <span className="stats-kpi-sub">341 sur {formatInt(totals.products_priced)} produits342 </span>343 </div>344 <div className="stats-kpi">345 <span className="stats-kpi-value">346 {formatPrice(totals.price_avg) || '—'}347 </span>348 <span className="stats-kpi-label">Prix moyen</span>349 <span className="stats-kpi-sub">panier type</span>350 </div>351 </>352 ) : (353 Array.from({ length: 6 }, (_, i) => (354 <div className="stats-kpi" key={i}>355 <Skeleton width="80px" height="2rem" />356 <Skeleton width="110px" height="0.8rem" />357 <Skeleton width="90px" height="0.7rem" />358 </div>359 ))360 )}361 </section>362363 {!stats ? (364 <div className="stats-loading" aria-hidden="true">365 <Skeleton height="220px" radius="12px" />366 <Skeleton height="320px" radius="12px" />367 <Skeleton height="220px" radius="12px" />368 </div>369 ) : (370 <>371 {/* 3 — nouveautés du marché */}372 {stats.newest.length > 0 && (373 <section className="stats-section" aria-label="Nouveautés du marché">374 <header className="section-header">375 <h2>Nouveautés du marché</h2>376 <Link className="section-see-all" to="/produits?sort=recent">377 Voir les récents378 </Link>379 </header>380 <div className="stats-newest-rail">381 {stats.newest.map((p) => (382 <Link383 key={p.uid}384 className="card stats-newest-card"385 to={`/produits/${encodeURIComponent(p.uid)}`}386 >387 <span className="stats-newest-chip">388 {p.category_label || 'Divers'}389 </span>390 <span className="stats-newest-title">{p.title}</span>391 <span className="stats-newest-foot">392 <span className="stats-newest-price">393 {formatPrice(p.price) || 'Prix n.d.'}394 </span>395 <span className="stats-newest-store">{p.store_name}</span>396 </span>397 </Link>398 ))}399 </div>400 </section>401 )}402403 {/* 4 — price histogram */}404 {stats.price_buckets.length > 0 && totals && (405 <section className="stats-section" aria-label="Répartition des prix">406 <header className="section-header">407 <h2>Répartition des prix</h2>408 </header>409 <p className="stats-section-note">410 {formatInt(totals.products_priced)} produits avec prix affiché.411 </p>412 <div className="stats-bars">413 {stats.price_buckets.map((b) => (414 <div className="stats-bar-row" key={b.bucket}>415 <span className="stats-bar-name">416 {BUCKET_LABELS[b.bucket] ?? b.bucket}417 </span>418 <span className="stats-bar-track">419 <span420 className="stats-bar-fill stats-bar-fill-terracotta"421 style={{ width: `${(b.n / bucketMax) * 100}%` }}422 />423 </span>424 <span className="stats-bar-value">425 {formatInt(b.n)}426 <em>{formatShare(b.n, totals.products_priced)}</em>427 </span>428 </div>429 ))}430 </div>431 <p className="stats-hist-caption">432 Prix médian{' '}433 <strong>{formatPrice(totals.price_median) || '—'}</strong> · prix434 moyen <strong>{formatPrice(totals.price_avg) || '—'}</strong>435 </p>436 </section>437 )}438439 {/* 4 — categories */}440 {stats.by_category.length > 0 && (441 <section className="stats-section" aria-label="Catégories">442 <header className="section-header">443 <h2>Par catégorie</h2>444 <Link className="section-see-all" to="/produits">445 Tout le catalogue446 </Link>447 </header>448449 {/* desktop table */}450 <div className="stats-cat-table-wrap">451 <table className="stats-cat-table">452 <thead>453 <tr>454 <th>Catégorie</th>455 <th className="num">Produits ↓</th>456 <th className="num">Boutiques</th>457 <th className="num">Prix médian</th>458 <th className="num">Prix moyen</th>459 <th className="num">Fourchette</th>460 </tr>461 </thead>462 <tbody>463 {stats.by_category.map((c) => (464 <tr key={c.key}>465 <td>466 <Link467 className="stats-cat-link"468 to={`/produits?category=${encodeURIComponent(c.key)}`}469 >470 {c.label}471 </Link>472 </td>473 <td className="num stats-cat-prod">474 <span className="stats-cat-prod-inner">475 <span476 className="stats-cat-prod-bar"477 aria-hidden="true"478 >479 <span480 className="stats-cat-prod-fill"481 style={{482 width: `${(c.products / catMax) * 100}%`,483 }}484 />485 </span>486 <span className="stats-cat-prod-n">487 {formatInt(c.products)}488 </span>489 </span>490 </td>491 <td className="num">{formatInt(c.stores)}</td>492 <td className="num">{formatPrice(c.price_median) || '—'}</td>493 <td className="num">{formatPrice(c.price_avg) || '—'}</td>494 <td className="num stats-cat-range">495 {c.price_min !== null && c.price_max !== null496 ? `${formatPriceCompact(c.price_min)} – ${formatPriceCompact(c.price_max)}`497 : '—'}498 </td>499 </tr>500 ))}501 </tbody>502 </table>503 </div>504505 {/* mobile stacked cards */}506 <div className="stats-cat-cards">507 {stats.by_category.map((c) => (508 <Link509 key={c.key}510 className="card stats-cat-card"511 to={`/produits?category=${encodeURIComponent(c.key)}`}512 >513 <div className="stats-cat-card-head">514 <strong>{c.label}</strong>515 <span className="stats-cat-card-count">516 {formatInt(c.products)} produits517 </span>518 </div>519 <dl className="stats-cat-card-grid">520 <div>521 <dt>Boutiques</dt>522 <dd>{formatInt(c.stores)}</dd>523 </div>524 <div>525 <dt>Prix médian</dt>526 <dd>{formatPrice(c.price_median) || '—'}</dd>527 </div>528 <div>529 <dt>Prix moyen</dt>530 <dd>{formatPrice(c.price_avg) || '—'}</dd>531 </div>532 <div>533 <dt>Fourchette</dt>534 <dd>535 {c.price_min !== null && c.price_max !== null536 ? `${formatPriceCompact(c.price_min)} – ${formatPriceCompact(c.price_max)}`537 : '—'}538 </dd>539 </div>540 </dl>541 </Link>542 ))}543 </div>544 </section>545 )}546547 {/* 5 — regions */}548 {stats.by_region.length > 0 && (549 <section className="stats-section" aria-label="Régions">550 <header className="section-header">551 <h2>Par région</h2>552 </header>553 <div className="stats-regions">554 {stats.by_region.map((r) => (555 <div className="stats-region-row" key={r.key}>556 <div className="stats-region-head">557 <Link558 className="stats-region-name"559 to={`/produits?region=${encodeURIComponent(r.key)}`}560 >561 {r.key}562 </Link>563 <span className="stats-region-meta">564 {formatInt(r.stores)} boutique{r.stores > 1 ? 's' : ''}565 {r.price_avg !== null &&566 ` · prix moyen ${formatPriceCompact(r.price_avg)}`}567 </span>568 </div>569 <div className="stats-bar-row stats-bar-row-flat">570 <span className="stats-bar-track">571 <span572 className="stats-bar-fill stats-bar-fill-pine"573 style={{ width: `${(r.products / regionMax) * 100}%` }}574 />575 </span>576 <span className="stats-bar-value">{formatInt(r.products)}</span>577 </div>578 </div>579 ))}580 </div>581 </section>582 )}583584 {/* 6 — growth */}585 {stats.growth.length > 1 && (586 <section className="stats-section" aria-label="Croissance">587 <header className="section-header">588 <h2>Produits ajoutés par jour (30 jours)</h2>589 </header>590 <GrowthChart points={stats.growth} />591 </section>592 )}593594 {/* 7 — origin distribution */}595 {originRows.length > 0 && originTotal > 0 && (596 <section className="stats-section" aria-label="Origine">597 <header className="section-header">598 <h2>Par origine</h2>599 </header>600 <div601 className="stats-origin-bar"602 role="img"603 aria-label={originRows604 .map(605 (r) =>606 `${ORIGIN_LABELS[r.key]} : ${formatInt(r.stat.products)} produits`607 )608 .join(' · ')}609 >610 {originRows611 .filter((r) => r.stat.products > 0)612 .map((r) => {613 const pct = (r.stat.products / originTotal) * 100614 return (615 <span616 key={r.key}617 className={`stats-origin-seg origin-${r.key}`}618 style={{ width: `${pct}%` }}619 title={`${ORIGIN_LABELS[r.key]} — ${formatInt(r.stat.products)} produits (${formatShare(r.stat.products, originTotal)})`}620 >621 {pct >= 6 && (622 <span className="stats-origin-seg-letter">{r.key}</span>623 )}624 </span>625 )626 })}627 </div>628 <ul className="stats-origin-legend">629 {originRows.map((r) => (630 <li key={r.key}>631 <OriginBadge origin={r.key} withLabel />632 <span className="stats-origin-legend-counts">633 {formatInt(r.stat.stores)} boutique634 {r.stat.stores > 1 ? 's' : ''} ·{' '}635 {formatInt(r.stat.products)} produits636 {originTotal > 0 &&637 r.stat.products > 0 &&638 ` (${formatShare(r.stat.products, originTotal)})`}639 </span>640 </li>641 ))}642 </ul>643 </section>644 )}645646 {/* 8 — platforms */}647 {stats.by_platform.length > 0 && (648 <section className="stats-section" aria-label="Plateformes">649 <header className="section-header">650 <h2>Plateformes</h2>651 </header>652 <div className="stats-platforms">653 {stats.by_platform.map((p) => (654 <span className="stats-platform-chip" key={p.key}>655 <strong>{p.key}</strong> × {formatInt(p.stores)} boutique656 {p.stores > 1 ? 's' : ''} ({formatInt(p.products)} produits)657 </span>658 ))}659 </div>660 </section>661 )}662663 {/* disponibilité */}664 {availabilityRows.length > 0 && availabilityTotal > 0 && (665 <section className="stats-section" aria-label="Disponibilité">666 <header className="section-header">667 <h2>Disponibilité</h2>668 </header>669 <div670 className="stats-avail-bar"671 role="img"672 aria-label={availabilityRows673 .map(674 (a) =>675 `${AVAILABILITY_META[a.key].label} : ${formatInt(a.n)} produits`676 )677 .join(' · ')}678 >679 {availabilityRows680 .filter((a) => a.n > 0)681 .map((a) => (682 <span683 key={a.key}684 className={`stats-avail-seg stats-avail-${AVAILABILITY_META[a.key].slug}`}685 style={{ width: `${(a.n / availabilityTotal) * 100}%` }}686 title={`${AVAILABILITY_META[a.key].label} — ${formatInt(a.n)} (${formatShare(a.n, availabilityTotal)})`}687 />688 ))}689 </div>690 <ul className="stats-avail-legend">691 {availabilityRows.map((a) => (692 <li key={a.key}>693 <span694 className={`stats-avail-dot stats-avail-${AVAILABILITY_META[a.key].slug}`}695 aria-hidden="true"696 />697 <span>698 {AVAILABILITY_META[a.key].label}{' '}699 <strong>{formatInt(a.n)}</strong>{' '}700 <em>{formatShare(a.n, availabilityTotal)}</em>701 </span>702 </li>703 ))}704 </ul>705 </section>706 )}707708 {/* complétude des données */}709 {coverageMeters.length > 0 && (710 <section711 className="stats-section"712 aria-label="Complétude des données"713 >714 <header className="section-header">715 <h2>Complétude des données</h2>716 </header>717 <div className="stats-coverage">718 {coverageMeters.map((m) => (719 <div className="stats-meter" key={m.label}>720 <div className="stats-meter-head">721 <span className="stats-meter-label">{m.label}</span>722 <span className="stats-meter-pct">723 {percentFormatter.format(m.pct)}724 </span>725 </div>726 <span className="stats-meter-track">727 <span728 className="stats-meter-fill"729 style={{ width: `${m.pct * 100}%` }}730 />731 </span>732 <span className="stats-meter-sub">733 {formatInt(m.value)} / {formatInt(m.total)}734 </span>735 </div>736 ))}737 </div>738 </section>739 )}740741 <div className="stats-columns">742 {/* 9 — top stores */}743 {stats.top_stores.length > 0 && (744 <section className="stats-section" aria-label="Top boutiques">745 <header className="section-header">746 <h2>Top boutiques</h2>747 <Link className="section-see-all" to="/boutiques">748 Toutes les boutiques749 </Link>750 </header>751 <ol className="stats-top-stores">752 {stats.top_stores.map((s, i) => (753 <li key={s.id}>754 <span className="stats-rank">{i + 1}</span>755 <StoreLogo756 storeId={s.id}757 name={s.name}758 logoUrl={s.logo_url}759 size="sm"760 />761 <span className="stats-top-store-id">762 <Link to={`/boutiques/${encodeURIComponent(s.id)}`}>763 {s.name}764 </Link>765 {s.region && (766 <span className="stats-top-store-region">{s.region}</span>767 )}768 </span>769 <span className="stats-top-store-count">770 {formatInt(s.products)}771 </span>772 </li>773 ))}774 </ol>775 </section>776 )}777778 {/* 10 — most expensive */}779 {stats.most_expensive.length > 0 && (780 <section className="stats-section" aria-label="Produits les plus chers">781 <header className="section-header">782 <h2>Les plus chers</h2>783 </header>784 <p className="stats-section-note">785 Le grand luxe made in Québec — véridique, promis.786 </p>787 <ol className="stats-expensive">788 {stats.most_expensive.map((p) => (789 <li key={p.uid}>790 <Link791 className="stats-expensive-link"792 to={`/produits/${encodeURIComponent(p.uid)}`}793 >794 <span className="stats-expensive-title">{p.title}</span>795 <span className="stats-expensive-store">{p.store_name}</span>796 </Link>797 <span className="stats-expensive-price">798 {formatPrice(p.price)}799 </span>800 </li>801 ))}802 </ol>803 </section>804 )}805 </div>806 </>807 )}808809 {/* report download — footer */}810 <section className="stats-report-footer" aria-label="Rapport de marché">811 <a812 className="btn btn-secondary stats-report-btn"813 href={REPORT_URL}814 download815 >816 <IconDownload size={18} />817 Télécharger le rapport PDF818 </a>819 <span className="stats-report-hint">{REPORT_HINT}</span>820 </section>821 </div>822 )823}824