spb/ora-ka Public
Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)
Python 80%
TypeScript 12.9%
CSS 6.8%
1import { useEffect, useMemo, useState } from 'react'2import { Link } from 'react-router-dom'3import {4 API_BASE,5 ExtendedStats,6 fetchExtendedStats,7 formatInt,8 formatPrice,9 formatPriceCompact,10 GrowthPoint,11 ORIGIN_KEYS,12 ORIGIN_LABELS,13} from '../api'14import CountUp from '../components/CountUp'15import EmptyState from '../components/EmptyState'16import { IconDownload } from '../components/Icons'17import OriginBadge from '../components/OriginBadge'18import Skeleton from '../components/Skeleton'19import StoreLogo from '../components/StoreLogo'2021const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endroit.'2223// ---------------------------------------------------------------------------24// Formatting helpers local to the stats page25// ---------------------------------------------------------------------------2627const percentFormatter = new Intl.NumberFormat('fr-CA', {28 style: 'percent',29 maximumFractionDigits: 1,30})3132function formatShare(part: number, total: number): string {33 if (!total) return ''34 return percentFormatter.format(part / total)35}3637const dayFormatter = new Intl.DateTimeFormat('fr-CA', {38 day: 'numeric',39 month: 'short',40})4142function formatDay(day: string): string {43 const d = new Date(`${day}T00:00:00`)44 return Number.isNaN(d.getTime()) ? day : dayFormatter.format(d)45}4647const BUCKET_LABELS: Record<string, string> = {48 '0-10': 'Moins de 10 $',49 '10-25': '10 – 25 $',50 '25-50': '25 – 50 $',51 '50-100': '50 – 100 $',52 '100-250': '100 – 250 $',53 '250-1000': '250 – 1 000 $',54 '1000+': '1 000 $ et plus',55}5657const REPORT_URL = `${API_BASE}/api/report.pdf`58const REPORT_HINT = 'Rapport de marché complet — PDF, mise à jour en continu'5960// Availability keys → French label + CSS slug (pine / muted / border)61const AVAILABILITY_META: Record<62 string,63 { label: string; slug: string; order: number }64> = {65 'en stock': { label: 'En stock', slug: 'stock', order: 0 },66 rupture: { label: 'En rupture', slug: 'rupture', order: 1 },67 inconnu: { label: 'Inconnu', slug: 'inconnu', order: 2 },68}6970// ---------------------------------------------------------------------------71// Growth — hand-rolled SVG area chart (terracotta line on sand fill)72// ---------------------------------------------------------------------------7374function GrowthChart({ points }: { points: GrowthPoint[] }) {75 // API returns the last 30 days DESC → plot ASC.76 const asc = [...points].sort((a, b) => a.day.localeCompare(b.day))77 const W = 64078 const H = 20079 const PAD_X = 880 const PAD_TOP = 2681 const PAD_BOTTOM = 2682 const innerW = W - PAD_X * 283 const innerH = H - PAD_TOP - PAD_BOTTOM84 const baseline = H - PAD_BOTTOM8586 const max = Math.max(...asc.map((p) => p.n), 1)87 const min = Math.min(...asc.map((p) => p.n))88 const x = (i: number) =>89 PAD_X + (asc.length > 1 ? (i * innerW) / (asc.length - 1) : innerW / 2)90 const y = (n: number) => PAD_TOP + (1 - n / max) * innerH9192 const line = asc93 .map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.n).toFixed(1)}`)94 .join(' ')95 const area = `${line} L${x(asc.length - 1).toFixed(1)},${baseline} L${x(0).toFixed(1)},${baseline} Z`9697 const maxIdx = asc.findIndex((p) => p.n === max)98 const minIdx = asc.findIndex((p) => p.n === min)99 const clampX = (v: number) => Math.min(W - 30, Math.max(30, v))100101 return (102 <figure className="stats-growth-figure">103 <svg104 viewBox={`0 0 ${W} ${H}`}105 className="stats-growth-svg"106 role="img"107 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)}.`}108 >109 <line110 x1={PAD_X}111 y1={baseline}112 x2={W - PAD_X}113 y2={baseline}114 className="stats-growth-baseline"115 />116 <path d={area} className="stats-growth-area" />117 <path d={line} className="stats-growth-line" />118119 {/* min/max direct labels only — no axis machinery */}120 <circle cx={x(maxIdx)} cy={y(max)} r={3.5} className="stats-growth-dot" />121 <text122 x={clampX(x(maxIdx))}123 y={y(max) - 9}124 textAnchor="middle"125 className="stats-growth-label"126 >127 {formatInt(max)}128 </text>129 {minIdx !== maxIdx && (130 <>131 <circle132 cx={x(minIdx)}133 cy={y(min)}134 r={3}135 className="stats-growth-dot stats-growth-dot-min"136 />137 <text138 x={clampX(x(minIdx))}139 y={Math.min(y(min) + 16, baseline - 4)}140 textAnchor="middle"141 className="stats-growth-label"142 >143 {formatInt(min)}144 </text>145 </>146 )}147148 <text x={PAD_X} y={H - 8} className="stats-growth-axis">149 {formatDay(asc[0].day)}150 </text>151 <text x={W - PAD_X} y={H - 8} textAnchor="end" className="stats-growth-axis">152 {formatDay(asc[asc.length - 1].day)}153 </text>154155 {/* hover layer: one generous hit target per point */}156 {asc.map((p, i) => (157 <circle key={p.day} cx={x(i)} cy={y(p.n)} r={9} fill="transparent">158 <title>{`${formatDay(p.day)} — ${formatInt(p.n)} produit${p.n > 1 ? 's' : ''}`}</title>159 </circle>160 ))}161 </svg>162 </figure>163 )164}165166// ---------------------------------------------------------------------------167// Page168// ---------------------------------------------------------------------------169170export default function Stats() {171 const [stats, setStats] = useState<ExtendedStats | null>(null)172 const [error, setError] = useState(false)173174 useEffect(() => {175 const controller = new AbortController()176 fetchExtendedStats(controller.signal)177 .then(setStats)178 .catch((err: unknown) => {179 if (err instanceof DOMException && err.name === 'AbortError') return180 setError(true)181 })182 window.scrollTo({ top: 0 })183 return () => controller.abort()184 }, [])185186 useEffect(() => {187 document.title = 'Statistiques — Fabri-Ka'188 return () => {189 document.title = DEFAULT_TITLE190 }191 }, [])192193 const generatedOn = useMemo(194 () =>195 new Intl.DateTimeFormat('fr-CA', { dateStyle: 'long' }).format(new Date()),196 []197 )198199 if (error) {200 return (201 <div className="page stats">202 <EmptyState203 variant="error"204 title="Statistiques indisponibles"205 message="Impossible de charger les statistiques pour le moment. Réessayez dans quelques instants."206 >207 <Link className="btn btn-secondary" to="/">208 Retour à l'accueil209 </Link>210 </EmptyState>211 </div>212 )213 }214215 const totals = stats?.totals216 const bucketMax = stats217 ? Math.max(...stats.price_buckets.map((b) => b.n), 1)218 : 1219 const regionMax = stats220 ? Math.max(...stats.by_region.map((r) => r.products), 1)221 : 1222 // fixed A→E order (identity colors follow the entity, never its rank)223 const originRows = stats224 ? ORIGIN_KEYS.flatMap((key) => {225 const stat = stats.by_origin.find((o) => o.key === key)226 return stat && (stat.products > 0 || stat.stores > 0)227 ? [{ key, stat }]228 : []229 })230 : []231 const originTotal = originRows.reduce((sum, r) => sum + r.stat.products, 0)232 const catMax = stats233 ? Math.max(...stats.by_category.map((c) => c.products), 1)234 : 1235236 const availabilityRows = stats237 ? [...stats.availability]238 .filter((a) => AVAILABILITY_META[a.key])239 .sort(240 (a, b) => AVAILABILITY_META[a.key].order - AVAILABILITY_META[b.key].order241 )242 : []243 const availabilityTotal = availabilityRows.reduce((sum, a) => sum + a.n, 0)244245 const coverageMeters =246 stats && totals247 ? [248 {249 label: 'Produits avec image',250 value: stats.coverage.with_image,251 total: totals.products,252 },253 {254 label: 'Produits avec description',255 value: stats.coverage.with_desc,256 total: totals.products,257 },258 {259 label: 'Boutiques avec logo',260 value: stats.coverage.with_logo,261 total: totals.stores_registry,262 },263 {264 label: 'Boutiques géolocalisées',265 value: stats.coverage.with_region,266 total: totals.stores_registry,267 },268 ].map((m) => ({ ...m, pct: m.total ? m.value / m.total : 0 }))269 : []270271 return (272 <div className="page stats">273 {/* 1 — editorial header */}274 <section className="stats-hero">275 <p className="hero-eyebrow">Statistiques</p>276 <h1 className="stats-hero-title">277 Le Québec qui vend en ligne, <em>en chiffres.</em>278 </h1>279 <p className="stats-hero-sub">280 Portrait généré à partir des catalogues publics agrégés par Fabri-Ka —281 données au {generatedOn}, recalculées en continu. Chaque chiffre est282 vivant : il se met à jour à mesure que les boutiques se synchronisent.283 Pour le détail complet, téléchargez le rapport de marché.284 </p>285 <div className="stats-hero-actions">286 <a287 className="btn btn-primary stats-report-btn"288 href={REPORT_URL}289 download290 >291 <IconDownload size={18} />292 Télécharger le rapport PDF293 </a>294 <span className="stats-report-hint">{REPORT_HINT}</span>295 </div>296 </section>297298 {/* 2 — KPI tiles */}299 <section className="stats-kpis" aria-label="Chiffres clés">300 {totals ? (301 <>302 <div className="stats-kpi">303 <span className="stats-kpi-value">304 <CountUp value={totals.products} />305 </span>306 <span className="stats-kpi-label">Produits</span>307 <span className="stats-kpi-sub">308 {formatInt(totals.products_priced)} avec prix affiché309 </span>310 </div>311 <div className="stats-kpi">312 <span className="stats-kpi-value">313 <CountUp value={totals.stores_live} />314 </span>315 <span className="stats-kpi-label">Boutiques actives</span>316 <span className="stats-kpi-sub">317 {formatShare(totals.stores_live, totals.stores_registry) ||318 '—'}{' '}319 du registre320 </span>321 </div>322 <div className="stats-kpi">323 <span className="stats-kpi-value">324 <CountUp value={totals.stores_registry} />325 </span>326 <span className="stats-kpi-label">Boutiques au registre</span>327 <span className="stats-kpi-sub">boutiques suivies</span>328 </div>329 <div className="stats-kpi">330 <span className="stats-kpi-value">331 <CountUp value={totals.regions} />332 </span>333 <span className="stats-kpi-label">Régions</span>334 <span className="stats-kpi-sub">du Québec couvertes</span>335 </div>336 <div className="stats-kpi">337 <span className="stats-kpi-value">338 {formatPrice(totals.price_median) || '—'}339 </span>340 <span className="stats-kpi-label">Prix médian</span>341 <span className="stats-kpi-sub">342 sur {formatInt(totals.products_priced)} produits343 </span>344 </div>345 <div className="stats-kpi">346 <span className="stats-kpi-value">347 {formatPrice(totals.price_avg) || '—'}348 </span>349 <span className="stats-kpi-label">Prix moyen</span>350 <span className="stats-kpi-sub">panier type</span>351 </div>352 </>353 ) : (354 Array.from({ length: 6 }, (_, i) => (355 <div className="stats-kpi" key={i}>356 <Skeleton width="80px" height="2rem" />357 <Skeleton width="110px" height="0.8rem" />358 <Skeleton width="90px" height="0.7rem" />359 </div>360 ))361 )}362 </section>363364 {!stats ? (365 <div className="stats-loading" aria-hidden="true">366 <Skeleton height="220px" radius="12px" />367 <Skeleton height="320px" radius="12px" />368 <Skeleton height="220px" radius="12px" />369 </div>370 ) : (371 <>372 {/* 3 — nouveautés du marché */}373 {stats.newest.length > 0 && (374 <section className="stats-section" aria-label="Nouveautés du marché">375 <header className="section-header">376 <h2>Nouveautés du marché</h2>377 <Link className="section-see-all" to="/produits?sort=recent">378 Voir les récents379 </Link>380 </header>381 <div className="stats-newest-rail">382 {stats.newest.map((p) => (383 <Link384 key={p.uid}385 className="card stats-newest-card"386 to={`/produits/${encodeURIComponent(p.uid)}`}387 >388 <span className="stats-newest-chip">389 {p.category_label || 'Divers'}390 </span>391 <span className="stats-newest-title">{p.title}</span>392 <span className="stats-newest-foot">393 <span className="stats-newest-price">394 {formatPrice(p.price) || 'Prix n.d.'}395 </span>396 <span className="stats-newest-store">{p.store_name}</span>397 </span>398 </Link>399 ))}400 </div>401 </section>402 )}403404 {/* 4 — price histogram */}405 {stats.price_buckets.length > 0 && totals && (406 <section className="stats-section" aria-label="Répartition des prix">407 <header className="section-header">408 <h2>Répartition des prix</h2>409 </header>410 <p className="stats-section-note">411 {formatInt(totals.products_priced)} produits avec prix affiché.412 </p>413 <div className="stats-bars">414 {stats.price_buckets.map((b) => (415 <div className="stats-bar-row" key={b.bucket}>416 <span className="stats-bar-name">417 {BUCKET_LABELS[b.bucket] ?? b.bucket}418 </span>419 <span className="stats-bar-track">420 <span421 className="stats-bar-fill stats-bar-fill-terracotta"422 style={{ width: `${(b.n / bucketMax) * 100}%` }}423 />424 </span>425 <span className="stats-bar-value">426 {formatInt(b.n)}427 <em>{formatShare(b.n, totals.products_priced)}</em>428 </span>429 </div>430 ))}431 </div>432 <p className="stats-hist-caption">433 Prix médian{' '}434 <strong>{formatPrice(totals.price_median) || '—'}</strong> · prix435 moyen <strong>{formatPrice(totals.price_avg) || '—'}</strong>436 </p>437 </section>438 )}439440 {/* 4 — categories */}441 {stats.by_category.length > 0 && (442 <section className="stats-section" aria-label="Catégories">443 <header className="section-header">444 <h2>Par catégorie</h2>445 <Link className="section-see-all" to="/produits">446 Tout le catalogue447 </Link>448 </header>449450 {/* desktop table */}451 <div className="stats-cat-table-wrap">452 <table className="stats-cat-table">453 <thead>454 <tr>455 <th>Catégorie</th>456 <th className="num">Produits ↓</th>457 <th className="num">Boutiques</th>458 <th className="num">Prix médian</th>459 <th className="num">Prix moyen</th>460 <th className="num">Fourchette</th>461 </tr>462 </thead>463 <tbody>464 {stats.by_category.map((c) => (465 <tr key={c.key}>466 <td>467 <Link468 className="stats-cat-link"469 to={`/produits?category=${encodeURIComponent(c.key)}`}470 >471 {c.label}472 </Link>473 </td>474 <td className="num stats-cat-prod">475 <span className="stats-cat-prod-inner">476 <span477 className="stats-cat-prod-bar"478 aria-hidden="true"479 >480 <span481 className="stats-cat-prod-fill"482 style={{483 width: `${(c.products / catMax) * 100}%`,484 }}485 />486 </span>487 <span className="stats-cat-prod-n">488 {formatInt(c.products)}489 </span>490 </span>491 </td>492 <td className="num">{formatInt(c.stores)}</td>493 <td className="num">{formatPrice(c.price_median) || '—'}</td>494 <td className="num">{formatPrice(c.price_avg) || '—'}</td>495 <td className="num stats-cat-range">496 {c.price_min !== null && c.price_max !== null497 ? `${formatPriceCompact(c.price_min)} – ${formatPriceCompact(c.price_max)}`498 : '—'}499 </td>500 </tr>501 ))}502 </tbody>503 </table>504 </div>505506 {/* mobile stacked cards */}507 <div className="stats-cat-cards">508 {stats.by_category.map((c) => (509 <Link510 key={c.key}511 className="card stats-cat-card"512 to={`/produits?category=${encodeURIComponent(c.key)}`}513 >514 <div className="stats-cat-card-head">515 <strong>{c.label}</strong>516 <span className="stats-cat-card-count">517 {formatInt(c.products)} produits518 </span>519 </div>520 <dl className="stats-cat-card-grid">521 <div>522 <dt>Boutiques</dt>523 <dd>{formatInt(c.stores)}</dd>524 </div>525 <div>526 <dt>Prix médian</dt>527 <dd>{formatPrice(c.price_median) || '—'}</dd>528 </div>529 <div>530 <dt>Prix moyen</dt>531 <dd>{formatPrice(c.price_avg) || '—'}</dd>532 </div>533 <div>534 <dt>Fourchette</dt>535 <dd>536 {c.price_min !== null && c.price_max !== null537 ? `${formatPriceCompact(c.price_min)} – ${formatPriceCompact(c.price_max)}`538 : '—'}539 </dd>540 </div>541 </dl>542 </Link>543 ))}544 </div>545 </section>546 )}547548 {/* 5 — regions */}549 {stats.by_region.length > 0 && (550 <section className="stats-section" aria-label="Régions">551 <header className="section-header">552 <h2>Par région</h2>553 </header>554 <div className="stats-regions">555 {stats.by_region.map((r) => (556 <div className="stats-region-row" key={r.key}>557 <div className="stats-region-head">558 <Link559 className="stats-region-name"560 to={`/produits?region=${encodeURIComponent(r.key)}`}561 >562 {r.key}563 </Link>564 <span className="stats-region-meta">565 {formatInt(r.stores)} boutique{r.stores > 1 ? 's' : ''}566 {r.price_avg !== null &&567 ` · prix moyen ${formatPriceCompact(r.price_avg)}`}568 </span>569 </div>570 <div className="stats-bar-row stats-bar-row-flat">571 <span className="stats-bar-track">572 <span573 className="stats-bar-fill stats-bar-fill-pine"574 style={{ width: `${(r.products / regionMax) * 100}%` }}575 />576 </span>577 <span className="stats-bar-value">{formatInt(r.products)}</span>578 </div>579 </div>580 ))}581 </div>582 </section>583 )}584585 {/* 6 — growth */}586 {stats.growth.length > 1 && (587 <section className="stats-section" aria-label="Croissance">588 <header className="section-header">589 <h2>Produits ajoutés par jour (30 jours)</h2>590 </header>591 <GrowthChart points={stats.growth} />592 </section>593 )}594595 {/* 7 — origin distribution */}596 {originRows.length > 0 && originTotal > 0 && (597 <section className="stats-section" aria-label="Origine">598 <header className="section-header">599 <h2>Par origine</h2>600 </header>601 <div602 className="stats-origin-bar"603 role="img"604 aria-label={originRows605 .map(606 (r) =>607 `${ORIGIN_LABELS[r.key]} : ${formatInt(r.stat.products)} produits`608 )609 .join(' · ')}610 >611 {originRows612 .filter((r) => r.stat.products > 0)613 .map((r) => {614 const pct = (r.stat.products / originTotal) * 100615 return (616 <span617 key={r.key}618 className={`stats-origin-seg origin-${r.key}`}619 style={{ width: `${pct}%` }}620 title={`${ORIGIN_LABELS[r.key]} — ${formatInt(r.stat.products)} produits (${formatShare(r.stat.products, originTotal)})`}621 >622 {pct >= 6 && (623 <span className="stats-origin-seg-letter">{r.key}</span>624 )}625 </span>626 )627 })}628 </div>629 <ul className="stats-origin-legend">630 {originRows.map((r) => (631 <li key={r.key}>632 <OriginBadge origin={r.key} withLabel />633 <span className="stats-origin-legend-counts">634 {formatInt(r.stat.stores)} boutique635 {r.stat.stores > 1 ? 's' : ''} ·{' '}636 {formatInt(r.stat.products)} produits637 {originTotal > 0 &&638 r.stat.products > 0 &&639 ` (${formatShare(r.stat.products, originTotal)})`}640 </span>641 </li>642 ))}643 </ul>644 </section>645 )}646647 {/* 8 — platforms */}648 {stats.by_platform.length > 0 && (649 <section className="stats-section" aria-label="Plateformes">650 <header className="section-header">651 <h2>Plateformes</h2>652 </header>653 <div className="stats-platforms">654 {stats.by_platform.map((p) => (655 <span className="stats-platform-chip" key={p.key}>656 <strong>{p.key}</strong> × {formatInt(p.stores)} boutique657 {p.stores > 1 ? 's' : ''} ({formatInt(p.products)} produits)658 </span>659 ))}660 </div>661 </section>662 )}663664 {/* disponibilité */}665 {availabilityRows.length > 0 && availabilityTotal > 0 && (666 <section className="stats-section" aria-label="Disponibilité">667 <header className="section-header">668 <h2>Disponibilité</h2>669 </header>670 <div671 className="stats-avail-bar"672 role="img"673 aria-label={availabilityRows674 .map(675 (a) =>676 `${AVAILABILITY_META[a.key].label} : ${formatInt(a.n)} produits`677 )678 .join(' · ')}679 >680 {availabilityRows681 .filter((a) => a.n > 0)682 .map((a) => (683 <span684 key={a.key}685 className={`stats-avail-seg stats-avail-${AVAILABILITY_META[a.key].slug}`}686 style={{ width: `${(a.n / availabilityTotal) * 100}%` }}687 title={`${AVAILABILITY_META[a.key].label} — ${formatInt(a.n)} (${formatShare(a.n, availabilityTotal)})`}688 />689 ))}690 </div>691 <ul className="stats-avail-legend">692 {availabilityRows.map((a) => (693 <li key={a.key}>694 <span695 className={`stats-avail-dot stats-avail-${AVAILABILITY_META[a.key].slug}`}696 aria-hidden="true"697 />698 <span>699 {AVAILABILITY_META[a.key].label}{' '}700 <strong>{formatInt(a.n)}</strong>{' '}701 <em>{formatShare(a.n, availabilityTotal)}</em>702 </span>703 </li>704 ))}705 </ul>706 </section>707 )}708709 {/* complétude des données */}710 {coverageMeters.length > 0 && (711 <section712 className="stats-section"713 aria-label="Complétude des données"714 >715 <header className="section-header">716 <h2>Complétude des données</h2>717 </header>718 <div className="stats-coverage">719 {coverageMeters.map((m) => (720 <div className="stats-meter" key={m.label}>721 <div className="stats-meter-head">722 <span className="stats-meter-label">{m.label}</span>723 <span className="stats-meter-pct">724 {percentFormatter.format(m.pct)}725 </span>726 </div>727 <span className="stats-meter-track">728 <span729 className="stats-meter-fill"730 style={{ width: `${m.pct * 100}%` }}731 />732 </span>733 <span className="stats-meter-sub">734 {formatInt(m.value)} / {formatInt(m.total)}735 </span>736 </div>737 ))}738 </div>739 </section>740 )}741742 <div className="stats-columns">743 {/* 9 — top stores */}744 {stats.top_stores.length > 0 && (745 <section className="stats-section" aria-label="Top boutiques">746 <header className="section-header">747 <h2>Top boutiques</h2>748 <Link className="section-see-all" to="/boutiques">749 Toutes les boutiques750 </Link>751 </header>752 <ol className="stats-top-stores">753 {stats.top_stores.map((s, i) => (754 <li key={s.id}>755 <span className="stats-rank">{i + 1}</span>756 <StoreLogo757 storeId={s.id}758 name={s.name}759 logoUrl={s.logo_url}760 size="sm"761 />762 <span className="stats-top-store-id">763 <Link to={`/boutiques/${encodeURIComponent(s.id)}`}>764 {s.name}765 </Link>766 {s.region && (767 <span className="stats-top-store-region">{s.region}</span>768 )}769 </span>770 <span className="stats-top-store-count">771 {formatInt(s.products)}772 </span>773 </li>774 ))}775 </ol>776 </section>777 )}778779 {/* 10 — most expensive */}780 {stats.most_expensive.length > 0 && (781 <section className="stats-section" aria-label="Produits les plus chers">782 <header className="section-header">783 <h2>Les plus chers</h2>784 </header>785 <p className="stats-section-note">786 Le grand luxe made in Québec — véridique, promis.787 </p>788 <ol className="stats-expensive">789 {stats.most_expensive.map((p) => (790 <li key={p.uid}>791 <Link792 className="stats-expensive-link"793 to={`/produits/${encodeURIComponent(p.uid)}`}794 >795 <span className="stats-expensive-title">{p.title}</span>796 <span className="stats-expensive-store">{p.store_name}</span>797 </Link>798 <span className="stats-expensive-price">799 {formatPrice(p.price)}800 </span>801 </li>802 ))}803 </ol>804 </section>805 )}806 </div>807 </>808 )}809810 {/* report download — footer */}811 <section className="stats-report-footer" aria-label="Rapport de marché">812 <a813 className="btn btn-secondary stats-report-btn"814 href={REPORT_URL}815 download816 >817 <IconDownload size={18} />818 Télécharger le rapport PDF819 </a>820 <span className="stats-report-hint">{REPORT_HINT}</span>821 </section>822 </div>823 )824}825