import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { ExtendedStats, fetchExtendedStats, formatInt, formatPrice, formatPriceCompact, GrowthPoint, ORIGIN_KEYS, ORIGIN_LABELS, } from '../api' import CountUp from '../components/CountUp' import EmptyState from '../components/EmptyState' import { IconDownload } from '../components/Icons' import OriginBadge from '../components/OriginBadge' import Skeleton from '../components/Skeleton' import StoreLogo from '../components/StoreLogo' const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endroit.' // --------------------------------------------------------------------------- // Formatting helpers local to the stats page // --------------------------------------------------------------------------- const percentFormatter = new Intl.NumberFormat('fr-CA', { style: 'percent', maximumFractionDigits: 1, }) function formatShare(part: number, total: number): string { if (!total) return '' return percentFormatter.format(part / total) } const dayFormatter = new Intl.DateTimeFormat('fr-CA', { day: 'numeric', month: 'short', }) function formatDay(day: string): string { const d = new Date(`${day}T00:00:00`) return Number.isNaN(d.getTime()) ? day : dayFormatter.format(d) } const BUCKET_LABELS: Record = { '0-10': 'Moins de 10 $', '10-25': '10 – 25 $', '25-50': '25 – 50 $', '50-100': '50 – 100 $', '100-250': '100 – 250 $', '250-1000': '250 – 1 000 $', '1000+': '1 000 $ et plus', } const REPORT_URL = '/api/report.pdf' const REPORT_HINT = 'Rapport de marché complet — PDF, mise à jour en continu' // Availability keys → French label + CSS slug (pine / muted / border) const AVAILABILITY_META: Record< string, { label: string; slug: string; order: number } > = { 'en stock': { label: 'En stock', slug: 'stock', order: 0 }, rupture: { label: 'En rupture', slug: 'rupture', order: 1 }, inconnu: { label: 'Inconnu', slug: 'inconnu', order: 2 }, } // --------------------------------------------------------------------------- // Growth — hand-rolled SVG area chart (terracotta line on sand fill) // --------------------------------------------------------------------------- function GrowthChart({ points }: { points: GrowthPoint[] }) { // API returns the last 30 days DESC → plot ASC. const asc = [...points].sort((a, b) => a.day.localeCompare(b.day)) const W = 640 const H = 200 const PAD_X = 8 const PAD_TOP = 26 const PAD_BOTTOM = 26 const innerW = W - PAD_X * 2 const innerH = H - PAD_TOP - PAD_BOTTOM const baseline = H - PAD_BOTTOM const max = Math.max(...asc.map((p) => p.n), 1) const min = Math.min(...asc.map((p) => p.n)) const x = (i: number) => PAD_X + (asc.length > 1 ? (i * innerW) / (asc.length - 1) : innerW / 2) const y = (n: number) => PAD_TOP + (1 - n / max) * innerH const line = asc .map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.n).toFixed(1)}`) .join(' ') const area = `${line} L${x(asc.length - 1).toFixed(1)},${baseline} L${x(0).toFixed(1)},${baseline} Z` const maxIdx = asc.findIndex((p) => p.n === max) const minIdx = asc.findIndex((p) => p.n === min) const clampX = (v: number) => Math.min(W - 30, Math.max(30, v)) return (
{/* min/max direct labels only — no axis machinery */} {formatInt(max)} {minIdx !== maxIdx && ( <> {formatInt(min)} )} {formatDay(asc[0].day)} {formatDay(asc[asc.length - 1].day)} {/* hover layer: one generous hit target per point */} {asc.map((p, i) => ( {`${formatDay(p.day)} — ${formatInt(p.n)} produit${p.n > 1 ? 's' : ''}`} ))}
) } // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- export default function Stats() { const [stats, setStats] = useState(null) const [error, setError] = useState(false) useEffect(() => { const controller = new AbortController() fetchExtendedStats(controller.signal) .then(setStats) .catch((err: unknown) => { if (err instanceof DOMException && err.name === 'AbortError') return setError(true) }) window.scrollTo({ top: 0 }) return () => controller.abort() }, []) useEffect(() => { document.title = 'Statistiques — Fabri-Ka' return () => { document.title = DEFAULT_TITLE } }, []) const generatedOn = useMemo( () => new Intl.DateTimeFormat('fr-CA', { dateStyle: 'long' }).format(new Date()), [] ) if (error) { return (
Retour à l'accueil
) } const totals = stats?.totals const bucketMax = stats ? Math.max(...stats.price_buckets.map((b) => b.n), 1) : 1 const regionMax = stats ? Math.max(...stats.by_region.map((r) => r.products), 1) : 1 // fixed A→E order (identity colors follow the entity, never its rank) const originRows = stats ? ORIGIN_KEYS.flatMap((key) => { const stat = stats.by_origin.find((o) => o.key === key) return stat && (stat.products > 0 || stat.stores > 0) ? [{ key, stat }] : [] }) : [] const originTotal = originRows.reduce((sum, r) => sum + r.stat.products, 0) const catMax = stats ? Math.max(...stats.by_category.map((c) => c.products), 1) : 1 const availabilityRows = stats ? [...stats.availability] .filter((a) => AVAILABILITY_META[a.key]) .sort( (a, b) => AVAILABILITY_META[a.key].order - AVAILABILITY_META[b.key].order ) : [] const availabilityTotal = availabilityRows.reduce((sum, a) => sum + a.n, 0) const coverageMeters = stats && totals ? [ { label: 'Produits avec image', value: stats.coverage.with_image, total: totals.products, }, { label: 'Produits avec description', value: stats.coverage.with_desc, total: totals.products, }, { label: 'Boutiques avec logo', value: stats.coverage.with_logo, total: totals.stores_registry, }, { label: 'Boutiques géolocalisées', value: stats.coverage.with_region, total: totals.stores_registry, }, ].map((m) => ({ ...m, pct: m.total ? m.value / m.total : 0 })) : [] return (
{/* 1 — editorial header */}

Statistiques

Le Québec qui vend en ligne, en chiffres.

Portrait généré à partir des catalogues publics agrégés par Fabri-Ka — données au {generatedOn}, recalculées en continu. Chaque chiffre est vivant : il se met à jour à mesure que les boutiques se synchronisent. Pour le détail complet, téléchargez le rapport de marché.

Télécharger le rapport PDF {REPORT_HINT}
{/* 2 — KPI tiles */}
{totals ? ( <>
Produits {formatInt(totals.products_priced)} avec prix affiché
Boutiques actives {formatShare(totals.stores_live, totals.stores_registry) || '—'}{' '} du registre
Boutiques au registre boutiques suivies
Régions du Québec couvertes
{formatPrice(totals.price_median) || '—'} Prix médian sur {formatInt(totals.products_priced)} produits
{formatPrice(totals.price_avg) || '—'} Prix moyen panier type
) : ( Array.from({ length: 6 }, (_, i) => (
)) )}
{!stats ? ( ) : ( <> {/* 3 — nouveautés du marché */} {stats.newest.length > 0 && (

Nouveautés du marché

Voir les récents
{stats.newest.map((p) => ( {p.category_label || 'Divers'} {p.title} {formatPrice(p.price) || 'Prix n.d.'} {p.store_name} ))}
)} {/* 4 — price histogram */} {stats.price_buckets.length > 0 && totals && (

Répartition des prix

{formatInt(totals.products_priced)} produits avec prix affiché.

{stats.price_buckets.map((b) => (
{BUCKET_LABELS[b.bucket] ?? b.bucket} {formatInt(b.n)} {formatShare(b.n, totals.products_priced)}
))}

Prix médian{' '} {formatPrice(totals.price_median) || '—'} · prix moyen {formatPrice(totals.price_avg) || '—'}

)} {/* 4 — categories */} {stats.by_category.length > 0 && (

Par catégorie

Tout le catalogue
{/* desktop table */}
{stats.by_category.map((c) => ( ))}
Catégorie Produits ↓ Boutiques Prix médian Prix moyen Fourchette
{c.label} {formatInt(c.stores)} {formatPrice(c.price_median) || '—'} {formatPrice(c.price_avg) || '—'} {c.price_min !== null && c.price_max !== null ? `${formatPriceCompact(c.price_min)} – ${formatPriceCompact(c.price_max)}` : '—'}
{/* mobile stacked cards */}
{stats.by_category.map((c) => (
{c.label} {formatInt(c.products)} produits
Boutiques
{formatInt(c.stores)}
Prix médian
{formatPrice(c.price_median) || '—'}
Prix moyen
{formatPrice(c.price_avg) || '—'}
Fourchette
{c.price_min !== null && c.price_max !== null ? `${formatPriceCompact(c.price_min)} – ${formatPriceCompact(c.price_max)}` : '—'}
))}
)} {/* 5 — regions */} {stats.by_region.length > 0 && (

Par région

{stats.by_region.map((r) => (
{r.key} {formatInt(r.stores)} boutique{r.stores > 1 ? 's' : ''} {r.price_avg !== null && ` · prix moyen ${formatPriceCompact(r.price_avg)}`}
{formatInt(r.products)}
))}
)} {/* 6 — growth */} {stats.growth.length > 1 && (

Produits ajoutés par jour (30 jours)

)} {/* 7 — origin distribution */} {originRows.length > 0 && originTotal > 0 && (

Par origine

`${ORIGIN_LABELS[r.key]} : ${formatInt(r.stat.products)} produits` ) .join(' · ')} > {originRows .filter((r) => r.stat.products > 0) .map((r) => { const pct = (r.stat.products / originTotal) * 100 return ( {pct >= 6 && ( {r.key} )} ) })}
    {originRows.map((r) => (
  • {formatInt(r.stat.stores)} boutique {r.stat.stores > 1 ? 's' : ''} ·{' '} {formatInt(r.stat.products)} produits {originTotal > 0 && r.stat.products > 0 && ` (${formatShare(r.stat.products, originTotal)})`}
  • ))}
)} {/* 8 — platforms */} {stats.by_platform.length > 0 && (

Plateformes

{stats.by_platform.map((p) => ( {p.key} × {formatInt(p.stores)} boutique {p.stores > 1 ? 's' : ''} ({formatInt(p.products)} produits) ))}
)} {/* disponibilité */} {availabilityRows.length > 0 && availabilityTotal > 0 && (

Disponibilité

`${AVAILABILITY_META[a.key].label} : ${formatInt(a.n)} produits` ) .join(' · ')} > {availabilityRows .filter((a) => a.n > 0) .map((a) => ( ))}
    {availabilityRows.map((a) => (
  • ))}
)} {/* complétude des données */} {coverageMeters.length > 0 && (

Complétude des données

{coverageMeters.map((m) => (
{m.label} {percentFormatter.format(m.pct)}
{formatInt(m.value)} / {formatInt(m.total)}
))}
)}
{/* 9 — top stores */} {stats.top_stores.length > 0 && (

Top boutiques

Toutes les boutiques
    {stats.top_stores.map((s, i) => (
  1. {i + 1} {s.name} {s.region && ( {s.region} )} {formatInt(s.products)}
  2. ))}
)} {/* 10 — most expensive */} {stats.most_expensive.length > 0 && (

Les plus chers

Le grand luxe made in Québec — véridique, promis.

    {stats.most_expensive.map((p) => (
  1. {p.title} {p.store_name} {formatPrice(p.price)}
  2. ))}
)}
)} {/* report download — footer */}
Télécharger le rapport PDF {REPORT_HINT}
) }