TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2"use client";3import { useRef, useState } from "react";4import { useRouter } from "next/navigation";5import { useLang } from "./LangContext";67export interface Suggestion {8 id: string;9 adresse: string | null;10 apt: string | null;11 municipalite: string | null;12 typeProp: string;13}1415export default function SearchBox({16 onPick,17 placeholder,18 variant = "default",19}: {20 /** Si fourni : appelé au choix d'une adresse (le champ se vide) au lieu de naviguer. */21 onPick?: (s: Suggestion) => void;22 placeholder?: string;23 /** « hero » : grand champ sur aplat encre (page d'accueil). */24 variant?: "default" | "hero";25}) {26 const { t, lang } = useLang();27 const fr = lang === "fr";28 const router = useRouter();29 const [q, setQ] = useState("");30 const [sugg, setSugg] = useState<Suggestion[]>([]);31 const [open, setOpen] = useState(false);32 const [searched, setSearched] = useState(false);33 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);34 const input = useRef<HTMLInputElement>(null);3536 const onChange = (value: string) => {37 setQ(value);38 if (timer.current) clearTimeout(timer.current);39 if (value.trim().length < 3) {40 setSugg([]);41 setSearched(false);42 return;43 }44 timer.current = setTimeout(async () => {45 const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);46 const data = (await res.json()) as { results: Suggestion[] };47 setSugg(data.results);48 setSearched(true);49 setOpen(true);50 }, 200);51 };5253 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 };6364 const hero = variant === "hero";6566 return (67 <div className="relative w-full">68 <div className={hero ? "flex items-stretch gap-0" : undefined}>69 <input70 id={variant === "hero" ? "vp-adresse" : undefined}71 ref={input}72 value={q}73 onChange={(e) => onChange(e.target.value)}74 onFocus={() => setOpen(true)}75 onBlur={() => setTimeout(() => setOpen(false), 200)}76 placeholder={77 placeholder ?? (hero ? (fr ? "ex. 861 route Elgin" : "e.g. 861 route Elgin") : t("searchPlaceholder"))78 }79 className={80 hero81 ? "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]"82 : "vp-input text-base"83 }84 aria-label={placeholder ?? t("searchPlaceholder")}85 />86 {hero && (87 <button88 type="button"89 aria-label={fr ? "Analyser cette adresse" : "Analyze this address"}90 onMouseDown={(e) => {91 // mousedown : devance le blur de l'input (sinon le menu se ferme avant)92 e.preventDefault();93 if (sugg.length > 0) pick(sugg[0]);94 else input.current?.focus();95 }}96 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]"97 >98 {fr ? "Analyser" : "Analyze"}99 <span aria-hidden="true">→</span>100 </button>101 )}102 </div>103 {open && sugg.length > 0 && (104 <ul105 className={`absolute z-[var(--z-dropdown,700)] mt-2 w-full overflow-hidden rounded-[2px] ${106 hero107 ? "border border-[rgba(245,243,238,0.25)] bg-[var(--ink-deep)] shadow-[0_14px_40px_rgba(0,0,0,0.5)]"108 : "border border-[var(--line-strong)] bg-surface shadow-[0_10px_30px_rgba(20,24,20,0.14)]"109 }`}110 >111 {sugg.map((s, i) => (112 <li113 key={s.id}114 className={115 hero116 ? "border-b border-[rgba(245,243,238,0.1)] last:border-0"117 : "border-b border-[rgba(20,24,20,0.08)] last:border-0"118 }119 >120 <button121 className={`flex w-full items-baseline justify-between gap-3 px-4 py-3 text-left transition-colors ${122 hero ? "hover:bg-[rgba(245,243,238,0.08)]" : "hover:bg-surface-2"123 }`}124 onMouseDown={() => pick(s)}125 >126 <span className="flex min-w-0 items-baseline gap-2.5">127 <span128 className={`vp-mono shrink-0 text-[10px] ${129 hero ? "text-[var(--accent-bright)]" : "text-accent"130 }`}131 >132 {String(i + 1).padStart(2, "0")}133 </span>134 <span135 className={`vp-display truncate font-bold ${136 hero ? "text-[var(--paper-on-ink)]" : ""137 }`}138 >139 {s.adresse}140 {s.apt ? ` app. ${s.apt}` : ""}141 </span>142 </span>143 <span144 className={`vp-mono shrink-0 text-[10.5px] uppercase tracking-[0.05em] ${145 hero ? "text-[rgba(245,243,238,0.55)]" : "text-ink-3"146 }`}147 >148 {s.municipalite}149 </span>150 </button>151 </li>152 ))}153 </ul>154 )}155 {open && searched && sugg.length === 0 && q.trim().length >= 3 && (156 <div157 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] ${158 hero159 ? "border border-[rgba(245,243,238,0.25)] bg-[var(--ink-deep)] text-[rgba(245,243,238,0.7)]"160 : "border border-[var(--line-strong)] bg-surface text-ink-2"161 }`}162 >163 {t("searchNoResult")}164 </div>165 )}166 </div>167 );168}169