Métriques LIVE (src/lib/live.ts — /api/stats des plateformes, ISR 10 min, replis) : ticker, héros, cartes plateformes, équation, socle, figures ; page /status (état des 12 sites vérifié en direct) + liens nav/footer
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 changed files +426 −42
modified
src/app/figures.tsx
+26 −8
@@ -688,8 +688,13 @@ const INVENTORY: { name: string; value: number; unit: string }[] = [ | ||
| 688 | 688 | |
| 689 | 689 | const fmt = (n: number) => n.toLocaleString("fr-CA").replace(/ /g, " "); |
| 690 | 690 | |
| 691 | −export function InventoryBars() { | |
| 692 | − const max = Math.max(...INVENTORY.map((d) => d.value)); | |
| 691 | +export function InventoryBars({ | |
| 692 | + data, | |
| 693 | +}: { | |
| 694 | + data?: { name: string; value: number; unit: string }[]; | |
| 695 | +}) { | |
| 696 | + const rows = [...(data ?? INVENTORY)].sort((a, b) => b.value - a.value); | |
| 697 | + const max = Math.max(...rows.map((d) => d.value)); | |
| 693 | 698 | return ( |
| 694 | 699 | <figure className="gk-card overflow-hidden p-0"> |
| 695 | 700 | <div className="p-5 sm:p-7"> |
@@ -698,7 +703,7 @@ export function InventoryBars() { | ||
| 698 | 703 | Ce qui est en ligne en ce moment |
| 699 | 704 | </h3> |
| 700 | 705 | <div className="mt-6 space-y-5"> |
| 701 | − {INVENTORY.map((d) => ( | |
| 706 | + {rows.map((d) => ( | |
| 702 | 707 | <div key={d.name} title={`${d.name} — ${fmt(d.value)}+ ${d.unit}`}> |
| 703 | 708 | <div className="flex items-baseline justify-between gap-3"> |
| 704 | 709 | <span className="flex items-center gap-2 text-[13px] font-bold"> |
@@ -744,16 +749,24 @@ const CONNECTORS: { name: string; count: number; kind: string }[] = [ | ||
| 744 | 749 | { name: "Sorti·Ka", count: 4, kind: "billetteries & données ouvertes" }, |
| 745 | 750 | ]; |
| 746 | 751 | |
| 747 | −export function ConnectorMatrix() { | |
| 752 | +export function ConnectorMatrix({ | |
| 753 | + data, | |
| 754 | + total, | |
| 755 | +}: { | |
| 756 | + data?: { name: string; count: number; kind: string }[]; | |
| 757 | + total?: number; | |
| 758 | +}) { | |
| 759 | + const rows = [...(data ?? CONNECTORS)].sort((a, b) => b.count - a.count); | |
| 760 | + const sum = total ?? rows.reduce((s, c) => s + c.count, 0); | |
| 748 | 761 | return ( |
| 749 | 762 | <figure className="gk-card overflow-hidden p-0"> |
| 750 | 763 | <div className="p-5 sm:p-7"> |
| 751 | 764 | <p className="klabel">La flotte · un point = un connecteur</p> |
| 752 | 765 | <h3 className="gk-display mt-2 text-[17px] font-bold uppercase"> |
| 753 | − 340 connecteurs, dessinés un par un | |
| 766 | + {fmt(sum)} connecteurs, dessinés un par un | |
| 754 | 767 | </h3> |
| 755 | 768 | <div className="mt-6 space-y-6"> |
| 756 | − {CONNECTORS.map((c) => ( | |
| 769 | + {rows.map((c) => ( | |
| 757 | 770 | <div key={c.name} title={`${c.name} — ${c.count} connecteurs (${c.kind})`}> |
| 758 | 771 | <div className="flex items-baseline justify-between gap-3"> |
| 759 | 772 | <span className="flex items-center gap-2 text-[13px] font-bold"> |
@@ -813,10 +826,15 @@ const COVERAGE: { | ||
| 813 | 826 | }, |
| 814 | 827 | ]; |
| 815 | 828 | |
| 816 | −export function CoverageTiles() { | |
| 829 | +export function CoverageTiles({ vraiPrixUnits }: { vraiPrixUnits?: number }) { | |
| 830 | + const tiles = COVERAGE.map((c) => | |
| 831 | + c.name === "Vrai-Prix" && vraiPrixUnits | |
| 832 | + ? { ...c, value: fmt(vraiPrixUnits) } | |
| 833 | + : c, | |
| 834 | + ); | |
| 817 | 835 | return ( |
| 818 | 836 | <div className="flex h-full flex-col gap-5"> |
| 819 | − {COVERAGE.map((c) => ( | |
| 837 | + {tiles.map((c) => ( | |
| 820 | 838 | <article key={c.name} className="gk-card gk-card-hover flex-1 p-5 sm:p-7"> |
| 821 | 839 | <p className="klabel flex items-center gap-2"> |
| 822 | 840 | <BrandChip name={c.name} /> |
modified
src/app/layout.tsx
+22 −15
@@ -2,6 +2,7 @@ | ||
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; |
| 4 | 4 | import UserButton from "./UserButton"; |
| 5 | +import { getLiveMetrics, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live"; | |
| 5 | 6 | import "./globals.css"; |
| 6 | 7 | |
| 7 | 8 | const spaceGrotesk = Space_Grotesk({ |
@@ -37,24 +38,25 @@ const NAV_ITEMS = [ | ||
| 37 | 38 | { href: "/#produits", label: "Plateformes" }, |
| 38 | 39 | { href: "/#socle", label: "Socle" }, |
| 39 | 40 | { href: "/#bots", label: "Bots" }, |
| 40 | − { href: "/#mission", label: "Mission" }, | |
| 41 | + { href: "/status", label: "Statut" }, | |
| 41 | 42 | { href: "/#contact", label: "Contact" }, |
| 42 | 43 | ]; |
| 43 | 44 | |
| 44 | −const TICKER_ITEMS = [ | |
| 45 | +// Ticker construit sur les métriques LIVE des plateformes (src/lib/live.ts). | |
| 46 | +const tickerItems = (m: LiveMetrics): string[] => [ | |
| 45 | 47 | "·Ka = agréger — mille sites, un seul endroit", |
| 46 | − "Trouve·Ka cherche le Québec — le moteur de recherche du web d'ici", | |
| 48 | + `Trouve·Ka cherche le Québec — ${fmtPlus(m.trouveka.pages)} pages du web d'ici indexées`, | |
| 47 | 49 | "Dix agrégateurs · une obsession : la transparence", |
| 48 | − "450+ connecteurs lisent les sources jour et nuit", | |
| 49 | − "7 000+ logements à louer agrégés en continu", | |
| 50 | − "54 000+ propriétés à vendre, lues à la source", | |
| 51 | − "15 900+ voitures usagées chez 124 concessionnaires", | |
| 52 | − "281 000+ produits québécois de 1 204 boutiques", | |
| 53 | − "23 700+ prix d'épicerie suivis chez 20 bannières", | |
| 54 | − "61 000+ plats et prix réels chez 540+ restos — resto-ka.com", | |
| 55 | − "11 100+ sorties et événements dans les 17 régions — sorti-ka.com", | |
| 56 | − "2 200+ créateurs québécois, tous leurs liens regroupés — crea-ka.com", | |
| 57 | − "3 747 008 propriétés évaluées au Québec", | |
| 50 | + `${fmtInt(m.totals.connectors)}+ connecteurs lisent les sources jour et nuit`, | |
| 51 | + `${fmtPlus(m.louka.total)} logements à louer agrégés en continu`, | |
| 52 | + `${fmtPlus(m.immoka.total)} propriétés à vendre, lues à la source`, | |
| 53 | + `${fmtPlus(m.autoka.total)} voitures usagées chez ${m.autoka.sources} concessionnaires`, | |
| 54 | + `${fmtPlus(m.fabrika.products)} produits québécois de ${fmtInt(m.fabrika.stores)} boutiques`, | |
| 55 | + `${fmtPlus(m.foodka.total)} prix d'épicerie suivis chez ${m.foodka.sources} bannières`, | |
| 56 | + `${fmtPlus(m.restoka.items)} plats et prix réels chez ${fmtPlus(m.restoka.restaurants)} restos — resto-ka.com`, | |
| 57 | + `${fmtPlus(m.sortika.active)} sorties et événements dans les 17 régions — sorti-ka.com`, | |
| 58 | + `${fmtPlus(m.creaka.creators)} créateurs québécois, tous leurs liens regroupés — crea-ka.com`, | |
| 59 | + `${fmtInt(m.vraiprix.units)} propriétés évaluées au Québec`, | |
| 58 | 60 | "393 867 plex · 1 733 744 portes", |
| 59 | 61 | "Ka2 & Ka4 cartographient le web québécois — ka2.bot · ka4.bot", |
| 60 | 62 | "Zéro boîte noire — tout est automatisé, rien n'est inventé", |
@@ -81,9 +83,10 @@ function Wordmark({ size = "header" }: { size?: "header" | "footer" }) { | ||
| 81 | 83 | ); |
| 82 | 84 | } |
| 83 | 85 | |
| 84 | −export default function RootLayout({ | |
| 86 | +export default async function RootLayout({ | |
| 85 | 87 | children, |
| 86 | 88 | }: Readonly<{ children: React.ReactNode }>) { |
| 89 | + const ticker = tickerItems(await getLiveMetrics()); | |
| 87 | 90 | return ( |
| 88 | 91 | <html lang="fr"> |
| 89 | 92 | <body |
@@ -181,7 +184,7 @@ export default function RootLayout({ | ||
| 181 | 184 | <div className="ticker-inner"> |
| 182 | 185 | {[0, 1].map((dup) => ( |
| 183 | 186 | <span key={dup}> |
| 184 | − {TICKER_ITEMS.map((item) => ( | |
| 187 | + {ticker.map((item) => ( | |
| 185 | 188 | <span key={item} className="ticker-item"> |
| 186 | 189 | {item} |
| 187 | 190 | </span> |
@@ -295,6 +298,10 @@ export default function RootLayout({ | ||
| 295 | 298 | Transparence des robots d'indexation |
| 296 | 299 | </a>{" "} |
| 297 | 300 | ·{" "} |
| 301 | + <a href="/status" className="underline-offset-4 hover:text-lime hover:underline"> | |
| 302 | + État des services | |
| 303 | + </a>{" "} | |
| 304 | + ·{" "} | |
| 298 | 305 | <a href="/retrait" className="underline-offset-4 hover:text-lime hover:underline"> |
| 299 | 306 | Politique de retrait et de révision |
| 300 | 307 | </a>{" "} |
modified
src/app/page.tsx
+101 −19
@@ -8,6 +8,10 @@ import { | ||
| 8 | 8 | ConnectorMatrix, |
| 9 | 9 | CoverageTiles, |
| 10 | 10 | } from "./figures"; |
| 11 | +import { getLiveMetrics, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live"; | |
| 12 | + | |
| 13 | +// Métriques live re-mesurées au plus toutes les 10 minutes (ISR). | |
| 14 | +export const revalidate = 600; | |
| 11 | 15 | |
| 12 | 16 | type Product = { |
| 13 | 17 | num: string; |
@@ -298,23 +302,23 @@ const MANIFESTO = [ | ||
| 298 | 302 | }, |
| 299 | 303 | ]; |
| 300 | 304 | |
| 301 | −const SOCLE_ROWS: [string, string, string, string][] = [ | |
| 305 | +const socleRows = (m: LiveMetrics): [string, string, string, string][] => [ | |
| 302 | 306 | [ |
| 303 | 307 | "Trouve·Ka", |
| 304 | 308 | "Recherche — tout le web québécois", |
| 305 | − "Crawler dédié + index de recherche plein texte", | |
| 309 | + `Crawler dédié + index plein texte — ${fmtPlus(m.trouveka.pages)} pages`, | |
| 306 | 310 | "www.trouve-ka.com", |
| 307 | 311 | ], |
| 308 | 312 | [ |
| 309 | 313 | "Lou·Ka", |
| 310 | 314 | "Location résidentielle", |
| 311 | − "108 connecteurs sources + synchronisation horaire", | |
| 315 | + `${m.louka.sources} connecteurs sources + synchronisation horaire`, | |
| 312 | 316 | "www.lou-ka.com", |
| 313 | 317 | ], |
| 314 | 318 | [ |
| 315 | 319 | "Immo·Ka", |
| 316 | 320 | "Propriétés à vendre", |
| 317 | − "65 connecteurs d'agences + détection des ventes", | |
| 321 | + `${m.immoka.sources} connecteurs d'agences + détection des ventes`, | |
| 318 | 322 | "www.immo-ka.com", |
| 319 | 323 | ], |
| 320 | 324 | [ |
@@ -332,25 +336,25 @@ const SOCLE_ROWS: [string, string, string, string][] = [ | ||
| 332 | 336 | [ |
| 333 | 337 | "Auto·Ka", |
| 334 | 338 | "Voitures usagées", |
| 335 | − "125 connecteurs concessionnaires + suivi des prix", | |
| 339 | + `${m.autoka.sources} connecteurs concessionnaires + suivi des prix`, | |
| 336 | 340 | "www.auto-ka.com", |
| 337 | 341 | ], |
| 338 | 342 | [ |
| 339 | 343 | "Fabri·Ka", |
| 340 | 344 | "Produits québécois", |
| 341 | − "1 204 boutiques en ligne indexées par région", | |
| 345 | + `${fmtInt(m.fabrika.stores)} boutiques en ligne indexées par région`, | |
| 342 | 346 | "www.fabri-ka.com", |
| 343 | 347 | ], |
| 344 | 348 | [ |
| 345 | 349 | "Food·Ka", |
| 346 | 350 | "Épicerie", |
| 347 | − "20 bannières — prix, soldes et prix unitaires", | |
| 351 | + `${m.foodka.sources} bannières — prix, soldes et prix unitaires`, | |
| 348 | 352 | "www.food-ka.com", |
| 349 | 353 | ], |
| 350 | 354 | [ |
| 351 | 355 | "Resto·Ka", |
| 352 | 356 | "Restaurants — menus & prix", |
| 353 | − "19 chaînes intégrées — 61 000+ plats suivis", | |
| 357 | + `${m.restoka.chains} chaînes intégrées — ${fmtPlus(m.restoka.items)} plats suivis`, | |
| 354 | 358 | "www.resto-ka.com", |
| 355 | 359 | ], |
| 356 | 360 | [ |
@@ -529,7 +533,60 @@ function ProductCard({ | ||
| 529 | 533 | ); |
| 530 | 534 | } |
| 531 | 535 | |
| 532 | −export default function Home() { | |
| 536 | +/** Chips des cartes plateformes, calculées sur les métriques LIVE (clé = domaine). */ | |
| 537 | +function liveChips(m: LiveMetrics): Record<string, string[]> { | |
| 538 | + return { | |
| 539 | + "www.lou-ka.com": [ | |
| 540 | + `${fmtPlus(m.louka.total)} annonces`, | |
| 541 | + `${m.louka.sources} connecteurs actifs`, | |
| 542 | + "MàJ horaire", | |
| 543 | + ], | |
| 544 | + "www.immo-ka.com": [ | |
| 545 | + `${fmtPlus(m.immoka.total)} propriétés`, | |
| 546 | + `${m.immoka.sources} connecteurs`, | |
| 547 | + `${fmtInt(m.immoka.cities)} villes`, | |
| 548 | + ], | |
| 549 | + "www.auto-ka.com": [ | |
| 550 | + `${fmtPlus(m.autoka.total)} véhicules`, | |
| 551 | + `${m.autoka.sources} concessionnaires`, | |
| 552 | + `${m.autoka.regions} régions`, | |
| 553 | + ], | |
| 554 | + "www.fabri-ka.com": [ | |
| 555 | + `${fmtPlus(m.fabrika.products)} produits`, | |
| 556 | + `${fmtInt(m.fabrika.stores)} boutiques`, | |
| 557 | + `${m.fabrika.regions} régions`, | |
| 558 | + ], | |
| 559 | + "www.food-ka.com": [ | |
| 560 | + `${fmtPlus(m.foodka.total)} produits`, | |
| 561 | + `${m.foodka.sources} bannières`, | |
| 562 | + `${fmtPlus(m.foodka.onSale)} soldes actifs`, | |
| 563 | + ], | |
| 564 | + "www.resto-ka.com": [ | |
| 565 | + `${fmtPlus(m.restoka.restaurants)} restaurants`, | |
| 566 | + `${fmtPlus(m.restoka.items)} plats & prix`, | |
| 567 | + `${m.restoka.regions} régions`, | |
| 568 | + ], | |
| 569 | + "www.sorti-ka.com": [ | |
| 570 | + `${fmtPlus(m.sortika.active)} événements`, | |
| 571 | + `${fmtPlus(m.sortika.free)} sorties gratuites`, | |
| 572 | + `${fmtInt(m.sortika.cities)} villes`, | |
| 573 | + ], | |
| 574 | + "www.crea-ka.com": [ | |
| 575 | + `${fmtPlus(m.creaka.creators)} créateurs`, | |
| 576 | + `${fmtPlus(m.creaka.accounts)} comptes reliés`, | |
| 577 | + `${m.creaka.platforms} plateformes`, | |
| 578 | + ], | |
| 579 | + "www.vrai-prix.com": [ | |
| 580 | + `${fmtInt(m.vraiprix.units)} propriétés estimées`, | |
| 581 | + `${fmtPlus(m.vraiprix.transactions)} transactions réelles`, | |
| 582 | + "Fourchette P10–P90", | |
| 583 | + ], | |
| 584 | + }; | |
| 585 | +} | |
| 586 | + | |
| 587 | +export default async function Home() { | |
| 588 | + const m = await getLiveMetrics(); | |
| 589 | + const chips = liveChips(m); | |
| 533 | 590 | return ( |
| 534 | 591 | <main className="mx-auto max-w-6xl px-4 pb-16 sm:px-6"> |
| 535 | 592 | {/* ---------- Héros ---------- */} |
@@ -559,9 +616,11 @@ export default function Home() { | ||
| 559 | 616 | 10 agrégateurs en ligne |
| 560 | 617 | </span> |
| 561 | 618 | <span className="stat-chip">1 moteur de recherche québécois</span> |
| 562 | − <span className="stat-chip">450+ connecteurs sources</span> | |
| 619 | + <span className="stat-chip">{fmtInt(m.totals.connectors)}+ connecteurs sources</span> | |
| 563 | 620 | <span className="stat-chip">22 M+ d'observations immobilières</span> |
| 564 | − <span className="stat-chip">450 000+ annonces, produits & événements</span> | |
| 621 | + <span className="stat-chip"> | |
| 622 | + {fmtPlus(m.totals.items)} annonces, produits & événements | |
| 623 | + </span> | |
| 565 | 624 | <span className="stat-chip">100 % données à la source</span> |
| 566 | 625 | </div> |
| 567 | 626 | <div className="rise mt-6 flex flex-wrap gap-3 [animation-delay:0.3s]"> |
@@ -630,10 +689,10 @@ export default function Home() { | ||
| 630 | 689 | </div> |
| 631 | 690 | <div className="mt-5 flex flex-wrap gap-2"> |
| 632 | 691 | <span className="stat-chip !text-[11px]"> |
| 633 | − Des milliers de domaines d'ici | |
| 692 | + {fmtPlus(m.trouveka.pages)} pages indexées | |
| 634 | 693 | </span> |
| 635 | 694 | <span className="stat-chip !text-[11px]"> |
| 636 | − Index en croissance heure par heure | |
| 695 | + {fmtPlus(m.trouveka.domains)} domaines d'ici | |
| 637 | 696 | </span> |
| 638 | 697 | <span className="stat-chip !text-[11px]">100 % Québec</span> |
| 639 | 698 | </div> |
@@ -751,8 +810,29 @@ export default function Home() { | ||
| 751 | 810 | flotte de connecteurs, couverture des moteurs d'évaluation. |
| 752 | 811 | </p> |
| 753 | 812 | <div className="mt-7 grid items-stretch gap-6 lg:grid-cols-2"> |
| 754 | − <InventoryBars /> | |
| 755 | − <ConnectorMatrix /> | |
| 813 | + <InventoryBars | |
| 814 | + data={[ | |
| 815 | + { name: "Fabri·Ka", value: m.fabrika.products, unit: "produits québécois" }, | |
| 816 | + { name: "Resto·Ka", value: m.restoka.items, unit: "plats & prix réels" }, | |
| 817 | + { name: "Immo·Ka", value: m.immoka.total, unit: "propriétés à vendre" }, | |
| 818 | + { name: "Food·Ka", value: m.foodka.total, unit: "prix d'épicerie" }, | |
| 819 | + { name: "Auto·Ka", value: m.autoka.total, unit: "véhicules d'occasion" }, | |
| 820 | + { name: "Sorti·Ka", value: m.sortika.active, unit: "événements & sorties" }, | |
| 821 | + { name: "Lou·Ka", value: m.louka.total, unit: "logements à louer" }, | |
| 822 | + { name: "Créa·Ka", value: m.creaka.creators, unit: "créateurs d'ici" }, | |
| 823 | + ]} | |
| 824 | + /> | |
| 825 | + <ConnectorMatrix | |
| 826 | + data={[ | |
| 827 | + { name: "Lou·Ka", count: m.louka.sources, kind: "gestionnaires & portails" }, | |
| 828 | + { name: "Auto·Ka", count: m.autoka.sources, kind: "concessionnaires" }, | |
| 829 | + { name: "Food·Ka", count: m.foodka.sources, kind: "bannières" }, | |
| 830 | + { name: "Immo·Ka", count: m.immoka.sources, kind: "agences" }, | |
| 831 | + { name: "Resto·Ka", count: m.restoka.chains, kind: "chaînes de restos" }, | |
| 832 | + { name: "Sorti·Ka", count: 4, kind: "billetteries & données ouvertes" }, | |
| 833 | + ]} | |
| 834 | + total={m.totals.connectors} | |
| 835 | + /> | |
| 756 | 836 | </div> |
| 757 | 837 | <div className="mt-6 grid items-start gap-6 lg:grid-cols-2"> |
| 758 | 838 | <div className="gk-card !bg-ink p-6 text-paper sm:p-7"> |
@@ -760,7 +840,9 @@ export default function Home() { | ||
| 760 | 840 | <p className="gk-display mt-4 text-[clamp(26px,3.5vw,38px)] leading-[1.05] font-bold tracking-[-0.03em] uppercase"> |
| 761 | 841 | 1 000+ sites lus |
| 762 | 842 | <br /> |
| 763 | − <span className="text-lime">→ 450+ connecteurs</span> | |
| 843 | + <span className="text-lime"> | |
| 844 | + → {fmtInt(m.totals.connectors)}+ connecteurs | |
| 845 | + </span> | |
| 764 | 846 | <br />→ 10 plateformes |
| 765 | 847 | </p> |
| 766 | 848 | <p className="mt-4 max-w-md text-[13.5px] text-[rgba(245,243,238,0.75)]"> |
@@ -768,7 +850,7 @@ export default function Home() { | ||
| 768 | 850 | aucune main humaine — c'est le suffixe ·Ka qui travaille. |
| 769 | 851 | </p> |
| 770 | 852 | </div> |
| 771 | − <CoverageTiles /> | |
| 853 | + <CoverageTiles vraiPrixUnits={m.vraiprix.units} /> | |
| 772 | 854 | </div> |
| 773 | 855 | </Section> |
| 774 | 856 | |
@@ -793,7 +875,7 @@ export default function Home() { | ||
| 793 | 875 | {PRODUCTS.map((p, i) => ( |
| 794 | 876 | <ProductCard |
| 795 | 877 | key={p.num} |
| 796 | − p={p} | |
| 878 | + p={{ ...p, chips: chips[p.domain] ?? p.chips }} | |
| 797 | 879 | index={i} |
| 798 | 880 | wide={i === PRODUCTS.length - 1} |
| 799 | 881 | /> |
@@ -825,7 +907,7 @@ export default function Home() { | ||
| 825 | 907 | </tr> |
| 826 | 908 | </thead> |
| 827 | 909 | <tbody> |
| 828 | − {SOCLE_ROWS.map(([produit, marche, moteur, adresse]) => ( | |
| 910 | + {socleRows(m).map(([produit, marche, moteur, adresse]) => ( | |
| 829 | 911 | <tr key={produit}> |
| 830 | 912 | <td className="gk-display font-bold whitespace-nowrap"> |
| 831 | 913 | {produit} |
added
src/app/status/page.tsx
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /status — état des services de l'écosystème Groupe KA, vérifié EN DIRECT au | |
| 3 | +// chargement de la page (aucune valeur figée) : disponibilité + latence de | |
| 4 | +// chacune des 12 plateformes, mesurées depuis le serveur du hub. | |
| 5 | +import type { Metadata } from "next"; | |
| 6 | +import eco from "@/ka/ecosystem.json"; | |
| 7 | + | |
| 8 | +export const dynamic = "force-dynamic"; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { | |
| 11 | + title: "Statut des services — Groupe KA", | |
| 12 | + description: | |
| 13 | + "État en direct des 12 plateformes de l'écosystème Groupe KA : disponibilité et latence, vérifiées à chaque chargement.", | |
| 14 | +}; | |
| 15 | + | |
| 16 | +type Check = { | |
| 17 | + id: string; | |
| 18 | + wordmark: string; | |
| 19 | + domain: string; | |
| 20 | + accent: string; | |
| 21 | + tagline: string; | |
| 22 | + up: boolean; | |
| 23 | + code: number | null; | |
| 24 | + ms: number | null; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +async function checkSite(s: (typeof eco.sites)[number]): Promise<Check> { | |
| 28 | + const started = Date.now(); | |
| 29 | + try { | |
| 30 | + const res = await fetch(`https://${s.domain}/`, { | |
| 31 | + cache: "no-store", | |
| 32 | + redirect: "follow", | |
| 33 | + signal: AbortSignal.timeout(9000), | |
| 34 | + headers: { "user-agent": "GroupeKA-Status/1.0 (+https://www.groupe-ka.com/status)" }, | |
| 35 | + }); | |
| 36 | + return { | |
| 37 | + id: s.id, | |
| 38 | + wordmark: s.wordmark, | |
| 39 | + domain: s.domain, | |
| 40 | + accent: s.accent, | |
| 41 | + tagline: s.tagline, | |
| 42 | + up: res.ok, | |
| 43 | + code: res.status, | |
| 44 | + ms: Date.now() - started, | |
| 45 | + }; | |
| 46 | + } catch { | |
| 47 | + return { | |
| 48 | + id: s.id, | |
| 49 | + wordmark: s.wordmark, | |
| 50 | + domain: s.domain, | |
| 51 | + accent: s.accent, | |
| 52 | + tagline: s.tagline, | |
| 53 | + up: false, | |
| 54 | + code: null, | |
| 55 | + ms: null, | |
| 56 | + }; | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +export default async function StatusPage() { | |
| 61 | + const checks = await Promise.all(eco.sites.map(checkSite)); | |
| 62 | + const upCount = checks.filter((c) => c.up).length; | |
| 63 | + const allUp = upCount === checks.length; | |
| 64 | + const when = new Date().toLocaleString("fr-CA", { | |
| 65 | + timeZone: "America/Toronto", | |
| 66 | + dateStyle: "long", | |
| 67 | + timeStyle: "medium", | |
| 68 | + }); | |
| 69 | + | |
| 70 | + return ( | |
| 71 | + <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6"> | |
| 72 | + <p className="kicker">État des services · vérifié en direct</p> | |
| 73 | + <h1 className="gk-display mt-3 text-[clamp(28px,4.5vw,44px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase"> | |
| 74 | + Les 12 plateformes,{" "} | |
| 75 | + <span className="hl">{allUp ? "toutes en ligne" : `${upCount}/12 en ligne`}</span> | |
| 76 | + </h1> | |
| 77 | + <p className="mt-4 max-w-xl text-[14.5px] text-ink-2"> | |
| 78 | + Chaque ligne est une vraie requête envoyée à la plateforme au moment où | |
| 79 | + cette page se charge — disponibilité et latence mesurées depuis le | |
| 80 | + serveur du hub, rien de mis en cache. | |
| 81 | + </p> | |
| 82 | + <p className="gk-mono mt-2 text-[11px] text-ink-3">Vérifié le {when} (heure de l'Est)</p> | |
| 83 | + | |
| 84 | + <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 85 | + {checks.map((c) => ( | |
| 86 | + <article key={c.id} className="gk-card gk-card-hover p-5"> | |
| 87 | + <div className="flex items-center justify-between gap-3"> | |
| 88 | + <span className="flex items-center gap-2"> | |
| 89 | + <span | |
| 90 | + aria-hidden="true" | |
| 91 | + className="inline-block h-[10px] w-[10px] rounded-[3px] border border-ink" | |
| 92 | + style={{ background: c.accent }} | |
| 93 | + /> | |
| 94 | + <span className="gk-display text-[17px] font-bold tracking-[-0.02em]"> | |
| 95 | + {c.wordmark} | |
| 96 | + </span> | |
| 97 | + </span> | |
| 98 | + {c.up ? ( | |
| 99 | + <span className="gk-mono inline-flex items-center gap-[6px] rounded-full border-[1.5px] border-ink bg-lime px-3 py-[3px] text-[10px] font-bold tracking-[0.08em] text-ink uppercase"> | |
| 100 | + <span className="pulse-dot" aria-hidden="true" /> | |
| 101 | + En ligne | |
| 102 | + </span> | |
| 103 | + ) : ( | |
| 104 | + <span className="gk-mono inline-flex items-center rounded-full border-[1.5px] border-ink bg-[#fbe9e7] px-3 py-[3px] text-[10px] font-bold tracking-[0.08em] text-[#b3423a] uppercase"> | |
| 105 | + Hors ligne | |
| 106 | + </span> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + <p className="mt-2 text-[12.5px] text-ink-2">{c.tagline}</p> | |
| 110 | + <div className="gk-mono mt-4 flex items-center justify-between border-t border-dashed border-[rgba(20,24,20,0.25)] pt-3 text-[11px] text-ink-3"> | |
| 111 | + <a | |
| 112 | + href={`https://${c.domain}`} | |
| 113 | + target="_blank" | |
| 114 | + rel="noopener noreferrer" | |
| 115 | + className="font-bold text-ink-2 underline-offset-4 hover:text-ink hover:underline" | |
| 116 | + > | |
| 117 | + {c.domain} | |
| 118 | + </a> | |
| 119 | + <span> | |
| 120 | + {c.up ? `HTTP ${c.code} · ${c.ms} ms` : c.code ? `HTTP ${c.code}` : "délai dépassé"} | |
| 121 | + </span> | |
| 122 | + </div> | |
| 123 | + </article> | |
| 124 | + ))} | |
| 125 | + </div> | |
| 126 | + | |
| 127 | + <p className="gk-mono mt-8 text-[11px] leading-relaxed text-ink-3"> | |
| 128 | + Méthode : requête GET sur la page d'accueil de chaque domaine | |
| 129 | + (délai maximal 9 s), latence = temps de réponse complet vu du serveur | |
| 130 | + du hub. Recharger la page relance les 12 vérifications. | |
| 131 | + </p> | |
| 132 | + </main> | |
| 133 | + ); | |
| 134 | +} | |
added
src/lib/live.ts
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Métriques LIVE de l'écosystème — mesurées sur les plateformes elles-mêmes | |
| 3 | +// (endpoint public /api/stats de chaque site, cache serveur 10 min). Chaque | |
| 4 | +// valeur a un repli figé (dernier état connu du 2026-08-17) : si une | |
| 5 | +// plateforme ne répond pas, le site mère affiche le repli plutôt que zéro. | |
| 6 | + | |
| 7 | +type Json = Record<string, unknown>; | |
| 8 | + | |
| 9 | +async function fetchJson(url: string): Promise<Json | null> { | |
| 10 | + try { | |
| 11 | + const res = await fetch(url, { | |
| 12 | + next: { revalidate: 600 }, | |
| 13 | + signal: AbortSignal.timeout(6000), | |
| 14 | + headers: { accept: "application/json" }, | |
| 15 | + }); | |
| 16 | + if (!res.ok) return null; | |
| 17 | + return (await res.json()) as Json; | |
| 18 | + } catch { | |
| 19 | + return null; | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +const num = (o: Json | null, path: string, fallback: number): number => { | |
| 24 | + let cur: unknown = o; | |
| 25 | + for (const k of path.split(".")) { | |
| 26 | + if (cur == null || typeof cur !== "object") return fallback; | |
| 27 | + cur = (cur as Json)[k]; | |
| 28 | + } | |
| 29 | + return typeof cur === "number" && Number.isFinite(cur) ? cur : fallback; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export type LiveMetrics = { | |
| 33 | + louka: { total: number; sources: number }; | |
| 34 | + immoka: { total: number; sources: number; cities: number }; | |
| 35 | + autoka: { total: number; sources: number; regions: number }; | |
| 36 | + fabrika: { products: number; stores: number; regions: number }; | |
| 37 | + foodka: { total: number; sources: number; onSale: number }; | |
| 38 | + restoka: { restaurants: number; items: number; chains: number; regions: number }; | |
| 39 | + sortika: { active: number; free: number; cities: number }; | |
| 40 | + creaka: { creators: number; accounts: number; platforms: number }; | |
| 41 | + trouveka: { pages: number; domains: number; running: boolean }; | |
| 42 | + vraiprix: { units: number; transactions: number; valueTotal: number }; | |
| 43 | + totals: { connectors: number; items: number }; | |
| 44 | +}; | |
| 45 | + | |
| 46 | +export async function getLiveMetrics(): Promise<LiveMetrics> { | |
| 47 | + const [lou, immo, auto, fab, food, resto, sorti, crea, trouve, vp] = | |
| 48 | + await Promise.all([ | |
| 49 | + fetchJson("https://www.lou-ka.com/api/stats"), | |
| 50 | + fetchJson("https://www.immo-ka.com/api/stats"), | |
| 51 | + fetchJson("https://www.auto-ka.com/api/stats"), | |
| 52 | + fetchJson("https://www.fabri-ka.com/api/stats"), | |
| 53 | + fetchJson("https://www.food-ka.com/api/stats"), | |
| 54 | + fetchJson("https://www.resto-ka.com/api/stats"), | |
| 55 | + fetchJson("https://www.sorti-ka.com/api/stats"), | |
| 56 | + fetchJson("https://www.crea-ka.com/api/stats"), | |
| 57 | + fetchJson("https://www.trouve-ka.com/api/status"), | |
| 58 | + fetchJson("https://www.vrai-prix.com/api/stats"), | |
| 59 | + ]); | |
| 60 | + | |
| 61 | + const m: LiveMetrics = { | |
| 62 | + louka: { total: num(lou, "total", 33115), sources: num(lou, "sources", 207) }, | |
| 63 | + immoka: { | |
| 64 | + total: num(immo, "total", 67079), | |
| 65 | + sources: num(immo, "sources", 38), | |
| 66 | + cities: num(immo, "cities", 3114), | |
| 67 | + }, | |
| 68 | + autoka: { | |
| 69 | + total: num(auto, "total", 17098), | |
| 70 | + sources: num(auto, "sources", 125), | |
| 71 | + regions: num(auto, "regions", 18), | |
| 72 | + }, | |
| 73 | + fabrika: { | |
| 74 | + products: num(fab, "totals.products", 291557), | |
| 75 | + stores: num(fab, "totals.stores_live", 1265), | |
| 76 | + regions: num(fab, "totals.regions", 17), | |
| 77 | + }, | |
| 78 | + foodka: { | |
| 79 | + total: num(food, "total", 28050), | |
| 80 | + sources: num(food, "sources", 43), | |
| 81 | + onSale: num(food, "on_sale", 4922), | |
| 82 | + }, | |
| 83 | + restoka: { | |
| 84 | + restaurants: num(resto, "restaurants", 14091), | |
| 85 | + items: num(resto, "items", 79159), | |
| 86 | + chains: num(resto, "chains", 24), | |
| 87 | + regions: num(resto, "regions", 17), | |
| 88 | + }, | |
| 89 | + sortika: { | |
| 90 | + active: num(sorti, "total_active", 15229), | |
| 91 | + free: num(sorti, "free_upcoming", 3159), | |
| 92 | + cities: num(sorti, "cities", 428), | |
| 93 | + }, | |
| 94 | + creaka: { | |
| 95 | + creators: num(crea, "creators", 5103), | |
| 96 | + accounts: num(crea, "accounts", 5664), | |
| 97 | + platforms: crea && typeof crea.by_platform === "object" && crea.by_platform | |
| 98 | + ? Object.keys(crea.by_platform as Json).length | |
| 99 | + : 10, | |
| 100 | + }, | |
| 101 | + trouveka: { | |
| 102 | + pages: num(trouve, "pages_indexed", 1170502), | |
| 103 | + domains: num(trouve, "domains_count", 72280), | |
| 104 | + running: (trouve?.crawler_state ?? "running") === "running", | |
| 105 | + }, | |
| 106 | + vraiprix: { | |
| 107 | + units: num(vp, "units_total", 3747008), | |
| 108 | + transactions: num(vp, "transactions", 745119), | |
| 109 | + valueTotal: num(vp, "value_total_2026", 2009452277400), | |
| 110 | + }, | |
| 111 | + totals: { connectors: 0, items: 0 }, | |
| 112 | + }; | |
| 113 | + | |
| 114 | + m.totals.connectors = | |
| 115 | + m.louka.sources + | |
| 116 | + m.immoka.sources + | |
| 117 | + m.autoka.sources + | |
| 118 | + m.foodka.sources + | |
| 119 | + m.restoka.chains + | |
| 120 | + 4; // Sorti·Ka : billetteries & données ouvertes (pas d'API de comptage) | |
| 121 | + m.totals.items = | |
| 122 | + m.louka.total + | |
| 123 | + m.immoka.total + | |
| 124 | + m.autoka.total + | |
| 125 | + m.fabrika.products + | |
| 126 | + m.foodka.total + | |
| 127 | + m.restoka.items + | |
| 128 | + m.sortika.active + | |
| 129 | + m.creaka.creators; | |
| 130 | + | |
| 131 | + return m; | |
| 132 | +} | |
| 133 | + | |
| 134 | +/* ---- Formatage fr-CA ---- */ | |
| 135 | +const NBSP = " "; | |
| 136 | +export const fmtInt = (n: number): string => | |
| 137 | + n.toLocaleString("fr-CA").replace(/\s/g, NBSP); | |
| 138 | + | |
| 139 | +/** Arrondi « éditorial » vers le bas + suffixe « + » : 33 115 → « 33 100+ ». */ | |
| 140 | +export const fmtPlus = (n: number): string => { | |
| 141 | + const step = n >= 1000000 ? 10000 : n >= 100000 ? 1000 : n >= 10000 ? 100 : n >= 1000 ? 100 : 10; | |
| 142 | + return `${fmtInt(Math.floor(n / step) * step)}+`; | |
| 143 | +}; | |
| 144 | ||