Barre de navigation mobile commune Groupe KA (KA Tabbar v1)
Pilule flottante fixe en bas (standard ka-ui/nav, variante Next.js) : Estimer · Marché · Ventes · Méthode, accent rouge signature, bilingue fr/en, safe-area iOS. Estimer mène immédiatement à la recherche d adresse (focus du champ héros, id vp-adresse) ; Ventes ancre le flux des dernières ventes réelles de l accueil (id ventes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +400 −1
modified
src/app/HomeClient.tsx
+1 −1
@@ -284,7 +284,7 @@ export default function HomeClient({ | ||
| 284 | 284 | |
| 285 | 285 | {/* ================= VENTES RÉELLES — flux prix-d'abord ================= */} |
| 286 | 286 | {recent.length > 0 && ( |
| 287 | − <section className="mt-14"> | |
| 287 | + <section id="ventes" className="mt-14 scroll-mt-20"> | |
| 288 | 288 | <SectionHead |
| 289 | 289 | num="02" |
| 290 | 290 | kicker={fr ? "Flux en continu" : "Live feed"} |
modified
src/app/layout.tsx
+3 −0
@@ -2,10 +2,12 @@ | ||
| 2 | 2 | import type { Metadata, Viewport } from "next"; |
| 3 | 3 | import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; |
| 4 | 4 | import "./globals.css"; |
| 5 | +import "../ka/ka-tabbar.css"; | |
| 5 | 6 | import { LangProvider, LangToggle } from "@/components/LangContext"; |
| 6 | 7 | import AccountButton from "@/components/AccountButton"; |
| 7 | 8 | import CookieConsent from "@/components/CookieConsent"; |
| 8 | 9 | import FooterNav from "@/components/FooterNav"; |
| 10 | +import MobileTabBar from "@/components/MobileTabBar"; | |
| 9 | 11 | import HeaderNav from "@/components/HeaderNav"; |
| 10 | 12 | import GroupeKaBadge from "@/ka/GroupeKaBadge"; |
| 11 | 13 | import KaFooter from "@/ka/KaFooter"; |
@@ -176,6 +178,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { | ||
| 176 | 178 | { label: "Méthodologie", href: "/methodologie" }, |
| 177 | 179 | ]} |
| 178 | 180 | /> |
| 181 | + <MobileTabBar /> | |
| 179 | 182 | <CookieConsent /> |
| 180 | 183 | <script |
| 181 | 184 | dangerouslySetInnerHTML={{ |
added
src/components/MobileTabBar.tsx
+74 −0
@@ -0,0 +1,74 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Barre de navigation mobile commune Groupe KA (ka-ui/nav — KA Tabbar v1) : | |
| 4 | + * Estimer · Marché · Ventes · Méthode, accent rouge signature, bilingue. | |
| 5 | + * Estimer mène immédiatement à la recherche d'adresse (focus du champ héros) ; | |
| 6 | + * Ventes ancre le flux « dernières ventes réelles » de l'accueil. | |
| 7 | + */ | |
| 8 | +"use client"; | |
| 9 | +import { useEffect, useState } from "react"; | |
| 10 | +import { usePathname } from "next/navigation"; | |
| 11 | +import KaTabbar from "../ka/KaTabbar"; | |
| 12 | +import { useLang } from "./LangContext"; | |
| 13 | + | |
| 14 | +/** focus du champ d'adresse (réessaie le temps que l'accueil se monte) */ | |
| 15 | +function focusAdresse(tries = 10) { | |
| 16 | + const el = document.getElementById("vp-adresse") as HTMLInputElement | null; | |
| 17 | + if (el) { | |
| 18 | + el.scrollIntoView({ block: "center", behavior: "smooth" }); | |
| 19 | + el.focus({ preventScroll: true }); | |
| 20 | + } else if (tries > 0) { | |
| 21 | + window.setTimeout(() => focusAdresse(tries - 1), 100); | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default function MobileTabBar() { | |
| 26 | + const path = usePathname(); | |
| 27 | + const { lang } = useLang(); | |
| 28 | + const fr = lang === "fr"; | |
| 29 | + const [hash, setHash] = useState(""); | |
| 30 | + | |
| 31 | + useEffect(() => { | |
| 32 | + const sync = () => setHash(window.location.hash); | |
| 33 | + sync(); | |
| 34 | + window.addEventListener("hashchange", sync); | |
| 35 | + return () => window.removeEventListener("hashchange", sync); | |
| 36 | + }, [path]); | |
| 37 | + | |
| 38 | + const ventes = path === "/" && hash === "#ventes"; | |
| 39 | + return ( | |
| 40 | + <KaTabbar | |
| 41 | + tabs={[ | |
| 42 | + { | |
| 43 | + label: fr ? "Estimer" : "Estimate", | |
| 44 | + icon: "target", | |
| 45 | + to: "/", | |
| 46 | + onClick: () => { | |
| 47 | + setHash(""); | |
| 48 | + focusAdresse(); | |
| 49 | + }, | |
| 50 | + active: (path === "/" && !ventes) || path.startsWith("/estimation"), | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + label: fr ? "Marché" : "Market", | |
| 54 | + icon: "trendup", | |
| 55 | + to: "/stats", | |
| 56 | + active: path.startsWith("/stats") || path.startsWith("/municipalite"), | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + label: fr ? "Ventes" : "Sales", | |
| 60 | + icon: "receipt", | |
| 61 | + to: "/#ventes", | |
| 62 | + onClick: () => setHash("#ventes"), | |
| 63 | + active: ventes, | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + label: fr ? "Méthode" : "Method", | |
| 67 | + icon: "book", | |
| 68 | + to: "/methodologie", | |
| 69 | + active: path.startsWith("/methodologie"), | |
| 70 | + }, | |
| 71 | + ]} | |
| 72 | + /> | |
| 73 | + ); | |
| 74 | +} | |
modified
src/components/SearchBox.tsx
+1 −0
@@ -67,6 +67,7 @@ export default function SearchBox({ | ||
| 67 | 67 | <div className="relative w-full"> |
| 68 | 68 | <div className={hero ? "flex items-stretch gap-0" : undefined}> |
| 69 | 69 | <input |
| 70 | + id={variant === "hero" ? "vp-adresse" : undefined} | |
| 70 | 71 | ref={input} |
| 71 | 72 | value={q} |
| 72 | 73 | onChange={(e) => onChange(e.target.value)} |
added
src/ka/KaTabbar.tsx
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +"use client"; | |
| 2 | +// KA Tabbar v1 — variante Next.js du canonique ka-ui.git/nav/KaTabbar.tsx | |
| 3 | +// (Link next/link ; le CSS global ka-tabbar.css est importé par layout.tsx). | |
| 4 | +import type { CSSProperties, ReactElement } from "react"; | |
| 5 | +import Link from "next/link"; | |
| 6 | + | |
| 7 | +export type KaTabIcon = | |
| 8 | + | "compass" | "search" | "heart" | "user" | "map" | "pin" | "tag" | |
| 9 | + | "home" | "grid" | "store" | "basket" | "target" | "trendup" | |
| 10 | + | "receipt" | "book" | "chart" | "calendar"; | |
| 11 | + | |
| 12 | +const ICONS: Record<KaTabIcon, ReactElement> = { | |
| 13 | + compass: ( | |
| 14 | + <> | |
| 15 | + <circle cx="12" cy="12" r="9" /> | |
| 16 | + <path d="m15.5 8.5-2.2 5-4.8 2 2.2-5 4.8-2z" /> | |
| 17 | + </> | |
| 18 | + ), | |
| 19 | + search: ( | |
| 20 | + <> | |
| 21 | + <circle cx="11" cy="11" r="7" /> | |
| 22 | + <path d="m20.5 20.5-4.2-4.2" /> | |
| 23 | + </> | |
| 24 | + ), | |
| 25 | + heart: ( | |
| 26 | + <path d="M12 20.5S4.5 15.8 2.6 11.4A5.3 5.3 0 0 1 12 6.6a5.3 5.3 0 0 1 9.4 4.8C19.5 15.8 12 20.5 12 20.5z" /> | |
| 27 | + ), | |
| 28 | + user: ( | |
| 29 | + <> | |
| 30 | + <circle cx="12" cy="8.2" r="3.7" /> | |
| 31 | + <path d="M4.5 20.2a7.5 7.5 0 0 1 15 0" /> | |
| 32 | + </> | |
| 33 | + ), | |
| 34 | + map: ( | |
| 35 | + <> | |
| 36 | + <path d="M9 4 3.5 6v14L9 18l6 2 5.5-2V4L15 6 9 4z" /> | |
| 37 | + <path d="M9 4v14M15 6v14" /> | |
| 38 | + </> | |
| 39 | + ), | |
| 40 | + pin: ( | |
| 41 | + <> | |
| 42 | + <path d="M12 21s-7-5.6-7-11a7 7 0 0 1 14 0c0 5.4-7 11-7 11z" /> | |
| 43 | + <circle cx="12" cy="10" r="2.6" /> | |
| 44 | + </> | |
| 45 | + ), | |
| 46 | + tag: ( | |
| 47 | + <> | |
| 48 | + <path d="M20.6 13.4 12 22 2 12V2h10l8.6 8.6a2 2 0 0 1 0 2.8z" /> | |
| 49 | + <circle cx="7" cy="7" r="1.6" /> | |
| 50 | + </> | |
| 51 | + ), | |
| 52 | + home: ( | |
| 53 | + <> | |
| 54 | + <path d="m3.5 10.5 8.5-7 8.5 7" /> | |
| 55 | + <path d="M5.5 9v11h13V9" /> | |
| 56 | + </> | |
| 57 | + ), | |
| 58 | + grid: ( | |
| 59 | + <> | |
| 60 | + <rect x="4" y="4" width="7" height="7" rx="1.5" /> | |
| 61 | + <rect x="13" y="4" width="7" height="7" rx="1.5" /> | |
| 62 | + <rect x="4" y="13" width="7" height="7" rx="1.5" /> | |
| 63 | + <rect x="13" y="13" width="7" height="7" rx="1.5" /> | |
| 64 | + </> | |
| 65 | + ), | |
| 66 | + store: ( | |
| 67 | + <> | |
| 68 | + <path d="M4.5 9.5 6 4.5h12l1.5 5" /> | |
| 69 | + <path d="M5 9.5h14V20H5V9.5z" /> | |
| 70 | + <path d="M10 20v-5h4v5" /> | |
| 71 | + </> | |
| 72 | + ), | |
| 73 | + basket: ( | |
| 74 | + <> | |
| 75 | + <path d="M4 9h16l-1.6 10a2 2 0 0 1-2 1.7H7.6a2 2 0 0 1-2-1.7L4 9z" /> | |
| 76 | + <path d="M8.5 9 12 3l3.5 6" /> | |
| 77 | + </> | |
| 78 | + ), | |
| 79 | + target: ( | |
| 80 | + <> | |
| 81 | + <circle cx="12" cy="12" r="8.5" /> | |
| 82 | + <circle cx="12" cy="12" r="4.5" /> | |
| 83 | + <circle cx="12" cy="12" r="0.8" /> | |
| 84 | + </> | |
| 85 | + ), | |
| 86 | + trendup: ( | |
| 87 | + <> | |
| 88 | + <path d="m3.5 16.5 5.5-5.5 3.5 3.5 7.5-7.5" /> | |
| 89 | + <path d="M14.5 7H20v5.5" /> | |
| 90 | + </> | |
| 91 | + ), | |
| 92 | + receipt: ( | |
| 93 | + <> | |
| 94 | + <path d="M6 3.5h12V21l-2-1.4-2 1.4-2-1.4-2 1.4-2-1.4L6 21V3.5z" /> | |
| 95 | + <path d="M9 8h6M9 11.5h6M9 15h4" /> | |
| 96 | + </> | |
| 97 | + ), | |
| 98 | + book: ( | |
| 99 | + <> | |
| 100 | + <path d="M12 6.5C10.5 5 8.5 4.5 5.5 4.5H3.5V19h2c3 0 5 .5 6.5 2 1.5-1.5 3.5-2 6.5-2h2V4.5h-2c-3 0-5 .5-6.5 2z" /> | |
| 101 | + <path d="M12 6.5V21" /> | |
| 102 | + </> | |
| 103 | + ), | |
| 104 | + chart: ( | |
| 105 | + <> | |
| 106 | + <path d="M4 20h16" /> | |
| 107 | + <path d="M7.5 20v-6M12 20V9M16.5 20v-4" /> | |
| 108 | + </> | |
| 109 | + ), | |
| 110 | + calendar: ( | |
| 111 | + <> | |
| 112 | + <rect x="3.5" y="5" width="17" height="15.5" rx="2" /> | |
| 113 | + <path d="M8 3v4M16 3v4M3.5 10h17" /> | |
| 114 | + </> | |
| 115 | + ), | |
| 116 | +}; | |
| 117 | + | |
| 118 | +export function KaTabIco({ name, size = 21 }: { name: KaTabIcon; size?: number }) { | |
| 119 | + return ( | |
| 120 | + <svg | |
| 121 | + width={size} | |
| 122 | + height={size} | |
| 123 | + viewBox="0 0 24 24" | |
| 124 | + fill="none" | |
| 125 | + stroke="currentColor" | |
| 126 | + strokeWidth={1.7} | |
| 127 | + strokeLinecap="round" | |
| 128 | + strokeLinejoin="round" | |
| 129 | + aria-hidden="true" | |
| 130 | + > | |
| 131 | + {ICONS[name]} | |
| 132 | + </svg> | |
| 133 | + ); | |
| 134 | +} | |
| 135 | + | |
| 136 | +export type KaTab = { | |
| 137 | + label: string; | |
| 138 | + icon: KaTabIcon; | |
| 139 | + /** route interne (react-router Link) */ | |
| 140 | + to?: string; | |
| 141 | + /** lien externe (ex. hub univers KA) */ | |
| 142 | + href?: string; | |
| 143 | + /** action locale (ex. ouvrir la recherche) — rend un <button> */ | |
| 144 | + onPress?: () => void; | |
| 145 | + /** complément d'un lien `to` (ex. focus d'un champ une fois arrivé) */ | |
| 146 | + onClick?: () => void; | |
| 147 | + active?: boolean; | |
| 148 | +}; | |
| 149 | + | |
| 150 | +export default function KaTabbar({ | |
| 151 | + tabs, | |
| 152 | + theme = "light", | |
| 153 | + accent, | |
| 154 | + className, | |
| 155 | +}: { | |
| 156 | + tabs: KaTab[]; | |
| 157 | + theme?: "light" | "dark"; | |
| 158 | + /** couleur signature de la marque (défaut : var(--accent) du site) */ | |
| 159 | + accent?: string; | |
| 160 | + className?: string; | |
| 161 | +}) { | |
| 162 | + const cls = | |
| 163 | + "ka-tabbar" + | |
| 164 | + (theme === "dark" ? " ka-tabbar--dark" : "") + | |
| 165 | + (className ? " " + className : ""); | |
| 166 | + const style = accent ? ({ "--ktb-accent": accent } as CSSProperties) : undefined; | |
| 167 | + return ( | |
| 168 | + <nav className={cls} style={style} aria-label="Navigation mobile"> | |
| 169 | + {tabs.map((t) => { | |
| 170 | + const inner = ( | |
| 171 | + <> | |
| 172 | + <KaTabIco name={t.icon} /> | |
| 173 | + {t.label} | |
| 174 | + </> | |
| 175 | + ); | |
| 176 | + const c = t.active ? "active" : ""; | |
| 177 | + if (t.to) | |
| 178 | + return ( | |
| 179 | + <Link | |
| 180 | + key={t.label} | |
| 181 | + href={t.to} | |
| 182 | + className={c} | |
| 183 | + onClick={t.onClick} | |
| 184 | + aria-current={t.active ? "page" : undefined} | |
| 185 | + > | |
| 186 | + {inner} | |
| 187 | + </Link> | |
| 188 | + ); | |
| 189 | + if (t.href) | |
| 190 | + return ( | |
| 191 | + <a key={t.label} href={t.href} className={c} target="_blank" rel="noreferrer"> | |
| 192 | + {inner} | |
| 193 | + </a> | |
| 194 | + ); | |
| 195 | + return ( | |
| 196 | + <button key={t.label} type="button" className={c} onClick={t.onPress}> | |
| 197 | + {inner} | |
| 198 | + </button> | |
| 199 | + ); | |
| 200 | + })} | |
| 201 | + </nav> | |
| 202 | + ); | |
| 203 | +} | |
added
src/ka/ka-tabbar.css
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +/* ============================================================ | |
| 2 | + KA Tabbar v1 — barre de navigation mobile commune Groupe KA | |
| 3 | + Canonique : ka-ui.git/nav/ka-tabbar.css — copiée telle quelle | |
| 4 | + dans chaque app (src/ka/ka-tabbar.css) et importée par le | |
| 5 | + composant KaTabbar. Voir TABBAR-STANDARD.md. | |
| 6 | + | |
| 7 | + Pilule flottante détachée du bas d'écran (langage Immo-Ka), | |
| 8 | + 4-5 onglets, safe-area iOS, thème clair (défaut) ou sombre. | |
| 9 | + Config par app : | |
| 10 | + --ktb-accent couleur signature (trait actif + icône) | |
| 11 | + --ktb-active-label couleur du label actif (défaut : encre) | |
| 12 | + .ka-tabbar--dark variante sombre (fond encre/brand) | |
| 13 | + --ktb-bg fond (surcharge libre, ex. vert profond) | |
| 14 | + ============================================================ */ | |
| 15 | + | |
| 16 | +.ka-tabbar { display: none; } | |
| 17 | + | |
| 18 | +@media (max-width: 760px) { | |
| 19 | + .ka-tabbar { | |
| 20 | + /* thème clair (défaut) — papier off-white légèrement translucide */ | |
| 21 | + --ktb-bg: rgba(250, 249, 245, 0.94); | |
| 22 | + --ktb-ink: var(--ink, #16191a); | |
| 23 | + --ktb-dim: var(--ink-3, #70756d); | |
| 24 | + --ktb-accent: var(--accent, #d6336c); | |
| 25 | + --ktb-line: var(--hairline-strong, rgba(20, 24, 20, 0.16)); | |
| 26 | + --ktb-shadow: 0 10px 30px rgba(20, 24, 20, 0.16), 0 2px 6px rgba(20, 24, 20, 0.07); | |
| 27 | + | |
| 28 | + position: fixed; | |
| 29 | + left: max(12px, env(safe-area-inset-left)); | |
| 30 | + right: max(12px, env(safe-area-inset-right)); | |
| 31 | + bottom: calc(10px + env(safe-area-inset-bottom)); | |
| 32 | + z-index: var(--z-bottombar, 600); | |
| 33 | + display: grid; | |
| 34 | + grid-auto-flow: column; | |
| 35 | + grid-auto-columns: 1fr; | |
| 36 | + max-width: 440px; | |
| 37 | + margin-inline: auto; | |
| 38 | + padding: 5px; | |
| 39 | + background: var(--ktb-bg); | |
| 40 | + -webkit-backdrop-filter: blur(14px) saturate(1.1); | |
| 41 | + backdrop-filter: blur(14px) saturate(1.1); | |
| 42 | + border: 1px solid var(--ktb-line); | |
| 43 | + border-radius: 18px; | |
| 44 | + box-shadow: var(--ktb-shadow); | |
| 45 | + } | |
| 46 | + | |
| 47 | + .ka-tabbar--dark { | |
| 48 | + --ktb-bg: rgba(18, 22, 19, 0.93); | |
| 49 | + --ktb-ink: #f5f3ee; | |
| 50 | + --ktb-dim: rgba(245, 243, 238, 0.62); | |
| 51 | + --ktb-line: rgba(245, 243, 238, 0.16); | |
| 52 | + --ktb-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); | |
| 53 | + } | |
| 54 | + | |
| 55 | + .ka-tabbar a, | |
| 56 | + .ka-tabbar button { | |
| 57 | + appearance: none; | |
| 58 | + background: none; | |
| 59 | + border: 0; | |
| 60 | + cursor: pointer; | |
| 61 | + display: flex; | |
| 62 | + flex-direction: column; | |
| 63 | + align-items: center; | |
| 64 | + justify-content: center; | |
| 65 | + gap: 3px; | |
| 66 | + min-height: 50px; | |
| 67 | + padding: 6px 2px 5px; | |
| 68 | + border-radius: 13px; | |
| 69 | + font-family: var(--font-display, inherit); | |
| 70 | + font-size: 10px; | |
| 71 | + font-weight: 650; | |
| 72 | + letter-spacing: 0.01em; | |
| 73 | + line-height: 1; | |
| 74 | + color: var(--ktb-dim); | |
| 75 | + text-decoration: none; | |
| 76 | + -webkit-tap-highlight-color: transparent; | |
| 77 | + transition: color 0.15s ease; | |
| 78 | + } | |
| 79 | + .ka-tabbar svg { display: block; transition: transform 0.15s ease; } | |
| 80 | + .ka-tabbar a:active svg, | |
| 81 | + .ka-tabbar button:active svg { transform: translateY(1px) scale(0.9); } | |
| 82 | + | |
| 83 | + /* état actif : icône couleur signature, label encre, trait accent */ | |
| 84 | + .ka-tabbar .active { color: var(--ktb-active-label, var(--ktb-ink)); } | |
| 85 | + .ka-tabbar .active svg { color: var(--ktb-accent); animation: ktb-pop 0.25s ease; } | |
| 86 | + .ka-tabbar .active::after { | |
| 87 | + content: ""; | |
| 88 | + width: 16px; | |
| 89 | + height: 2.5px; | |
| 90 | + border-radius: 2px; | |
| 91 | + background: var(--ktb-accent); | |
| 92 | + animation: ktb-under 0.22s ease; | |
| 93 | + } | |
| 94 | + | |
| 95 | + /* le contenu respire sous la pilule (footer compris) */ | |
| 96 | + body:has(.ka-tabbar) { padding-bottom: calc(84px + env(safe-area-inset-bottom)); } | |
| 97 | + | |
| 98 | + /* un CTA sticky de fiche prend le bas d'écran : la pilule s'efface */ | |
| 99 | + body:has(.cta-sticky) .ka-tabbar { display: none; } | |
| 100 | + body:has(.cta-sticky) { padding-bottom: 0; } | |
| 101 | + | |
| 102 | + /* widget KA Agent : dégagé de la pilule (style injecté après le bundle | |
| 103 | + → spécificité doublée pour gagner) */ | |
| 104 | + body:has(.ka-tabbar) .kaa-btn.kaa-btn { bottom: calc(88px + env(safe-area-inset-bottom, 0px)); right: 14px; } | |
| 105 | + body:has(.ka-tabbar) .kaa-panel.kaa-panel:not(.kaa-full) { bottom: calc(154px + env(safe-area-inset-bottom, 0px)); } | |
| 106 | +} | |
| 107 | + | |
| 108 | +@keyframes ktb-under { | |
| 109 | + from { transform: scaleX(0); opacity: 0; } | |
| 110 | +} | |
| 111 | +@keyframes ktb-pop { | |
| 112 | + 40% { transform: translateY(-2px); } | |
| 113 | +} | |
| 114 | + | |
| 115 | +@media (prefers-reduced-motion: reduce) { | |
| 116 | + .ka-tabbar svg { transition: none; } | |
| 117 | + .ka-tabbar .active svg, .ka-tabbar .active::after { animation: none; } | |
| 118 | +} | |
| 119 | ||