SPB Git

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%
30.6 KB · 825 lines tsx
Raw Blame History
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