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