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%

recherche: barre écosystème dans le header + page /recherche propulsée par Trouve-Ka (pivot moteur Groupe KA)

Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 195d74b

5 changed files +437 −3

added src/app/SearchBar.tsx +84 −0
@@ -0,0 +1,84 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Barre de recherche « tout l'écosystème KA » — simple formulaire GET vers
3 +// /recherche (rendu serveur, moteur Trouve·Ka). Deux tailles : compacte pour
4 +// le header desktop (défaut), grande pour la page /recherche (hero).
5 +"use client";
6 +
7 +export default function SearchBar({
8 + size = "header",
9 + defaultQuery = "",
10 + site,
11 +}: {
12 + size?: "header" | "hero";
13 + defaultQuery?: string;
14 + site?: string;
15 +}) {
16 + if (size === "hero") {
17 + return (
18 + <form
19 + action="/recherche"
20 + role="search"
21 + className="flex w-full max-w-xl items-center gap-2"
22 + >
23 + <input
24 + type="search"
25 + name="q"
26 + defaultValue={defaultQuery}
27 + className="input flex-1"
28 + placeholder="Rechercher dans l'écosystème KA…"
29 + aria-label="Recherche"
30 + autoComplete="off"
31 + />
32 + {site ? <input type="hidden" name="site" value={site} /> : null}
33 + <button type="submit" className="btn btn-primary shrink-0">
34 + Rechercher
35 + </button>
36 + </form>
37 + );
38 + }
39 + // Header : la rangée de nav est déjà très chargée (12 items + compte +
40 + // CTA). En dessous de ~1900 px, la barre complète pousserait le bouton
41 + // compte et « Écrivez-nous » hors écran (mesuré Playwright 2026-08-23) :
42 + // on affiche alors une icône-lien vers /recherche, et la barre complète
43 + // seulement sur les très grands écrans. Affichage conditionnel en CSS pur
44 + // (aucun réordonnancement entre breakpoints).
45 + // NB : .btn est redéfini HORS layer dans globals.css (il bat les utilities
46 + // Tailwind) → la visibilité responsive se pose sur des conteneurs neutres,
47 + // jamais sur l'élément .btn lui-même.
48 + return (
49 + <div className="flex items-center">
50 + <span className="min-[1950px]:hidden">
51 + <a
52 + href="/recherche"
53 + className="btn"
54 + aria-label="Recherche dans l'écosystème KA"
55 + title="Rechercher dans l'écosystème KA"
56 + >
57 + ⌕
58 + </a>
59 + </span>
60 + <form
61 + action="/recherche"
62 + role="search"
63 + className="hidden items-center gap-1.5 min-[1950px]:flex"
64 + >
65 + <input
66 + type="search"
67 + name="q"
68 + className="input w-40 px-3 text-[13.5px] min-[2100px]:w-56"
69 + placeholder="Rechercher dans l'écosystème KA…"
70 + aria-label="Recherche"
71 + autoComplete="off"
72 + />
73 + <button
74 + type="submit"
75 + className="btn shrink-0"
76 + aria-label="Lancer la recherche"
77 + title="Rechercher dans l'écosystème KA"
78 + >
79 + ⌕
80 + </button>
81 + </form>
82 + </div>
83 + );
84 +}
modified src/app/layout.tsx +17 −3
@@ -2,6 +2,7 @@
2 2 import type { Metadata, Viewport } from "next";
3 3 import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
4 4 import UserButton from "./UserButton";
5 +import SearchBar from "./SearchBar";
5 6 import { getLiveMetrics, fmtDec, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live";
6 7 import "./globals.css";
7 8
@@ -126,6 +127,13 @@ const NAV_ITEMS = [
126 127 { href: "/#contact", label: "Contact" },
127 128 ];
128 129
130 +// Nav mobile = NAV_ITEMS + « Recherche » (sur desktop, la barre de recherche
131 +// du header suffit — pas d'item redondant dans la nav).
132 +const MOBILE_NAV_ITEMS = [
133 + { href: "/recherche", label: "Recherche" },
134 + ...NAV_ITEMS,
135 +];
136 +
129 137 // Ticker construit sur les métriques LIVE des plateformes (src/lib/live.ts).
130 138 const tickerItems = (m: LiveMetrics): string[] => [
131 139 "·Ka = agréger — mille sites, un seul endroit",
@@ -191,17 +199,23 @@ export default async function RootLayout({
191 199 <Wordmark />
192 200 </a>
193 201 <nav aria-label="Navigation principale" className="hidden xl:block">
194 − <ul className="flex items-center gap-1">
202 + {/* gap-0.5 + px-2.5 (au lieu de gap-1 + px-3) : la rangée était
203 + déjà plus large que le conteneur max-w-6xl sous ~1660 px —
204 + resserrée pour faire place à la recherche sans rien perdre. */}
205 + <ul className="flex items-center gap-0.5">
195 206 {NAV_ITEMS.map((item) => (
196 207 <li key={item.href}>
197 208 <a
198 209 href={item.href}
199 − className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[7px] text-[13.5px] font-bold whitespace-nowrap text-ink-2 transition-colors hover:border-ink hover:text-ink"
210 + className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-2.5 py-[7px] text-[13.5px] font-bold whitespace-nowrap text-ink-2 transition-colors hover:border-ink hover:text-ink"
200 211 >
201 212 {item.label}
202 213 </a>
203 214 </li>
204 215 ))}
216 + <li className="ml-1 hidden xl:flex">
217 + <SearchBar />
218 + </li>
205 219 <li className="ml-1">
206 220 <UserButton />
207 221 </li>
@@ -250,7 +264,7 @@ export default async function RootLayout({
250 264 </div>
251 265 <nav aria-label="Menu mobile" className="min-h-0 flex-1 overflow-y-auto px-6 pt-8 pb-10">
252 266 <ul>
253 − {NAV_ITEMS.map((item, i) => (
267 + {MOBILE_NAV_ITEMS.map((item, i) => (
254 268 <li
255 269 key={item.href}
256 270 className="border-b border-dashed border-[rgba(20,24,20,0.25)]"
added src/app/recherche/page.tsx +233 −0
@@ -0,0 +1,233 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /recherche — la recherche « tout l'écosystème KA », propulsée par le
3 +// moteur Trouve·Ka (pivot 2026-08 : Trouve·Ka devient le moteur de recherche
4 +// du Groupe KA et n'indexe plus que les 14 sites de l'écosystème).
5 +// Rendu 100 % serveur : formulaire GET, pagination et filtres par site en
6 +// liens. Échec réseau = repli poli, jamais de page cassée.
7 +import type { Metadata } from "next";
8 +import SearchBar from "../SearchBar";
9 +import { decodeEntities, sanitizeSnippet, searchTrouveKa } from "@/lib/search";
10 +import { fmtInt } from "@/lib/live";
11 +
12 +export const metadata: Metadata = {
13 + title: { absolute: "Recherche — Groupe KA" },
14 + description:
15 + "Recherchez dans tout l'écosystème KA — logements, propriétés, autos, produits d'ici, épicerie, restos, sorties, créateurs et emplois — avec le moteur Trouve·Ka.",
16 +};
17 +
18 +// Les 14 sites de l'écosystème (filtre par domaine du moteur).
19 +const SITES: [string, string][] = [
20 + ["www.lou-ka.com", "Lou·Ka"],
21 + ["www.immo-ka.com", "Immo·Ka"],
22 + ["www.vrai-prix.com", "Vrai-Prix"],
23 + ["www.toit-ka.com", "Toit·Ka"],
24 + ["www.auto-ka.com", "Auto·Ka"],
25 + ["www.fabri-ka.com", "Fabri·Ka"],
26 + ["www.food-ka.com", "Food·Ka"],
27 + ["www.resto-ka.com", "Resto·Ka"],
28 + ["www.sorti-ka.com", "Sorti·Ka"],
29 + ["www.crea-ka.com", "Créa·Ka"],
30 + ["www.job-ka.com", "Job·Ka"],
31 + ["www.valoplex.com", "Valoplex"],
32 + ["www.groupe-ka.com", "Groupe KA"],
33 + ["www.trouve-ka.com", "Trouve·Ka"],
34 +];
35 +
36 +const PoweredBy = () => (
37 + <p className="gk-mono mt-4 text-[11.5px] text-ink-3">
38 + Propulsé par{" "}
39 + <a
40 + href="https://www.trouve-ka.com"
41 + target="_blank"
42 + rel="noopener noreferrer"
43 + className="font-bold text-green underline-offset-4 hover:underline"
44 + >
45 + Trouve·Ka
46 + </a>{" "}
47 + — le moteur de recherche du Groupe KA.
48 + </p>
49 +);
50 +
51 +export default async function RecherchePage({
52 + searchParams,
53 +}: {
54 + searchParams: Promise<{ q?: string; page?: string; site?: string }>;
55 +}) {
56 + const sp = await searchParams;
57 + const q = (sp.q ?? "").trim();
58 + const page = Math.max(1, Number.parseInt(sp.page ?? "1", 10) || 1);
59 + const site = SITES.some(([d]) => d === sp.site) ? sp.site : undefined;
60 +
61 + // Lien interne en conservant q / site / page voulus.
62 + const href = (over: { page?: number; site?: string | null }) => {
63 + const u = new URLSearchParams({ q });
64 + const s = over.site === undefined ? site : (over.site ?? undefined);
65 + if (s) u.set("site", s);
66 + if (over.page && over.page > 1) u.set("page", String(over.page));
67 + return `/recherche?${u.toString()}`;
68 + };
69 +
70 + /* ---- État d'accueil (pas de requête) ---- */
71 + if (!q) {
72 + return (
73 + <main className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
74 + <p className="kicker">Recherche · tout l’écosystème</p>
75 + <h1 className="gk-display mt-3 max-w-3xl text-[clamp(28px,4.5vw,44px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase">
76 + Rechercher dans tout <span className="hl">l’écosystème KA</span>
77 + </h1>
78 + <p className="mt-4 max-w-2xl text-[14.5px] text-ink-2">
79 + Un seul champ pour chercher sur les 14 sites du Groupe KA :
80 + logements, propriétés, autos, produits d’ici, épicerie, restos,
81 + sorties, créateurs et emplois.
82 + </p>
83 + <div className="mt-8">
84 + <SearchBar size="hero" />
85 + </div>
86 + <PoweredBy />
87 + </main>
88 + );
89 + }
90 +
91 + /* ---- Résultats ---- */
92 + const outcome = await searchTrouveKa(q, { page, site });
93 +
94 + return (
95 + <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
96 + <p className="kicker">Recherche · tout l’écosystème</p>
97 + <h1 className="gk-display mt-3 text-[clamp(24px,4vw,36px)] leading-[1.05] font-bold tracking-[-0.035em] uppercase">
98 + Résultats pour «&nbsp;{q}&nbsp;»
99 + </h1>
100 +
101 + <div className="mt-6">
102 + <SearchBar size="hero" defaultQuery={q} site={site} />
103 + </div>
104 +
105 + {/* Filtres par site */}
106 + <div className="mt-6 flex flex-wrap gap-2" aria-label="Filtrer par site">
107 + <a
108 + href={href({ site: null })}
109 + className={!site ? "chip chip-accent" : "chip"}
110 + aria-current={!site ? "true" : undefined}
111 + >
112 + Tous
113 + </a>
114 + {SITES.map(([domain, label]) => (
115 + <a
116 + key={domain}
117 + href={href({ site: domain })}
118 + className={site === domain ? "chip chip-accent" : "chip"}
119 + aria-current={site === domain ? "true" : undefined}
120 + >
121 + {label}
122 + </a>
123 + ))}
124 + </div>
125 +
126 + {!outcome.ok ? (
127 + /* Repli : moteur injoignable — la page reste servie. */
128 + <div className="card mt-8 max-w-2xl p-6">
129 + <p className="gk-display text-[18px] font-bold">
130 + Le moteur de recherche est momentanément indisponible.
131 + </p>
132 + <p className="mt-2 text-[14px] text-ink-2">
133 + Réessayez dans un instant, ou cherchez directement sur{" "}
134 + <a
135 + href={`https://www.trouve-ka.com/?q=${encodeURIComponent(q)}`}
136 + target="_blank"
137 + rel="noopener noreferrer"
138 + className="font-bold text-green underline-offset-4 hover:underline"
139 + >
140 + trouve-ka.com
141 + </a>
142 + .
143 + </p>
144 + </div>
145 + ) : (
146 + <>
147 + <p className="gk-mono mt-6 text-[12px] text-ink-2">
148 + {fmtInt(outcome.data.total)}{" "}
149 + {outcome.data.total > 1 ? "résultats" : "résultat"} (
150 + {fmtInt(outcome.data.took_ms)}&nbsp;ms)
151 + </p>
152 +
153 + {outcome.data.results.length === 0 ? (
154 + <div className="card mt-6 max-w-2xl p-6">
155 + <p className="gk-display text-[18px] font-bold">
156 + Aucun résultat{site ? " sur ce site" : ""} pour «&nbsp;{q}&nbsp;».
157 + </p>
158 + <p className="mt-2 text-[14px] text-ink-2">
159 + Essayez d’autres mots-clés{site ? (
160 + <>
161 + {" "}
162 + ou{" "}
163 + <a
164 + href={href({ site: null, page: 1 })}
165 + className="font-bold text-green underline-offset-4 hover:underline"
166 + >
167 + cherchez sur tous les sites
168 + </a>
169 + </>
170 + ) : null}
171 + .
172 + </p>
173 + </div>
174 + ) : (
175 + <ol className="mt-6 flex max-w-3xl flex-col gap-4">
176 + {outcome.data.results.map((r) => (
177 + <li key={r.url} className="card p-5">
178 + <p className="gk-mono text-[11px] break-all text-ink-3">
179 + {r.display_url}
180 + </p>
181 + <h2 className="mt-1">
182 + <a
183 + href={r.url}
184 + target="_blank"
185 + rel="noopener noreferrer"
186 + className="gk-display text-[18px] leading-snug font-bold underline-offset-4 hover:underline"
187 + >
188 + {r.title ? decodeEntities(r.title) : r.url}
189 + </a>
190 + </h2>
191 + {r.snippet ? (
192 + <p
193 + className="mt-2 text-[13.5px] leading-relaxed text-ink-2 [&_em]:rounded-[3px] [&_em]:bg-[var(--lime)] [&_em]:px-[3px] [&_em]:font-semibold [&_em]:not-italic [&_em]:text-ink"
194 + dangerouslySetInnerHTML={{
195 + __html: sanitizeSnippet(r.snippet),
196 + }}
197 + />
198 + ) : null}
199 + <p className="mt-3">
200 + <span className="chip chip-soft">{r.domain}</span>
201 + </p>
202 + </li>
203 + ))}
204 + </ol>
205 + )}
206 +
207 + {/* Pagination */}
208 + <nav
209 + className="mt-8 flex max-w-3xl items-center justify-between gap-4"
210 + aria-label="Pagination des résultats"
211 + >
212 + {page > 1 ? (
213 + <a href={href({ page: page - 1 })} className="btn">
214 + ← Précédent
215 + </a>
216 + ) : (
217 + <span aria-hidden="true" />
218 + )}
219 + {page * outcome.data.limit < outcome.data.total ? (
220 + <a href={href({ page: page + 1 })} className="btn">
221 + Suivant →
222 + </a>
223 + ) : (
224 + <span aria-hidden="true" />
225 + )}
226 + </nav>
227 + </>
228 + )}
229 +
230 + <PoweredBy />
231 + </main>
232 + );
233 +}
modified src/app/sitemap.ts +1 −0
@@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
14 14 priority: number;
15 15 }[] = [
16 16 { path: "/", changeFrequency: "daily", priority: 1 },
17 + { path: "/recherche", changeFrequency: "weekly", priority: 0.7 },
17 18 { path: "/stats", changeFrequency: "daily", priority: 0.8 },
18 19 { path: "/rapports", changeFrequency: "daily", priority: 0.7 },
19 20 { path: "/status", changeFrequency: "daily", priority: 0.6 },
added src/lib/search.ts +102 −0
@@ -0,0 +1,102 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Client du moteur Trouve·Ka — la recherche de tout l'écosystème Groupe KA.
3 +// Contrat public : GET /api/search?q=…&page=…&limit=…&site=<domaine> →
4 +// { query, total, took_ms, page, limit, related[], results[] }.
5 +// Jamais de throw : tout échec (réseau, HTTP, JSON invalide) retourne
6 +// { ok: false } et la page affiche un repli poli — la recherche ne doit
7 +// JAMAIS faire planter le site mère.
8 +// ⚠️ Le snippet contient du HTML de surlignage (<em>) : il DOIT passer par
9 +// sanitizeSnippet() avant tout dangerouslySetInnerHTML.
10 +
11 +export type SearchResult = {
12 + title: string;
13 + url: string;
14 + display_url: string;
15 + snippet: string;
16 + domain: string;
17 + language: string;
18 + quebec_score: number;
19 + badges: string[];
20 + published_at: string | null;
21 + image: string | null;
22 +};
23 +
24 +export type SearchData = {
25 + query: string;
26 + total: number;
27 + took_ms: number;
28 + page: number;
29 + limit: number;
30 + related: string[];
31 + results: SearchResult[];
32 +};
33 +
34 +export type SearchOutcome = { ok: true; data: SearchData } | { ok: false };
35 +
36 +// URL de base surchargée en environnement (tests, bascule d'infra) :
37 +// TROUVEKA_SEARCH_URL=https://…/api/search
38 +const BASE_URL =
39 + process.env.TROUVEKA_SEARCH_URL || "https://www.trouve-ka.com/api/search";
40 +
41 +export async function searchTrouveKa(
42 + q: string,
43 + opts?: { page?: number; site?: string },
44 +): Promise<SearchOutcome> {
45 + try {
46 + const url = new URL(BASE_URL);
47 + url.searchParams.set("q", q);
48 + url.searchParams.set("page", String(opts?.page ?? 1));
49 + url.searchParams.set("limit", "10");
50 + // Filtre par domaine (en cours d'ajout côté moteur — inoffensif s'il
51 + // est ignoré : on filtre alors « tous les sites »).
52 + if (opts?.site) url.searchParams.set("site", opts.site);
53 + const res = await fetch(url.toString(), {
54 + cache: "no-store",
55 + signal: AbortSignal.timeout(6000),
56 + headers: { accept: "application/json" },
57 + });
58 + if (!res.ok) return { ok: false };
59 + const data = (await res.json()) as SearchData;
60 + if (!data || !Array.isArray(data.results)) return { ok: false };
61 + return { ok: true, data };
62 + } catch {
63 + return { ok: false };
64 + }
65 +}
66 +
67 +/**
68 + * Décode les entités HTML déjà présentes dans les textes du moteur
69 + * (&#x27;, &amp;, &lt;…) vers du texte brut. Sans ce décodage, le
70 + * ré-échappement de sanitizeSnippet doublerait l'encodage et afficherait
71 + * « &#x27; » littéralement (observé en prod 2026-08-23).
72 + */
73 +export function decodeEntities(s: string): string {
74 + const cp = (n: number) =>
75 + Number.isFinite(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
76 + return s
77 + .replace(/&#x([0-9a-f]+);/gi, (_, h: string) => cp(parseInt(h, 16)))
78 + .replace(/&#(\d+);/g, (_, d: string) => cp(parseInt(d, 10)))
79 + .replaceAll("&lt;", "<")
80 + .replaceAll("&gt;", ">")
81 + .replaceAll("&quot;", '"')
82 + .replaceAll("&apos;", "'")
83 + .replaceAll("&nbsp;", " ")
84 + .replaceAll("&amp;", "&");
85 +}
86 +
87 +/**
88 + * Assainit un snippet du moteur : décode les entités existantes, échappe
89 + * TOUT le HTML (&, <, >, guillemets) puis ne ré-autorise que les balises de
90 + * surlignage <em>/</em> ajoutées par Trouve·Ka. Résultat sûr pour
91 + * dangerouslySetInnerHTML.
92 + */
93 +export function sanitizeSnippet(html: string): string {
94 + return decodeEntities(html)
95 + .replaceAll("&", "&amp;")
96 + .replaceAll("<", "&lt;")
97 + .replaceAll(">", "&gt;")
98 + .replaceAll('"', "&quot;")
99 + .replaceAll("'", "&#39;")
100 + .replaceAll("&lt;em&gt;", "<em>")
101 + .replaceAll("&lt;/em&gt;", "</em>");
102 +}
103