SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 3fbe0b7

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&apos;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&apos;observations immobilières</span>
564 − <span className="stat-chip">450 000+ annonces, produits &amp; événements</span>
621 + <span className="stat-chip">
622 + {fmtPlus(m.totals.items)} annonces, produits &amp; é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&apos;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&apos;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&apos;é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&apos;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&apos;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&apos;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