refonte v2 « terminal éditorial » : hero encre pleine largeur, recherche centrale CTA rouge, graphique marché multi-séries interactif (palette CVD validée), flux de ventes prix-d'abord, rapport de valeur recomposé (grands chiffres, sections à filets), méthodologie article + chaîne de calcul, header compact, zéro ombre décalée/carte superflue
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 changed files +1,398 −803
modified
src/app/HomeClient.tsx
+327 −207
@@ -1,9 +1,10 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | "use client"; |
| 3 | +import { useState } from "react"; | |
| 3 | 4 | import Link from "next/link"; |
| 4 | 5 | import SearchBox from "@/components/SearchBox"; |
| 5 | 6 | import EstimateForm from "@/components/EstimateForm"; |
| 6 | −import { IndexSpark, locNum } from "@/components/MetricViz"; | |
| 7 | +import { locNum, MarketPulseChart } from "@/components/MetricViz"; | |
| 7 | 8 | import { useLang } from "@/components/LangContext"; |
| 8 | 9 | |
| 9 | 10 | /** Indice de marché d'un grand type — série + variations (calculé côté serveur). */ |
@@ -31,21 +32,57 @@ const PULSE_LABELS: Record<PulseItem["type"], { fr: string; en: string }> = { | ||
| 31 | 32 | condo: { fr: "Condo", en: "Condo" }, |
| 32 | 33 | plex: { fr: "Plex", en: "Plex" }, |
| 33 | 34 | }; |
| 35 | +/* attribution FIXE des couleurs de série (jamais recyclée) */ | |
| 36 | +const PULSE_COLORS: Record<PulseItem["type"], string> = { | |
| 37 | + unifamilial: "var(--chart-1)", | |
| 38 | + condo: "var(--chart-2)", | |
| 39 | + plex: "var(--chart-3)", | |
| 40 | +}; | |
| 34 | 41 | |
| 35 | −function TrendBadge({ pct, label }: { pct: number | null; label: string }) { | |
| 36 | − if (pct == null) return null; | |
| 42 | +/** Variation signée — l'encre porte le positif, le rouge danger le négatif. */ | |
| 43 | +function Pct({ v, digits = 1 }: { v: number | null; digits?: number }) { | |
| 44 | + if (v == null) return <span className="text-ink-3">—</span>; | |
| 37 | 45 | return ( |
| 38 | − <span | |
| 39 | − className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ | |
| 40 | − pct >= 0 ? "bg-lime text-ink" : "bg-danger text-white" | |
| 41 | − }`} | |
| 42 | − > | |
| 43 | − {label} {pct >= 0 ? "+" : ""} | |
| 44 | − {pct.toFixed(1)} % | |
| 46 | + <span className={v >= 0 ? "text-ink" : "text-[var(--danger)]"}> | |
| 47 | + {v >= 0 ? "+" : "−"} | |
| 48 | + {Math.abs(v).toFixed(digits)} % | |
| 45 | 49 | </span> |
| 46 | 50 | ); |
| 47 | 51 | } |
| 48 | 52 | |
| 53 | +/** En-tête de section éditorial : filet fort, numéro mono rouge, titre display. */ | |
| 54 | +function SectionHead({ | |
| 55 | + num, | |
| 56 | + kicker, | |
| 57 | + title, | |
| 58 | + lede, | |
| 59 | +}: { | |
| 60 | + num: string; | |
| 61 | + kicker: string; | |
| 62 | + title: string; | |
| 63 | + lede?: string; | |
| 64 | +}) { | |
| 65 | + return ( | |
| 66 | + <header className="sec"> | |
| 67 | + <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1"> | |
| 68 | + <span className="sec-num"> | |
| 69 | + {num} — {kicker} | |
| 70 | + </span> | |
| 71 | + </div> | |
| 72 | + <h2 className="vp-display mt-2 max-w-3xl text-[clamp(24px,3.6vw,36px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> | |
| 73 | + {title} | |
| 74 | + </h2> | |
| 75 | + {lede && <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{lede}</p>} | |
| 76 | + </header> | |
| 77 | + ); | |
| 78 | +} | |
| 79 | + | |
| 80 | +const PERIODS = [ | |
| 81 | + { key: "12", months: 12, fr: "12 mois", en: "12 mo" }, | |
| 82 | + { key: "24", months: 24, fr: "24 mois", en: "24 mo" }, | |
| 83 | + { key: "all", months: null as number | null, fr: "Depuis 2021", en: "Since 2021" }, | |
| 84 | +] as const; | |
| 85 | + | |
| 49 | 86 | export default function HomeClient({ |
| 50 | 87 | pulse, |
| 51 | 88 | recent, |
@@ -57,6 +94,7 @@ export default function HomeClient({ | ||
| 57 | 94 | }) { |
| 58 | 95 | const { lang, t } = useLang(); |
| 59 | 96 | const fr = lang === "fr"; |
| 97 | + const [period, setPeriod] = useState<(typeof PERIODS)[number]["key"]>("24"); | |
| 60 | 98 | const money = (v: number) => |
| 61 | 99 | new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", { |
| 62 | 100 | style: "currency", |
@@ -69,233 +107,315 @@ export default function HomeClient({ | ||
| 69 | 107 | month: "short", |
| 70 | 108 | }); |
| 71 | 109 | |
| 72 | − return ( | |
| 73 | − <div className="pb-6 pt-10 sm:pt-14"> | |
| 74 | − {/* ---- Héros ---- */} | |
| 75 | − <section> | |
| 76 | − <span className="kicker"> | |
| 77 | − {lang === "fr" ? "Québec · 2021-2026" : "Québec · 2021-2026"} | |
| 78 | − </span> | |
| 79 | − <h1 className="vp-display mt-3 max-w-4xl text-[clamp(34px,6.4vw,72px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]"> | |
| 80 | − {lang === "fr" ? ( | |
| 81 | − <> | |
| 82 | − La <span className="outline-txt">vraie</span> valeur. | |
| 83 | − <br /> | |
| 84 | − <span className="hl">Sans boîte noire.</span> | |
| 85 | − </> | |
| 86 | − ) : ( | |
| 87 | − <> | |
| 88 | − The <span className="outline-txt">true</span> value. | |
| 89 | − <br /> | |
| 90 | − <span className="hl">No black box.</span> | |
| 91 | − </> | |
| 92 | − )} | |
| 93 | − </h1> | |
| 94 | − <p className="mt-5 max-w-xl text-[16.5px] text-ink-2">{t("sub")}</p> | |
| 110 | + const months = PERIODS.find((p) => p.key === period)?.months ?? 24; | |
| 111 | + const chartData = pulse.map((p) => ({ | |
| 112 | + key: p.type, | |
| 113 | + label: PULSE_LABELS[p.type][lang], | |
| 114 | + color: PULSE_COLORS[p.type], | |
| 115 | + series: p.series, | |
| 116 | + })); | |
| 95 | 117 | |
| 96 | − <div className="mt-7 flex gap-2.5 overflow-x-auto pb-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| 97 | − <span className="stat-chip"> | |
| 98 | − <span className="pulse" /> | |
| 99 | − <b>3 747 008</b> {lang === "fr" ? "propriétés" : "properties"} | |
| 100 | − </span> | |
| 101 | − <span className="stat-chip"> | |
| 102 | − <b>745 119</b> {lang === "fr" ? "ventes réelles" : "real sales"} | |
| 103 | − </span> | |
| 104 | − <span className="stat-chip"> | |
| 105 | − <b>{locNum(salesLast30d, lang, { maxFrac: 0 })}</b>{" "} | |
| 106 | − {lang === "fr" ? "ventes captées / 30 j" : "sales captured / 30 d"} | |
| 107 | − </span> | |
| 108 | − <span className="stat-chip"> | |
| 109 | − <b>1 100</b> {lang === "fr" ? "municipalités" : "municipalities"} | |
| 110 | − </span> | |
| 111 | − <span className="stat-chip"> | |
| 112 | − MdAPE <b>11 %</b> | |
| 113 | − </span> | |
| 114 | − </div> | |
| 115 | − </section> | |
| 118 | + const proofs: [string, string][] = [ | |
| 119 | + ["3 747 008", fr ? "propriétés estimées" : "estimated properties"], | |
| 120 | + ["745 119", fr ? "ventes réelles 2021-2026" : "real sales 2021-2026"], | |
| 121 | + [locNum(salesLast30d, lang, { maxFrac: 0 }), fr ? "ventes captées / 30 jours" : "sales captured / 30 days"], | |
| 122 | + ["1 100", fr ? "municipalités couvertes" : "municipalities covered"], | |
| 123 | + ]; | |
| 116 | 124 | |
| 117 | − {/* ---- Recherche ---- */} | |
| 118 | − <section className="mt-9"> | |
| 119 | − <div className="vp-card p-4 sm:p-5"> | |
| 120 | − <p className="klabel mb-2 pl-0.5"> | |
| 121 | − {lang === "fr" ? "Adresse de la propriété" : "Property address"} | |
| 125 | + return ( | |
| 126 | + <div className="pb-6"> | |
| 127 | + {/* ================= HÉRO — terminal encre pleine largeur ================= */} | |
| 128 | + <section className="bleed vp-dark"> | |
| 129 | + <div className="mx-auto max-w-6xl px-4 pb-10 pt-12 sm:px-6 sm:pb-14 sm:pt-16"> | |
| 130 | + <span className="kicker !text-[var(--accent-bright)] [&::before]:!bg-[var(--accent-bright)]"> | |
| 131 | + {fr | |
| 132 | + ? "Terminal public de valeur immobilière — Québec" | |
| 133 | + : "Public real-estate value terminal — Québec"} | |
| 134 | + </span> | |
| 135 | + <h1 className="vp-display mt-4 text-[clamp(38px,8vw,92px)] font-bold uppercase leading-[0.95] tracking-[-0.035em]"> | |
| 136 | + {fr ? ( | |
| 137 | + <> | |
| 138 | + La <span className="outline-txt">vraie</span> valeur. | |
| 139 | + <br /> | |
| 140 | + <span className="text-[var(--accent-bright)]">Sans boîte noire.</span> | |
| 141 | + </> | |
| 142 | + ) : ( | |
| 143 | + <> | |
| 144 | + The <span className="outline-txt">true</span> value. | |
| 145 | + <br /> | |
| 146 | + <span className="text-[var(--accent-bright)]">No black box.</span> | |
| 147 | + </> | |
| 148 | + )} | |
| 149 | + </h1> | |
| 150 | + <p className="mt-5 max-w-xl text-[15.5px] leading-relaxed text-[rgba(245,243,238,0.72)]"> | |
| 151 | + {t("sub")} | |
| 122 | 152 | </p> |
| 123 | − <SearchBox /> | |
| 153 | + | |
| 154 | + {/* -------- la recherche : l'action centrale de la page -------- */} | |
| 155 | + <div className="mt-9 max-w-3xl"> | |
| 156 | + <p className="vp-mono mb-1 text-[10.5px] font-medium uppercase tracking-[0.16em] text-[rgba(245,243,238,0.55)]"> | |
| 157 | + {fr ? "Entrez une adresse au Québec" : "Enter a Québec address"} | |
| 158 | + </p> | |
| 159 | + <SearchBox variant="hero" /> | |
| 160 | + <p className="vp-mono mt-2.5 text-[10px] uppercase tracking-[0.06em] text-[rgba(245,243,238,0.4)]"> | |
| 161 | + {t("dataNote")} | |
| 162 | + </p> | |
| 163 | + </div> | |
| 164 | + | |
| 165 | + {/* -------- preuves de profondeur de données, intégrées -------- */} | |
| 166 | + <div className="mt-10 grid grid-cols-2 gap-x-6 gap-y-5 border-t border-[rgba(245,243,238,0.16)] pt-6 sm:grid-cols-4"> | |
| 167 | + {proofs.map(([num, label], i) => ( | |
| 168 | + <div key={label}> | |
| 169 | + <p className="vp-display flex items-baseline gap-2 text-[clamp(20px,2.6vw,28px)] font-bold leading-none tracking-[-0.02em] text-[var(--paper-on-ink)]"> | |
| 170 | + {num} | |
| 171 | + {i === 2 && ( | |
| 172 | + <span | |
| 173 | + aria-hidden="true" | |
| 174 | + className="inline-block h-[7px] w-[7px] rounded-full bg-[var(--accent-bright)] [animation:pulse_2.4s_ease_infinite]" | |
| 175 | + /> | |
| 176 | + )} | |
| 177 | + </p> | |
| 178 | + <p className="vp-mono mt-1.5 text-[9.5px] uppercase tracking-[0.12em] text-[rgba(245,243,238,0.5)]"> | |
| 179 | + {label} | |
| 180 | + </p> | |
| 181 | + </div> | |
| 182 | + ))} | |
| 183 | + </div> | |
| 124 | 184 | </div> |
| 125 | − <p className="vp-mono mt-2.5 pl-1 text-[11px] uppercase tracking-[0.06em] text-ink-3"> | |
| 126 | − {t("dataNote")} | |
| 127 | − </p> | |
| 128 | 185 | </section> |
| 129 | 186 | |
| 130 | − {/* ---- Comment ça marche (bande compacte) ---- */} | |
| 131 | − <section className="mt-10 grid gap-3 sm:grid-cols-3"> | |
| 132 | − {(fr | |
| 133 | − ? [ | |
| 134 | − ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."], | |
| 135 | − ["02", "Le moteur croise deux sources", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."], | |
| 136 | − ["03", "Fourchette + confiance", "P10-P90, indice A-D, rapport PDF — le calcul montré en entier."], | |
| 137 | − ] | |
| 138 | − : [ | |
| 139 | − ["01", "Search the address", "3.7M assessment units, typo-tolerant search."], | |
| 140 | − ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."], | |
| 141 | − ["03", "Range + confidence", "P10-P90, A-D index, PDF report — the full math, shown."], | |
| 142 | − ] | |
| 143 | − ).map(([num, title, body], i) => ( | |
| 144 | − <div key={num} className="flex items-start gap-3 rounded-[10px] border-[1.5px] border-dashed border-[rgba(20,24,20,0.3)] bg-surface-2 p-4"> | |
| 145 | − <span className="vp-mono flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[12px] font-bold text-lime"> | |
| 146 | − {num} | |
| 147 | − </span> | |
| 148 | − <div> | |
| 149 | − <p className="vp-display text-[14.5px] font-bold uppercase tracking-[-0.01em]"> | |
| 150 | − {title} | |
| 151 | − {i < 2 && <span className="ml-2 text-ink-3">→</span>} | |
| 187 | + {/* ============ le principe, en une ligne (pas de boîtes) ============ */} | |
| 188 | + <section className="border-b border-[var(--line)] py-6"> | |
| 189 | + <div className="grid gap-x-8 gap-y-4 sm:grid-cols-3"> | |
| 190 | + {(fr | |
| 191 | + ? [ | |
| 192 | + ["01", "Cherchez l'adresse", "3,7 M d'unités d'évaluation, recherche tolérante aux fautes."], | |
| 193 | + ["02", "Deux sources croisées", "Modèle hédonique (690 k ventes) + comparables réels ajustés en dollars."], | |
| 194 | + ["03", "Fourchette + confiance", "P10-P90, indice A-D — le calcul montré en entier."], | |
| 195 | + ] | |
| 196 | + : [ | |
| 197 | + ["01", "Search the address", "3.7M assessment units, typo-tolerant search."], | |
| 198 | + ["02", "Two sources, crossed", "Hedonic model (690k sales) + real comparables adjusted in dollars."], | |
| 199 | + ["03", "Range + confidence", "P10-P90, A-D index — the full math, shown."], | |
| 200 | + ] | |
| 201 | + ).map(([num, title, body]) => ( | |
| 202 | + <div key={num} className="flex items-start gap-3"> | |
| 203 | + <span className="vp-mono pt-[3px] text-[11px] font-bold text-accent">{num}</span> | |
| 204 | + <p className="text-[13px] leading-snug text-ink-2"> | |
| 205 | + <span className="vp-display block text-[14px] font-bold uppercase tracking-[-0.01em] text-ink"> | |
| 206 | + {title} | |
| 207 | + </span> | |
| 208 | + {body} | |
| 152 | 209 | </p> |
| 153 | − <p className="mt-1 text-[12.5px] leading-snug text-ink-2">{body}</p> | |
| 154 | 210 | </div> |
| 155 | − </div> | |
| 156 | − ))} | |
| 211 | + ))} | |
| 212 | + </div> | |
| 157 | 213 | </section> |
| 158 | 214 | |
| 159 | − {/* ---- Pouls du marché ---- */} | |
| 160 | − <section className="mt-14"> | |
| 161 | − <span className="kicker">{fr ? "Indice Vrai-Prix" : "Vrai-Prix index"}</span> | |
| 162 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 163 | − {fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"} | |
| 164 | − </h2> | |
| 165 | − <p className="mt-2 max-w-2xl text-[14px] text-ink-2"> | |
| 166 | − {fr | |
| 167 | − ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Base 100 il y a 24 mois." | |
| 168 | − : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Base 100 twenty-four months ago."} | |
| 169 | − </p> | |
| 170 | − <div className="mt-4 grid gap-4 sm:grid-cols-3"> | |
| 215 | + {/* ================= MARCHÉ — data visualization éditoriale ================= */} | |
| 216 | + <section className="mt-12"> | |
| 217 | + <SectionHead | |
| 218 | + num="01" | |
| 219 | + kicker={fr ? "Indice Vrai-Prix" : "Vrai-Prix index"} | |
| 220 | + title={fr ? "Le pouls du marché québécois" : "The pulse of the Québec market"} | |
| 221 | + lede={ | |
| 222 | + fr | |
| 223 | + ? "L'indice mensuel calculé sur les ventes réelles — celui-là même qui ajuste les comparables de chaque estimation. Trois grands types, une même échelle : base 100 au début de la période." | |
| 224 | + : "The monthly index built from real sales — the same one adjusting every estimate's comparables. Three major types, one scale: base 100 at the period start." | |
| 225 | + } | |
| 226 | + /> | |
| 227 | + | |
| 228 | + {/* contrôles : période (une rangée, au-dessus du graphique) */} | |
| 229 | + <div className="mt-5 flex flex-wrap items-end justify-between gap-x-6 gap-y-2 border-b border-[var(--line)]"> | |
| 230 | + <div className="flex gap-5"> | |
| 231 | + {PERIODS.map((p) => ( | |
| 232 | + <button | |
| 233 | + key={p.key} | |
| 234 | + type="button" | |
| 235 | + className="vp-tab" | |
| 236 | + aria-pressed={period === p.key} | |
| 237 | + onClick={() => setPeriod(p.key)} | |
| 238 | + > | |
| 239 | + {p[lang]} | |
| 240 | + </button> | |
| 241 | + ))} | |
| 242 | + </div> | |
| 243 | + {/* légende — l'identité ne repose jamais sur la couleur seule */} | |
| 244 | + <div className="flex flex-wrap gap-x-4 gap-y-1 pb-2"> | |
| 245 | + {chartData.map((s) => ( | |
| 246 | + <span key={s.key} className="vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] text-ink-2"> | |
| 247 | + <span className="inline-block h-[2px] w-[14px]" style={{ background: s.color }} /> | |
| 248 | + {s.label} | |
| 249 | + </span> | |
| 250 | + ))} | |
| 251 | + </div> | |
| 252 | + </div> | |
| 253 | + | |
| 254 | + <div className="mt-4"> | |
| 255 | + <MarketPulseChart data={chartData} lang={lang} months={months} /> | |
| 256 | + </div> | |
| 257 | + | |
| 258 | + {/* lecture par type : variations en colonnes de données */} | |
| 259 | + <div className="mt-5 grid gap-x-8 gap-y-3 border-t border-[var(--line)] pt-4 sm:grid-cols-3"> | |
| 171 | 260 | {pulse.map((p) => ( |
| 172 | − <div key={p.type} className="vp-card vp-card-hover p-5"> | |
| 173 | − <div className="flex items-center justify-between gap-2"> | |
| 174 | − <p className="vp-display text-[16px] font-bold uppercase tracking-[-0.01em]"> | |
| 175 | − {PULSE_LABELS[p.type][lang]} | |
| 176 | − </p> | |
| 177 | − <TrendBadge pct={p.pct12m} label={fr ? "12 mois" : "12 mo"} /> | |
| 178 | − </div> | |
| 179 | − <div className="mt-3"> | |
| 180 | − <IndexSpark series={p.series} /> | |
| 181 | − </div> | |
| 182 | − {p.since2021 != null && ( | |
| 183 | − <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-ink-3"> | |
| 184 | − {fr ? "Depuis 2021" : "Since 2021"} :{" "} | |
| 185 | − <b className="text-ink"> | |
| 186 | − {p.since2021 >= 0 ? "+" : ""} | |
| 187 | − {p.since2021.toFixed(0)} % | |
| 261 | + <div key={p.type} className="flex items-baseline justify-between gap-4 sm:block"> | |
| 262 | + <p className="vp-mono flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-[0.08em] text-ink"> | |
| 263 | + <span className="inline-block h-[2px] w-[12px]" style={{ background: PULSE_COLORS[p.type] }} /> | |
| 264 | + {PULSE_LABELS[p.type][lang]} | |
| 265 | + </p> | |
| 266 | + <p className="vp-mono mt-1 flex gap-4 text-[11px] text-ink-2"> | |
| 267 | + <span> | |
| 268 | + 12 {fr ? "mois" : "mo"} | |
| 269 | + <b className="vp-display text-[14px]"> | |
| 270 | + <Pct v={p.pct12m} /> | |
| 188 | 271 | </b> |
| 189 | − </p> | |
| 190 | − )} | |
| 272 | + </span> | |
| 273 | + <span> | |
| 274 | + 2021→ | |
| 275 | + <b className="vp-display text-[14px]"> | |
| 276 | + <Pct v={p.since2021} digits={0} /> | |
| 277 | + </b> | |
| 278 | + </span> | |
| 279 | + </p> | |
| 191 | 280 | </div> |
| 192 | 281 | ))} |
| 193 | 282 | </div> |
| 194 | 283 | </section> |
| 195 | 284 | |
| 196 | − {/* ---- Dernières ventes captées ---- */} | |
| 285 | + {/* ================= VENTES RÉELLES — flux prix-d'abord ================= */} | |
| 197 | 286 | {recent.length > 0 && ( |
| 198 | 287 | <section className="mt-14"> |
| 199 | − <span className="kicker"> | |
| 200 | − <span className="pulse" style={{ width: 8, height: 8 }} /> | |
| 201 | − {fr ? "Flux en continu" : "Live feed"} | |
| 202 | − </span> | |
| 203 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 204 | − {fr ? "Dernières ventes réelles captées" : "Latest real sales captured"} | |
| 205 | − </h2> | |
| 206 | − <p className="mt-2 max-w-2xl text-[14px] text-ink-2"> | |
| 207 | − {fr | |
| 208 | − ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Cliquez pour voir l'estimation complète de la propriété vendue." | |
| 209 | − : "Published transactions, automatically matched to their assessment unit. Click for the sold property's full estimate."} | |
| 210 | − </p> | |
| 211 | − <div className="src-wrap mt-4"> | |
| 212 | − <table className="src-table"> | |
| 213 | − <thead> | |
| 214 | − <tr> | |
| 215 | − <th>{fr ? "Date" : "Date"}</th> | |
| 216 | − <th>{fr ? "Propriété" : "Property"}</th> | |
| 217 | − <th className="text-right">{fr ? "Prix de vente" : "Sale price"}</th> | |
| 218 | − <th className="text-right"></th> | |
| 219 | − </tr> | |
| 220 | − </thead> | |
| 221 | − <tbody> | |
| 222 | − {recent.map((s) => ( | |
| 223 | − <tr key={s.id}> | |
| 224 | − <td className="vp-mono whitespace-nowrap text-[12px] font-bold uppercase"> | |
| 288 | + <SectionHead | |
| 289 | + num="02" | |
| 290 | + kicker={fr ? "Flux en continu" : "Live feed"} | |
| 291 | + title={fr ? "Dernières ventes réelles captées" : "Latest real sales captured"} | |
| 292 | + lede={ | |
| 293 | + fr | |
| 294 | + ? "Transactions publiées, appariées automatiquement à leur unité d'évaluation. Chaque vente mène à l'estimation complète de la propriété." | |
| 295 | + : "Published transactions, automatically matched to their assessment unit. Each sale leads to the property's full estimate." | |
| 296 | + } | |
| 297 | + /> | |
| 298 | + <div className="mt-5"> | |
| 299 | + {recent.map((s) => { | |
| 300 | + const inner = ( | |
| 301 | + <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 sm:flex-nowrap"> | |
| 302 | + {/* le prix d'abord — la donnée mène */} | |
| 303 | + <p className="vp-display w-full text-[22px] font-bold leading-none tracking-[-0.02em] sm:w-[168px] sm:shrink-0 sm:text-[20px]"> | |
| 304 | + {money(s.amount)} | |
| 305 | + </p> | |
| 306 | + <div className="min-w-0 flex-1"> | |
| 307 | + <p className="truncate text-[14.5px] font-semibold leading-snug"> | |
| 308 | + {s.street} | |
| 309 | + {s.city ? `, ${s.city}` : ""} | |
| 310 | + </p> | |
| 311 | + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3"> | |
| 225 | 312 | {dateFmt(s.date)} |
| 226 | − </td> | |
| 227 | − <td> | |
| 228 | − <p className="font-semibold leading-tight"> | |
| 229 | − {s.street} | |
| 230 | − {s.city ? `, ${s.city}` : ""} | |
| 231 | − </p> | |
| 232 | − {s.propertyType && s.propertyType !== "indéterminé" && ( | |
| 233 | − <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3"> | |
| 234 | − {s.propertyType} | |
| 235 | − </p> | |
| 236 | − )} | |
| 237 | − </td> | |
| 238 | − <td className="text-right"> | |
| 239 | − <span className="vp-display font-bold">{money(s.amount)}</span> | |
| 313 | + {s.propertyType && s.propertyType !== "indéterminé" ? ` · ${s.propertyType}` : ""} | |
| 240 | 314 | {s.vsRolePct != null && Math.abs(s.vsRolePct) < 400 && ( |
| 241 | − <p className="vp-mono mt-0.5 text-[10px] text-ink-3"> | |
| 242 | − {s.vsRolePct >= 0 ? "+" : "−"} | |
| 243 | − {Math.abs(s.vsRolePct).toFixed(0)} %{" "} | |
| 244 | − {fr ? "vs rôle" : "vs roll"} | |
| 245 | − </p> | |
| 246 | − )} | |
| 247 | − </td> | |
| 248 | − <td className="text-right"> | |
| 249 | − {s.idProvinc && ( | |
| 250 | − <Link | |
| 251 | − href={`/estimation/${encodeURIComponent(s.idProvinc)}`} | |
| 252 | − className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.04em] transition-colors hover:bg-lime" | |
| 253 | − > | |
| 254 | − {fr ? "Estimer" : "Estimate"} → | |
| 255 | − </Link> | |
| 315 | + <> | |
| 316 | + {" · "} | |
| 317 | + <span className={s.vsRolePct >= 0 ? "text-ink-2" : "text-[var(--danger)]"}> | |
| 318 | + {s.vsRolePct >= 0 ? "+" : "−"} | |
| 319 | + {Math.abs(s.vsRolePct).toFixed(0)} % {fr ? "vs rôle" : "vs roll"} | |
| 320 | + </span> | |
| 321 | + </> | |
| 256 | 322 | )} |
| 257 | − </td> | |
| 258 | − </tr> | |
| 259 | − ))} | |
| 260 | − </tbody> | |
| 261 | − </table> | |
| 323 | + </p> | |
| 324 | + </div> | |
| 325 | + {s.idProvinc && ( | |
| 326 | + <p className="vp-mono shrink-0 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent"> | |
| 327 | + {fr ? "Voir l'estimation" : "See the estimate"} → | |
| 328 | + </p> | |
| 329 | + )} | |
| 330 | + </div> | |
| 331 | + ); | |
| 332 | + return s.idProvinc ? ( | |
| 333 | + <Link key={s.id} href={`/estimation/${encodeURIComponent(s.idProvinc)}`} className="feed-row"> | |
| 334 | + {inner} | |
| 335 | + </Link> | |
| 336 | + ) : ( | |
| 337 | + <div key={s.id} className="feed-row"> | |
| 338 | + {inner} | |
| 339 | + </div> | |
| 340 | + ); | |
| 341 | + })} | |
| 262 | 342 | </div> |
| 263 | 343 | </section> |
| 264 | 344 | )} |
| 265 | 345 | |
| 266 | − {/* ---- Formulaire manuel ---- */} | |
| 346 | + {/* ================= LE CALCUL, MONTRÉ — promesse centrale ================= */} | |
| 267 | 347 | <section className="mt-14"> |
| 268 | − <span className="kicker">{lang === "fr" ? "Plan B" : "Plan B"}</span> | |
| 269 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 270 | − {t("manualTitle")} | |
| 271 | − </h2> | |
| 272 | − <div className="vp-card mt-4 p-4 sm:p-6"> | |
| 273 | − <EstimateForm /> | |
| 348 | + <SectionHead | |
| 349 | + num="03" | |
| 350 | + kicker={fr ? "Sans boîte noire" : "No black box"} | |
| 351 | + title={fr ? "Voici exactement comment le prix est calculé" : "Here is exactly how the price is computed"} | |
| 352 | + /> | |
| 353 | + <div className="mt-6 grid gap-10 lg:grid-cols-[1.2fr_1fr]"> | |
| 354 | + <ol className="tl"> | |
| 355 | + {(fr | |
| 356 | + ? [ | |
| 357 | + ["Propriété identifiée", "3,7 M d'unités d'évaluation géoréférencées (MAMH)."], | |
| 358 | + ["Rôle d'évaluation récupéré", "Valeurs terrain + bâtiment, six millésimes 2021-2026."], | |
| 359 | + ["Ventes comparables sélectionnées", "Rayon adaptatif, même famille de type, aire ±20 %."], | |
| 360 | + ["Caractéristiques ajustées", "Marché, superficie, âge — chaque ajustement affiché en dollars."], | |
| 361 | + ["Estimation calculée", "65 % modèle hédonique + 35 % comparables pondérés."], | |
| 362 | + ["Intervalle de confiance produit", "Fourchette P10-P90 et indice A-D, toujours affichés."], | |
| 363 | + ] | |
| 364 | + : [ | |
| 365 | + ["Property identified", "3.7M georeferenced assessment units (MAMH)."], | |
| 366 | + ["Assessment roll retrieved", "Land + building values, six vintages 2021-2026."], | |
| 367 | + ["Comparable sales selected", "Adaptive radius, same type family, area ±20%."], | |
| 368 | + ["Characteristics adjusted", "Market, area, age — every adjustment shown in dollars."], | |
| 369 | + ["Estimate computed", "65% hedonic model + 35% weighted comparables."], | |
| 370 | + ["Confidence interval produced", "P10-P90 range and A-D index, always shown."], | |
| 371 | + ] | |
| 372 | + ).map(([title, body], i, arr) => ( | |
| 373 | + <li key={title} className="tl-step" data-accent={i === arr.length - 1 ? "true" : undefined}> | |
| 374 | + <p className="vp-display flex items-baseline gap-2.5 text-[15px] font-bold uppercase tracking-[-0.01em]"> | |
| 375 | + <span className="vp-mono text-[10.5px] font-bold text-accent">0{i + 1}</span> | |
| 376 | + {title} | |
| 377 | + </p> | |
| 378 | + <p className="mt-1 text-[13px] leading-snug text-ink-2">{body}</p> | |
| 379 | + </li> | |
| 380 | + ))} | |
| 381 | + </ol> | |
| 382 | + <div> | |
| 383 | + {(lang === "fr" | |
| 384 | + ? [ | |
| 385 | + ["Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."], | |
| 386 | + ["Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."], | |
| 387 | + ["Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."], | |
| 388 | + ] | |
| 389 | + : [ | |
| 390 | + ["The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."], | |
| 391 | + ["Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."], | |
| 392 | + ["Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."], | |
| 393 | + ] | |
| 394 | + ).map(([title, body]) => ( | |
| 395 | + <div key={title} className="border-t border-[var(--line)] py-4 first:border-t-0 first:pt-0"> | |
| 396 | + <h3 className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">{title}</h3> | |
| 397 | + <p className="mt-1.5 text-[13px] leading-snug text-ink-2">{body}</p> | |
| 398 | + </div> | |
| 399 | + ))} | |
| 400 | + <p className="vp-mono mt-2 border-t border-[var(--line)] pt-4 text-[11px] uppercase tracking-[0.06em]"> | |
| 401 | + <Link href="/methodologie" className="border-b border-accent pb-px text-accent-deep hover:text-accent"> | |
| 402 | + {fr ? "Méthodologie complète — niveau rapport technique" : "Full methodology — technical-report depth"} → | |
| 403 | + </Link> | |
| 404 | + </p> | |
| 405 | + </div> | |
| 274 | 406 | </div> |
| 275 | 407 | </section> |
| 276 | 408 | |
| 277 | − {/* ---- Manifeste transparence ---- */} | |
| 278 | − <section className="mt-14 grid gap-4 sm:grid-cols-3"> | |
| 279 | − {(lang === "fr" | |
| 280 | − ? [ | |
| 281 | − ["01", "Le calcul, montré", "Chaque ajustement de chaque comparable est affiché en dollars. Pas de score magique."], | |
| 282 | − ["02", "Fourchette honnête", "Un prix unique est un mensonge statistique. On donne P10-P90 et un indice de confiance A-D."], | |
| 283 | − ["03", "Données réelles", "Rôles fonciers officiels (MAMH) et ventes publiées — locales et récentes en priorité."], | |
| 284 | − ] | |
| 285 | − : [ | |
| 286 | − ["01", "The math, shown", "Every adjustment on every comparable is displayed in dollars. No magic score."], | |
| 287 | − ["02", "Honest range", "A single price is a statistical lie. We give P10-P90 and an A-D confidence index."], | |
| 288 | − ["03", "Real data", "Official assessment rolls (MAMH) and published sales — local and recent first."], | |
| 289 | − ] | |
| 290 | − ).map(([num, title, body]) => ( | |
| 291 | − <div key={num} className="vp-card vp-card-hover p-5"> | |
| 292 | − <p className="vp-mono text-[11px] font-bold text-green">{num}</p> | |
| 293 | − <h3 className="vp-display mt-1.5 text-[17px] font-bold uppercase tracking-[-0.01em]"> | |
| 294 | − {title} | |
| 295 | − </h3> | |
| 296 | − <p className="mt-2 text-[13.5px] text-ink-2">{body}</p> | |
| 297 | − </div> | |
| 298 | − ))} | |
| 409 | + {/* ================= PLAN B — estimation sans adresse ================= */} | |
| 410 | + <section className="mt-14"> | |
| 411 | + <SectionHead | |
| 412 | + num="04" | |
| 413 | + kicker={fr ? "Plan B" : "Plan B"} | |
| 414 | + title={t("manualTitle")} | |
| 415 | + /> | |
| 416 | + <div className="mt-6 max-w-3xl"> | |
| 417 | + <EstimateForm /> | |
| 418 | + </div> | |
| 299 | 419 | </section> |
| 300 | 420 | </div> |
| 301 | 421 | ); |
modified
src/app/compte/CompteClient.tsx
+1 −1
@@ -345,7 +345,7 @@ export default function CompteClient({ | ||
| 345 | 345 | href={socialUrl(net, v)} |
| 346 | 346 | target="_blank" |
| 347 | 347 | rel="noopener noreferrer" |
| 348 | − className="stat-chip hover:bg-lime-soft" | |
| 348 | + className="stat-chip transition-colors hover:text-accent-deep" | |
| 349 | 349 | > |
| 350 | 350 | <b>{SOCIAL_LABELS[net]}</b> {v} |
| 351 | 351 | </a> |
modified
src/app/globals.css
+180 −88
@@ -1,10 +1,11 @@ | ||
| 1 | 1 | /* ----------------------------------------------------------------------------- |
| 2 | 2 | Vrai-Prix — estimation immobilière transparente (Québec) |
| 3 | 3 | Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | − Système de design « éditorial sharp » — identité ROUGE distincte de Lou-Ka : | |
| 4 | + Système de design « terminal éditorial » v2 — Bloomberg × FT data × institutionnel : | |
| 5 | 5 | · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono |
| 6 | − · Signature : bordures encre + ombres décalées (néo-brutalisme raffiné) | |
| 7 | − · Accent rouge vif #ff5148 + bordeaux #9e2a25 sur encre | |
| 6 | + · Signature : filets fins (hairlines), grands chiffres, compositions asymétriques, | |
| 7 | + encre profonde + rouge signal réservé à l'information importante | |
| 8 | + · Zéro ombre décalée, zéro pilule, radius 0-2 px — la donnée fait le décor | |
| 8 | 9 | ----------------------------------------------------------------------------- */ |
| 9 | 10 | @import "tailwindcss"; |
| 10 | 11 | /* ka-ui (Groupe KA) — tokens + composants communs (.gk-badge, .ka-footer, .card…). |
@@ -16,7 +17,8 @@ | ||
| 16 | 17 | |
| 17 | 18 | :root { |
| 18 | 19 | /* ---- Accent Vrai-Prix (tokens canoniques ka-ui) — identité rouge ---- */ |
| 19 | − --accent: #ff5148; | |
| 20 | + --accent: #d8372e; /* rouge signal éditorial (lisible sur papier) */ | |
| 21 | + --accent-bright: #ff5148; /* rouge vif — marqueurs, points de donnée sur encre */ | |
| 20 | 22 | --accent-soft: #ffe3e0; |
| 21 | 23 | --accent-deep: #9e2a25; |
| 22 | 24 | --on-accent: #ffffff; |
@@ -28,6 +30,7 @@ | ||
| 28 | 30 | --ink-2: #4d5551; |
| 29 | 31 | --ink-3: #8b928c; |
| 30 | 32 | --line: rgba(20, 24, 20, 0.14); |
| 33 | + --line-soft: rgba(20, 24, 20, 0.08); | |
| 31 | 34 | --line-strong: rgba(20, 24, 20, 0.85); |
| 32 | 35 | --green: #9e2a25; |
| 33 | 36 | --green-deep: #771f1b; |
@@ -37,11 +40,20 @@ | ||
| 37 | 40 | --amber-soft: #fdf3e2; |
| 38 | 41 | --danger: #b3423a; |
| 39 | 42 | --danger-soft: #fbe9e7; |
| 40 | − --r-card: 10px; | |
| 41 | − --r-ctl: 6px; | |
| 43 | + /* série de graphiques (palette validée : CVD 28,6 · contraste ≥3:1) */ | |
| 44 | + --chart-1: #141814; /* unifamiliale */ | |
| 45 | + --chart-2: #d8372e; /* condo */ | |
| 46 | + --chart-3: #6b736d; /* plex */ | |
| 47 | + --r-card: 2px; | |
| 48 | + --r-ctl: 2px; | |
| 42 | 49 | --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); |
| 43 | − --shadow-off: 6px 6px 0 var(--ink); | |
| 44 | − --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 50 | + /* ombres décalées héritées — aplaties : le v2 ne porte aucune ombre dure */ | |
| 51 | + --shadow-off: 0 1px 2px rgba(20, 24, 20, 0.08); | |
| 52 | + --shadow-off-soft: none; | |
| 53 | + --shadow-off-mid: 0 1px 2px rgba(20, 24, 20, 0.08); | |
| 54 | + /* encre légèrement plus profonde pour les aplats pleine largeur */ | |
| 55 | + --ink-deep: #101410; | |
| 56 | + --paper-on-ink: #edeae2; | |
| 45 | 57 | } |
| 46 | 58 | |
| 47 | 59 | @theme inline { |
@@ -58,6 +70,9 @@ | ||
| 58 | 70 | --color-amber: var(--amber); |
| 59 | 71 | --color-amber-soft: var(--amber-soft); |
| 60 | 72 | --color-danger: var(--danger); |
| 73 | + --color-accent: var(--accent); | |
| 74 | + --color-accent-deep: var(--accent-deep); | |
| 75 | + --color-accent-bright: var(--accent-bright); | |
| 61 | 76 | --font-display: var(--font-space-grotesk), system-ui, sans-serif; |
| 62 | 77 | --font-body: var(--font-inter), system-ui, sans-serif; |
| 63 | 78 | --font-mono: var(--font-jetbrains), ui-monospace, monospace; |
@@ -80,10 +95,10 @@ body { | ||
| 80 | 95 | -webkit-font-smoothing: antialiased; |
| 81 | 96 | padding-bottom: env(safe-area-inset-bottom); |
| 82 | 97 | } |
| 83 | −/* grain subtil — texture signature */ | |
| 98 | +/* grain subtil — texture signature (discret, presque imperceptible) */ | |
| 84 | 99 | body::before { |
| 85 | 100 | content: ""; |
| 86 | − position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35; | |
| 101 | + position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.22; | |
| 87 | 102 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); |
| 88 | 103 | } |
| 89 | 104 | /* au-dessus du grain (::before positionné, premier dans l'ordre de peinture) ; |
@@ -95,146 +110,223 @@ h1, h2, h3, h4 { | ||
| 95 | 110 | font-family: var(--font-space-grotesk), system-ui, sans-serif; |
| 96 | 111 | letter-spacing: -0.03em; |
| 97 | 112 | } |
| 98 | −::selection { background: var(--lime); color: var(--ink); } | |
| 113 | +::selection { background: var(--ink); color: var(--paper); } | |
| 99 | 114 | |
| 100 | 115 | /* ---------- primitives signature ---------- */ |
| 101 | 116 | .vp-display { font-family: var(--font-space-grotesk), system-ui, sans-serif; } |
| 102 | 117 | .vp-mono { font-family: var(--font-jetbrains), ui-monospace, monospace; } |
| 103 | 118 | |
| 119 | +/* pleine largeur depuis un conteneur centré (héro, bandes encre) */ | |
| 120 | +.bleed { width: 100vw; margin-left: calc(50% - 50vw); } | |
| 121 | + | |
| 122 | +/* aplat encre : héro, blocs terminal */ | |
| 123 | +.vp-dark { background: var(--ink-deep); color: var(--paper-on-ink); } | |
| 124 | +.vp-dark ::selection { background: var(--accent-bright); color: var(--ink); } | |
| 125 | + | |
| 104 | 126 | .kicker { |
| 105 | 127 | font-family: var(--font-jetbrains), ui-monospace, monospace; |
| 106 | − font-size: 11.5px; font-weight: 500; text-transform: uppercase; | |
| 107 | − letter-spacing: 0.14em; color: var(--green); | |
| 108 | − display: inline-flex; align-items: center; gap: 8px; | |
| 128 | + font-size: 11px; font-weight: 500; text-transform: uppercase; | |
| 129 | + letter-spacing: 0.16em; color: var(--accent); | |
| 130 | + display: inline-flex; align-items: center; gap: 9px; | |
| 109 | 131 | } |
| 110 | −.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 132 | +.kicker::before { content: ""; width: 20px; height: 1.5px; background: var(--accent); } | |
| 111 | 133 | |
| 112 | 134 | .klabel { |
| 113 | 135 | font-family: var(--font-jetbrains), ui-monospace, monospace; |
| 114 | − font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; | |
| 115 | − color: var(--ink-3); font-weight: 700; | |
| 136 | + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; | |
| 137 | + color: var(--ink-3); font-weight: 500; | |
| 138 | +} | |
| 139 | + | |
| 140 | +/* en-tête de section éditorial : filet fort + numéro mono */ | |
| 141 | +.sec { | |
| 142 | + border-top: 2px solid var(--ink); | |
| 143 | + padding-top: 14px; | |
| 144 | +} | |
| 145 | +.sec-num { | |
| 146 | + font-family: var(--font-jetbrains), ui-monospace, monospace; | |
| 147 | + font-size: 11px; font-weight: 700; color: var(--accent); | |
| 148 | + letter-spacing: 0.1em; | |
| 116 | 149 | } |
| 117 | 150 | |
| 151 | +/* carte héritée — aplatie : surface + hairline, aucune ombre */ | |
| 118 | 152 | .vp-card { |
| 119 | 153 | background: var(--surface); |
| 120 | − border: 1.5px solid var(--ink); | |
| 154 | + border: 1px solid var(--line); | |
| 121 | 155 | border-radius: var(--r-card); |
| 122 | − box-shadow: var(--shadow-off-soft); | |
| 156 | + box-shadow: none; | |
| 123 | 157 | } |
| 124 | −.vp-card-hover { transition: transform 0.16s ease, box-shadow 0.16s ease; } | |
| 125 | −.vp-card-hover:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); } | |
| 158 | +.vp-card-hover { transition: border-color 0.15s ease; } | |
| 159 | +.vp-card-hover:hover { transform: none; box-shadow: none; border-color: var(--line-strong); } | |
| 126 | 160 | |
| 161 | +/* champ : soulignement éditorial (le cadre disparaît) */ | |
| 127 | 162 | .vp-input { |
| 128 | − border: 1.5px solid var(--line); background: var(--surface-2); | |
| 129 | − border-radius: var(--r-ctl); padding: 11px 12px; font-size: 15px; | |
| 163 | + border: 0; border-bottom: 1px solid var(--line-strong); background: transparent; | |
| 164 | + border-radius: 0; padding: 10px 2px; font-size: 15px; | |
| 130 | 165 | color: var(--ink); outline: none; font-family: inherit; width: 100%; |
| 131 | 166 | transition: border-color 0.15s ease, box-shadow 0.15s ease; min-height: 44px; |
| 132 | 167 | appearance: none; -webkit-appearance: none; |
| 133 | 168 | } |
| 134 | −.vp-input:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); } | |
| 169 | +.vp-input::placeholder { color: var(--ink-3); } | |
| 170 | +.vp-input:focus { border-color: var(--accent); box-shadow: 0 1px 0 var(--accent); } | |
| 135 | 171 | select.vp-input { |
| 136 | 172 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); |
| 137 | − background-repeat: no-repeat; background-position: right 12px center; padding-right: 30px; | |
| 173 | + background-repeat: no-repeat; background-position: right 6px center; padding-right: 26px; | |
| 138 | 174 | } |
| 139 | 175 | @media (max-width: 900px) { .vp-input { font-size: 16px; } } /* anti-zoom iOS */ |
| 140 | 176 | |
| 141 | 177 | .btn { |
| 142 | − border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; | |
| 143 | − font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; | |
| 144 | − transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; | |
| 145 | − font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: 0.01em; | |
| 178 | + border: 1px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 22px; | |
| 179 | + font-weight: 600; font-size: 13.5px; cursor: pointer; min-height: 44px; | |
| 180 | + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; | |
| 181 | + font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: 0.02em; | |
| 146 | 182 | display: inline-flex; align-items: center; justify-content: center; gap: 8px; |
| 147 | 183 | } |
| 148 | −.btn:active { transform: translate(2px, 2px); box-shadow: none !important; } | |
| 149 | −.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); } | |
| 150 | −.btn-primary:hover { background: var(--green-deep); } | |
| 151 | −.btn-ghost { background: transparent; color: var(--ink); border-color: var(--ink); } | |
| 152 | −.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); } | |
| 184 | +.btn:active { opacity: 0.85; } | |
| 185 | +.btn-primary { background: var(--ink); color: var(--paper); } | |
| 186 | +.btn-primary:hover { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| 187 | +.btn-ghost { background: transparent; color: var(--ink); border-color: var(--line-strong); } | |
| 188 | +.btn-ghost:hover { border-color: var(--accent); color: var(--accent-deep); } | |
| 189 | +.btn-accent { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| 190 | +.btn-accent:hover { background: var(--accent-deep); border-color: var(--accent-deep); } | |
| 153 | 191 | |
| 192 | +/* preuve de données : chiffre fort + libellé mono, séparés par filets */ | |
| 154 | 193 | .stat-chip { |
| 155 | − background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; | |
| 156 | − padding: 8px 16px; font-family: var(--font-jetbrains), ui-monospace, monospace; | |
| 157 | − font-size: 12px; color: var(--ink-2); display: inline-flex; gap: 8px; align-items: center; | |
| 158 | − box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); white-space: nowrap; | |
| 194 | + background: transparent; border: 0; border-left: 1px solid var(--line); | |
| 195 | + border-radius: 0; padding: 2px 0 2px 14px; | |
| 196 | + font-family: var(--font-jetbrains), ui-monospace, monospace; | |
| 197 | + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; | |
| 198 | + color: var(--ink-2); display: inline-flex; gap: 7px; align-items: baseline; | |
| 199 | + box-shadow: none; white-space: nowrap; | |
| 200 | +} | |
| 201 | +.stat-chip:first-child { border-left: 0; padding-left: 0; } | |
| 202 | +.stat-chip b { | |
| 203 | + color: var(--ink); font-weight: 700; font-size: 15px; | |
| 204 | + font-family: var(--font-space-grotesk), system-ui, sans-serif; letter-spacing: -0.01em; | |
| 159 | 205 | } |
| 160 | −.stat-chip b { color: var(--ink); font-weight: 700; } | |
| 161 | 206 | .stat-chip .pulse { |
| 162 | − width: 8px; height: 8px; border-radius: 50%; background: var(--green); | |
| 163 | − box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; | |
| 207 | + width: 7px; height: 7px; border-radius: 50%; background: var(--accent); | |
| 208 | + align-self: center; animation: pulse 2.4s ease infinite; | |
| 164 | 209 | } |
| 165 | −@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(255, 81, 72, 0.4); } } | |
| 210 | +@keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(216, 55, 46, 0.18); } } | |
| 166 | 211 | |
| 212 | +/* surligné signature : marqueur rouge sous le texte (plus de boîte inclinée) */ | |
| 167 | 213 | .hl { |
| 168 | − background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block; | |
| 169 | − transform: rotate(-1deg); | |
| 214 | + background: linear-gradient(transparent 64%, rgba(216, 55, 46, 0.3) 64%); | |
| 215 | + padding: 0 2px; border-radius: 0; display: inline; transform: none; | |
| 170 | 216 | } |
| 171 | −.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); } | |
| 217 | +.outline-txt { color: transparent; -webkit-text-stroke: 1.5px var(--ink); } | |
| 218 | +.vp-dark .outline-txt { -webkit-text-stroke: 1.5px var(--paper-on-ink); } | |
| 172 | 219 | |
| 173 | −/* ticker */ | |
| 220 | +/* ticker — bande de cotation (signature terminal) */ | |
| 174 | 221 | .ticker { |
| 175 | − background: var(--ink); color: var(--lime); overflow: hidden; | |
| 222 | + background: var(--ink-deep); color: var(--paper-on-ink); overflow: hidden; | |
| 176 | 223 | font-family: var(--font-jetbrains), ui-monospace, monospace; |
| 177 | − font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; | |
| 178 | − padding: 7px 0; white-space: nowrap; | |
| 179 | − border-bottom: 1px solid rgba(255, 81, 72, 0.25); | |
| 224 | + font-size: 10.5px; letter-spacing: 0.09em; text-transform: uppercase; | |
| 225 | + padding: 6px 0; white-space: nowrap; | |
| 180 | 226 | } |
| 181 | −.ticker-track { display: inline-flex; animation: ticker 44s linear infinite; will-change: transform; } | |
| 182 | −.ticker span { padding: 0 26px; position: relative; } | |
| 183 | −.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; } | |
| 227 | +.ticker-track { display: inline-flex; animation: ticker 48s linear infinite; will-change: transform; } | |
| 228 | +.ticker span { padding: 0 24px; position: relative; opacity: 0.85; } | |
| 229 | +.ticker span::after { content: ""; position: absolute; right: -2px; top: 50%; width: 4px; height: 4px; transform: translateY(-50%) rotate(45deg); background: var(--accent-bright); opacity: 0.8; } | |
| 184 | 230 | @keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } } |
| 185 | 231 | @media (prefers-reduced-motion: reduce) { |
| 186 | 232 | .ticker-track { animation: none; } |
| 187 | 233 | * { transition-duration: 0.01ms !important; } |
| 188 | 234 | } |
| 189 | 235 | |
| 190 | −/* kv cells (fiche) */ | |
| 236 | +/* kv cells (fiche) — cellule de données éditoriale : filet haut, pas de boîte */ | |
| 191 | 237 | .kv-cell { |
| 192 | − background: var(--surface-2); border: 1.5px solid var(--line); | |
| 193 | − border-radius: var(--r-ctl); padding: 10px 13px; | |
| 238 | + background: transparent; border: 0; border-top: 1px solid var(--line); | |
| 239 | + border-radius: 0; padding: 8px 2px 2px; | |
| 194 | 240 | } |
| 195 | 241 | .kv-cell .k { |
| 196 | 242 | font-family: var(--font-jetbrains), ui-monospace, monospace; |
| 197 | 243 | font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; |
| 198 | − color: var(--ink-3); font-weight: 700; | |
| 244 | + color: var(--ink-3); font-weight: 500; | |
| 199 | 245 | } |
| 200 | 246 | .kv-cell .v { |
| 201 | 247 | font-weight: 700; font-size: 14.5px; margin-top: 2px; |
| 202 | 248 | font-family: var(--font-space-grotesk), system-ui, sans-serif; |
| 203 | 249 | } |
| 204 | 250 | |
| 205 | −/* pills */ | |
| 251 | +/* pills (indice de confiance) — carrés, fins */ | |
| 206 | 252 | .pill { |
| 207 | − display: inline-block; border-radius: 4px; padding: 3px 10px; | |
| 253 | + display: inline-block; border-radius: 2px; padding: 3px 9px; | |
| 208 | 254 | font-family: var(--font-jetbrains), ui-monospace, monospace; |
| 209 | 255 | font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; |
| 210 | 256 | } |
| 211 | −.pill-conf-A { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); } | |
| 212 | −.pill-conf-B { background: var(--lime-soft); color: var(--green-deep); border: 1px solid var(--green); } | |
| 257 | +.pill-conf-A { background: var(--ink); color: var(--paper); border: 1px solid var(--ink); } | |
| 258 | +.pill-conf-B { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); } | |
| 213 | 259 | .pill-conf-C { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); } |
| 214 | 260 | .pill-conf-D { background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger); } |
| 215 | 261 | |
| 216 | −/* tableau éditorial */ | |
| 262 | +/* tableau éditorial — filets fins, en-tête discret, zéro cadre épais */ | |
| 217 | 263 | .src-wrap { |
| 218 | − overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); | |
| 219 | − box-shadow: var(--shadow-off-soft); background: var(--surface); | |
| 264 | + overflow-x: auto; border: 0; border-top: 2px solid var(--ink); border-radius: 0; | |
| 265 | + box-shadow: none; background: transparent; | |
| 220 | 266 | } |
| 221 | 267 | .src-table { width: 100%; border-collapse: separate; border-spacing: 0; } |
| 222 | 268 | .src-table th { |
| 223 | 269 | text-align: left; font-family: var(--font-jetbrains), ui-monospace, monospace; |
| 224 | 270 | font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); |
| 225 | − padding: 12px 14px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); | |
| 226 | − white-space: nowrap; | |
| 271 | + padding: 10px 12px 8px; border-bottom: 1px solid var(--line-strong); background: transparent; | |
| 272 | + white-space: nowrap; font-weight: 500; | |
| 227 | 273 | } |
| 228 | −.src-table td { padding: 11px 14px; border-bottom: 1px solid var(--line); font-size: 13.5px; vertical-align: top; } | |
| 274 | +.src-table td { padding: 12px; border-bottom: 1px solid var(--line); font-size: 13.5px; vertical-align: top; } | |
| 229 | 275 | .src-table tr:last-child td { border-bottom: none; } |
| 230 | 276 | .src-table tr:hover td { background: var(--surface-2); } |
| 231 | 277 | |
| 278 | +/* flux de ventes (accueil) — rangée prix-d'abord */ | |
| 279 | +.feed-row { | |
| 280 | + display: block; border-bottom: 1px solid var(--line); padding: 14px 2px; | |
| 281 | + transition: background 0.12s ease; | |
| 282 | +} | |
| 283 | +.feed-row:hover { background: var(--surface-2); } | |
| 284 | + | |
| 285 | +/* onglets de contrôle (période / catégorie) */ | |
| 286 | +.vp-tab { | |
| 287 | + font-family: var(--font-jetbrains), ui-monospace, monospace; | |
| 288 | + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; | |
| 289 | + color: var(--ink-3); padding: 8px 2px 6px; border: 0; background: transparent; | |
| 290 | + border-bottom: 2px solid transparent; cursor: pointer; min-height: 34px; | |
| 291 | + transition: color 0.12s ease, border-color 0.12s ease; | |
| 292 | +} | |
| 293 | +.vp-tab:hover { color: var(--ink); } | |
| 294 | +.vp-tab[aria-pressed="true"], .vp-tab[aria-selected="true"] { | |
| 295 | + color: var(--ink); border-bottom-color: var(--accent); | |
| 296 | +} | |
| 297 | + | |
| 298 | +/* infobulle de graphique */ | |
| 299 | +.vv-tip { | |
| 300 | + position: absolute; pointer-events: none; z-index: 5; | |
| 301 | + background: var(--ink-deep); color: var(--paper-on-ink); | |
| 302 | + border-radius: 2px; padding: 8px 10px; min-width: 130px; | |
| 303 | + font-family: var(--font-jetbrains), ui-monospace, monospace; font-size: 10.5px; | |
| 304 | + box-shadow: 0 2px 10px rgba(20, 24, 20, 0.25); | |
| 305 | +} | |
| 306 | +.vv-tip .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 1.5px 0; } | |
| 307 | +.vv-tip .key { display: inline-block; width: 10px; height: 2px; margin-right: 6px; vertical-align: middle; } | |
| 308 | +.vv-tip .val { font-weight: 700; color: #fff; } | |
| 309 | + | |
| 310 | +/* timeline méthodologie */ | |
| 311 | +.tl { position: relative; padding-left: 26px; } | |
| 312 | +.tl::before { | |
| 313 | + content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px; | |
| 314 | + width: 1px; background: var(--line-strong); | |
| 315 | +} | |
| 316 | +.tl-step { position: relative; padding: 0 0 26px; } | |
| 317 | +.tl-step:last-child { padding-bottom: 0; } | |
| 318 | +.tl-step::before { | |
| 319 | + content: ""; position: absolute; left: -24px; top: 5px; width: 9px; height: 9px; | |
| 320 | + background: var(--paper); border: 1.5px solid var(--ink); border-radius: 50%; | |
| 321 | +} | |
| 322 | +.tl-step[data-accent="true"]::before { background: var(--accent); border-color: var(--accent); } | |
| 323 | + | |
| 232 | 324 | /* animations de révélation */ |
| 233 | 325 | @keyframes rise { |
| 234 | − from { opacity: 0; transform: translateY(12px); } | |
| 326 | + from { opacity: 0; transform: translateY(10px); } | |
| 235 | 327 | to { opacity: 1; transform: none; } |
| 236 | 328 | } |
| 237 | −.rise { animation: rise 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) both; } | |
| 329 | +.rise { animation: rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both; } | |
| 238 | 330 | @keyframes growX { from { transform: scaleX(0); } } |
| 239 | 331 | .grow-x { transform-origin: left; animation: growX 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; } |
| 240 | 332 | @keyframes winPop { from { opacity: 0; } } |
@@ -243,13 +335,13 @@ select.vp-input { | ||
| 243 | 335 | .rise, .grow-x, .vv-win { animation: none; } |
| 244 | 336 | } |
| 245 | 337 | |
| 246 | −/* barres horizontales (historique) */ | |
| 247 | −.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; } | |
| 338 | +/* barres horizontales (historique) — fines, encre, millésime courant en rouge */ | |
| 339 | +.hbar-track { background: var(--line-soft); border-radius: 0; height: 12px; overflow: hidden; } | |
| 248 | 340 | .hbar-fill { |
| 249 | − display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; | |
| 341 | + display: block; height: 100%; background: var(--ink); border-radius: 0; | |
| 250 | 342 | min-width: 2px; transition: background 0.12s ease; |
| 251 | 343 | } |
| 252 | −.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } | |
| 344 | +.hbar-row:hover .hbar-fill { background: var(--accent); } | |
| 253 | 345 | |
| 254 | 346 | |
| 255 | 347 | /* Vrai-Prix Maps (framework Ka Maps par Groupe Ka) — recherche par carte */ |
@@ -258,20 +350,20 @@ select.vp-input { | ||
| 258 | 350 | height: 62vh; |
| 259 | 351 | min-height: 420px; |
| 260 | 352 | width: 100%; |
| 261 | − border: 1.5px solid var(--ink); | |
| 262 | − border-radius: 16px; | |
| 353 | + border: 1px solid var(--line-strong); | |
| 354 | + border-radius: 2px; | |
| 263 | 355 | overflow: hidden; |
| 264 | − box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.22); | |
| 356 | + box-shadow: none; | |
| 265 | 357 | z-index: 0; |
| 266 | 358 | } |
| 267 | 359 | .vpm-wrap .ka-map { |
| 268 | − --ka-accent: var(--lime); | |
| 360 | + --ka-accent: var(--accent); | |
| 269 | 361 | --ka-on-accent: #ffffff; |
| 270 | 362 | --ka-surface: var(--surface); |
| 271 | 363 | --ka-ink: var(--ink); |
| 272 | − --ka-line: var(--ink); | |
| 273 | − --ka-radius: 10px; | |
| 274 | − --ka-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); | |
| 364 | + --ka-line: var(--line-strong); | |
| 365 | + --ka-radius: 2px; | |
| 366 | + --ka-shadow: 0 2px 10px rgba(20, 24, 20, 0.18); | |
| 275 | 367 | --ka-font: var(--font-inter), system-ui, sans-serif; |
| 276 | 368 | position: absolute; |
| 277 | 369 | inset: 0; |
@@ -281,10 +373,10 @@ select.vp-input { | ||
| 281 | 373 | bottom: 12px; |
| 282 | 374 | left: 12px; |
| 283 | 375 | z-index: 9; |
| 284 | − background: var(--surface); | |
| 285 | − color: var(--ink); | |
| 286 | − border: 1.5px solid var(--ink); | |
| 287 | − border-radius: 999px; | |
| 376 | + background: var(--ink-deep); | |
| 377 | + color: var(--paper-on-ink); | |
| 378 | + border: 0; | |
| 379 | + border-radius: 2px; | |
| 288 | 380 | padding: 6px 12px; |
| 289 | 381 | font-family: var(--font-jetbrains), monospace; |
| 290 | 382 | font-size: 10.5px; |
@@ -306,11 +398,11 @@ select.vp-input { | ||
| 306 | 398 | margin-top: 12px; |
| 307 | 399 | padding: 9px 14px; |
| 308 | 400 | background: var(--ink); |
| 309 | − color: var(--lime); | |
| 310 | − border: 1.5px solid var(--ink); | |
| 311 | − border-radius: 999px; | |
| 401 | + color: var(--paper); | |
| 402 | + border: 1px solid var(--ink); | |
| 403 | + border-radius: 2px; | |
| 312 | 404 | font-size: 12.5px; |
| 313 | 405 | font-weight: 600; |
| 314 | 406 | cursor: pointer; |
| 315 | 407 | } |
| 316 | −.vpm-pop-cta:active { transform: translate(2px, 2px); } | |
| 408 | +.vpm-pop-cta:active { opacity: 0.85; } | |
modified
src/app/layout.tsx
+20 −14
@@ -111,25 +111,30 @@ export default function RootLayout({ children }: LayoutProps<"/">) { | ||
| 111 | 111 | dangerouslySetInnerHTML={{ __html: JSON.stringify(ORG_JSONLD) }} |
| 112 | 112 | /> |
| 113 | 113 | <LangProvider> |
| 114 | − {/* header opaque (standard header/menu KA 2026-08-22) */} | |
| 115 | − <header className="sticky top-0 z-[var(--z-header,500)] border-b-2 border-ink bg-paper"> | |
| 116 | − <div className="relative mx-auto flex h-16 max-w-6xl items-center gap-5 px-4 sm:px-6"> | |
| 114 | + {/* header opaque compact (standard header/menu KA 2026-08-22) */} | |
| 115 | + <header className="sticky top-0 z-[var(--z-header,500)] border-b border-[var(--line-strong)] bg-paper"> | |
| 116 | + <div className="relative mx-auto flex h-14 max-w-6xl items-center gap-6 px-4 sm:px-6"> | |
| 117 | 117 | <Link |
| 118 | 118 | href="/" |
| 119 | − className="vp-display group relative flex items-center text-[22px] font-bold leading-none tracking-[-0.04em] after:absolute after:-inset-2 after:content-[''] sm:text-[26px]" | |
| 119 | + className="vp-display relative flex items-baseline gap-1.5 text-[19px] font-bold leading-none tracking-[-0.04em] after:absolute after:-inset-2 after:content-['']" | |
| 120 | 120 | > |
| 121 | − Vrai | |
| 122 | − <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime transition-transform duration-200 group-hover:rotate-0"> | |
| 123 | − Prix | |
| 124 | − </span> | |
| 121 | + Vrai Prix | |
| 122 | + <span | |
| 123 | + aria-hidden="true" | |
| 124 | + className="inline-block h-[7px] w-[7px] bg-accent" | |
| 125 | + /> | |
| 125 | 126 | </Link> |
| 126 | 127 | {/* badge Groupe KA — desktop seulement (l'espace manque sous xl ; |
| 127 | 128 | le menu mobile porte sa propre entrée) */} |
| 128 | 129 | <span className="hidden xl:inline-flex"> |
| 129 | 130 | <GroupeKaBadge /> |
| 130 | 131 | </span> |
| 131 | − <div className="ml-auto flex items-center gap-2"> | |
| 132 | + <div className="ml-auto flex items-center gap-3 sm:gap-4"> | |
| 132 | 133 | <HeaderNav /> |
| 134 | + <span | |
| 135 | + aria-hidden="true" | |
| 136 | + className="hidden h-4 w-px bg-[var(--line)] lg:inline-block" | |
| 137 | + /> | |
| 133 | 138 | <AccountButton /> |
| 134 | 139 | <LangToggle /> |
| 135 | 140 | </div> |
@@ -147,11 +152,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) { | ||
| 147 | 152 | puis le bloc encre final = KaFooter (commun Groupe KA) */} |
| 148 | 153 | <div className="mx-auto max-w-6xl px-4 sm:px-6"> |
| 149 | 154 | <div className="border-t-2 border-ink pt-7 text-[13px] text-ink-2"> |
| 150 | − <p className="vp-display mb-3 text-[26px] font-bold tracking-[-0.04em] text-ink"> | |
| 151 | − Vrai | |
| 152 | − <span className="ml-1 inline-block rotate-[-2deg] rounded-md bg-ink px-[6px] pb-[3px] pt-[1px] text-lime"> | |
| 153 | − Prix | |
| 154 | − </span> | |
| 155 | + <p className="vp-display mb-3 flex items-baseline gap-2 text-[24px] font-bold tracking-[-0.04em] text-ink"> | |
| 156 | + Vrai Prix | |
| 157 | + <span | |
| 158 | + aria-hidden="true" | |
| 159 | + className="inline-block h-[8px] w-[8px] bg-accent" | |
| 160 | + /> | |
| 155 | 161 | </p> |
| 156 | 162 | <p className="max-w-2xl"> |
| 157 | 163 | Estimations bâties sur les rôles d'évaluation foncière du |
modified
src/app/methodologie/MethodologieClient.tsx
+70 −46
@@ -192,71 +192,95 @@ export default function MethodologieClient() { | ||
| 192 | 192 | <span>{fr ? "Méthodologie" : "Methodology"}</span> |
| 193 | 193 | </nav> |
| 194 | 194 | <span className="kicker">{fr ? "Le calcul, en entier" : "The math, in full"}</span> |
| 195 | − <h1 className="vp-display mt-3 max-w-3xl text-[clamp(30px,5vw,52px)] font-bold uppercase leading-[1] tracking-[-0.03em]"> | |
| 195 | + <h1 className="vp-display mt-3 max-w-3xl text-[clamp(32px,5.5vw,58px)] font-bold uppercase leading-[0.98] tracking-[-0.03em]"> | |
| 196 | 196 | {fr ? ( |
| 197 | 197 | <> |
| 198 | − Méthodologie <span className="hl">ultra-détaillée</span> | |
| 198 | + Voici exactement | |
| 199 | + <br /> | |
| 200 | + <span className="hl">comment le prix est calculé</span> | |
| 199 | 201 | </> |
| 200 | 202 | ) : ( |
| 201 | 203 | <> |
| 202 | − The <span className="hl">full</span> methodology | |
| 204 | + Here is exactly | |
| 205 | + <br /> | |
| 206 | + <span className="hl">how the price is computed</span> | |
| 203 | 207 | </> |
| 204 | 208 | )} |
| 205 | 209 | </h1> |
| 206 | − <p className="mt-4 max-w-2xl text-[16px] text-ink-2"> | |
| 210 | + <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2"> | |
| 207 | 211 | {fr |
| 208 | 212 | ? "Notre différenciateur est la transparence totale. Cette page documente chaque étape — données, fusion, modèle, validation, moteur — au niveau de détail d'un rapport technique." |
| 209 | 213 | : "Our differentiator is total transparency. This page documents every step — data, merge, model, validation, engine — at technical-report depth."} |
| 210 | 214 | </p> |
| 211 | 215 | |
| 212 | − <div className="mt-10 space-y-8"> | |
| 216 | + {/* la chaîne de calcul, d'un coup d'œil */} | |
| 217 | + <ol className="mt-9 grid gap-x-6 gap-y-4 border-y border-[var(--line)] py-5 sm:grid-cols-3 lg:grid-cols-6"> | |
| 218 | + {(fr | |
| 219 | + ? ["Propriété identifiée", "Rôle d'évaluation récupéré", "Ventes comparables sélectionnées", "Caractéristiques ajustées", "Estimation calculée", "Intervalle de confiance produit"] | |
| 220 | + : ["Property identified", "Assessment roll retrieved", "Comparable sales selected", "Characteristics adjusted", "Estimate computed", "Confidence interval produced"] | |
| 221 | + ).map((step, i, arr) => ( | |
| 222 | + <li key={step} className="relative flex items-start gap-2.5 lg:block"> | |
| 223 | + <span className={`vp-mono text-[11px] font-bold ${i === arr.length - 1 ? "text-accent" : "text-ink-3"}`}> | |
| 224 | + 0{i + 1} | |
| 225 | + </span> | |
| 226 | + <p className="vp-display text-[13px] font-bold uppercase leading-tight tracking-[-0.01em] lg:mt-1.5"> | |
| 227 | + {step} | |
| 228 | + {i < arr.length - 1 && <span className="ml-1.5 text-ink-3" aria-hidden="true">→</span>} | |
| 229 | + </p> | |
| 230 | + </li> | |
| 231 | + ))} | |
| 232 | + </ol> | |
| 233 | + | |
| 234 | + <div className="mt-12 space-y-12"> | |
| 213 | 235 | {sections.map((sct) => ( |
| 214 | − <section key={sct.num} className="vp-card p-6 sm:p-8"> | |
| 215 | − <div className="flex items-baseline gap-4"> | |
| 216 | − <span className="vp-mono text-[13px] font-bold text-green">{sct.num}</span> | |
| 217 | − <h2 className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]"> | |
| 236 | + <section key={sct.num} className="sec grid gap-x-10 gap-y-3 lg:grid-cols-[110px_1fr]"> | |
| 237 | + <span className="vp-display text-[clamp(26px,3vw,36px)] font-bold leading-none text-accent lg:pt-1"> | |
| 238 | + {sct.num} | |
| 239 | + </span> | |
| 240 | + <div> | |
| 241 | + <h2 className="vp-display text-[clamp(19px,2.4vw,24px)] font-bold uppercase tracking-[-0.02em]"> | |
| 218 | 242 | {sct.title} |
| 219 | 243 | </h2> |
| 220 | − </div> | |
| 221 | − <div className="mt-4 space-y-3.5"> | |
| 222 | − {sct.paras.map((p, i) => ( | |
| 223 | − <p key={i} className="max-w-3xl text-[14.5px] leading-relaxed text-ink-2"> | |
| 224 | − {p} | |
| 225 | − </p> | |
| 226 | − ))} | |
| 227 | − </div> | |
| 228 | − {sct.kv && ( | |
| 229 | − <div className="mt-5 grid grid-cols-2 gap-2.5 sm:grid-cols-3"> | |
| 230 | − {sct.kv.map(([k, v]) => ( | |
| 231 | − <div key={k} className="kv-cell"> | |
| 232 | − <p className="k">{k}</p> | |
| 233 | − <p className="v">{v}</p> | |
| 234 | − </div> | |
| 244 | + <div className="mt-4 space-y-3.5"> | |
| 245 | + {sct.paras.map((p, i) => ( | |
| 246 | + <p key={i} className="max-w-3xl text-[14.5px] leading-relaxed text-ink-2"> | |
| 247 | + {p} | |
| 248 | + </p> | |
| 235 | 249 | ))} |
| 236 | 250 | </div> |
| 237 | − )} | |
| 238 | − {sct.table && ( | |
| 239 | − <div className="src-wrap mt-5"> | |
| 240 | − <table className="src-table"> | |
| 241 | − <thead> | |
| 242 | − <tr> | |
| 243 | − {sct.table.head.map((h) => ( | |
| 244 | − <th key={h}>{h}</th> | |
| 245 | − ))} | |
| 246 | − </tr> | |
| 247 | − </thead> | |
| 248 | − <tbody> | |
| 249 | − {sct.table.rows.map(([a, b, c]) => ( | |
| 250 | − <tr key={a}> | |
| 251 | − <td className="font-semibold">{a}</td> | |
| 252 | − <td className="vp-mono text-[12.5px]">{b}</td> | |
| 253 | − <td className="vp-mono text-[12.5px]">{c}</td> | |
| 251 | + {sct.kv && ( | |
| 252 | + <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3"> | |
| 253 | + {sct.kv.map(([k, v]) => ( | |
| 254 | + <div key={k} className="kv-cell"> | |
| 255 | + <p className="k">{k}</p> | |
| 256 | + <p className="v">{v}</p> | |
| 257 | + </div> | |
| 258 | + ))} | |
| 259 | + </div> | |
| 260 | + )} | |
| 261 | + {sct.table && ( | |
| 262 | + <div className="src-wrap mt-6 max-w-3xl"> | |
| 263 | + <table className="src-table"> | |
| 264 | + <thead> | |
| 265 | + <tr> | |
| 266 | + {sct.table.head.map((h) => ( | |
| 267 | + <th key={h}>{h}</th> | |
| 268 | + ))} | |
| 254 | 269 | </tr> |
| 255 | − ))} | |
| 256 | − </tbody> | |
| 257 | − </table> | |
| 258 | − </div> | |
| 259 | − )} | |
| 270 | + </thead> | |
| 271 | + <tbody> | |
| 272 | + {sct.table.rows.map(([a, b, c]) => ( | |
| 273 | + <tr key={a}> | |
| 274 | + <td className="font-semibold">{a}</td> | |
| 275 | + <td className="vp-mono text-[12.5px]">{b}</td> | |
| 276 | + <td className="vp-mono text-[12.5px]">{c}</td> | |
| 277 | + </tr> | |
| 278 | + ))} | |
| 279 | + </tbody> | |
| 280 | + </table> | |
| 281 | + </div> | |
| 282 | + )} | |
| 283 | + </div> | |
| 260 | 284 | </section> |
| 261 | 285 | ))} |
| 262 | 286 | </div> |
modified
src/app/municipalite/[slug]/[type]/page.tsx
+2 −2
@@ -164,7 +164,7 @@ export default async function MunTypePage({ | ||
| 164 | 164 | <li key={x.type}> |
| 165 | 165 | <Link |
| 166 | 166 | href={`/municipalite/${slug}/${TYPE_SLUGS[x.type] ?? x.type}`} |
| 167 | − className="stat-chip hover:bg-lime" | |
| 167 | + className="stat-chip transition-colors hover:text-accent-deep" | |
| 168 | 168 | > |
| 169 | 169 | {TYPE_LABELS[x.type]?.pluriel ?? x.type} |
| 170 | 170 | <b>{numFr(x.n)}</b> |
@@ -172,7 +172,7 @@ export default async function MunTypePage({ | ||
| 172 | 172 | </li> |
| 173 | 173 | ))} |
| 174 | 174 | <li> |
| 175 | − <Link href={`/municipalite/${slug}`} className="stat-chip hover:bg-lime"> | |
| 175 | + <Link href={`/municipalite/${slug}`} className="stat-chip transition-colors hover:text-accent-deep"> | |
| 176 | 176 | ← Tout {mun.nom} |
| 177 | 177 | </Link> |
| 178 | 178 | </li> |
modified
src/app/municipalite/[slug]/page.tsx
+1 −1
@@ -219,7 +219,7 @@ export default async function MunicipalitePage({ | ||
| 219 | 219 | <li key={v.slug}> |
| 220 | 220 | <Link |
| 221 | 221 | href={`/municipalite/${v.slug}`} |
| 222 | − className="stat-chip hover:bg-lime" | |
| 222 | + className="stat-chip transition-colors hover:text-accent-deep" | |
| 223 | 223 | > |
| 224 | 224 | {v.nom} |
| 225 | 225 | </Link> |
modified
src/app/page.tsx
+21 −16
@@ -36,11 +36,11 @@ const TOP_VILLES = ( | ||
| 36 | 36 | stats as { par_ville: { ville: string; n: number; mediane: number | null }[] } |
| 37 | 37 | ).par_ville.slice(0, 24); |
| 38 | 38 | |
| 39 | −/** Séries d'indice par grand type — 25 derniers mois, avec variations. */ | |
| 39 | +/** Séries d'indice par grand type — historique complet (le client fenêtre 12/24/tout). */ | |
| 40 | 40 | function buildPulse(): PulseItem[] { |
| 41 | 41 | return (["unifamilial", "condo", "plex"] as const).map((type) => { |
| 42 | 42 | const full = getMarketIndex(type); |
| 43 | − const series = full.slice(-25); | |
| 43 | + const series = full; | |
| 44 | 44 | const last = full.length ? full[full.length - 1].idx : null; |
| 45 | 45 | const yearAgo = full.length >= 13 ? full[full.length - 13].idx : null; |
| 46 | 46 | const first = full.length >= 2 ? full[0].idx : null; |
@@ -89,33 +89,38 @@ export default function Home() { | ||
| 89 | 89 | /> |
| 90 | 90 | {/* ---- Explorer par municipalité (maillage interne indexable) ---- */} |
| 91 | 91 | <section className="mt-14"> |
| 92 | − <span className="kicker">Explorer par municipalité</span> | |
| 93 | − <h2 className="vp-display mt-3 text-[clamp(22px,3.6vw,34px)] font-bold uppercase leading-[1.05] tracking-[-0.03em]"> | |
| 94 | − La valeur des propriétés, ville par ville | |
| 95 | − </h2> | |
| 96 | − <p className="mt-3 max-w-2xl text-[15px] text-ink-2"> | |
| 97 | − Statistiques d'évaluation, valeur médiane et propriétés estimées | |
| 98 | − pour chacune des 1 097 municipalités du Québec. | |
| 99 | − </p> | |
| 100 | − <ul className="mt-6 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3 lg:grid-cols-4"> | |
| 92 | + <header className="sec"> | |
| 93 | + <span className="sec-num">05 — Explorer par municipalité</span> | |
| 94 | + <h2 className="vp-display mt-2 max-w-3xl text-[clamp(24px,3.6vw,36px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> | |
| 95 | + La valeur des propriétés, ville par ville | |
| 96 | + </h2> | |
| 97 | + <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2"> | |
| 98 | + Statistiques d'évaluation, valeur médiane et propriétés estimées | |
| 99 | + pour chacune des 1 097 municipalités du Québec. | |
| 100 | + </p> | |
| 101 | + </header> | |
| 102 | + <ul className="mt-6 grid grid-cols-2 gap-x-8 sm:grid-cols-3 lg:grid-cols-4"> | |
| 101 | 103 | {TOP_VILLES.map((v) => ( |
| 102 | 104 | <li key={v.ville}> |
| 103 | 105 | <Link |
| 104 | 106 | href={`/municipalite/${slugify(v.ville)}`} |
| 105 | − className="group flex items-baseline justify-between gap-2 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[14px]" | |
| 107 | + className="group flex items-baseline justify-between gap-2 border-b border-[rgba(20,24,20,0.1)] py-2 text-[13.5px]" | |
| 106 | 108 | > |
| 107 | − <span className="font-medium group-hover:underline"> | |
| 109 | + <span className="font-medium group-hover:text-accent-deep"> | |
| 108 | 110 | {v.ville} |
| 109 | 111 | </span> |
| 110 | − <span className="vp-mono text-[11px] text-ink-2"> | |
| 112 | + <span className="vp-mono text-[10.5px] text-ink-3"> | |
| 111 | 113 | {v.mediane != null ? moneyFr(v.mediane) : ""} |
| 112 | 114 | </span> |
| 113 | 115 | </Link> |
| 114 | 116 | </li> |
| 115 | 117 | ))} |
| 116 | 118 | </ul> |
| 117 | − <p className="mt-5"> | |
| 118 | − <Link href="/municipalites" className="btn btn-ghost"> | |
| 119 | + <p className="mt-6"> | |
| 120 | + <Link | |
| 121 | + href="/municipalites" | |
| 122 | + className="vp-mono border-b border-accent pb-px text-[11px] uppercase tracking-[0.08em] text-accent-deep hover:text-accent" | |
| 123 | + > | |
| 119 | 124 | Toutes les municipalités → |
| 120 | 125 | </Link> |
| 121 | 126 | </p> |
modified
src/components/AccountButton.tsx
+5 −5
@@ -45,7 +45,7 @@ export default function AccountButton() { | ||
| 45 | 45 | return ( |
| 46 | 46 | <a |
| 47 | 47 | href={`/api/auth/ka/login?next=${encodeURIComponent(path || "/")}`} |
| 48 | − className="vp-mono inline-flex min-h-[36px] items-center rounded-full border-[1.5px] border-ink bg-ink px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-lime transition-all hover:bg-green-deep" | |
| 48 | + className="vp-mono inline-flex min-h-[36px] items-center rounded-[2px] bg-ink px-3.5 py-1.5 text-[11px] font-medium uppercase tracking-[0.1em] text-paper transition-colors hover:bg-accent" | |
| 49 | 49 | title={fr ? "Se connecter avec KA ID" : "Sign in with KA ID"} |
| 50 | 50 | > |
| 51 | 51 | KA ID |
@@ -57,7 +57,7 @@ export default function AccountButton() { | ||
| 57 | 57 | return ( |
| 58 | 58 | <Link |
| 59 | 59 | href="/compte" |
| 60 | − className="group inline-flex min-h-[36px] items-center gap-2 rounded-full border-[1.5px] border-ink bg-surface py-1 pl-1 pr-3 transition-all hover:bg-lime-soft" | |
| 60 | + className="group inline-flex min-h-[36px] items-center gap-2 py-1" | |
| 61 | 61 | title={fr ? "Mon compte — carte de membre Groupe KA" : "My account — Groupe KA member card"} |
| 62 | 62 | > |
| 63 | 63 | {user.picture ? ( |
@@ -66,14 +66,14 @@ export default function AccountButton() { | ||
| 66 | 66 | src={user.picture} |
| 67 | 67 | alt="" |
| 68 | 68 | referrerPolicy="no-referrer" |
| 69 | − className="h-7 w-7 rounded-full border border-ink object-cover" | |
| 69 | + className="h-7 w-7 rounded-full object-cover grayscale transition-[filter] group-hover:grayscale-0" | |
| 70 | 70 | /> |
| 71 | 71 | ) : ( |
| 72 | − <span className="vp-display flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[13px] font-bold text-lime"> | |
| 72 | + <span className="vp-display flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[13px] font-bold text-paper"> | |
| 73 | 73 | {prenom.charAt(0).toUpperCase()} |
| 74 | 74 | </span> |
| 75 | 75 | )} |
| 76 | − <span className="vp-display max-w-[92px] truncate text-[13px] font-bold text-ink"> | |
| 76 | + <span className="vp-display max-w-[92px] truncate border-b-2 border-transparent text-[13px] font-bold text-ink group-hover:border-accent"> | |
| 77 | 77 | {prenom} |
| 78 | 78 | </span> |
| 79 | 79 | </Link> |
modified
src/components/CookieConsent.tsx
+5 −5
@@ -164,8 +164,8 @@ export default function CookieConsent() { | ||
| 164 | 164 | <> |
| 165 | 165 | {showBanner && ( |
| 166 | 166 | <div className="fixed inset-x-0 bottom-0 z-[var(--z-toast,950)] p-3 pb-[calc(12px+env(safe-area-inset-bottom))] sm:p-5 sm:pb-[calc(20px+env(safe-area-inset-bottom))]"> |
| 167 | − <div className="mx-auto max-w-3xl rounded-[10px] border-[1.5px] border-ink bg-ink p-5 text-paper shadow-[0_-8px_32px_rgba(16,18,16,0.35),6px_6px_0_rgba(20,24,20,0.3)]"> | |
| 168 | − <p className="klabel !text-lime">🍪 {t.prefsTitle}</p> | |
| 167 | + <div className="mx-auto max-w-3xl rounded-[2px] bg-[var(--ink-deep)] p-5 text-paper shadow-[0_-8px_32px_rgba(16,18,16,0.35)]"> | |
| 168 | + <p className="klabel !text-[var(--accent-bright)]">🍪 {t.prefsTitle}</p> | |
| 169 | 169 | <p className="mt-2 text-[13.5px] leading-relaxed text-[rgba(245,243,238,0.85)]"> |
| 170 | 170 | {t.banner}{" "} |
| 171 | 171 | <a href="/confidentialite" className="border-b border-lime text-lime"> |
@@ -173,7 +173,7 @@ export default function CookieConsent() { | ||
| 173 | 173 | </a> |
| 174 | 174 | </p> |
| 175 | 175 | <div className="mt-4 flex flex-wrap gap-2.5"> |
| 176 | − <button className="btn border-lime bg-lime text-ink" onClick={() => persist(true, true)}> | |
| 176 | + <button className="btn btn-accent" onClick={() => persist(true, true)}> | |
| 177 | 177 | {t.acceptAll} |
| 178 | 178 | </button> |
| 179 | 179 | <button |
@@ -199,7 +199,7 @@ export default function CookieConsent() { | ||
| 199 | 199 | onClick={() => setPanel(false)} |
| 200 | 200 | > |
| 201 | 201 | <div |
| 202 | − className="w-full max-w-lg rounded-[10px] border-[1.5px] border-ink bg-surface p-6 shadow-[8px_8px_0_rgba(20,24,20,0.3)]" | |
| 202 | + className="w-full max-w-lg rounded-[2px] border border-[var(--line-strong)] bg-surface p-6 shadow-[0_12px_40px_rgba(20,24,20,0.3)]" | |
| 203 | 203 | onClick={(e) => e.stopPropagation()} |
| 204 | 204 | role="dialog" |
| 205 | 205 | aria-modal="true" |
@@ -214,7 +214,7 @@ export default function CookieConsent() { | ||
| 214 | 214 | { k: t.prefs, d: t.prefsDesc, on: prefs, lock: false, set: setPrefs }, |
| 215 | 215 | { k: t.analytics, d: t.analyticsDesc, on: analytics, lock: false, set: setAnalytics }, |
| 216 | 216 | ].map((row) => ( |
| 217 | − <div key={row.k} className="flex items-start justify-between gap-4 rounded-lg border-[1.5px] border-[rgba(20,24,20,0.14)] bg-surface-2 p-4"> | |
| 217 | + <div key={row.k} className="flex items-start justify-between gap-4 border-t border-[var(--line)] p-4 pl-0"> | |
| 218 | 218 | <div> |
| 219 | 219 | <p className="vp-display text-[14px] font-bold"> |
| 220 | 220 | {row.k} |
modified
src/components/HeaderNav.tsx
+34 −31
@@ -40,16 +40,16 @@ export default function HeaderNav() { | ||
| 40 | 40 | |
| 41 | 41 | return ( |
| 42 | 42 | <> |
| 43 | − {/* nav pilule — desktop */} | |
| 44 | − <nav className="hidden items-center gap-1 lg:flex"> | |
| 43 | + {/* nav texte — desktop : soulignement rouge = page courante */} | |
| 44 | + <nav className="hidden items-center gap-5 lg:flex"> | |
| 45 | 45 | {items.map((it) => ( |
| 46 | 46 | <Link |
| 47 | 47 | key={it.href} |
| 48 | 48 | href={it.href} |
| 49 | − className={`vp-display inline-flex min-h-[38px] items-center rounded-full border-[1.5px] px-3.5 py-1.5 text-[13.5px] font-bold transition-all ${ | |
| 49 | + className={`vp-mono inline-flex min-h-[38px] items-center border-b-2 pt-[2px] text-[11px] font-medium uppercase tracking-[0.1em] transition-colors ${ | |
| 50 | 50 | isActive(it.href) |
| 51 | − ? "border-ink bg-ink text-lime" | |
| 52 | − : "border-transparent text-ink-2 hover:border-ink hover:text-ink" | |
| 51 | + ? "border-accent text-ink" | |
| 52 | + : "border-transparent text-ink-2 hover:text-ink" | |
| 53 | 53 | }`} |
| 54 | 54 | > |
| 55 | 55 | {it.label} |
@@ -71,39 +71,36 @@ export default function HeaderNav() { | ||
| 71 | 71 | <label |
| 72 | 72 | htmlFor="vp-menu" |
| 73 | 73 | aria-hidden="true" |
| 74 | − className="flex h-11 w-11 cursor-pointer flex-col items-center justify-center gap-[5px] rounded-full border-[1.5px] border-ink bg-surface active:bg-lime-soft" | |
| 74 | + className="flex h-11 w-11 cursor-pointer flex-col items-center justify-center gap-[5px]" | |
| 75 | 75 | > |
| 76 | − <span className="h-[2px] w-[18px] bg-ink" /> | |
| 77 | − <span className="h-[2px] w-[18px] bg-ink" /> | |
| 78 | − <span className="h-[2px] w-[18px] bg-ink" /> | |
| 76 | + <span className="h-px w-[20px] bg-ink" /> | |
| 77 | + <span className="h-px w-[20px] bg-ink" /> | |
| 78 | + <span className="h-px w-[12px] self-center ml-[8px] bg-accent" /> | |
| 79 | 79 | </label> |
| 80 | 80 | |
| 81 | − {/* panneau plein écran — sibling du checkbox, affiché via peer-checked */} | |
| 81 | + {/* panneau plein écran encre — sibling du checkbox, affiché via peer-checked */} | |
| 82 | 82 | <div |
| 83 | − className="fixed inset-0 z-[var(--z-modal,900)] hidden flex-col overflow-y-auto peer-checked:flex" | |
| 84 | − style={{ background: "var(--paper)" }} | |
| 83 | + className="vp-dark fixed inset-0 z-[var(--z-modal,900)] hidden flex-col overflow-y-auto peer-checked:flex" | |
| 85 | 84 | role="dialog" |
| 86 | 85 | aria-modal="true" |
| 87 | 86 | aria-label="Menu" |
| 88 | 87 | > |
| 89 | 88 | {/* barre du haut : marque + fermer */} |
| 90 | − <div | |
| 91 | − className="flex h-16 shrink-0 items-center justify-between border-b-2 border-ink px-4" | |
| 92 | − style={{ background: "var(--paper)" }} | |
| 93 | − > | |
| 89 | + <div className="flex h-14 shrink-0 items-center justify-between border-b border-[rgba(245,243,238,0.18)] px-4"> | |
| 94 | 90 | <Link |
| 95 | 91 | href="/" |
| 96 | − className="vp-display flex items-center text-[22px] font-bold leading-none tracking-[-0.04em]" | |
| 92 | + className="vp-display flex items-baseline gap-1.5 text-[19px] font-bold leading-none tracking-[-0.04em] text-[var(--paper-on-ink)]" | |
| 97 | 93 | > |
| 98 | − Vrai | |
| 99 | − <span className="ml-1 rotate-[-2deg] rounded-md bg-ink px-[7px] pb-1 pt-[2px] text-lime"> | |
| 100 | − Prix | |
| 101 | − </span> | |
| 94 | + Vrai Prix | |
| 95 | + <span | |
| 96 | + aria-hidden="true" | |
| 97 | + className="inline-block h-[7px] w-[7px] bg-[var(--accent-bright)]" | |
| 98 | + /> | |
| 102 | 99 | </Link> |
| 103 | 100 | <label |
| 104 | 101 | htmlFor="vp-menu" |
| 105 | 102 | aria-label={fr ? "Fermer le menu" : "Close menu"} |
| 106 | − className="vp-display flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[17px] font-bold text-lime" | |
| 103 | + className="vp-mono flex h-11 w-11 cursor-pointer items-center justify-center text-[15px] text-[var(--paper-on-ink)]" | |
| 107 | 104 | > |
| 108 | 105 | ✕ |
| 109 | 106 | </label> |
@@ -115,37 +112,43 @@ export default function HeaderNav() { | ||
| 115 | 112 | <Link |
| 116 | 113 | key={it.href} |
| 117 | 114 | href={it.href} |
| 118 | − className="group flex items-baseline gap-4 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.18)] py-5 last:border-0" | |
| 115 | + className="group flex items-baseline gap-4 border-b border-[rgba(245,243,238,0.12)] py-[18px] last:border-0" | |
| 119 | 116 | > |
| 120 | − <span className="vp-mono text-[12px] font-bold text-green"> | |
| 117 | + <span className="vp-mono text-[11px] font-medium text-[var(--accent-bright)]"> | |
| 121 | 118 | 0{i + 1} |
| 122 | 119 | </span> |
| 123 | 120 | <span |
| 124 | − className={`vp-display text-[29px] font-bold uppercase leading-none tracking-[-0.03em] ${ | |
| 121 | + className={`vp-display text-[28px] font-bold uppercase leading-none tracking-[-0.03em] ${ | |
| 125 | 122 | isActive(it.href) |
| 126 | − ? "rounded-md bg-ink px-2.5 py-1 text-lime" | |
| 127 | − : "text-ink group-active:text-green-deep" | |
| 123 | + ? "text-[var(--accent-bright)]" | |
| 124 | + : "text-[var(--paper-on-ink)] group-active:text-[var(--accent-bright)]" | |
| 128 | 125 | }`} |
| 129 | 126 | > |
| 130 | 127 | {it.label} |
| 131 | 128 | </span> |
| 129 | + {isActive(it.href) && ( | |
| 130 | + <span | |
| 131 | + aria-hidden="true" | |
| 132 | + className="mb-[2px] inline-block h-[6px] w-[6px] self-center bg-[var(--accent-bright)]" | |
| 133 | + /> | |
| 134 | + )} | |
| 132 | 135 | </Link> |
| 133 | 136 | ))} |
| 134 | 137 | </nav> |
| 135 | 138 | |
| 136 | 139 | {/* pied du menu */} |
| 137 | − <div className="shrink-0 border-t-2 border-ink px-6 pb-[max(20px,env(safe-area-inset-bottom))] pt-4"> | |
| 140 | + <div className="shrink-0 border-t border-[rgba(245,243,238,0.18)] px-6 pb-[max(20px,env(safe-area-inset-bottom))] pt-4"> | |
| 138 | 141 | {/* entrée « Un service Groupe KA » du menu mobile */} |
| 139 | − <div className="mb-3"> | |
| 142 | + <div className="mb-4"> | |
| 140 | 143 | <GroupeKaBadge /> |
| 141 | 144 | </div> |
| 142 | 145 | <button |
| 143 | 146 | onClick={() => setLang(fr ? "en" : "fr")} |
| 144 | − className="vp-mono mb-3 rounded-full border-[1.5px] border-ink bg-surface px-4 py-2 text-[12px] font-bold uppercase tracking-[0.08em]" | |
| 147 | + className="vp-mono mb-4 border-b border-[var(--paper-on-ink)] pb-1 text-[12px] font-medium uppercase tracking-[0.1em] text-[var(--paper-on-ink)]" | |
| 145 | 148 | > |
| 146 | 149 | {fr ? "English version" : "Version française"} |
| 147 | 150 | </button> |
| 148 | − <div className="vp-mono flex flex-wrap gap-x-5 gap-y-1.5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3"> | |
| 151 | + <div className="vp-mono flex flex-wrap gap-x-5 gap-y-1.5 text-[10.5px] uppercase tracking-[0.06em] text-[rgba(245,243,238,0.55)]"> | |
| 149 | 152 | <Link href="/conditions">{fr ? "Conditions" : "Terms"}</Link> |
| 150 | 153 | <Link href="/confidentialite"> |
| 151 | 154 | {fr ? "Confidentialité" : "Privacy"} |
modified
src/components/KaChat.tsx
+5 −5
@@ -316,8 +316,8 @@ export default function KaChat() { | ||
| 316 | 316 | > |
| 317 | 317 | {messages.length === 0 && ( |
| 318 | 318 | <div className="my-auto text-center"> |
| 319 | − <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border-[1.5px] border-ink bg-ink shadow-[5px_5px_0_var(--lime)] sm:h-16 sm:w-16"> | |
| 320 | − <span className="vp-display text-[22px] font-bold text-lime sm:text-[26px]">Ka</span> | |
| 319 | + <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-[2px] bg-[var(--ink-deep)] sm:h-16 sm:w-16"> | |
| 320 | + <span className="vp-display text-[22px] font-bold text-[var(--accent-bright)] sm:text-[26px]">Ka</span> | |
| 321 | 321 | </div> |
| 322 | 322 | <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]"> |
| 323 | 323 | {fr ? "Dis-moi quelle propriété t'intéresse." : "Tell me which property you're curious about."} |
@@ -332,7 +332,7 @@ export default function KaChat() { | ||
| 332 | 332 | <button |
| 333 | 333 | key={s} |
| 334 | 334 | onClick={() => send(s)} |
| 335 | − className="rounded-full border-[1.5px] border-ink bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-all hover:bg-lime-soft hover:shadow-[3px_3px_0_var(--ink)] active:translate-y-[1px]" | |
| 335 | + className="rounded-[2px] border border-[var(--line-strong)] bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-colors hover:border-accent hover:text-accent-deep" | |
| 336 | 336 | > |
| 337 | 337 | {s} |
| 338 | 338 | </button> |
@@ -349,8 +349,8 @@ export default function KaChat() { | ||
| 349 | 349 | </div> |
| 350 | 350 | ) : ( |
| 351 | 351 | <div className="flex w-full max-w-full gap-2 sm:max-w-[94%] sm:gap-3"> |
| 352 | − <div className="mt-0.5 hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[1.5px] border-ink bg-ink sm:flex"> | |
| 353 | − <span className="vp-display text-[12px] font-bold text-lime">Ka</span> | |
| 352 | + <div className="mt-0.5 hidden h-8 w-8 shrink-0 items-center justify-center rounded-[2px] bg-[var(--ink-deep)] sm:flex"> | |
| 353 | + <span className="vp-display text-[12px] font-bold text-[var(--accent-bright)]">Ka</span> | |
| 354 | 354 | </div> |
| 355 | 355 | <div className="min-w-0 flex-1"> |
| 356 | 356 | {m.tools && m.tools.length > 0 && ( |
modified
src/components/LangContext.tsx
+1 −1
@@ -37,7 +37,7 @@ export function LangToggle() { | ||
| 37 | 37 | return ( |
| 38 | 38 | <button |
| 39 | 39 | onClick={() => setLang(lang === "fr" ? "en" : "fr")} |
| 40 | − className="vp-mono min-h-[36px] rounded-full border-[1.5px] border-ink bg-surface px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-ink transition-all hover:bg-ink hover:text-lime" | |
| 40 | + className="vp-mono min-h-[36px] border-b-2 border-transparent px-1 py-1.5 text-[11px] font-medium uppercase tracking-[0.1em] text-ink-2 transition-colors hover:border-accent hover:text-ink" | |
| 41 | 41 | aria-label="Changer de langue / Switch language" |
| 42 | 42 | > |
| 43 | 43 | {lang === "fr" ? "EN" : "FR"} |
modified
src/components/LeadForm.tsx
+7 −5
@@ -29,25 +29,27 @@ export default function LeadForm({ | ||
| 29 | 29 | |
| 30 | 30 | if (done) |
| 31 | 31 | return ( |
| 32 | − <p className="vp-mono rounded-md border border-lime bg-[rgba(255,81,72,0.12)] px-4 py-3 text-[12px] font-bold uppercase tracking-[0.05em] text-lime"> | |
| 32 | + <p className="vp-mono border-l-2 border-[var(--accent-bright)] py-1 pl-4 text-[12px] font-bold uppercase tracking-[0.05em] text-[var(--paper-on-ink)]"> | |
| 33 | 33 | ✓ {t("leadOk")} |
| 34 | 34 | </p> |
| 35 | 35 | ); |
| 36 | 36 | return ( |
| 37 | − <form onSubmit={submit} className="flex flex-col gap-2.5 sm:flex-row"> | |
| 37 | + <form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row sm:gap-0"> | |
| 38 | 38 | <input |
| 39 | 39 | type="email" |
| 40 | 40 | required |
| 41 | 41 | value={email} |
| 42 | 42 | onChange={(e) => setEmail(e.target.value)} |
| 43 | 43 | placeholder={t("emailPlaceholder")} |
| 44 | − className={`min-h-[44px] flex-1 rounded-md border-[1.5px] bg-[rgba(245,243,238,0.08)] px-3.5 py-2.5 text-paper placeholder-[rgba(245,243,238,0.4)] outline-none transition-shadow focus:shadow-[3px_3px_0_var(--lime)] ${ | |
| 45 | − err ? "border-danger" : "border-[rgba(245,243,238,0.35)] focus:border-lime" | |
| 44 | + className={`min-h-[50px] flex-1 border-0 border-b bg-transparent px-1 py-2.5 text-[16px] text-[var(--paper-on-ink)] outline-none transition-colors placeholder:text-[rgba(245,243,238,0.4)] ${ | |
| 45 | + err | |
| 46 | + ? "border-[var(--danger)]" | |
| 47 | + : "border-[rgba(245,243,238,0.45)] focus:border-[var(--accent-bright)]" | |
| 46 | 48 | }`} |
| 47 | 49 | /> |
| 48 | 50 | <button |
| 49 | 51 | type="submit" |
| 50 | − className="btn min-h-[44px] border-lime bg-lime text-ink shadow-[4px_4px_0_rgba(255,81,72,0.25)] hover:bg-paper" | |
| 52 | + className="vp-display flex min-h-[50px] shrink-0 items-center justify-center gap-2 bg-[var(--accent-bright)] px-6 text-[14px] font-bold text-ink transition-colors hover:bg-[#ff6b63]" | |
| 51 | 53 | > |
| 52 | 54 | {t("leadBtn")} → |
| 53 | 55 | </button> |
modified
src/components/MetricViz.tsx
+428 −184
@@ -1,14 +1,14 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | /** |
| 3 | 3 | * MetricViz — visualisations signature générées par les données du registre. |
| 4 | − * Chaque propriété produit ses propres dessins : façade SVG (étages, logements, | |
| 5 | − * genre de construction), schéma de terrain à l'échelle (frontage × profondeur), | |
| 6 | − * composition de valeur terrain/bâtiment, jauge de confiance, sparkline 2021-2026, | |
| 7 | − * compteurs animés. SVG pur, palette encre/lime, aucun aléatoire : mêmes données, | |
| 8 | − * même dessin. | |
| 4 | + * Langage v2 « terminal éditorial » : lignes 2 px, grilles hairline pleines, | |
| 5 | + * étiquettes directes sur les marques, point courant rouge signal, infobulles | |
| 6 | + * crosshair au pointeur ET au toucher. SVG pur, aucun aléatoire : mêmes données, | |
| 7 | + * même dessin. Palette séries validée (CVD 28,6 / contraste ≥ 3:1) : | |
| 8 | + * encre #141814 · rouge #d8372e · gris #6b736d. | |
| 9 | 9 | */ |
| 10 | 10 | "use client"; |
| 11 | −import { useEffect, useState } from "react"; | |
| 11 | +import { useEffect, useRef, useState } from "react"; | |
| 12 | 12 | |
| 13 | 13 | /** Nombre localisé (« 1 206,6 » en FR, « 1,206.6 » en EN). */ |
| 14 | 14 | export function locNum( |
@@ -23,6 +23,14 @@ export function locNum( | ||
| 23 | 23 | return opts?.unit ? `${s} ${opts.unit}` : s; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | +const MONTHS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."]; | |
| 27 | +const MONTHS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 28 | +function fmtMonth(month: string, lang: string): string { | |
| 29 | + const [y, m] = month.split("-").map(Number); | |
| 30 | + const names = lang === "fr" ? MONTHS_FR : MONTHS_EN; | |
| 31 | + return `${names[(m ?? 1) - 1]} ${y}`; | |
| 32 | +} | |
| 33 | + | |
| 26 | 34 | /* ---------------------------- compteur animé ---------------------------- */ |
| 27 | 35 | /** Rendu serveur = valeur finale (jamais « 0 $ » à l'écran) ; anime 0 → valeur au montage. */ |
| 28 | 36 | export function CountUp({ |
@@ -72,38 +80,50 @@ export function ConfidenceDial({ | ||
| 72 | 80 | const CIRC = Math.PI * R; // demi-cercle |
| 73 | 81 | const frac = mounted ? Math.max(0.02, Math.min(1, pct / 100)) : 0.02; |
| 74 | 82 | const color = |
| 75 | − level === "A" ? "var(--lime)" : level === "B" ? "var(--green)" : level === "C" ? "var(--amber)" : "var(--danger)"; | |
| 83 | + level === "A" || level === "B" | |
| 84 | + ? "var(--ink)" | |
| 85 | + : level === "C" | |
| 86 | + ? "var(--amber)" | |
| 87 | + : "var(--danger)"; | |
| 76 | 88 | return ( |
| 77 | 89 | <div className="flex flex-col items-center" aria-label={`${label} : ${level} ${pct} %`}> |
| 78 | − <svg viewBox="0 0 128 78" className="w-[128px]"> | |
| 79 | − <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="var(--surface-2)" strokeWidth="12" strokeLinecap="round" /> | |
| 80 | − <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="rgba(20,24,20,0.85)" strokeWidth="13.5" strokeLinecap="round" opacity="0.12" /> | |
| 90 | + <svg viewBox="0 0 128 78" className="w-[124px]"> | |
| 91 | + <path d="M 12 66 A 52 52 0 0 1 116 66" fill="none" stroke="var(--line)" strokeWidth="8" strokeLinecap="butt" /> | |
| 81 | 92 | <path |
| 82 | 93 | d="M 12 66 A 52 52 0 0 1 116 66" |
| 83 | 94 | fill="none" |
| 84 | 95 | stroke={color} |
| 85 | − strokeWidth="12" | |
| 86 | − strokeLinecap="round" | |
| 96 | + strokeWidth="8" | |
| 97 | + strokeLinecap="butt" | |
| 87 | 98 | strokeDasharray={`${CIRC * frac} ${CIRC}`} |
| 88 | 99 | style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.8,.2,1)" }} |
| 89 | 100 | /> |
| 90 | 101 | {/* graduations */} |
| 91 | 102 | {[0, 0.25, 0.5, 0.75, 1].map((g) => { |
| 92 | 103 | const a = Math.PI * (1 - g); |
| 93 | − const x1 = 64 + Math.cos(a) * 40; | |
| 94 | − const y1 = 66 - Math.sin(a) * 40; | |
| 95 | − const x2 = 64 + Math.cos(a) * 45; | |
| 96 | − const y2 = 66 - Math.sin(a) * 45; | |
| 97 | − return <line key={g} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth="1.4" opacity="0.5" />; | |
| 104 | + const x1 = 64 + Math.cos(a) * 42; | |
| 105 | + const y1 = 66 - Math.sin(a) * 42; | |
| 106 | + const x2 = 64 + Math.cos(a) * 46; | |
| 107 | + const y2 = 66 - Math.sin(a) * 46; | |
| 108 | + return <line key={g} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth="1" opacity="0.4" />; | |
| 98 | 109 | })} |
| 99 | − <text x="64" y="52" textAnchor="middle" fontSize="26" fontWeight="700" fill="var(--ink)" fontFamily="var(--font-space-grotesk)"> | |
| 110 | + {/* pointe de lecture — rouge signal */} | |
| 111 | + {(() => { | |
| 112 | + const a = Math.PI * (1 - Math.max(0.02, Math.min(1, pct / 100))); | |
| 113 | + const x1 = 64 + Math.cos(a) * 44; | |
| 114 | + const y1 = 66 - Math.sin(a) * 44; | |
| 115 | + const x2 = 64 + Math.cos(a) * 60; | |
| 116 | + const y2 = 66 - Math.sin(a) * 60; | |
| 117 | + return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--accent)" strokeWidth="2" />; | |
| 118 | + })()} | |
| 119 | + <text x="64" y="52" textAnchor="middle" fontSize="27" fontWeight="700" fill="var(--ink)" fontFamily="var(--font-space-grotesk)"> | |
| 100 | 120 | {level} |
| 101 | 121 | </text> |
| 102 | − <text x="64" y="70" textAnchor="middle" fontSize="10.5" fontWeight="700" fill="var(--ink-2)" fontFamily="var(--font-jetbrains)"> | |
| 122 | + <text x="64" y="70" textAnchor="middle" fontSize="10.5" fontWeight="500" fill="var(--ink-2)" fontFamily="var(--font-jetbrains)"> | |
| 103 | 123 | {pct} % |
| 104 | 124 | </text> |
| 105 | 125 | </svg> |
| 106 | − <p className="klabel mt-1 text-center">{label}</p> | |
| 126 | + <p className="klabel mt-1 max-w-[150px] text-center">{label}</p> | |
| 107 | 127 | </div> |
| 108 | 128 | ); |
| 109 | 129 | } |
@@ -113,7 +133,7 @@ export function ConfidenceDial({ | ||
| 113 | 133 | function VacantGlyph({ lang }: { lang: string }) { |
| 114 | 134 | const tree = (x: number, sc: number) => ( |
| 115 | 135 | <g transform={`translate(${x}, 128) scale(${sc})`}> |
| 116 | − <path d="M0,-46 L13,-14 L5,-14 L16,8 L-16,8 L-5,-14 L-13,-14 Z" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 136 | + <path d="M0,-46 L13,-14 L5,-14 L16,8 L-16,8 L-5,-14 L-13,-14 Z" fill="none" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 117 | 137 | <rect x="-2.5" y="8" width="5" height="9" fill="var(--ink)" /> |
| 118 | 138 | </g> |
| 119 | 139 | ); |
@@ -125,9 +145,9 @@ function VacantGlyph({ lang }: { lang: string }) { | ||
| 125 | 145 | {/* borne d'arpenteur */} |
| 126 | 146 | <g transform="translate(24, 128)"> |
| 127 | 147 | <rect x="-2.5" y="-26" width="5" height="26" fill="var(--ink)" /> |
| 128 | − <rect x="-8" y="-34" width="16" height="10" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" /> | |
| 148 | + <rect x="-8" y="-34" width="16" height="10" fill="var(--accent)" stroke="var(--ink)" strokeWidth="1.4" /> | |
| 129 | 149 | </g> |
| 130 | − <line x1="8" y1="145" x2="182" y2="145" stroke="var(--ink)" strokeWidth="2.2" /> | |
| 150 | + <line x1="8" y1="145" x2="182" y2="145" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 131 | 151 | <text x="95" y="120" textAnchor="middle" fontSize="9" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2"> |
| 132 | 152 | {lang === "fr" ? "SANS BÂTIMENT" : "NO BUILDING"} |
| 133 | 153 | </text> |
@@ -178,9 +198,9 @@ export function BuildingGlyph({ | ||
| 178 | 198 | y={bodyTop + f * fh + 6} |
| 179 | 199 | width="12" |
| 180 | 200 | height="11" |
| 181 | − fill={idx < nD ? "var(--lime)" : "var(--surface-2)"} | |
| 201 | + fill={idx < nD ? "var(--accent)" : "var(--surface-2)"} | |
| 182 | 202 | stroke="var(--ink)" |
| 183 | − strokeWidth="1.2" | |
| 203 | + strokeWidth="1" | |
| 184 | 204 | className="vv-win" |
| 185 | 205 | style={{ animationDelay: `${idx * 60}ms` }} |
| 186 | 206 | /> |
@@ -191,33 +211,30 @@ export function BuildingGlyph({ | ||
| 191 | 211 | return ( |
| 192 | 212 | <svg viewBox={`0 0 ${W} 150`} className="w-full max-w-[210px]" aria-label="Silhouette du bâtiment selon le registre"> |
| 193 | 213 | {/* soleil-repère */} |
| 194 | − <circle cx={W - 22} cy="22" r="9" fill="none" stroke="var(--ink)" strokeWidth="1.5" /> | |
| 195 | − <circle cx={W - 22} cy="22" r="3.5" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1" /> | |
| 196 | − {/* ombre décalée signature */} | |
| 197 | − <rect x={bx + 5} y={bodyTop + 5} width={bw} height={nF * fh} fill="rgba(20,24,20,0.12)" /> | |
| 214 | + <circle cx={W - 22} cy="22" r="9" fill="none" stroke="var(--ink)" strokeWidth="1.2" /> | |
| 215 | + <circle cx={W - 22} cy="22" r="3" fill="var(--accent)" /> | |
| 198 | 216 | {/* corps */} |
| 199 | − <rect x={bx} y={bodyTop} width={bw} height={nF * fh} fill="var(--surface)" stroke="var(--ink)" strokeWidth="2" /> | |
| 217 | + <rect x={bx} y={bodyTop} width={bw} height={nF * fh} fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 200 | 218 | {/* séparations d'étages */} |
| 201 | 219 | {Array.from({ length: nF - 1 }, (_, i) => ( |
| 202 | − <line key={i} x1={bx} y1={bodyTop + (i + 1) * fh} x2={bx + bw} y2={bodyTop + (i + 1) * fh} stroke="var(--ink)" strokeWidth="1" strokeDasharray="3 3" opacity="0.4" /> | |
| 220 | + <line key={i} x1={bx} y1={bodyTop + (i + 1) * fh} x2={bx + bw} y2={bodyTop + (i + 1) * fh} stroke="var(--ink)" strokeWidth="0.8" opacity="0.35" /> | |
| 203 | 221 | ))} |
| 204 | 222 | {/* toit */} |
| 205 | 223 | {flat ? ( |
| 206 | − <rect x={bx - 6} y={bodyTop - 8} width={bw + 12} height="8" fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 224 | + <rect x={bx - 6} y={bodyTop - 8} width={bw + 12} height="8" fill="var(--ink)" /> | |
| 207 | 225 | ) : mansard ? ( |
| 208 | − <path d={`M ${bx - 6} ${bodyTop} L ${bx + 10} ${bodyTop - 20} L ${bx + bw - 10} ${bodyTop - 20} L ${bx + bw + 6} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 226 | + <path d={`M ${bx - 6} ${bodyTop} L ${bx + 10} ${bodyTop - 20} L ${bx + bw - 10} ${bodyTop - 20} L ${bx + bw + 6} ${bodyTop} Z`} fill="var(--ink)" /> | |
| 209 | 227 | ) : ( |
| 210 | − <path d={`M ${bx - 8} ${bodyTop} L ${bx + bw / 2} ${bodyTop - 24} L ${bx + bw + 8} ${bodyTop} Z`} fill="var(--green)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 228 | + <path d={`M ${bx - 8} ${bodyTop} L ${bx + bw / 2} ${bodyTop - 24} L ${bx + bw + 8} ${bodyTop} Z`} fill="var(--ink)" /> | |
| 211 | 229 | )} |
| 212 | 230 | {/* cheminée */} |
| 213 | − <rect x={bx + bw - 22} y={bodyTop - (flat ? 20 : 30)} width="9" height={flat ? 14 : 18} fill="var(--ink)" /> | |
| 231 | + <rect x={bx + bw - 22} y={bodyTop - (flat ? 20 : 30)} width="8" height={flat ? 14 : 18} fill="var(--ink)" /> | |
| 214 | 232 | {windows} |
| 215 | 233 | {/* porte */} |
| 216 | 234 | <rect x={bx + bw / 2 - 7} y={groundY - 17} width="14" height="17" fill="var(--ink)" /> |
| 217 | − <circle cx={bx + bw / 2 + 3.5} cy={groundY - 8} r="1.4" fill="var(--lime)" /> | |
| 235 | + <circle cx={bx + bw / 2 + 3.5} cy={groundY - 8} r="1.4" fill="var(--accent)" /> | |
| 218 | 236 | {/* sol */} |
| 219 | − <line x1="8" y1={groundY} x2={W - 8} y2={groundY} stroke="var(--ink)" strokeWidth="2.2" /> | |
| 220 | − <line x1="8" y1={groundY + 4} x2={W - 8} y2={groundY + 4} stroke="var(--ink)" strokeWidth="1" opacity="0.3" /> | |
| 237 | + <line x1="8" y1={groundY} x2={W - 8} y2={groundY} stroke="var(--ink)" strokeWidth="1.6" /> | |
| 221 | 238 | {/* millésime gravé */} |
| 222 | 239 | {yearBuilt && ( |
| 223 | 240 | <text x={W / 2} y="146" textAnchor="middle" fontSize="10" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="2"> |
@@ -246,7 +263,7 @@ export function LotDiagram({ | ||
| 246 | 263 | if (!areaM2 || areaM2 <= 0) { |
| 247 | 264 | return ( |
| 248 | 265 | <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label={lang === "fr" ? "Terrain non applicable" : "Lot not applicable"}> |
| 249 | − <rect x="30" y="30" width="130" height="86" fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.6" strokeDasharray="5 5" rx="6" /> | |
| 266 | + <rect x="30" y="30" width="130" height="86" fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.2" strokeDasharray="5 5" /> | |
| 250 | 267 | <text x="95" y="66" textAnchor="middle" fontSize="20" fill="var(--ink-3)">⌂</text> |
| 251 | 268 | <text x="95" y="90" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-2)" letterSpacing="1"> |
| 252 | 269 | {isCondo |
@@ -284,35 +301,34 @@ export function LotDiagram({ | ||
| 284 | 301 | <svg viewBox="0 0 190 150" className="w-full max-w-[210px]" aria-label="Schéma du terrain à l'échelle"> |
| 285 | 302 | <defs> |
| 286 | 303 | <pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse"> |
| 287 | − <line x1="0" y1="0" x2="0" y2="7" stroke="var(--green)" strokeWidth="1" opacity="0.35" /> | |
| 304 | + <line x1="0" y1="0" x2="0" y2="7" stroke="var(--ink)" strokeWidth="0.8" opacity="0.18" /> | |
| 288 | 305 | </pattern> |
| 289 | 306 | </defs> |
| 290 | − {/* ombre + parcelle */} | |
| 291 | − <rect x={x + 4} y={y + 4} width={w} height={h} fill="rgba(20,24,20,0.12)" /> | |
| 292 | − <rect x={x} y={y} width={w} height={h} fill="var(--lime-soft)" stroke="var(--ink)" strokeWidth="2" /> | |
| 307 | + {/* parcelle */} | |
| 308 | + <rect x={x} y={y} width={w} height={h} fill="var(--surface-2)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 293 | 309 | <rect x={x} y={y} width={w} height={h} fill="url(#hatch)" /> |
| 294 | 310 | {/* empreinte bâtiment */} |
| 295 | 311 | {fpFrac > 0.005 && ( |
| 296 | − <rect x={x + (w - fw) / 2} y={y + h - fh - h * 0.08} width={fw} height={fh} fill="var(--ink)" rx="2"> | |
| 312 | + <rect x={x + (w - fw) / 2} y={y + h - fh - h * 0.08} width={fw} height={fh} fill="var(--ink)"> | |
| 297 | 313 | <title>{lang === "fr" ? "Empreinte approximative du bâtiment" : "Approximate building footprint"}</title> |
| 298 | 314 | </rect> |
| 299 | 315 | )} |
| 300 | 316 | {/* cotes : frontage */} |
| 301 | − <line x1={x} y1={y + h + 12} x2={x + w} y2={y + h + 12} stroke="var(--ink)" strokeWidth="1.3" /> | |
| 302 | − <line x1={x} y1={y + h + 8} x2={x} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" /> | |
| 303 | − <line x1={x + w} y1={y + h + 8} x2={x + w} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1.3" /> | |
| 317 | + <line x1={x} y1={y + h + 12} x2={x + w} y2={y + h + 12} stroke="var(--ink)" strokeWidth="1" /> | |
| 318 | + <line x1={x} y1={y + h + 8} x2={x} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1" /> | |
| 319 | + <line x1={x + w} y1={y + h + 8} x2={x + w} y2={y + h + 16} stroke="var(--ink)" strokeWidth="1" /> | |
| 304 | 320 | <text x={x + w / 2} y={y + h + 24} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)"> |
| 305 | 321 | {frontageM ? fmtM(front) : `≈ ${fmtM(front)}`} |
| 306 | 322 | </text> |
| 307 | 323 | {/* cotes : profondeur */} |
| 308 | − <line x1={x - 10} y1={y} x2={x - 10} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" /> | |
| 309 | − <line x1={x - 14} y1={y} x2={x - 6} y2={y} stroke="var(--ink)" strokeWidth="1.3" /> | |
| 310 | − <line x1={x - 14} y1={y + h} x2={x - 6} y2={y + h} stroke="var(--ink)" strokeWidth="1.3" /> | |
| 324 | + <line x1={x - 10} y1={y} x2={x - 10} y2={y + h} stroke="var(--ink)" strokeWidth="1" /> | |
| 325 | + <line x1={x - 14} y1={y} x2={x - 6} y2={y} stroke="var(--ink)" strokeWidth="1" /> | |
| 326 | + <line x1={x - 14} y1={y + h} x2={x - 6} y2={y + h} stroke="var(--ink)" strokeWidth="1" /> | |
| 311 | 327 | <text x={x - 16} y={y + h / 2} textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)" transform={`rotate(-90 ${x - 16} ${y + h / 2})`}> |
| 312 | 328 | ≈ {fmtM(depth)} |
| 313 | 329 | </text> |
| 314 | 330 | {/* rue */} |
| 315 | − <line x1="8" y1="136" x2="182" y2="136" stroke="var(--ink)" strokeWidth="2.2" /> | |
| 331 | + <line x1="8" y1="136" x2="182" y2="136" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 316 | 332 | <text x="95" y="147" textAnchor="middle" fontSize="8.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)" letterSpacing="3"> |
| 317 | 333 | {lang === "fr" ? "RUE" : "STREET"} |
| 318 | 334 | </text> |
@@ -351,42 +367,43 @@ export function ValueSplit({ | ||
| 351 | 367 | const delta = previous && previous > 0 && total ? ((total / previous - 1) * 100) : null; |
| 352 | 368 | return ( |
| 353 | 369 | <div> |
| 354 | − <div className="flex h-9 w-full overflow-hidden rounded-md border-[1.5px] border-ink"> | |
| 370 | + {/* segments séparés par un jour de 2 px (le fond fait la séparation) */} | |
| 371 | + <div className="flex h-7 w-full gap-[2px]"> | |
| 355 | 372 | {lPct >= 1 && ( |
| 356 | 373 | <div |
| 357 | − className="flex items-center justify-center overflow-hidden bg-green transition-[width] duration-1000 ease-out" | |
| 374 | + className="flex items-center justify-center overflow-hidden bg-[var(--chart-3)] transition-[width] duration-1000 ease-out" | |
| 358 | 375 | style={{ width: on ? `${lPct}%` : "2%" }} |
| 359 | 376 | > |
| 360 | 377 | {lPct >= 14 && ( |
| 361 | − <span className="vp-mono px-1 text-[10px] font-bold text-paper">{Math.round(lPct)} %</span> | |
| 378 | + <span className="vp-mono px-1 text-[10px] font-bold text-white">{Math.round(lPct)} %</span> | |
| 362 | 379 | )} |
| 363 | 380 | </div> |
| 364 | 381 | )} |
| 365 | 382 | {lPct <= 99 && ( |
| 366 | 383 | <div className="flex flex-1 items-center justify-center overflow-hidden bg-ink"> |
| 367 | 384 | {100 - lPct >= 14 && ( |
| 368 | − <span className="vp-mono px-1 text-[10px] font-bold text-lime">{Math.round(100 - lPct)} %</span> | |
| 385 | + <span className="vp-mono px-1 text-[10px] font-bold text-paper">{Math.round(100 - lPct)} %</span> | |
| 369 | 386 | )} |
| 370 | 387 | </div> |
| 371 | 388 | )} |
| 372 | 389 | </div> |
| 373 | 390 | <div className="vp-mono mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10.5px] uppercase tracking-[0.05em]"> |
| 374 | 391 | <span className="flex items-center gap-1.5 text-ink-2"> |
| 375 | − <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-green" /> | |
| 392 | + <span className="inline-block h-2.5 w-2.5 bg-[var(--chart-3)]" /> | |
| 376 | 393 | {labels.land} {fmt(l)} |
| 377 | 394 | </span> |
| 378 | 395 | <span className="flex items-center gap-1.5 text-ink-2"> |
| 379 | − <span className="inline-block h-2.5 w-2.5 rounded-[2px] border border-ink bg-ink" /> | |
| 396 | + <span className="inline-block h-2.5 w-2.5 bg-ink" /> | |
| 380 | 397 | {labels.building} {fmt(b)} |
| 381 | 398 | </span> |
| 382 | 399 | </div> |
| 383 | 400 | {delta != null && ( |
| 384 | 401 | <p className="vp-mono mt-2.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3"> |
| 385 | 402 | {labels.previous} {fmt(previous!)}{" "} |
| 386 | − <span className={`ml-1 rounded-[4px] border border-ink px-1.5 py-0.5 font-bold ${delta >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}> | |
| 403 | + <b className={delta >= 0 ? "ml-1 text-ink" : "ml-1 text-[var(--danger)]"}> | |
| 387 | 404 | {delta >= 0 ? "+" : ""} |
| 388 | 405 | {delta.toFixed(1)} % |
| 389 | − </span> | |
| 406 | + </b> | |
| 390 | 407 | </p> |
| 391 | 408 | )} |
| 392 | 409 | </div> |
@@ -412,20 +429,15 @@ export function Sparkline({ | ||
| 412 | 429 | height - 8 - ((p.value - min) / span) * (height - 18), |
| 413 | 430 | ]); |
| 414 | 431 | const line = xy.map(([x, y]) => `${x},${y}`).join(" "); |
| 415 | − const area = `${xy[0][0]},${height - 4} ${line} ${xy[xy.length - 1][0]},${height - 4}`; | |
| 416 | 432 | const growth = ((pts[pts.length - 1].value / pts[0].value - 1) * 100); |
| 417 | 433 | const [x2, y2] = xy[xy.length - 1]; |
| 418 | 434 | return ( |
| 419 | 435 | <div className="flex items-end gap-2"> |
| 420 | 436 | <svg viewBox={`0 0 ${W} ${height}`} width={W} height={height} aria-label={`Évolution ${pts[0].year}-${pts[pts.length - 1].year}`}> |
| 421 | − <polygon points={area} fill="var(--lime-soft)" /> | |
| 422 | − <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" /> | |
| 423 | − {xy.slice(0, -1).map(([x, y], i) => ( | |
| 424 | − <circle key={i} cx={x} cy={y} r="2" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.1" /> | |
| 425 | − ))} | |
| 426 | − <circle cx={x2} cy={y2} r="3.6" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.5" /> | |
| 437 | + <polyline points={line} fill="none" stroke="var(--ink)" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" /> | |
| 438 | + <circle cx={x2} cy={y2} r="3.6" fill="var(--accent)" stroke="var(--paper)" strokeWidth="2" /> | |
| 427 | 439 | </svg> |
| 428 | − <span className={`vp-mono mb-0.5 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${growth >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}> | |
| 440 | + <span className={`vp-mono mb-0.5 text-[10.5px] font-bold ${growth >= 0 ? "text-ink" : "text-[var(--danger)]"}`}> | |
| 429 | 441 | {growth >= 0 ? "+" : ""} |
| 430 | 442 | {growth.toFixed(0)} % |
| 431 | 443 | </span> |
@@ -434,7 +446,7 @@ export function Sparkline({ | ||
| 434 | 446 | } |
| 435 | 447 | |
| 436 | 448 | /* ------------------------- fourchette visuelle P10-P90 ------------------------- */ |
| 437 | −/** Barre de fourchette : segment P10→P90, losange = estimation, tiret = rôle. */ | |
| 449 | +/** Barre de fourchette : segment P10→P90, losange rouge = estimation, tiret = rôle. */ | |
| 438 | 450 | export function RangeBar({ |
| 439 | 451 | low, |
| 440 | 452 | estimate, |
@@ -460,7 +472,7 @@ export function RangeBar({ | ||
| 460 | 472 | const min = lo - pad; |
| 461 | 473 | const max = hi + pad; |
| 462 | 474 | const X = (v: number) => Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100)); |
| 463 | − // les étiquettes (centrées, nowrap) restent dans la carte même aux extrêmes | |
| 475 | + // les étiquettes (centrées, nowrap) restent dans la composition même aux extrêmes | |
| 464 | 476 | const clampX = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, X(v))); |
| 465 | 477 | const fmt = (v: number) => |
| 466 | 478 | new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { |
@@ -477,46 +489,47 @@ export function RangeBar({ | ||
| 477 | 489 | Math.abs(X(role) - X(high)) < 15); |
| 478 | 490 | return ( |
| 479 | 491 | <div aria-label={`${fmt(low)} – ${fmt(high)}`}> |
| 480 | − <div className="relative h-[64px]"> | |
| 492 | + <div className="relative h-[62px]"> | |
| 481 | 493 | {/* étiquette estimation */} |
| 482 | 494 | <div |
| 483 | 495 | className="absolute top-0 -translate-x-1/2 whitespace-nowrap transition-[left] duration-700 ease-out" |
| 484 | 496 | style={{ left: `${on ? clampX(estimate, 18, 82) : 50}%` }} |
| 485 | 497 | > |
| 486 | − <span className="vp-mono rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime"> | |
| 498 | + <span className="vp-mono bg-ink px-1.5 py-0.5 text-[10px] font-bold text-paper"> | |
| 487 | 499 | {lang === "fr" ? "ESTIMATION" : "ESTIMATE"} {fmt(estimate)} |
| 488 | 500 | </span> |
| 489 | 501 | </div> |
| 490 | − {/* piste */} | |
| 491 | − <div className="absolute left-0 right-0 top-[30px] h-[14px] rounded-[7px] border-[1.5px] border-ink bg-surface-2" /> | |
| 492 | − {/* segment P10-P90 */} | |
| 502 | + {/* piste hairline */} | |
| 503 | + <div className="absolute left-0 right-0 top-[35px] h-px bg-[var(--line-strong)] opacity-40" /> | |
| 504 | + {/* segment P10-P90 — encre pleine, fine */} | |
| 493 | 505 | <div |
| 494 | − className="absolute top-[30px] h-[14px] origin-left rounded-[7px] border-[1.5px] border-ink transition-transform duration-1000 ease-out" | |
| 506 | + className="absolute top-[33px] h-[5px] origin-left bg-ink transition-transform duration-1000 ease-out" | |
| 495 | 507 | style={{ |
| 496 | 508 | left: `${X(low)}%`, |
| 497 | 509 | width: `${Math.max(2, X(high) - X(low))}%`, |
| 498 | 510 | transform: on ? "scaleX(1)" : "scaleX(0.05)", |
| 499 | − background: | |
| 500 | − "repeating-linear-gradient(-45deg, var(--lime-soft), var(--lime-soft) 5px, rgba(255,81,72,0.35) 5px, rgba(255,81,72,0.35) 7px)", | |
| 501 | 511 | }} |
| 502 | 512 | /> |
| 503 | − {/* marqueur estimation (losange) */} | |
| 513 | + {/* bornes : ticks */} | |
| 514 | + <div className="absolute top-[28px] h-[15px] w-px bg-ink" style={{ left: `${X(low)}%` }} /> | |
| 515 | + <div className="absolute top-[28px] h-[15px] w-px bg-ink" style={{ left: `${X(high)}%` }} /> | |
| 516 | + {/* marqueur estimation (losange rouge signal) */} | |
| 504 | 517 | <div |
| 505 | − className="absolute top-[29px] -translate-x-1/2 transition-[left] duration-700 ease-out" | |
| 518 | + className="absolute top-[35px] -translate-x-1/2 -translate-y-1/2 transition-[left] duration-700 ease-out" | |
| 506 | 519 | style={{ left: `${on ? X(estimate) : 50}%` }} |
| 507 | 520 | > |
| 508 | − <div className="h-[16px] w-[16px] rotate-45 border-[1.8px] border-ink bg-lime" /> | |
| 521 | + <div className="h-[13px] w-[13px] rotate-45 border-2 border-[var(--paper)] bg-[var(--accent)]" /> | |
| 509 | 522 | </div> |
| 510 | 523 | {/* marqueur rôle */} |
| 511 | 524 | {role != null && role > 0 && ( |
| 512 | 525 | <> |
| 513 | 526 | <div |
| 514 | − className="absolute top-[22px] h-[30px] w-0 -translate-x-1/2 border-l-[2px] border-dashed border-ink opacity-70" | |
| 527 | + className="absolute top-[24px] h-[23px] w-0 -translate-x-1/2 border-l border-dashed border-ink opacity-70" | |
| 515 | 528 | style={{ left: `${X(role)}%` }} |
| 516 | 529 | /> |
| 517 | 530 | {!roleClose && ( |
| 518 | 531 | <span |
| 519 | − className="vp-mono absolute top-[52px] -translate-x-1/2 whitespace-nowrap text-[9px] font-bold uppercase tracking-[0.06em] text-ink-2" | |
| 532 | + className="vp-mono absolute top-[49px] -translate-x-1/2 whitespace-nowrap text-[9px] font-medium uppercase tracking-[0.06em] text-ink-2" | |
| 520 | 533 | style={{ left: `${clampX(role, 11, 89)}%` }} |
| 521 | 534 | > |
| 522 | 535 | {lang === "fr" ? "Rôle" : "Roll"} {fmt(role)} |
@@ -526,13 +539,13 @@ export function RangeBar({ | ||
| 526 | 539 | )} |
| 527 | 540 | {/* bornes */} |
| 528 | 541 | <span |
| 529 | − className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2" | |
| 542 | + className="vp-mono absolute top-[49px] -translate-x-1/2 whitespace-nowrap text-[10px] font-medium text-ink-2" | |
| 530 | 543 | style={{ left: `${clampX(low, 11, 89)}%` }} |
| 531 | 544 | > |
| 532 | 545 | P10 {fmt(low)} |
| 533 | 546 | </span> |
| 534 | 547 | <span |
| 535 | − className="vp-mono absolute top-[50px] -translate-x-1/2 whitespace-nowrap text-[10px] font-bold text-ink-2" | |
| 548 | + className="vp-mono absolute top-[49px] -translate-x-1/2 whitespace-nowrap text-[10px] font-medium text-ink-2" | |
| 536 | 549 | style={{ left: `${clampX(high, 11, 89)}%` }} |
| 537 | 550 | > |
| 538 | 551 | P90 {fmt(high)} |
@@ -542,6 +555,213 @@ export function RangeBar({ | ||
| 542 | 555 | ); |
| 543 | 556 | } |
| 544 | 557 | |
| 558 | +/* ----------------------- pouls du marché (multi-séries) ----------------------- */ | |
| 559 | +export interface PulseChartSeries { | |
| 560 | + key: string; | |
| 561 | + label: string; | |
| 562 | + color: string; // var(--chart-1|2|3) | |
| 563 | + series: { month: string; idx: number }[]; | |
| 564 | +} | |
| 565 | + | |
| 566 | +/** | |
| 567 | + * Comparaison des grands types sur une même échelle (base 100 au début de la | |
| 568 | + * fenêtre). Étiquettes directes en fin de ligne + légende + crosshair tactile : | |
| 569 | + * l'identité ne repose jamais sur la couleur seule. | |
| 570 | + */ | |
| 571 | +export function MarketPulseChart({ | |
| 572 | + data, | |
| 573 | + lang, | |
| 574 | + months, | |
| 575 | +}: { | |
| 576 | + data: PulseChartSeries[]; | |
| 577 | + lang: string; | |
| 578 | + /** Fenêtre en mois (null = tout l'historique). */ | |
| 579 | + months: number | null; | |
| 580 | +}) { | |
| 581 | + const wrap = useRef<HTMLDivElement>(null); | |
| 582 | + const [hi, setHi] = useState<number | null>(null); | |
| 583 | + | |
| 584 | + // fenêtre commune : mois de la série la plus longue, tronquée à `months` | |
| 585 | + const longest = data.reduce((a, b) => (b.series.length > a.series.length ? b : a), data[0]); | |
| 586 | + if (!longest || longest.series.length < 2) return null; | |
| 587 | + const windowMonths = (months ? longest.series.slice(-(months + 1)) : longest.series).map((p) => p.month); | |
| 588 | + const n = windowMonths.length; | |
| 589 | + if (n < 2) return null; | |
| 590 | + | |
| 591 | + // valeurs rebasées à 100 au premier mois de la fenêtre, par série | |
| 592 | + const rebased = data.map((s) => { | |
| 593 | + const byMonth = new Map(s.series.map((p) => [p.month, p.idx])); | |
| 594 | + const first = windowMonths.find((m) => byMonth.has(m)); | |
| 595 | + const base = first != null ? byMonth.get(first)! : null; | |
| 596 | + return { | |
| 597 | + ...s, | |
| 598 | + vals: windowMonths.map((m) => { | |
| 599 | + const v = byMonth.get(m); | |
| 600 | + return v != null && base ? (v / base) * 100 : null; | |
| 601 | + }), | |
| 602 | + }; | |
| 603 | + }); | |
| 604 | + | |
| 605 | + const W = 720; | |
| 606 | + const H = 264; | |
| 607 | + const padL = 34; | |
| 608 | + const padR = 8; | |
| 609 | + const padT = 14; | |
| 610 | + const padB = 40; // réserve l'espace des étiquettes directes sous la ligne de base | |
| 611 | + const all = rebased.flatMap((s) => s.vals).filter((v): v is number => v != null); | |
| 612 | + const min = Math.min(...all); | |
| 613 | + const max = Math.max(...all); | |
| 614 | + const span = max - min || 1; | |
| 615 | + const x = (i: number) => padL + (i / (n - 1)) * (W - padL - padR); | |
| 616 | + const y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB); | |
| 617 | + | |
| 618 | + // grille : 100 + min/max arrondis à 5 | |
| 619 | + const gridVals = [...new Set([Math.ceil(min / 5) * 5, 100, Math.floor(max / 5) * 5])].filter( | |
| 620 | + (v) => v >= min - 0.5 && v <= max + 0.5 | |
| 621 | + ); | |
| 622 | + // graduations x : janvier de chaque année (ou premier/dernier si fenêtre courte) | |
| 623 | + const yearTicks = windowMonths | |
| 624 | + .map((m, i) => ({ i, m })) | |
| 625 | + .filter((p) => p.m.endsWith("-01")); | |
| 626 | + | |
| 627 | + // étiquettes directes en fin de ligne, anticollision (≥ 15 px d'écart) | |
| 628 | + const ends = rebased | |
| 629 | + .map((s) => { | |
| 630 | + const lastIdx = s.vals.reduce<number>((acc, v, i) => (v != null ? i : acc), -1); | |
| 631 | + return lastIdx >= 0 ? { s, i: lastIdx, v: s.vals[lastIdx]! } : null; | |
| 632 | + }) | |
| 633 | + .filter((e): e is { s: (typeof rebased)[number]; i: number; v: number } => e != null) | |
| 634 | + .sort((a, b) => y(a.v) - y(b.v)); | |
| 635 | + const labelYs: number[] = []; | |
| 636 | + for (const e of ends) { | |
| 637 | + let ly = y(e.v); | |
| 638 | + const prev = labelYs[labelYs.length - 1]; | |
| 639 | + if (prev != null && ly - prev < 15) ly = prev + 15; | |
| 640 | + labelYs.push(ly); | |
| 641 | + } | |
| 642 | + | |
| 643 | + const onMove = (clientX: number) => { | |
| 644 | + const el = wrap.current; | |
| 645 | + if (!el) return; | |
| 646 | + const r = el.getBoundingClientRect(); | |
| 647 | + const fx = ((clientX - r.left) / r.width) * W; | |
| 648 | + const i = Math.round(((fx - padL) / (W - padL - padR)) * (n - 1)); | |
| 649 | + setHi(Math.max(0, Math.min(n - 1, i))); | |
| 650 | + }; | |
| 651 | + | |
| 652 | + const fmtIdx = (v: number | null) => | |
| 653 | + v == null ? "—" : v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 }); | |
| 654 | + | |
| 655 | + return ( | |
| 656 | + <div className="relative" ref={wrap}> | |
| 657 | + <svg | |
| 658 | + viewBox={`0 0 ${W} ${H}`} | |
| 659 | + className="h-auto w-full touch-pan-y select-none" | |
| 660 | + role="img" | |
| 661 | + aria-label={ | |
| 662 | + lang === "fr" | |
| 663 | + ? `Indice de marché par type, ${windowMonths[0]} à ${windowMonths[n - 1]}` | |
| 664 | + : `Market index by type, ${windowMonths[0]} to ${windowMonths[n - 1]}` | |
| 665 | + } | |
| 666 | + onPointerDown={(e) => onMove(e.clientX)} | |
| 667 | + onPointerMove={(e) => onMove(e.clientX)} | |
| 668 | + onPointerLeave={() => setHi(null)} | |
| 669 | + > | |
| 670 | + {/* grille hairline pleine */} | |
| 671 | + {gridVals.map((v) => ( | |
| 672 | + <g key={v}> | |
| 673 | + <line x1={padL} y1={y(v)} x2={W - padR} y2={y(v)} stroke="var(--ink)" strokeWidth="0.7" opacity={v === 100 ? 0.35 : 0.12} /> | |
| 674 | + <text x={padL - 6} y={y(v) + 3.5} textAnchor="end" fontSize="10" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)"> | |
| 675 | + {v} | |
| 676 | + </text> | |
| 677 | + </g> | |
| 678 | + ))} | |
| 679 | + {yearTicks.map((p) => ( | |
| 680 | + <text key={p.m} x={x(p.i)} y={H - padB + 16} textAnchor="middle" fontSize="10" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)"> | |
| 681 | + {p.m.slice(0, 4)} | |
| 682 | + </text> | |
| 683 | + ))} | |
| 684 | + {/* crosshair */} | |
| 685 | + {hi != null && ( | |
| 686 | + <line x1={x(hi)} y1={padT} x2={x(hi)} y2={H - padB} stroke="var(--ink)" strokeWidth="0.8" opacity="0.4" /> | |
| 687 | + )} | |
| 688 | + {/* lignes */} | |
| 689 | + {rebased.map((s) => { | |
| 690 | + const pts = s.vals | |
| 691 | + .map((v, i) => (v != null ? `${x(i).toFixed(1)},${y(v).toFixed(1)}` : null)) | |
| 692 | + .filter(Boolean) | |
| 693 | + .join(" "); | |
| 694 | + return ( | |
| 695 | + <polyline | |
| 696 | + key={s.key} | |
| 697 | + points={pts} | |
| 698 | + fill="none" | |
| 699 | + stroke={s.color} | |
| 700 | + strokeWidth="2" | |
| 701 | + strokeLinejoin="round" | |
| 702 | + strokeLinecap="round" | |
| 703 | + /> | |
| 704 | + ); | |
| 705 | + })} | |
| 706 | + {/* points survolés (anneau papier) */} | |
| 707 | + {hi != null && | |
| 708 | + rebased.map((s) => | |
| 709 | + s.vals[hi] != null ? ( | |
| 710 | + <circle key={s.key} cx={x(hi)} cy={y(s.vals[hi]!)} r="4" fill={s.color} stroke="var(--paper)" strokeWidth="2" /> | |
| 711 | + ) : null | |
| 712 | + )} | |
| 713 | + {/* fins de ligne : point + étiquette directe (série + valeur). | |
| 714 | + Étiquettes masquées en mobile (SVG trop réduit) : la légende et les | |
| 715 | + colonnes de lecture sous le graphique portent alors l'identité. */} | |
| 716 | + {ends.map((e, k) => ( | |
| 717 | + <g key={e.s.key}> | |
| 718 | + <circle cx={x(e.i)} cy={y(e.v)} r="3.5" fill={e.s.color} stroke="var(--paper)" strokeWidth="2" /> | |
| 719 | + <g className="hidden sm:block"> | |
| 720 | + {labelYs[k] !== y(e.v) && ( | |
| 721 | + <line x1={x(e.i)} y1={y(e.v)} x2={x(e.i) - 4} y2={labelYs[k]} stroke={e.s.color} strokeWidth="0.8" opacity="0.6" /> | |
| 722 | + )} | |
| 723 | + <text | |
| 724 | + x={x(e.i) - 8} | |
| 725 | + y={labelYs[k] + 3.5} | |
| 726 | + textAnchor="end" | |
| 727 | + fontSize="10.5" | |
| 728 | + fontWeight="700" | |
| 729 | + fontFamily="var(--font-jetbrains)" | |
| 730 | + fill="var(--ink)" | |
| 731 | + style={{ paintOrder: "stroke", stroke: "var(--paper)", strokeWidth: 3 }} | |
| 732 | + > | |
| 733 | + {e.s.label} · {Math.round(e.v)} | |
| 734 | + </text> | |
| 735 | + </g> | |
| 736 | + </g> | |
| 737 | + ))} | |
| 738 | + </svg> | |
| 739 | + {/* infobulle : toutes les séries au mois visé */} | |
| 740 | + {hi != null && ( | |
| 741 | + <div | |
| 742 | + className="vv-tip" | |
| 743 | + style={{ | |
| 744 | + left: `${Math.min(78, Math.max(2, (x(hi) / W) * 100))}%`, | |
| 745 | + top: 0, | |
| 746 | + transform: "translateX(-50%)", | |
| 747 | + }} | |
| 748 | + > | |
| 749 | + <p className="mb-1 uppercase tracking-[0.08em] opacity-70">{fmtMonth(windowMonths[hi], lang)}</p> | |
| 750 | + {rebased.map((s) => ( | |
| 751 | + <p key={s.key} className="row"> | |
| 752 | + <span> | |
| 753 | + <span className="key" style={{ background: s.color === "var(--chart-1)" ? "var(--paper)" : s.color }} /> | |
| 754 | + {s.label} | |
| 755 | + </span> | |
| 756 | + <span className="val">{fmtIdx(s.vals[hi])}</span> | |
| 757 | + </p> | |
| 758 | + ))} | |
| 759 | + </div> | |
| 760 | + )} | |
| 761 | + </div> | |
| 762 | + ); | |
| 763 | +} | |
| 764 | + | |
| 545 | 765 | /* --------------------------- courbe d'indice de marché --------------------------- */ |
| 546 | 766 | /** Indice mensuel rebasé à 100 au premier mois — la trajectoire du marché local. */ |
| 547 | 767 | export function MarketIndexChart({ |
@@ -551,15 +771,17 @@ export function MarketIndexChart({ | ||
| 551 | 771 | series: { month: string; idx: number }[]; |
| 552 | 772 | lang: string; |
| 553 | 773 | }) { |
| 774 | + const wrap = useRef<HTMLDivElement>(null); | |
| 775 | + const [hi, setHi] = useState<number | null>(null); | |
| 554 | 776 | if (series.length < 6) return null; |
| 555 | 777 | const base = series[0].idx || 1; |
| 556 | 778 | const pts = series.map((p) => (p.idx / base) * 100); |
| 557 | 779 | const W = 640; |
| 558 | 780 | const H = 200; |
| 559 | − const padL = 40; | |
| 560 | − const padR = 52; | |
| 561 | − const padT = 18; | |
| 562 | − const padB = 28; | |
| 781 | + const padL = 36; | |
| 782 | + const padR = 46; | |
| 783 | + const padT = 14; | |
| 784 | + const padB = 26; | |
| 563 | 785 | const min = Math.min(...pts); |
| 564 | 786 | const max = Math.max(...pts); |
| 565 | 787 | const span = max - min || 1; |
@@ -571,98 +793,124 @@ export function MarketIndexChart({ | ||
| 571 | 793 | const years = series |
| 572 | 794 | .map((p, i) => ({ i, month: p.month })) |
| 573 | 795 | .filter((p) => p.month.endsWith("-01")); |
| 574 | − // lignes horizontales : min / 100 / max arrondis | |
| 575 | 796 | const gridVals = [...new Set([Math.round(min), 100, Math.round(max)])].filter( |
| 576 | 797 | (v) => v >= min - 1 && v <= max + 1 |
| 577 | 798 | ); |
| 578 | 799 | const lastV = pts[pts.length - 1]; |
| 800 | + | |
| 801 | + const onMove = (clientX: number) => { | |
| 802 | + const el = wrap.current; | |
| 803 | + if (!el) return; | |
| 804 | + const r = el.getBoundingClientRect(); | |
| 805 | + const fx = ((clientX - r.left) / r.width) * W; | |
| 806 | + const i = Math.round(((fx - padL) / (W - padL - padR)) * (pts.length - 1)); | |
| 807 | + setHi(Math.max(0, Math.min(pts.length - 1, i))); | |
| 808 | + }; | |
| 809 | + | |
| 579 | 810 | return ( |
| 580 | − <svg | |
| 581 | − viewBox={`0 0 ${W} ${H}`} | |
| 582 | − className="h-auto w-full" | |
| 583 | − role="img" | |
| 584 | − aria-label={ | |
| 585 | − lang === "fr" | |
| 586 | − ? `Indice de marché ${series[0].month} à ${series[series.length - 1].month}` | |
| 587 | − : `Market index ${series[0].month} to ${series[series.length - 1].month}` | |
| 588 | − } | |
| 589 | − > | |
| 590 | − {gridVals.map((v) => ( | |
| 591 | − <g key={v}> | |
| 592 | − <line | |
| 593 | − x1={padL} | |
| 594 | − y1={y(v)} | |
| 595 | − x2={W - padR} | |
| 596 | − y2={y(v)} | |
| 597 | − stroke="var(--ink)" | |
| 598 | − strokeWidth="1" | |
| 599 | − strokeDasharray="3 5" | |
| 600 | − opacity={v === 100 ? 0.45 : 0.18} | |
| 601 | − /> | |
| 602 | − <text | |
| 603 | − x={padL - 6} | |
| 604 | − y={y(v) + 3.5} | |
| 605 | − textAnchor="end" | |
| 606 | − fontSize="10" | |
| 607 | − fontFamily="var(--font-jetbrains)" | |
| 608 | − fill="var(--ink-3)" | |
| 609 | − > | |
| 610 | − {v} | |
| 611 | − </text> | |
| 612 | − </g> | |
| 613 | − ))} | |
| 614 | − {years.map((p) => ( | |
| 615 | − <g key={p.month}> | |
| 616 | − <line | |
| 617 | − x1={x(p.i)} | |
| 618 | − y1={padT} | |
| 619 | − x2={x(p.i)} | |
| 620 | − y2={H - padB} | |
| 621 | − stroke="var(--ink)" | |
| 622 | − strokeWidth="1" | |
| 623 | − opacity="0.1" | |
| 624 | − /> | |
| 811 | + <div className="relative" ref={wrap}> | |
| 812 | + <svg | |
| 813 | + viewBox={`0 0 ${W} ${H}`} | |
| 814 | + className="h-auto w-full touch-pan-y select-none" | |
| 815 | + role="img" | |
| 816 | + aria-label={ | |
| 817 | + lang === "fr" | |
| 818 | + ? `Indice de marché ${series[0].month} à ${series[series.length - 1].month}` | |
| 819 | + : `Market index ${series[0].month} to ${series[series.length - 1].month}` | |
| 820 | + } | |
| 821 | + onPointerDown={(e) => onMove(e.clientX)} | |
| 822 | + onPointerMove={(e) => onMove(e.clientX)} | |
| 823 | + onPointerLeave={() => setHi(null)} | |
| 824 | + > | |
| 825 | + {gridVals.map((v) => ( | |
| 826 | + <g key={v}> | |
| 827 | + <line | |
| 828 | + x1={padL} | |
| 829 | + y1={y(v)} | |
| 830 | + x2={W - padR} | |
| 831 | + y2={y(v)} | |
| 832 | + stroke="var(--ink)" | |
| 833 | + strokeWidth="0.7" | |
| 834 | + opacity={v === 100 ? 0.35 : 0.12} | |
| 835 | + /> | |
| 836 | + <text | |
| 837 | + x={padL - 6} | |
| 838 | + y={y(v) + 3.5} | |
| 839 | + textAnchor="end" | |
| 840 | + fontSize="10" | |
| 841 | + fontFamily="var(--font-jetbrains)" | |
| 842 | + fill="var(--ink-3)" | |
| 843 | + > | |
| 844 | + {v} | |
| 845 | + </text> | |
| 846 | + </g> | |
| 847 | + ))} | |
| 848 | + {years.map((p) => ( | |
| 625 | 849 | <text |
| 850 | + key={p.month} | |
| 626 | 851 | x={x(p.i)} |
| 627 | − y={H - 10} | |
| 852 | + y={H - 8} | |
| 628 | 853 | textAnchor="middle" |
| 629 | 854 | fontSize="10" |
| 630 | − fontWeight="700" | |
| 631 | 855 | fontFamily="var(--font-jetbrains)" |
| 632 | − fill="var(--ink-2)" | |
| 856 | + fill="var(--ink-3)" | |
| 633 | 857 | > |
| 634 | 858 | {p.month.slice(0, 4)} |
| 635 | 859 | </text> |
| 636 | − </g> | |
| 637 | − ))} | |
| 638 | − <polygon points={area} fill="var(--lime-soft)" opacity="0.8" /> | |
| 639 | − <polyline | |
| 640 | − points={line} | |
| 641 | − fill="none" | |
| 642 | − stroke="var(--green)" | |
| 643 | − strokeWidth="2.6" | |
| 644 | − strokeLinejoin="round" | |
| 645 | − strokeLinecap="round" | |
| 646 | − /> | |
| 647 | − <circle | |
| 648 | − cx={x(pts.length - 1)} | |
| 649 | − cy={y(lastV)} | |
| 650 | − r="5" | |
| 651 | − fill="var(--lime)" | |
| 652 | − stroke="var(--ink)" | |
| 653 | − strokeWidth="1.8" | |
| 654 | − /> | |
| 655 | − <text | |
| 656 | − x={x(pts.length - 1) + 9} | |
| 657 | − y={y(lastV) + 4} | |
| 658 | − fontSize="11.5" | |
| 659 | − fontWeight="700" | |
| 660 | − fontFamily="var(--font-jetbrains)" | |
| 661 | − fill="var(--ink)" | |
| 662 | − > | |
| 663 | − {Math.round(lastV)} | |
| 664 | − </text> | |
| 665 | − </svg> | |
| 860 | + ))} | |
| 861 | + <polygon points={area} fill="var(--ink)" opacity="0.05" /> | |
| 862 | + {hi != null && ( | |
| 863 | + <line x1={x(hi)} y1={padT} x2={x(hi)} y2={H - padB} stroke="var(--ink)" strokeWidth="0.8" opacity="0.4" /> | |
| 864 | + )} | |
| 865 | + <polyline | |
| 866 | + points={line} | |
| 867 | + fill="none" | |
| 868 | + stroke="var(--ink)" | |
| 869 | + strokeWidth="2" | |
| 870 | + strokeLinejoin="round" | |
| 871 | + strokeLinecap="round" | |
| 872 | + /> | |
| 873 | + {hi != null && ( | |
| 874 | + <circle cx={x(hi)} cy={y(pts[hi])} r="4" fill="var(--ink)" stroke="var(--paper)" strokeWidth="2" /> | |
| 875 | + )} | |
| 876 | + <circle | |
| 877 | + cx={x(pts.length - 1)} | |
| 878 | + cy={y(lastV)} | |
| 879 | + r="4" | |
| 880 | + fill="var(--accent)" | |
| 881 | + stroke="var(--paper)" | |
| 882 | + strokeWidth="2" | |
| 883 | + /> | |
| 884 | + <text | |
| 885 | + x={x(pts.length - 1) + 8} | |
| 886 | + y={y(lastV) + 4} | |
| 887 | + fontSize="11.5" | |
| 888 | + fontWeight="700" | |
| 889 | + fontFamily="var(--font-jetbrains)" | |
| 890 | + fill="var(--ink)" | |
| 891 | + > | |
| 892 | + {Math.round(lastV)} | |
| 893 | + </text> | |
| 894 | + </svg> | |
| 895 | + {hi != null && ( | |
| 896 | + <div | |
| 897 | + className="vv-tip" | |
| 898 | + style={{ | |
| 899 | + left: `${Math.min(74, Math.max(4, (x(hi) / W) * 100))}%`, | |
| 900 | + top: 0, | |
| 901 | + transform: "translateX(-50%)", | |
| 902 | + }} | |
| 903 | + > | |
| 904 | + <p className="mb-0.5 uppercase tracking-[0.08em] opacity-70">{fmtMonth(series[hi].month, lang)}</p> | |
| 905 | + <p className="row"> | |
| 906 | + <span>{lang === "fr" ? "Indice" : "Index"}</span> | |
| 907 | + <span className="val"> | |
| 908 | + {pts[hi].toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: 1 })} | |
| 909 | + </span> | |
| 910 | + </p> | |
| 911 | + </div> | |
| 912 | + )} | |
| 913 | + </div> | |
| 666 | 914 | ); |
| 667 | 915 | } |
| 668 | 916 | |
@@ -670,10 +918,9 @@ export function MarketIndexChart({ | ||
| 670 | 918 | /** Distribution stylisée (cloche) : les barres sous le percentile sont pleines. */ |
| 671 | 919 | export function PercentileBar({ |
| 672 | 920 | pct, |
| 673 | − lang, | |
| 674 | 921 | }: { |
| 675 | 922 | pct: number; |
| 676 | − lang: string; | |
| 923 | + lang?: string; | |
| 677 | 924 | }) { |
| 678 | 925 | const N = 36; |
| 679 | 926 | const bars = Array.from({ length: N }, (_, i) => { |
@@ -690,23 +937,20 @@ export function PercentileBar({ | ||
| 690 | 937 | return ( |
| 691 | 938 | <div |
| 692 | 939 | key={i} |
| 693 | − className="min-w-0 flex-1 rounded-t-[2px] border border-ink transition-colors" | |
| 940 | + className="min-w-0 flex-1 transition-colors" | |
| 694 | 941 | style={{ |
| 695 | 942 | height: `${8 + (b / maxB) * 88}%`, |
| 696 | − background: filled ? "var(--lime)" : "var(--surface-2)", | |
| 697 | − borderWidth: "1px", | |
| 698 | − opacity: filled ? 1 : 0.55, | |
| 943 | + background: filled ? "var(--ink)" : "var(--line-soft)", | |
| 699 | 944 | }} |
| 700 | 945 | /> |
| 701 | 946 | ); |
| 702 | 947 | })} |
| 703 | 948 | <div |
| 704 | − className="absolute bottom-0 top-[-6px] w-0 border-l-[2px] border-ink" | |
| 949 | + className="absolute bottom-0 top-[-6px] w-0 border-l-[2px] border-[var(--accent)]" | |
| 705 | 950 | style={{ left: `${p}%` }} |
| 706 | 951 | > |
| 707 | − <span className="vp-mono absolute -top-1 left-1.5 whitespace-nowrap rounded-[4px] border border-ink bg-ink px-1.5 py-0.5 text-[10px] font-bold text-lime" style={p > 55 ? { left: "auto", right: "6px" } : undefined}> | |
| 708 | − {lang === "fr" ? "P" : "P"} | |
| 709 | − {Math.round(p)} | |
| 952 | + <span className="vp-mono absolute -top-1 left-1.5 whitespace-nowrap bg-ink px-1.5 py-0.5 text-[10px] font-bold text-paper" style={p > 55 ? { left: "auto", right: "6px" } : undefined}> | |
| 953 | + P{Math.round(p)} | |
| 710 | 954 | </span> |
| 711 | 955 | </div> |
| 712 | 956 | </div> |
@@ -719,10 +963,12 @@ export function IndexSpark({ | ||
| 719 | 963 | series, |
| 720 | 964 | width = 150, |
| 721 | 965 | height = 46, |
| 966 | + color = "var(--ink)", | |
| 722 | 967 | }: { |
| 723 | 968 | series: { month: string; idx: number }[]; |
| 724 | 969 | width?: number; |
| 725 | 970 | height?: number; |
| 971 | + color?: string; | |
| 726 | 972 | }) { |
| 727 | 973 | if (series.length < 2) return null; |
| 728 | 974 | const base = series[0].idx || 1; |
@@ -735,13 +981,11 @@ export function IndexSpark({ | ||
| 735 | 981 | height - 6 - ((v - min) / span) * (height - 14), |
| 736 | 982 | ]); |
| 737 | 983 | const line = xy.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "); |
| 738 | − const area = `${xy[0][0]},${height - 3} ${line} ${xy[xy.length - 1][0]},${height - 3}`; | |
| 739 | 984 | const [lx, ly] = xy[xy.length - 1]; |
| 740 | 985 | return ( |
| 741 | 986 | <svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" aria-hidden="true"> |
| 742 | − <polygon points={area} fill="var(--lime-soft)" /> | |
| 743 | − <polyline points={line} fill="none" stroke="var(--green)" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" /> | |
| 744 | − <circle cx={lx} cy={ly} r="3.4" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.4" /> | |
| 987 | + <polyline points={line} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" /> | |
| 988 | + <circle cx={lx} cy={ly} r="3.2" fill="var(--accent)" stroke="var(--paper)" strokeWidth="1.8" /> | |
| 745 | 989 | </svg> |
| 746 | 990 | ); |
| 747 | 991 | } |
@@ -757,12 +1001,12 @@ export function EraLine({ year, lang }: { year: number | null; lang: string }) { | ||
| 757 | 1001 | const age = 2026 - year; |
| 758 | 1002 | return ( |
| 759 | 1003 | <svg viewBox={`0 0 ${W} 40`} className="w-full max-w-[210px]" aria-label={`Construction ${year}`}> |
| 760 | − <line x1="12" y1="24" x2={W - 12} y2="24" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 1004 | + <line x1="12" y1="24" x2={W - 12} y2="24" stroke="var(--ink)" strokeWidth="1.2" /> | |
| 761 | 1005 | {[...new Set([min, Math.round((min + max) / 2 / 50) * 50, 2000])].map((yr) => { |
| 762 | 1006 | const gx = 12 + ((yr - min) / (max - min)) * (W - 24); |
| 763 | 1007 | return ( |
| 764 | 1008 | <g key={yr}> |
| 765 | − <line x1={gx} y1="20" x2={gx} y2="28" stroke="var(--ink)" strokeWidth="1.2" opacity="0.5" /> | |
| 1009 | + <line x1={gx} y1="20" x2={gx} y2="28" stroke="var(--ink)" strokeWidth="1" opacity="0.5" /> | |
| 766 | 1010 | <text x={gx} y="38" textAnchor="middle" fontSize="7.5" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)"> |
| 767 | 1011 | {yr} |
| 768 | 1012 | </text> |
@@ -770,7 +1014,7 @@ export function EraLine({ year, lang }: { year: number | null; lang: string }) { | ||
| 770 | 1014 | ); |
| 771 | 1015 | })} |
| 772 | 1016 | <g transform={`translate(${x}, 24)`}> |
| 773 | − <rect x="-7" y="-7" width="14" height="14" transform="rotate(45)" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.6" /> | |
| 1017 | + <rect x="-6" y="-6" width="12" height="12" transform="rotate(45)" fill="var(--accent)" stroke="var(--paper)" strokeWidth="1.6" /> | |
| 774 | 1018 | <text y="-13" textAnchor="middle" fontSize="9.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink)"> |
| 775 | 1019 | {year} |
| 776 | 1020 | </text> |
modified
src/components/ResultView.tsx
+185 −158
@@ -1,4 +1,10 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | +/** | |
| 3 | + * ResultView — le « rapport de valeur » : l'écran central du produit. | |
| 4 | + * Composition éditoriale v2 : masthead de rapport, estimation en très grands | |
| 5 | + * chiffres, sections numérotées à filets (registre, calcul, marché, historique, | |
| 6 | + * comparables), zéro carte — la hiérarchie vient de la typographie et des filets. | |
| 7 | + */ | |
| 2 | 8 | "use client"; |
| 3 | 9 | import { useState } from "react"; |
| 4 | 10 | import Link from "next/link"; |
@@ -43,6 +49,20 @@ function Chip({ k, v }: { k: string; v: string | number }) { | ||
| 43 | 49 | ); |
| 44 | 50 | } |
| 45 | 51 | |
| 52 | +/** En-tête de section du rapport : filet fort + numéro mono rouge. */ | |
| 53 | +function Sect({ num, kicker, title }: { num: string; kicker: string; title: string }) { | |
| 54 | + return ( | |
| 55 | + <header className="sec"> | |
| 56 | + <span className="sec-num"> | |
| 57 | + {num} — {kicker} | |
| 58 | + </span> | |
| 59 | + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]"> | |
| 60 | + {title} | |
| 61 | + </h2> | |
| 62 | + </header> | |
| 63 | + ); | |
| 64 | +} | |
| 65 | + | |
| 46 | 66 | export default function ResultView({ data }: { data: UnitEstimate }) { |
| 47 | 67 | const { lang, t } = useLang(); |
| 48 | 68 | const [activeComp, setActiveComp] = useState<string | null>(null); |
@@ -60,49 +80,53 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 60 | 80 | }).format(Math.round(v / 100) * 100); |
| 61 | 81 | |
| 62 | 82 | return ( |
| 63 | − <div className="space-y-10 py-8"> | |
| 64 | − {/* fil d'ariane */} | |
| 65 | − <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3"> | |
| 66 | − <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green"> | |
| 67 | − Vrai-Prix | |
| 68 | − </Link> | |
| 69 | − <span>/</span> | |
| 70 | − <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span> | |
| 71 | − </nav> | |
| 83 | + <div className="space-y-12 py-8"> | |
| 84 | + {/* ================= masthead du rapport ================= */} | |
| 85 | + <section className="rise"> | |
| 86 | + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5"> | |
| 87 | + <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3"> | |
| 88 | + <Link href="/" className="text-ink-2 hover:text-accent-deep"> | |
| 89 | + Vrai-Prix | |
| 90 | + </Link> | |
| 91 | + <span aria-hidden="true">/</span> | |
| 92 | + <span>{fr ? "Rapport de valeur" : "Value report"}</span> | |
| 93 | + {u && ( | |
| 94 | + <> | |
| 95 | + <span aria-hidden="true">/</span> | |
| 96 | + <span className="truncate">{u.id}</span> | |
| 97 | + </> | |
| 98 | + )} | |
| 99 | + </nav> | |
| 100 | + {u && ( | |
| 101 | + <FavButton | |
| 102 | + size="lg" | |
| 103 | + item={{ | |
| 104 | + item_id: u.id, | |
| 105 | + title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`, | |
| 106 | + subtitle: u.municipalite ?? "", | |
| 107 | + price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "", | |
| 108 | + url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`, | |
| 109 | + }} | |
| 110 | + /> | |
| 111 | + )} | |
| 112 | + </div> | |
| 72 | 113 | |
| 73 | − {/* ---- Panneau prix ---- */} | |
| 74 | − <section className="vp-card rise p-6 sm:p-8"> | |
| 75 | − <div className="flex flex-wrap items-start justify-between gap-6"> | |
| 76 | − {/* basis large : sous ~380px de large la jauge passe dessous au lieu d'écraser la colonne */} | |
| 77 | − <div className="min-w-0 flex-1 basis-[300px]"> | |
| 78 | − <div className="flex items-center justify-between gap-3"> | |
| 79 | − <span className="kicker">{t("estimatedValue")}</span> | |
| 80 | − {/* ♥ Mon univers Ka — favori unifié Groupe KA */} | |
| 81 | − {u && ( | |
| 82 | − <FavButton | |
| 83 | − size="lg" | |
| 84 | − item={{ | |
| 85 | − item_id: u.id, | |
| 86 | − title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`, | |
| 87 | − subtitle: u.municipalite ?? "", | |
| 88 | − price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "", | |
| 89 | − url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`, | |
| 90 | − }} | |
| 91 | − /> | |
| 92 | − )} | |
| 93 | − </div> | |
| 114 | + <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]"> | |
| 115 | + {/* colonne principale : adresse + estimation en très grands chiffres */} | |
| 116 | + <div className="min-w-0"> | |
| 94 | 117 | {u && ( |
| 95 | − <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]"> | |
| 118 | + <h1 className="vp-display text-[clamp(19px,2.6vw,26px)] font-bold uppercase leading-tight tracking-[-0.02em]"> | |
| 96 | 119 | {u.adresse} |
| 97 | 120 | {s?.apt ? ` app. ${s.apt}` : ""} |
| 98 | 121 | {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null} |
| 99 | 122 | </h1> |
| 100 | 123 | )} |
| 101 | − <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]"> | |
| 124 | + <p className="klabel mt-5">{t("estimatedValue")}</p> | |
| 125 | + <p className="vp-display mt-1 text-[clamp(52px,9vw,96px)] font-bold leading-none tracking-[-0.045em]"> | |
| 102 | 126 | <CountUp value={r.estimate} format={moneyFmt} /> |
| 103 | 127 | </p> |
| 104 | − {/* faits rapides */} | |
| 105 | − <div className="mt-4 flex flex-wrap gap-2"> | |
| 128 | + {/* faits rapides — ligne mono, pas de pastilles */} | |
| 129 | + <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.06em] text-ink-2"> | |
| 106 | 130 | {[ |
| 107 | 131 | u ? t(u.typeProp as TKey) : null, |
| 108 | 132 | u?.aireEtagesM2 && r.estimate > 0 |
@@ -115,17 +139,10 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 115 | 139 | : null, |
| 116 | 140 | ] |
| 117 | 141 | .filter((x): x is string => x != null) |
| 118 | − .map((x) => ( | |
| 119 | − <span | |
| 120 | − key={x} | |
| 121 | − className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.04em]" | |
| 122 | − > | |
| 123 | − {x} | |
| 124 | − </span> | |
| 125 | − ))} | |
| 126 | − </div> | |
| 142 | + .join(" · ")} | |
| 143 | + </p> | |
| 127 | 144 | {/* fourchette visuelle P10-P90 avec position du rôle */} |
| 128 | − <div className="mt-6 max-w-2xl"> | |
| 145 | + <div className="mt-7 max-w-2xl"> | |
| 129 | 146 | <p className="klabel mb-1">{t("range")}</p> |
| 130 | 147 | <RangeBar |
| 131 | 148 | low={r.low} |
@@ -135,25 +152,8 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 135 | 152 | lang={lang} |
| 136 | 153 | /> |
| 137 | 154 | </div> |
| 138 | − <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3"> | |
| 139 | − {u?.valeurRole != null && ( | |
| 140 | − <div> | |
| 141 | − <p className="klabel">{t("assessed")}</p> | |
| 142 | − <p className="vp-display text-[19px] font-bold text-ink-2">{fmt(u.valeurRole, lang)}</p> | |
| 143 | − </div> | |
| 144 | − )} | |
| 145 | − {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && ( | |
| 146 | − <div> | |
| 147 | − <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p> | |
| 148 | − <p className="vp-display text-[19px] font-bold text-green-deep"> | |
| 149 | − {r.estimate >= u.valeurRole ? "+" : ""} | |
| 150 | − {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} % | |
| 151 | − </p> | |
| 152 | − </div> | |
| 153 | − )} | |
| 154 | − </div> | |
| 155 | 155 | {u && ( |
| 156 | − <div className="mt-6 flex flex-wrap gap-2.5"> | |
| 156 | + <div className="mt-7 flex flex-wrap gap-2.5"> | |
| 157 | 157 | <a |
| 158 | 158 | href={`/api/report?id=${encodeURIComponent(u.id)}`} |
| 159 | 159 | className="btn btn-primary" |
@@ -167,37 +167,63 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 167 | 167 | download |
| 168 | 168 | title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"} |
| 169 | 169 | > |
| 170 | − {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓ | |
| 170 | + {fr ? "Format professionnel (banque)" : "Professional format (bank)"} ↓ | |
| 171 | 171 | </a> |
| 172 | 172 | </div> |
| 173 | 173 | )} |
| 174 | 174 | </div> |
| 175 | − <div className="shrink-0"> | |
| 176 | − <ConfidenceDial | |
| 177 | − pct={r.confidencePct} | |
| 178 | − level={r.confidenceLevel} | |
| 179 | − label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`} | |
| 180 | − /> | |
| 181 | − </div> | |
| 175 | + | |
| 176 | + {/* colonne de lecture : confiance + rôle + écart, en cellules de données */} | |
| 177 | + <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10"> | |
| 178 | + <div className="flex items-start justify-between gap-6 lg:block"> | |
| 179 | + <ConfidenceDial | |
| 180 | + pct={r.confidencePct} | |
| 181 | + level={r.confidenceLevel} | |
| 182 | + label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`} | |
| 183 | + /> | |
| 184 | + <dl className="grid flex-1 grid-cols-1 gap-x-6 sm:grid-cols-2 lg:mt-6 lg:grid-cols-1"> | |
| 185 | + {u?.valeurRole != null && ( | |
| 186 | + <div className="border-t border-[var(--line)] py-3"> | |
| 187 | + <dt className="klabel">{t("assessed")}</dt> | |
| 188 | + <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] text-ink-2"> | |
| 189 | + {fmt(u.valeurRole, lang)} | |
| 190 | + </dd> | |
| 191 | + </div> | |
| 192 | + )} | |
| 193 | + {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && ( | |
| 194 | + <div className="border-t border-[var(--line)] py-3"> | |
| 195 | + <dt className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</dt> | |
| 196 | + <dd | |
| 197 | + className={`vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] ${ | |
| 198 | + r.estimate >= u.valeurRole ? "text-ink" : "text-[var(--danger)]" | |
| 199 | + }`} | |
| 200 | + > | |
| 201 | + {r.estimate >= u.valeurRole ? "+" : ""} | |
| 202 | + {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} % | |
| 203 | + </dd> | |
| 204 | + </div> | |
| 205 | + )} | |
| 206 | + </dl> | |
| 207 | + </div> | |
| 208 | + </aside> | |
| 182 | 209 | </div> |
| 183 | − <p className="vp-mono mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-3"> | |
| 210 | + <p className="vp-mono mt-7 border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3"> | |
| 184 | 211 | {t("disclaimer")} |
| 185 | 212 | </p> |
| 186 | 213 | </section> |
| 187 | 214 | |
| 188 | − {/* ---- Portrait de la propriété (bento du registre) ---- */} | |
| 215 | + {/* ================= 01 — portrait du registre ================= */} | |
| 189 | 216 | {u && s && ( |
| 190 | 217 | <section> |
| 191 | − <span className="kicker">{fr ? "Registre officiel — dessiné par les données" : "Official record — drawn by the data"}</span> | |
| 192 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 193 | − {fr ? "Portrait de la propriété" : "Property portrait"} | |
| 194 | − </h2> | |
| 195 | − <div className="mt-4 grid gap-4 lg:grid-cols-3"> | |
| 218 | + <Sect | |
| 219 | + num="01" | |
| 220 | + kicker={fr ? "Registre officiel" : "Official record"} | |
| 221 | + title={fr ? "Portrait de la propriété" : "Property portrait"} | |
| 222 | + /> | |
| 223 | + <div className="mt-6 grid gap-x-10 gap-y-8 lg:grid-cols-3 lg:divide-x lg:divide-[var(--line)]"> | |
| 196 | 224 | {/* Bâtiment */} |
| 197 | − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}> | |
| 198 | − <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2"> | |
| 199 | − {fr ? "Bâtiment" : "Building"} | |
| 200 | − </p> | |
| 225 | + <div className="rise" style={{ animationDelay: "60ms" }}> | |
| 226 | + <p className="klabel">{fr ? "Bâtiment" : "Building"}</p> | |
| 201 | 227 | <div className="flex justify-center py-2"> |
| 202 | 228 | <BuildingGlyph |
| 203 | 229 | floors={s.nbEtages} |
@@ -209,7 +235,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 209 | 235 | /> |
| 210 | 236 | </div> |
| 211 | 237 | <EraLine year={u.anneeConstruction} lang={lang} /> |
| 212 | − <div className="mt-3 grid grid-cols-2 gap-2"> | |
| 238 | + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2"> | |
| 213 | 239 | <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} /> |
| 214 | 240 | <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} /> |
| 215 | 241 | <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} /> |
@@ -217,10 +243,8 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 217 | 243 | </div> |
| 218 | 244 | </div> |
| 219 | 245 | {/* Terrain */} |
| 220 | − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}> | |
| 221 | − <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2"> | |
| 222 | − {fr ? "Terrain — à l'échelle" : "Lot — to scale"} | |
| 223 | − </p> | |
| 246 | + <div className="rise lg:pl-10" style={{ animationDelay: "140ms" }}> | |
| 247 | + <p className="klabel">{fr ? "Terrain — à l'échelle" : "Lot — to scale"}</p> | |
| 224 | 248 | <div className="flex justify-center py-2"> |
| 225 | 249 | <LotDiagram |
| 226 | 250 | areaM2={u.superficieTerrainM2} |
@@ -232,7 +256,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 232 | 256 | isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)} |
| 233 | 257 | /> |
| 234 | 258 | </div> |
| 235 | − <div className="mt-3 grid grid-cols-2 gap-2"> | |
| 259 | + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2"> | |
| 236 | 260 | <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} /> |
| 237 | 261 | <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} /> |
| 238 | 262 | <Chip k="CUBF" v={s.cubf ?? "—"} /> |
@@ -240,11 +264,9 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 240 | 264 | </div> |
| 241 | 265 | </div> |
| 242 | 266 | {/* Valeur au rôle */} |
| 243 | − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}> | |
| 244 | − <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2"> | |
| 245 | − {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"} | |
| 246 | − </p> | |
| 247 | − <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]"> | |
| 267 | + <div className="rise lg:pl-10" style={{ animationDelay: "220ms" }}> | |
| 268 | + <p className="klabel">{fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}</p> | |
| 269 | + <p className="vp-display py-2 text-[30px] font-bold tracking-[-0.025em]"> | |
| 248 | 270 | {fmt(u.valeurRole, lang)} |
| 249 | 271 | </p> |
| 250 | 272 | <ValueSplit |
@@ -259,18 +281,18 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 259 | 281 | previous: fr ? "Rôle antérieur" : "Previous roll", |
| 260 | 282 | }} |
| 261 | 283 | /> |
| 262 | − <div className="mt-3 grid grid-cols-2 gap-2"> | |
| 284 | + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2"> | |
| 263 | 285 | <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} /> |
| 264 | 286 | <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} /> |
| 265 | 287 | </div> |
| 266 | 288 | </div> |
| 267 | 289 | </div> |
| 268 | 290 | {/* registre brut complet */} |
| 269 | − <details className="vp-card mt-4 p-5"> | |
| 270 | − <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep"> | |
| 271 | − {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"} | |
| 291 | + <details className="mt-6 border-t border-[var(--line)] pt-4"> | |
| 292 | + <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-accent-deep hover:text-accent"> | |
| 293 | + ▸ {fr ? "Toutes les données du registre (brutes)" : "All registry data (raw)"} | |
| 272 | 294 | </summary> |
| 273 | − <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4"> | |
| 295 | + <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4"> | |
| 274 | 296 | <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} /> |
| 275 | 297 | <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} /> |
| 276 | 298 | <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} /> |
@@ -286,74 +308,78 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 286 | 308 | </section> |
| 287 | 309 | )} |
| 288 | 310 | |
| 289 | − {/* ---- Transparence du calcul ---- */} | |
| 311 | + {/* ================= 02 — transparence du calcul ================= */} | |
| 290 | 312 | <section> |
| 291 | − <span className="kicker">{fr ? "Transparence" : "Transparency"}</span> | |
| 292 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 293 | − {t("whyTitle")} | |
| 294 | − </h2> | |
| 295 | − <div className="mt-4 grid gap-4 sm:grid-cols-2"> | |
| 296 | − <div className="vp-card vp-card-hover rise p-5"> | |
| 313 | + <Sect num="02" kicker={fr ? "Transparence" : "Transparency"} title={t("whyTitle")} /> | |
| 314 | + <div className="mt-6 grid gap-x-10 gap-y-6 sm:grid-cols-2 sm:divide-x sm:divide-[var(--line)]"> | |
| 315 | + <div className="rise"> | |
| 297 | 316 | <p className="klabel">{t("modelPart")}</p> |
| 298 | − <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]"> | |
| 317 | + <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]"> | |
| 299 | 318 | {r.modelEstimate != null ? ( |
| 300 | 319 | <CountUp value={r.modelEstimate} format={moneyFmt} /> |
| 301 | 320 | ) : ( |
| 302 | 321 | "—" |
| 303 | 322 | )} |
| 304 | 323 | </p> |
| 305 | − <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep"> | |
| 306 | − {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} % | |
| 324 | + <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2"> | |
| 325 | + {t("weightModel")} : <b className="text-ink">{(r.modelWeight * 100).toFixed(0)} %</b> | |
| 307 | 326 | </p> |
| 308 | 327 | </div> |
| 309 | − <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}> | |
| 328 | + <div className="rise sm:pl-10" style={{ animationDelay: "100ms" }}> | |
| 310 | 329 | <p className="klabel">{t("compsPart")}</p> |
| 311 | − <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]"> | |
| 330 | + <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]"> | |
| 312 | 331 | {r.compsEstimate != null ? ( |
| 313 | 332 | <CountUp value={r.compsEstimate} format={moneyFmt} /> |
| 314 | 333 | ) : ( |
| 315 | 334 | "—" |
| 316 | 335 | )} |
| 317 | 336 | </p> |
| 318 | − <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep"> | |
| 319 | − {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} comps | |
| 337 | + <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2"> | |
| 338 | + {t("weightComps")} : <b className="text-ink">{((1 - r.modelWeight) * 100).toFixed(0)} %</b> · {r.nCompsUsed} comps | |
| 320 | 339 | {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""} |
| 321 | 340 | </p> |
| 322 | 341 | </div> |
| 323 | 342 | </div> |
| 324 | 343 | </section> |
| 325 | 344 | |
| 326 | − {/* ---- Marché local ---- */} | |
| 345 | + {/* ================= 03 — marché local ================= */} | |
| 327 | 346 | {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && ( |
| 328 | 347 | <section> |
| 329 | − <span className="kicker">{fr ? "Contexte de marché" : "Market context"}</span> | |
| 330 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 331 | − {fr ? "Le marché autour de cette propriété" : "The market around this property"} | |
| 332 | − </h2> | |
| 333 | − <div className="mt-4 grid items-stretch gap-4 lg:grid-cols-[1.35fr_1fr]"> | |
| 348 | + <Sect | |
| 349 | + num="03" | |
| 350 | + kicker={fr ? "Contexte de marché" : "Market context"} | |
| 351 | + title={fr ? "Le marché autour de cette propriété" : "The market around this property"} | |
| 352 | + /> | |
| 353 | + <div className="mt-6 grid items-start gap-x-10 gap-y-8 lg:grid-cols-[1.4fr_1fr]"> | |
| 334 | 354 | {/* trajectoire du marché */} |
| 335 | 355 | {ctx.index.length >= 6 && ( |
| 336 | − <div className="vp-card rise p-5"> | |
| 356 | + <div className="rise"> | |
| 337 | 357 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 338 | 358 | <p className="klabel"> |
| 339 | 359 | {fr |
| 340 | 360 | ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})` |
| 341 | 361 | : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`} |
| 342 | 362 | </p> |
| 343 | − <div className="flex gap-2"> | |
| 363 | + <p className="vp-mono flex gap-4 text-[11px] text-ink-2"> | |
| 344 | 364 | {ctx.index12mPct != null && ( |
| 345 | − <span className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ctx.index12mPct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}> | |
| 346 | − 12 {fr ? "mois" : "mo"} {ctx.index12mPct >= 0 ? "+" : ""} | |
| 347 | − {ctx.index12mPct.toFixed(1)} % | |
| 365 | + <span> | |
| 366 | + 12 {fr ? "mois" : "mo"}{" "} | |
| 367 | + <b className={ctx.index12mPct >= 0 ? "text-ink" : "text-[var(--danger)]"}> | |
| 368 | + {ctx.index12mPct >= 0 ? "+" : ""} | |
| 369 | + {ctx.index12mPct.toFixed(1)} % | |
| 370 | + </b> | |
| 348 | 371 | </span> |
| 349 | 372 | )} |
| 350 | 373 | {ctx.indexSince2021Pct != null && ( |
| 351 | − <span className="vp-mono rounded-[4px] border border-ink bg-surface-2 px-1.5 py-0.5 text-[10px] font-bold text-ink-2"> | |
| 352 | − {ctx.index[0].month.slice(0, 4)}→ {ctx.indexSince2021Pct >= 0 ? "+" : ""} | |
| 353 | − {ctx.indexSince2021Pct.toFixed(0)} % | |
| 374 | + <span> | |
| 375 | + {ctx.index[0].month.slice(0, 4)}→{" "} | |
| 376 | + <b className={ctx.indexSince2021Pct >= 0 ? "text-ink" : "text-[var(--danger)]"}> | |
| 377 | + {ctx.indexSince2021Pct >= 0 ? "+" : ""} | |
| 378 | + {ctx.indexSince2021Pct.toFixed(0)} % | |
| 379 | + </b> | |
| 354 | 380 | </span> |
| 355 | 381 | )} |
| 356 | − </div> | |
| 382 | + </p> | |
| 357 | 383 | </div> |
| 358 | 384 | <div className="mt-3"> |
| 359 | 385 | <MarketIndexChart series={ctx.index} lang={lang} /> |
@@ -365,16 +391,16 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 365 | 391 | </p> |
| 366 | 392 | </div> |
| 367 | 393 | )} |
| 368 | − <div className="flex flex-col gap-4"> | |
| 394 | + <div className="flex flex-col gap-7 lg:border-l lg:border-[var(--line)] lg:pl-10"> | |
| 369 | 395 | {/* ventes du secteur */} |
| 370 | 396 | {ctx.sector && ( |
| 371 | − <div className="vp-card rise p-5" style={{ animationDelay: "80ms" }}> | |
| 397 | + <div className="rise" style={{ animationDelay: "80ms" }}> | |
| 372 | 398 | <p className="klabel"> |
| 373 | 399 | {fr |
| 374 | 400 | ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois` |
| 375 | 401 | : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`} |
| 376 | 402 | </p> |
| 377 | − <div className="mt-3 grid grid-cols-3 gap-2"> | |
| 403 | + <div className="mt-2 grid grid-cols-3 gap-x-4"> | |
| 378 | 404 | <div className="kv-cell"> |
| 379 | 405 | <p className="k">{fr ? "Ventes" : "Sales"}</p> |
| 380 | 406 | <p className="v">{ctx.sector.nSales12m}</p> |
@@ -406,7 +432,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 406 | 432 | )} |
| 407 | 433 | {/* position dans la municipalité */} |
| 408 | 434 | {ctx.muniPercentile != null && u?.municipalite && ( |
| 409 | − <div className="vp-card rise flex-1 p-5" style={{ animationDelay: "160ms" }}> | |
| 435 | + <div className="rise" style={{ animationDelay: "160ms" }}> | |
| 410 | 436 | <p className="klabel"> |
| 411 | 437 | {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`} |
| 412 | 438 | </p> |
@@ -437,14 +463,11 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 437 | 463 | </section> |
| 438 | 464 | )} |
| 439 | 465 | |
| 440 | − {/* ---- Historique ---- */} | |
| 466 | + {/* ================= 04 — historique ================= */} | |
| 441 | 467 | {u && u.history.some((h) => h.value != null) && ( |
| 442 | 468 | <section> |
| 443 | − <span className="kicker">2021 → 2026</span> | |
| 444 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 445 | − {t("history")} | |
| 446 | − </h2> | |
| 447 | − <div className="vp-card rise mt-4 p-5 sm:p-6"> | |
| 469 | + <Sect num="04" kicker="2021 → 2026" title={t("history")} /> | |
| 470 | + <div className="mt-6 max-w-3xl"> | |
| 448 | 471 | <div className="flex flex-col gap-2"> |
| 449 | 472 | {u.history.map((h, i) => { |
| 450 | 473 | const prev = i > 0 ? u.history[i - 1].value : null; |
@@ -461,14 +484,14 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 461 | 484 | style={{ |
| 462 | 485 | width: `${((h.value ?? 0) / histMax) * 100}%`, |
| 463 | 486 | animationDelay: `${i * 90}ms`, |
| 464 | − background: h.year === 2026 ? "var(--ink)" : undefined, | |
| 487 | + background: h.year === 2026 ? "var(--accent)" : undefined, | |
| 465 | 488 | }} |
| 466 | 489 | /> |
| 467 | 490 | </span> |
| 468 | 491 | <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold"> |
| 469 | 492 | {fmt(h.value, lang)} |
| 470 | 493 | {yoy != null && ( |
| 471 | − <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}> | |
| 494 | + <span className={`text-[10px] font-medium ${yoy >= 0 ? "text-ink-2" : "text-[var(--danger)]"}`}> | |
| 472 | 495 | {yoy >= 0 ? "+" : ""} |
| 473 | 496 | {yoy.toFixed(0)} % |
| 474 | 497 | </span> |
@@ -482,16 +505,15 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 482 | 505 | </section> |
| 483 | 506 | )} |
| 484 | 507 | |
| 485 | − {/* ---- Plan des comparables (carte maison) ---- */} | |
| 508 | + {/* ================= 05 — comparables ================= */} | |
| 486 | 509 | <section> |
| 487 | − <span className="kicker"> | |
| 488 | − {r.nCompsUsed} {fr ? "ventes réelles" : "real sales"} | |
| 489 | − </span> | |
| 490 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | |
| 491 | − {t("compsTitle")} | |
| 492 | − </h2> | |
| 493 | − <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]"> | |
| 494 | − <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]"> | |
| 510 | + <Sect | |
| 511 | + num="05" | |
| 512 | + kicker={`${r.nCompsUsed} ${fr ? "ventes réelles" : "real sales"}`} | |
| 513 | + title={t("compsTitle")} | |
| 514 | + /> | |
| 515 | + <div className="mt-6 grid items-start gap-x-8 gap-y-6 lg:grid-cols-[1.05fr_1fr]"> | |
| 516 | + <div className="overflow-hidden border border-[var(--line-strong)]"> | |
| 495 | 517 | <RadarMap |
| 496 | 518 | subject={{ |
| 497 | 519 | lat: u?.lat ?? r.comps[0]?.lat ?? 46.81, |
@@ -515,7 +537,7 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 515 | 537 | </div> |
| 516 | 538 | <div className="src-wrap max-h-[560px] overflow-y-auto"> |
| 517 | 539 | <table className="src-table"> |
| 518 | − <thead className="sticky top-0 z-10"> | |
| 540 | + <thead className="sticky top-0 z-10 bg-paper"> | |
| 519 | 541 | <tr> |
| 520 | 542 | <th>#</th> |
| 521 | 543 | <th>{fr ? "Vente" : "Sale"}</th> |
@@ -528,12 +550,12 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 528 | 550 | key={c.id} |
| 529 | 551 | onMouseEnter={() => setActiveComp(c.id)} |
| 530 | 552 | onMouseLeave={() => setActiveComp(null)} |
| 531 | − className={activeComp === c.id ? "!bg-lime-soft" : ""} | |
| 553 | + className={activeComp === c.id ? "!bg-surface-2" : ""} | |
| 532 | 554 | > |
| 533 | 555 | <td> |
| 534 | 556 | <span |
| 535 | − className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${ | |
| 536 | − activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper" | |
| 557 | + className={`vp-mono inline-flex h-6 w-6 items-center justify-center text-[11px] font-bold ${ | |
| 558 | + activeComp === c.id ? "bg-accent text-white" : "bg-ink text-paper" | |
| 537 | 559 | }`} |
| 538 | 560 | > |
| 539 | 561 | {i + 1} |
@@ -579,13 +601,18 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 579 | 601 | </div> |
| 580 | 602 | </section> |
| 581 | 603 | |
| 582 | − {/* ---- Lead ---- */} | |
| 583 | − <section className="rounded-[10px] border-[1.5px] border-ink bg-ink p-6 text-paper shadow-[8px_8px_0_rgba(20,24,20,0.2)] sm:p-8"> | |
| 584 | − <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime"> | |
| 585 | − {t("leadTitle")} | |
| 586 | − </h2> | |
| 587 | − <div className="mt-4 max-w-xl"> | |
| 588 | − <LeadForm unitId={u?.id ?? null} estimate={r.estimate} /> | |
| 604 | + {/* ================= alerte de valeur ================= */} | |
| 605 | + <section className="bleed vp-dark"> | |
| 606 | + <div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-12"> | |
| 607 | + <span className="sec-num !text-[var(--accent-bright)]"> | |
| 608 | + 06 — {fr ? "Suivi" : "Watch"} | |
| 609 | + </span> | |
| 610 | + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase tracking-[-0.025em] text-[var(--paper-on-ink)]"> | |
| 611 | + {t("leadTitle")} | |
| 612 | + </h2> | |
| 613 | + <div className="mt-5 max-w-xl"> | |
| 614 | + <LeadForm unitId={u?.id ?? null} estimate={r.estimate} /> | |
| 615 | + </div> | |
| 589 | 616 | </div> |
| 590 | 617 | </section> |
| 591 | 618 | </div> |
modified
src/components/SearchBox.tsx
+101 −29
@@ -15,18 +15,23 @@ export interface Suggestion { | ||
| 15 | 15 | export default function SearchBox({ |
| 16 | 16 | onPick, |
| 17 | 17 | placeholder, |
| 18 | + variant = "default", | |
| 18 | 19 | }: { |
| 19 | 20 | /** Si fourni : appelé au choix d'une adresse (le champ se vide) au lieu de naviguer. */ |
| 20 | 21 | onPick?: (s: Suggestion) => void; |
| 21 | 22 | placeholder?: string; |
| 23 | + /** « hero » : grand champ sur aplat encre (page d'accueil). */ | |
| 24 | + variant?: "default" | "hero"; | |
| 22 | 25 | }) { |
| 23 | − const { t } = useLang(); | |
| 26 | + const { t, lang } = useLang(); | |
| 27 | + const fr = lang === "fr"; | |
| 24 | 28 | const router = useRouter(); |
| 25 | 29 | const [q, setQ] = useState(""); |
| 26 | 30 | const [sugg, setSugg] = useState<Suggestion[]>([]); |
| 27 | 31 | const [open, setOpen] = useState(false); |
| 28 | 32 | const [searched, setSearched] = useState(false); |
| 29 | 33 | const timer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 34 | + const input = useRef<HTMLInputElement>(null); | |
| 30 | 35 | |
| 31 | 36 | const onChange = (value: string) => { |
| 32 | 37 | setQ(value); |
@@ -45,39 +50,100 @@ export default function SearchBox({ | ||
| 45 | 50 | }, 200); |
| 46 | 51 | }; |
| 47 | 52 | |
| 53 | + const pick = (s: Suggestion) => { | |
| 54 | + if (onPick) { | |
| 55 | + onPick(s); | |
| 56 | + setQ(""); | |
| 57 | + setSugg([]); | |
| 58 | + setSearched(false); | |
| 59 | + } else { | |
| 60 | + router.push(`/estimation/${s.id}`); | |
| 61 | + } | |
| 62 | + }; | |
| 63 | + | |
| 64 | + const hero = variant === "hero"; | |
| 65 | + | |
| 48 | 66 | return ( |
| 49 | 67 | <div className="relative w-full"> |
| 50 | − <input | |
| 51 | − value={q} | |
| 52 | − onChange={(e) => onChange(e.target.value)} | |
| 53 | − onFocus={() => setOpen(true)} | |
| 54 | − onBlur={() => setTimeout(() => setOpen(false), 200)} | |
| 55 | − placeholder={placeholder ?? t("searchPlaceholder")} | |
| 56 | − className="vp-input text-base" | |
| 57 | − aria-label={placeholder ?? t("searchPlaceholder")} | |
| 58 | − /> | |
| 68 | + <div className={hero ? "flex items-stretch gap-0" : undefined}> | |
| 69 | + <input | |
| 70 | + ref={input} | |
| 71 | + value={q} | |
| 72 | + onChange={(e) => onChange(e.target.value)} | |
| 73 | + onFocus={() => setOpen(true)} | |
| 74 | + onBlur={() => setTimeout(() => setOpen(false), 200)} | |
| 75 | + placeholder={ | |
| 76 | + placeholder ?? (hero ? (fr ? "ex. 861 route Elgin" : "e.g. 861 route Elgin") : t("searchPlaceholder")) | |
| 77 | + } | |
| 78 | + className={ | |
| 79 | + hero | |
| 80 | + ? "min-h-[56px] w-full min-w-0 flex-1 border-0 border-b border-[rgba(245,243,238,0.45)] bg-transparent px-1 py-3 text-[16px] text-[var(--paper-on-ink)] outline-none transition-colors placeholder:text-[rgba(245,243,238,0.45)] focus:border-[var(--accent-bright)] sm:text-[19px]" | |
| 81 | + : "vp-input text-base" | |
| 82 | + } | |
| 83 | + aria-label={placeholder ?? t("searchPlaceholder")} | |
| 84 | + /> | |
| 85 | + {hero && ( | |
| 86 | + <button | |
| 87 | + type="button" | |
| 88 | + aria-label={fr ? "Estimer cette adresse" : "Estimate this address"} | |
| 89 | + onMouseDown={(e) => { | |
| 90 | + // mousedown : devance le blur de l'input (sinon le menu se ferme avant) | |
| 91 | + e.preventDefault(); | |
| 92 | + if (sugg.length > 0) pick(sugg[0]); | |
| 93 | + else input.current?.focus(); | |
| 94 | + }} | |
| 95 | + className="vp-display -mb-px flex min-h-[56px] shrink-0 items-center gap-2 self-end bg-[var(--accent-bright)] px-4 text-[14px] font-bold text-ink transition-colors hover:bg-[#ff6b63] sm:px-7 sm:text-[15px]" | |
| 96 | + > | |
| 97 | + {fr ? "Estimer" : "Estimate"} | |
| 98 | + <span aria-hidden="true">→</span> | |
| 99 | + </button> | |
| 100 | + )} | |
| 101 | + </div> | |
| 59 | 102 | {open && sugg.length > 0 && ( |
| 60 | − <ul className="absolute z-[var(--z-dropdown,700)] mt-2 w-full overflow-hidden rounded-[10px] border-[1.5px] border-ink bg-surface shadow-[6px_6px_0_rgba(20,24,20,0.85)]"> | |
| 61 | − {sugg.map((s) => ( | |
| 62 | − <li key={s.id} className="border-b border-[rgba(20,24,20,0.1)] last:border-0"> | |
| 103 | + <ul | |
| 104 | + className={`absolute z-[var(--z-dropdown,700)] mt-2 w-full overflow-hidden rounded-[2px] ${ | |
| 105 | + hero | |
| 106 | + ? "border border-[rgba(245,243,238,0.25)] bg-[var(--ink-deep)] shadow-[0_14px_40px_rgba(0,0,0,0.5)]" | |
| 107 | + : "border border-[var(--line-strong)] bg-surface shadow-[0_10px_30px_rgba(20,24,20,0.14)]" | |
| 108 | + }`} | |
| 109 | + > | |
| 110 | + {sugg.map((s, i) => ( | |
| 111 | + <li | |
| 112 | + key={s.id} | |
| 113 | + className={ | |
| 114 | + hero | |
| 115 | + ? "border-b border-[rgba(245,243,238,0.1)] last:border-0" | |
| 116 | + : "border-b border-[rgba(20,24,20,0.08)] last:border-0" | |
| 117 | + } | |
| 118 | + > | |
| 63 | 119 | <button |
| 64 | − className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors hover:bg-lime-soft" | |
| 65 | − onMouseDown={() => { | |
| 66 | − if (onPick) { | |
| 67 | − onPick(s); | |
| 68 | − setQ(""); | |
| 69 | − setSugg([]); | |
| 70 | − setSearched(false); | |
| 71 | − } else { | |
| 72 | − router.push(`/estimation/${s.id}`); | |
| 73 | − } | |
| 74 | − }} | |
| 120 | + className={`flex w-full items-baseline justify-between gap-3 px-4 py-3 text-left transition-colors ${ | |
| 121 | + hero ? "hover:bg-[rgba(245,243,238,0.08)]" : "hover:bg-surface-2" | |
| 122 | + }`} | |
| 123 | + onMouseDown={() => pick(s)} | |
| 75 | 124 | > |
| 76 | − <span className="vp-display font-bold"> | |
| 77 | − {s.adresse} | |
| 78 | − {s.apt ? ` app. ${s.apt}` : ""} | |
| 125 | + <span className="flex min-w-0 items-baseline gap-2.5"> | |
| 126 | + <span | |
| 127 | + className={`vp-mono shrink-0 text-[10px] ${ | |
| 128 | + hero ? "text-[var(--accent-bright)]" : "text-accent" | |
| 129 | + }`} | |
| 130 | + > | |
| 131 | + {String(i + 1).padStart(2, "0")} | |
| 132 | + </span> | |
| 133 | + <span | |
| 134 | + className={`vp-display truncate font-bold ${ | |
| 135 | + hero ? "text-[var(--paper-on-ink)]" : "" | |
| 136 | + }`} | |
| 137 | + > | |
| 138 | + {s.adresse} | |
| 139 | + {s.apt ? ` app. ${s.apt}` : ""} | |
| 140 | + </span> | |
| 79 | 141 | </span> |
| 80 | − <span className="vp-mono shrink-0 text-[11px] uppercase tracking-[0.05em] text-ink-3"> | |
| 142 | + <span | |
| 143 | + className={`vp-mono shrink-0 text-[10.5px] uppercase tracking-[0.05em] ${ | |
| 144 | + hero ? "text-[rgba(245,243,238,0.55)]" : "text-ink-3" | |
| 145 | + }`} | |
| 146 | + > | |
| 81 | 147 | {s.municipalite} |
| 82 | 148 | </span> |
| 83 | 149 | </button> |
@@ -86,7 +152,13 @@ export default function SearchBox({ | ||
| 86 | 152 | </ul> |
| 87 | 153 | )} |
| 88 | 154 | {open && searched && sugg.length === 0 && q.trim().length >= 3 && ( |
| 89 | − <div className="vp-mono absolute z-[var(--z-dropdown,700)] mt-2 w-full rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3 text-[12px] uppercase tracking-[0.04em] text-ink-2 shadow-[6px_6px_0_rgba(20,24,20,0.85)]"> | |
| 155 | + <div | |
| 156 | + className={`vp-mono absolute z-[var(--z-dropdown,700)] mt-2 w-full rounded-[2px] px-4 py-3 text-[12px] uppercase tracking-[0.04em] ${ | |
| 157 | + hero | |
| 158 | + ? "border border-[rgba(245,243,238,0.25)] bg-[var(--ink-deep)] text-[rgba(245,243,238,0.7)]" | |
| 159 | + : "border border-[var(--line-strong)] bg-surface text-ink-2" | |
| 160 | + }`} | |
| 161 | + > | |
| 90 | 162 | {t("searchNoResult")} |
| 91 | 163 | </div> |
| 92 | 164 | )} |
modified
src/components/StatsDashboard.tsx
+1 −1
@@ -61,7 +61,7 @@ export default function StatsDashboard({ initial }: { initial: DashboardPayload | ||
| 61 | 61 | return ( |
| 62 | 62 | <div> |
| 63 | 63 | {/* ---- fraîcheur + rapports PDF (5 modes) ---- */} |
| 64 | − <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3"> | |
| 64 | + <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 border-y border-[var(--line)] px-1 py-3"> | |
| 65 | 65 | <div> |
| 66 | 66 | <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3"> |
| 67 | 67 | {fr |
modified
src/components/StatsView.tsx
+4 −4
@@ -71,8 +71,8 @@ export default function StatsView({ s }: { s: ProvStats }) { | ||
| 71 | 71 | </> |
| 72 | 72 | )} |
| 73 | 73 | </h1> |
| 74 | − <div className="rise mt-6 rounded-[10px] border-[1.5px] border-ink bg-ink p-6 shadow-[10px_10px_0_rgba(20,24,20,0.25)] sm:p-10"> | |
| 75 | − <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-lime"> | |
| 74 | + <div className="rise mt-6 rounded-[2px] bg-[var(--ink-deep)] p-6 sm:p-10"> | |
| 75 | + <p className="vp-display break-all text-[clamp(30px,6.2vw,76px)] font-bold leading-none tracking-[-0.03em] text-[var(--accent-bright)]"> | |
| 76 | 76 | <CountUp |
| 77 | 77 | value={s.valeur_totale_2026} |
| 78 | 78 | duration={2600} |
@@ -93,8 +93,8 @@ export default function StatsView({ s }: { s: ProvStats }) { | ||
| 93 | 93 | [fr ? "depuis 2021 (périmètre constant)" : "since 2021 (constant perimeter)", `+${s.croissance_2021_2026_pct.toLocaleString(fr ? "fr-CA" : "en-CA")} %`], |
| 94 | 94 | ] as [string, string][] |
| 95 | 95 | ).map(([k, v]) => ( |
| 96 | − <span key={k} className="stat-chip max-w-full !whitespace-normal !border-[rgba(255,81,72,0.5)] !bg-transparent !text-[rgba(245,243,238,0.85)] !shadow-none"> | |
| 97 | − <b className="!text-lime">{v}</b> {k} | |
| 96 | + <span key={k} className="stat-chip max-w-full !whitespace-normal !border-[rgba(245,243,238,0.25)] !text-[rgba(245,243,238,0.85)]"> | |
| 97 | + <b className="!text-[var(--accent-bright)]">{v}</b> {k} | |
| 98 | 98 | </span> |
| 99 | 99 | ))} |
| 100 | 100 | </div> |
| 101 | 101 | |