1{"type":"user_prompt","text":"Peux tu afficher en premier les plat ou resto récemment afficher","ts":1787439716440}2{"type":"status","state":"running"}3{"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/resto-ka","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","ToolSearch","WebFetch","WebSearch","Write"],"mcp_servers":[],"model":"claude-fable-5","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api","clear","compact","context","cost","heapdump","init","review","security-review","insights","team-onboarding"],"apiKeySource":"ANTHROPIC_API_KEY","claude_code_version":"2.1.117","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api"],"plugins":[],"uuid":"a8d512db-7990-4904-aac2-a796aab9c788","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-resto-ka/memory/"},"fast_mode_state":"off"}}4{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c7f5eab0-f506-4174-ba22-013d71a729f7","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}5{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcugehzXUXdQYrC9KrF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStgUKpwEIERgCKkAQFXmwMZCebnz4BhHlYOLC+MQC5h0eg4NcyRcszT4HS9zFZ7l2SMisFodGaIjaWb0bIU35/F71zzr9/MmpCCCjMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHr1KjUBrABAhIMhbuO27qXQFJlGff7Ggw8X0A+QOBN9+YP2TMiMKrj7f9MunRiNHdQWhIv2GfuQvBD91xhpXdQIPUsYWjyOwZ2CjwV5UPSbn3IeHc2SCq7A0TqtxcTWAkRjG0AqYqyjOgehG/OnYGWYpiYfnZaHen2K5q6RKeE41HncITaO5Fl1blDWV/2TLjieIFhlBt7XkF50o+hYb+2Rk7RqYzwBlKwderMIUKhk2f+yawfG2WeNcljNDHk6pFnunvRP0CUOZukhOfMYqQizM4g3SLgX+wQcebRS9YB5kk2HDXZhfy4a5QOBlusLCbDYJTC7ItxKhfcLEPJ1BkWzz8SL9LuT3R1fDWch3akcyMzFiuAqi3lUe4S1ToiQ7ykq3ecbmfpa/iuH//axENGFnnXSb3xowzbIBZFmdHwYL3bY7yLgTchW9xdJ7pcLDLa9WNzGPCwqXph+CwXPO7BmJAF5i4IBMo1T/HpN5w8PEFWKHpteCz/ywjGjkYsNNzqLzFVSiHwdnAAQMXtcSs+MtLpmAoiYZQSLVZeB54sPZwnA0FJsZn7rcjc/GldMJ7ZaALuZXYMqwboTi2kZvCX0FwO5TjQzianUDTqkNER4I/u5tktdc8xtJ3aNmUyP16hWk4qow9STKRf6JZrWSJ/rbxRExWmsl5/F0R+G9nlypdmagb6My0tUTAICl1RsW+4e9HCGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44611,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44611,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"39b5fd1b-09aa-4739-8330-a89ff96f3442"}}6{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcugehzXUXdQYrC9KrF","type":"message","role":"assistant","content":[{"type":"text","text":"Je vais explorer la structure du projet pour comprendre comment la liste des restos/plats est affichée avant d'ajouter le tri « récemment consultés en premier »."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44611,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44611,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"467b7a05-e14e-42f6-80d3-7f73a05739c9"}}7{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcugehzXUXdQYrC9KrF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015ds9ZoUk69Vp5u3bLyrG4z","name":"Bash","input":{"command":"ls /Users/simon-pierreboucher/apps/resto-ka && ls /Users/simon-pierreboucher/apps/resto-ka/frontend 2>/dev/null","description":"List project structure"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44611,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44611,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"eec470dc-e4d4-4d89-8d0d-3f31d3ac5c09"}}8{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015ds9ZoUk69Vp5u3bLyrG4z","type":"tool_result","content":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\nrequirements.txt\nrestoka\nrun.py\nscripts\ntests\ndist\nindex.html\nnode_modules\npackage-lock.json\npackage.json\npublic\nsrc\ntsconfig.json\ntsconfig.tsbuildinfo\nvite.config.ts","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"bdde8de4-984f-4ded-b200-b071cc4ee778","timestamp":"2026-08-22T23:02:05.516Z","tool_use_result":{"stdout":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\nrequirements.txt\nrestoka\nrun.py\nscripts\ntests\ndist\nindex.html\nnode_modules\npackage-lock.json\npackage.json\npublic\nsrc\ntsconfig.json\ntsconfig.tsbuildinfo\nvite.config.ts","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}9{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3808a481-f309-46a9-ad2e-24f622c57c30","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}10{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcvLMqwzAifYrGTM1rn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012rbyfXhzW89wrEhqnSH46b","name":"Bash","input":{"command":"ls -R /Users/simon-pierreboucher/apps/resto-ka/frontend/src | head -60","description":"List frontend source files"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":442,"cache_read_input_tokens":44611,"cache_creation":{"ephemeral_5m_input_tokens":442,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1de79325-6e05-42af-b032-c6d43e1e47dd"}}11{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012rbyfXhzW89wrEhqnSH46b","type":"tool_result","content":"App.tsx\naccount.tsx\napi.ts\ncomponents\nka\nmain.tsx\npages\nstyles.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components:\nIcons.tsx\nLogo.tsx\nPager.tsx\nRestoCard.tsx\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages:\nContact.tsx\nFavoris.tsx\nHome.tsx\nResto.tsx\nSources.tsx\nStats.tsx","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0a7b435b-d39f-45cf-9610-5c5309250a97","timestamp":"2026-08-22T23:02:10.400Z","tool_use_result":{"stdout":"App.tsx\naccount.tsx\napi.ts\ncomponents\nka\nmain.tsx\npages\nstyles.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components:\nIcons.tsx\nLogo.tsx\nPager.tsx\nRestoCard.tsx\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages:\nContact.tsx\nFavoris.tsx\nHome.tsx\nResto.tsx\nSources.tsx\nStats.tsx","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}12{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"46bb084b-36de-489f-8138-01636b8b4009","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}13{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcvh8GL7ippH6Qub8ye","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MwaqF3SejXWE5hEMee3ZB2","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":428,"cache_read_input_tokens":45053,"cache_creation":{"ephemeral_5m_input_tokens":428,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"653b8878-00f2-4990-84bf-d35ad20bab6d"}}14{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MwaqF3SejXWE5hEMee3ZB2","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File: pages/Home.tsx\n4\t// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n5\t// tous les menus), chips cuisines, barre de filtres (région, ville,\n6\t// type, diète, prix), grilles de résultats + pagination numérotée.\n7\t// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n8\t// Lou·Ka, design éditorial sharp Groupe KA).\n9\t// ==============================================================================\n10\timport { useEffect, useMemo, useState } from \"react\";\n11\timport { Link, useSearchParams } from \"react-router-dom\";\n12\timport {\n13\t CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n14\t fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n15\t} from \"../api\";\n16\timport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\n17\timport Pager from \"../components/Pager\";\n18\timport RestoCard from \"../components/RestoCard\";\n19\t\n20\tconst PAGE_SIZE = 24;\n21\tconst DISH_PAGE_SIZE = 20;\n22\t\n23\tconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n24\t \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\n25\ttype FilterKey = typeof FILTER_KEYS[number];\n26\t\n27\tfunction DishRow({ d }: { d: Dish }) {\n28\t return (\n29\t <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n30\t {d.image ? (\n31\t <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n32\t ) : (\n33\t <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n34\t )}\n35\t <div className=\"dish-main\">\n36\t <div className=\"dish-line\">\n37\t <span className=\"dish-name\">{d.item}</span>\n38\t <span className=\"mi-dots\" aria-hidden=\"true\" />\n39\t <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n40\t </div>\n41\t {d.description && <p className=\"dish-desc\">{d.description}</p>}\n42\t <div className=\"dish-meta\">\n43\t <b>{d.brand}</b>\n44\t <span className=\"sep\" />\n45\t <span>{d.section}</span>\n46\t <span className=\"sep\" />\n47\t {d.locations > 1\n48\t ? <span>{d.locations} emplacements</span>\n49\t : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n50\t <span className={`ctx-pill ctx-${d.price_context}`}>\n51\t {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n52\t </span>\n53\t </div>\n54\t </div>\n55\t </Link>\n56\t );\n57\t}\n58\t\n59\texport default function Home() {\n60\t const [params, setParams] = useSearchParams();\n61\t const [facets, setFacets] = useState<Facets | null>(null);\n62\t const [restos, setRestos] = useState<Restaurant[]>([]);\n63\t const [dishes, setDishes] = useState<Dish[]>([]);\n64\t const [total, setTotal] = useState(0);\n65\t const [loading, setLoading] = useState(true);\n66\t const [error, setError] = useState(false);\n67\t const [page, setPage] = useState(0);\n68\t const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n69\t const [sheetOpen, setSheetOpen] = useState(false);\n70\t\n71\t // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n72\t // Escape (même pattern que le menu hamburger d'App.tsx)\n73\t useEffect(() => {\n74\t document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n75\t if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n76\t const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n77\t window.addEventListener(\"keydown\", onKey);\n78\t return () => {\n79\t document.body.style.overflow = \"\";\n80\t window.removeEventListener(\"keydown\", onKey);\n81\t };\n82\t }, [sheetOpen]);\n83\t\n84\t const filters = useMemo(() => {\n85\t const f: Partial<Record<FilterKey, string>> = {};\n86\t for (const k of FILTER_KEYS) {\n87\t const v = params.get(k);\n88\t if (v) f[k] = v;\n89\t }\n90\t return f;\n91\t }, [params]);\n92\t\n93\t const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n94\t const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n95\t\n96\t const setFilter = (key: FilterKey, value: string) => {\n97\t const next = new URLSearchParams(params);\n98\t if (value) next.set(key, value);\n99\t else next.delete(key);\n100\t setParams(next, { replace: true });\n101\t setPage(0);\n102\t };\n103\t\n104\t useEffect(() => {\n105\t fetchStats()\n106\t .then((s) => setStatChips([\n107\t { label: \"restaurants\", value: s.restaurants },\n108\t { label: \"avec menu complet\", value: s.with_menu },\n109\t { label: \"plats avec prix\", value: s.items },\n110\t { label: \"régions couvertes\", value: s.regions },\n111\t ]))\n112\t .catch(() => {});\n113\t }, []);\n114\t\n115\t useEffect(() => {\n116\t fetchFacets(filters.region).then(setFacets).catch(() => {});\n117\t }, [filters.region]);\n118\t\n119\t useEffect(() => {\n120\t setLoading(true);\n121\t setError(false);\n122\t if (mode === \"plats\") {\n123\t const q = (filters.q ?? \"\").trim();\n124\t if (q.length < 2) {\n125\t setDishes([]); setTotal(0); setLoading(false);\n126\t return;\n127\t }\n128\t fetchDishes({\n129\t q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n130\t limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n131\t })\n132\t .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n133\t .catch(() => setError(true))\n134\t .finally(() => setLoading(false));\n135\t } else {\n136\t const { mode: _m, ...rest } = filters;\n137\t fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n138\t .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n139\t .catch(() => setError(true))\n140\t .finally(() => setLoading(false));\n141\t }\n142\t }, [filters, page, mode]);\n143\t\n144\t const activePills: { key: FilterKey; label: string }[] = [];\n145\t if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n146\t if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n147\t if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n148\t if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n149\t if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n150\t if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n151\t if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n152\t if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n153\t if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n154\t\n155\t const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n156\t\n157\t const filterbar = (\n158\t <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n159\t <div className=\"sheet-head\">\n160\t Filtres\n161\t <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n162\t </div>\n163\t <div className=\"f-primary\">\n164\t <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n165\t <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n166\t aria-selected={mode === \"restos\"}\n167\t onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n168\t <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n169\t aria-selected={mode === \"plats\"}\n170\t onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n171\t </div>\n172\t <label className=\"f-search\">\n173\t <IcoSearch size={16} />\n174\t <input\n175\t type=\"search\"\n176\t placeholder={mode === \"plats\"\n177\t ? \"Poutine, tartare, pad thaï, sushi…\"\n178\t : \"Resto, chaîne, ville…\"}\n179\t value={filters.q ?? \"\"}\n180\t onChange={(e) => setFilter(\"q\", e.target.value)}\n181\t aria-label=\"Recherche\"\n182\t />\n183\t {filters.q && (\n184\t <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n185\t )}\n186\t </label>\n187\t <label className=\"f-ctl\">\n188\t <span>Région</span>\n189\t <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n190\t <option value=\"\">Toutes</option>\n191\t {(facets?.regions ?? []).map((r) => (\n192\t <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n193\t ))}\n194\t </select>\n195\t </label>\n196\t <label className=\"f-ctl\">\n197\t <span>Ville</span>\n198\t <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n199\t <option value=\"\">Toutes</option>\n200\t {(facets?.cities ?? []).map((c) => (\n201\t <option key={c} value={c}>{c}</option>\n202\t ))}\n203\t </select>\n204\t </label>\n205\t {mode === \"restos\" && (\n206\t <>\n207\t <label className=\"f-ctl\">\n208\t <span>Type</span>\n209\t <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n210\t <option value=\"\">Tous</option>\n211\t {(facets?.establishment_types ?? []).map((t) => (\n212\t <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n213\t ))}\n214\t </select>\n215\t </label>\n216\t <label className=\"f-ctl\">\n217\t <span>Diète</span>\n218\t <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n219\t <option value=\"\">Toutes</option>\n220\t {(facets?.diets ?? []).map((d) => (\n221\t <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n222\t ))}\n223\t </select>\n224\t </label>\n225\t <label className=\"f-ctl\">\n226\t <span>Prix</span>\n227\t <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n228\t <option value=\"\">Tous</option>\n229\t {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n230\t <option key={p} value={p}>{p}</option>\n231\t ))}\n232\t </select>\n233\t </label>\n234\t <label className=\"f-ctl\">\n235\t <span>Menu</span>\n236\t <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n237\t <option value=\"\">Tous</option>\n238\t <option value=\"1\">Avec menu complet</option>\n239\t <option value=\"0\">Fiche seulement</option>\n240\t </select>\n241\t </label>\n242\t <label className=\"f-ctl\">\n243\t <span>Tri</span>\n244\t <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n245\t <option value=\"menu\">Menus d'abord</option>\n246\t <option value=\"name\">Nom</option>\n247\t <option value=\"price\">Fourchette de prix</option>\n248\t <option value=\"recent\">Récents</option>\n249\t </select>\n250\t </label>\n251\t </>\n252\t )}\n253\t </div>\n254\t <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n255\t Voir les {total} résultats\n256\t </button>\n257\t </div>\n258\t );\n259\t\n260\t return (\n261\t <div className=\"container\">\n262\t <section className=\"hero\">\n263\t <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n264\t <h1>\n265\t Tous les restos. Tous les menus.<br />\n266\t <span className=\"hl\">Tous les prix.</span>\n267\t </h1>\n268\t <p className=\"lede\">\n269\t Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n270\t et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n271\t le contexte du prix (salle, emporter, livraison) est toujours affiché.\n272\t </p>\n273\t <div className=\"stat-row\">\n274\t <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n275\t {statChips.map((s) => (\n276\t <div className=\"stat-chip\" key={s.label}>\n277\t <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n278\t </div>\n279\t ))}\n280\t </div>\n281\t <div className=\"benefits\">\n282\t <div className=\"benefit\">\n283\t <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n284\t <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n285\t </div>\n286\t <div className=\"benefit\">\n287\t <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n288\t <span className=\"benefit-label\">17 régions du Québec</span>\n289\t </div>\n290\t <div className=\"benefit\">\n291\t <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n292\t <span className=\"benefit-label\">Recherche par plat</span>\n293\t </div>\n294\t <div className=\"benefit\">\n295\t <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n296\t <span className=\"benefit-label\">Filtres diète & cuisine</span>\n297\t </div>\n298\t </div>\n299\t </section>\n300\t\n301\t {/* chips cuisines (défilement horizontal) */}\n302\t <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n303\t <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n304\t onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n305\t {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n306\t <button\n307\t key={c.cuisine}\n308\t className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n309\t onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n310\t >\n311\t {cuisineLabel(c.cuisine)} · {c.n}\n312\t </button>\n313\t ))}\n314\t </div>\n315\t\n316\t {filterbar}\n317\t {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n318\t <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n319\t <IcoSearch size={16} /> Filtres\n320\t </button>\n321\t\n322\t {activePills.length > 0 && (\n323\t <div className=\"pills\">\n324\t {activePills.map((p) => (\n325\t <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n326\t {p.label} <span className=\"pill-x\">✕</span>\n327\t </button>\n328\t ))}\n329\t <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n330\t </div>\n331\t )}\n332\t\n333\t <div className=\"results-head\">\n334\t <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n335\t <span>\n336\t {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n337\t ? \"tapez un plat (min. 2 lettres)\"\n338\t : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n339\t </span>\n340\t </div>\n341\t\n342\t {error && (\n343\t <div className=\"notice\">\n344\t <h2>Impossible de charger les résultats</h2>\n345\t <p>Réessayez dans un instant.</p>\n346\t </div>\n347\t )}\n348\t\n349\t {loading ? (\n350\t <div className=\"grid\">\n351\t {Array.from({ length: 8 }).map((_, i) => (\n352\t <div className=\"skel\" key={i}>\n353\t <div className=\"sk-img\" />\n354\t <div className=\"sk-line\" />\n355\t <div className=\"sk-line short\" />\n356\t </div>\n357\t ))}\n358\t </div>\n359\t ) : mode === \"plats\" ? (\n360\t (filters.q ?? \"\").trim().length < 2 ? (\n361\t <div className=\"notice\">\n362\t <h2>Cherchez n'importe quel plat</h2>\n363\t <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n364\t du Québec sont indexés, avec leurs prix réels.</p>\n365\t </div>\n366\t ) : dishes.length === 0 ? (\n367\t <div className=\"notice\">\n368\t <h2>Aucun plat trouvé</h2>\n369\t <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n370\t </div>\n371\t ) : (\n372\t <>\n373\t <div className=\"dish-list\">\n374\t {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n375\t </div>\n376\t <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n377\t </>\n378\t )\n379\t ) : restos.length === 0 && !error ? (\n380\t <div className=\"notice\">\n381\t <h2>Aucun resto ne correspond</h2>\n382\t <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n383\t </div>\n384\t ) : (\n385\t <>\n386\t <div className=\"grid\">\n387\t {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n388\t </div>\n389\t <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n390\t </>\n391\t )}\n392\t </div>\n393\t );\n394\t}\n395\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"28946169-7d5e-4297-8c2a-e8c75f447556","timestamp":"2026-08-22T23:02:15.365Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Home.tsx\n// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n// tous les menus), chips cuisines, barre de filtres (région, ville,\n// type, diète, prix), grilles de résultats + pagination numérotée.\n// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n// Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n return (\n <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n {d.image ? (\n <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n ) : (\n <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n )}\n <div className=\"dish-main\">\n <div className=\"dish-line\">\n <span className=\"dish-name\">{d.item}</span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n </div>\n {d.description && <p className=\"dish-desc\">{d.description}</p>}\n <div className=\"dish-meta\">\n <b>{d.brand}</b>\n <span className=\"sep\" />\n <span>{d.section}</span>\n <span className=\"sep\" />\n {d.locations > 1\n ? <span>{d.locations} emplacements</span>\n : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n <span className={`ctx-pill ctx-${d.price_context}`}>\n {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n </span>\n </div>\n </div>\n </Link>\n );\n}\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState<Facets | null>(null);\n const [restos, setRestos] = useState<Restaurant[]>([]);\n const [dishes, setDishes] = useState<Dish[]>([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(false);\n const [page, setPage] = useState(0);\n const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);\n\n // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n // Escape (même pattern que le menu hamburger d'App.tsx)\n useEffect(() => {\n document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n window.addEventListener(\"keydown\", onKey);\n return () => {\n document.body.style.overflow = \"\";\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [sheetOpen]);\n\n const filters = useMemo(() => {\n const f: Partial<Record<FilterKey, string>> = {};\n for (const k of FILTER_KEYS) {\n const v = params.get(k);\n if (v) f[k] = v;\n }\n return f;\n }, [params]);\n\n const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n const setFilter = (key: FilterKey, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value);\n else next.delete(key);\n setParams(next, { replace: true });\n setPage(0);\n };\n\n useEffect(() => {\n fetchStats()\n .then((s) => setStatChips([\n { label: \"restaurants\", value: s.restaurants },\n { label: \"avec menu complet\", value: s.with_menu },\n { label: \"plats avec prix\", value: s.items },\n { label: \"régions couvertes\", value: s.regions },\n ]))\n .catch(() => {});\n }, []);\n\n useEffect(() => {\n fetchFacets(filters.region).then(setFacets).catch(() => {});\n }, [filters.region]);\n\n useEffect(() => {\n setLoading(true);\n setError(false);\n if (mode === \"plats\") {\n const q = (filters.q ?? \"\").trim();\n if (q.length < 2) {\n setDishes([]); setTotal(0); setLoading(false);\n return;\n }\n fetchDishes({\n q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n })\n .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n } else {\n const { mode: _m, ...rest } = filters;\n fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n }\n }, [filters, page, mode]);\n\n const activePills: { key: FilterKey; label: string }[] = [];\n if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n const filterbar = (\n <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n <div className=\"sheet-head\">\n Filtres\n <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n </div>\n <div className=\"f-primary\">\n <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"restos\"}\n onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"plats\"}\n onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n </div>\n <label className=\"f-search\">\n <IcoSearch size={16} />\n <input\n type=\"search\"\n placeholder={mode === \"plats\"\n ? \"Poutine, tartare, pad thaï, sushi…\"\n : \"Resto, chaîne, ville…\"}\n value={filters.q ?? \"\"}\n onChange={(e) => setFilter(\"q\", e.target.value)}\n aria-label=\"Recherche\"\n />\n {filters.q && (\n <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n )}\n </label>\n <label className=\"f-ctl\">\n <span>Région</span>\n <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n <option value=\"\">Toutes</option>\n {(facets?.regions ?? []).map((r) => (\n <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Ville</span>\n <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.cities ?? []).map((c) => (\n <option key={c} value={c}>{c}</option>\n ))}\n </select>\n </label>\n {mode === \"restos\" && (\n <>\n <label className=\"f-ctl\">\n <span>Type</span>\n <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n <option value=\"\">Tous</option>\n {(facets?.establishment_types ?? []).map((t) => (\n <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Diète</span>\n <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.diets ?? []).map((d) => (\n <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Prix</span>\n <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n <option value=\"\">Tous</option>\n {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n <option key={p} value={p}>{p}</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Menu</span>\n <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n <option value=\"\">Tous</option>\n <option value=\"1\">Avec menu complet</option>\n <option value=\"0\">Fiche seulement</option>\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Tri</span>\n <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n <option value=\"menu\">Menus d'abord</option>\n <option value=\"name\">Nom</option>\n <option value=\"price\">Fourchette de prix</option>\n <option value=\"recent\">Récents</option>\n </select>\n </label>\n </>\n )}\n </div>\n <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n Voir les {total} résultats\n </button>\n </div>\n );\n\n return (\n <div className=\"container\">\n <section className=\"hero\">\n <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n <h1>\n Tous les restos. Tous les menus.<br />\n <span className=\"hl\">Tous les prix.</span>\n </h1>\n <p className=\"lede\">\n Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n le contexte du prix (salle, emporter, livraison) est toujours affiché.\n </p>\n <div className=\"stat-row\">\n <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n {statChips.map((s) => (\n <div className=\"stat-chip\" key={s.label}>\n <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n </div>\n ))}\n </div>\n <div className=\"benefits\">\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n <span className=\"benefit-label\">17 régions du Québec</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n <span className=\"benefit-label\">Recherche par plat</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n <span className=\"benefit-label\">Filtres diète & cuisine</span>\n </div>\n </div>\n </section>\n\n {/* chips cuisines (défilement horizontal) */}\n <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n <button\n key={c.cuisine}\n className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n >\n {cuisineLabel(c.cuisine)} · {c.n}\n </button>\n ))}\n </div>\n\n {filterbar}\n {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n <IcoSearch size={16} /> Filtres\n </button>\n\n {activePills.length > 0 && (\n <div className=\"pills\">\n {activePills.map((p) => (\n <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n {p.label} <span className=\"pill-x\">✕</span>\n </button>\n ))}\n <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n </div>\n )}\n\n <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n <span>\n {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n ? \"tapez un plat (min. 2 lettres)\"\n : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n </span>\n </div>\n\n {error && (\n <div className=\"notice\">\n <h2>Impossible de charger les résultats</h2>\n <p>Réessayez dans un instant.</p>\n </div>\n )}\n\n {loading ? (\n <div className=\"grid\">\n {Array.from({ length: 8 }).map((_, i) => (\n <div className=\"skel\" key={i}>\n <div className=\"sk-img\" />\n <div className=\"sk-line\" />\n <div className=\"sk-line short\" />\n </div>\n ))}\n </div>\n ) : mode === \"plats\" ? (\n (filters.q ?? \"\").trim().length < 2 ? (\n <div className=\"notice\">\n <h2>Cherchez n'importe quel plat</h2>\n <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n du Québec sont indexés, avec leurs prix réels.</p>\n </div>\n ) : dishes.length === 0 ? (\n <div className=\"notice\">\n <h2>Aucun plat trouvé</h2>\n <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"dish-list\">\n {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )\n ) : restos.length === 0 && !error ? (\n <div className=\"notice\">\n <h2>Aucun resto ne correspond</h2>\n <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"grid\">\n {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )}\n </div>\n );\n}\n","numLines":395,"startLine":1,"totalLines":395}}}}15{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b80206db-0f99-4682-9078-fa4ca393ab86","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}16{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9AoKpwEIERgCKkAzlXdDau0huz9E3jibHT/9swBm3RHONi2Q+piiL87mRyVZUBS339/Adqljx+Hm1KuLAN7LNulgPTBy8Ekp1EMVMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGD1ajUBrABAhIMYwriawTI1U3vrXu7GgxzRISE3hwiBjl5GQ8iMPeMvm1knwUGCHAyFFLQJYVBkPhpkajGNuUZ6Rcl+o6fZSRmyflAwCxNOJ25UKW4Qir5CGvYxgTfCj6sQlgjDHTqzjmGV3tCJFRJOazmeAikh1ZIHLE+gE0uC/LZTxIUzKuxi9i3RZ6jJthVCt0Lj6GfQw7nbANAFEnLnNtZ1Lb3nsqppVsmR8x4u47dYz6MVv8iK2lQxEMnbOfRJ4KWk8/iC34VXDZrCBQVx8vQjIYsNlGnF9x/flrRlpEzO8yqjxMryRl93lygFiIlUgVITUiarfdR5RY71r3kB0knPS14ln6fTbBD9J7H4oWYd/T7AYMTZMfrFkWQxueeS5IYip5a4gogxlMn0x2fwEflX0rtILJ2lRoTJa6ufUkQ6gndxXVlky3y3sjzxjCQdGQD1CbPc8PpsiVR/ClIfNWU8p4ICuuIsk1kqkR6m8E9Qa0nmBudUqtqNsAbJeyHrHXHK6yRdDFp7lLADxaldOyktBKr+uAysUQl0HD4hf7J9aU1qUxeHFkXrNRCm4gjax5g6HDatlQC1ubIuLsaNNcf+LpLes7E+sALP9MVdCCA5zES0bNydkcnZ/0manC7ARJraooYtm4+7OCnC0BvzH57ke4/O+7WhsUhBG+ZxAI8n1y/urX4zAGRTATuE1xGf7b5H9ni64U8qCPgFIUYM2BbwyLlAgaVUnRHzrmHx7NoARDWZ2YPsTbrhgAvrxtkSPJBOwk3BQj8ari1XhAcZ9rVbESF2FsIMGOEXry8WObxiqi6Pj0bXjHiuwmuVNjzr1BJ61vu91NU1Z8Sx6/mLe6L49wot6sp6CtFVBS9JQhqvOgcRoXy8pVf9Q5kWU2tIIY3Au4Gj2by8P8d/H+DW6pbRIBOTw1SZUUYYYLfger6mwgi9lyOsIB7qBsc/jDORFjCmV9zSWpoVzKWDQizZHWyoIQsLSe1FGhhjWv5O025/H9EvsnopG4L2iN+7bMZLvq01TNYtY+27C9OXGAwQY3rQx/YKDWnwrHEcAQNoC1oC60tSMnwj8is1jMU+dn7T1ZtFvIu1q7IhIr55sh172o33oGPIdcJfguVTmzcwB4MvXTBy6sZhBd3dGsWyjLAuJYL8qlkIe0LVvhvPEToYktmpZURiGPqNbzOpqy3pHiViLq+2gyx6auELtZj5QC7n7XCv/WiKKSZbBqNJNzvgsyQwbijg0y4ifCpYjHbixyrr7h4wa+P+V/y8lkwt/mRqBtEB11498zMXkggCbrQBuCBDFadqdGx3PkmezpCzN2hci/XBhu1UxiW1zv3zDl+VGNPtuX9WLVtZRQifstM5oUvgG8kCiHA70jX6QHIYfEPD+Nvl/VVjrrH9kO68EBmZPUOYV3MtdYLiAqFZigk8GNhlNnJAbLHc0EFcSt7/r4eELpjr1hZTv4gwuf2HlgG8cdScBQTsQgTyj5F0OA1IM9jP1TKAzCQUBfZFhEiJ2UOsemd162gOBw4PiMCLj0FAHvt8QQ6qejAZEttVb5t11jG7zmwU9kqhKfF5bss1w/RkXqh2Dy9vKDiUxUD1hTabkfQ/IIGfv4gki/D1DEMcg1WC4/DeKVWOqD4XZGIbQ4IGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ee826a95-895f-41b7-bd70-f932c75d3b99"}}17{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"text","text":"La demande : afficher en premier les restos/plats **récemment consultés**. Je regarde comment la page Favoris et la fiche resto fonctionnent pour réutiliser le même patron (localStorage)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9017c54e-b32a-447d-8579-cd8e66421036"}}18{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0113rPBxtatomb9F8WCukZQ7","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Favoris.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3642358b-b8f0-4a78-be65-fb81a9015e18"}}19{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0113rPBxtatomb9F8WCukZQ7","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File: pages/Favoris.tsx\n4\t// Desc: Restos favoris du membre connecté (« Se connecter avec KA »).\n5\t// ==============================================================================\n6\timport { useEffect, useState } from \"react\";\n7\timport { useAccount } from \"../account\";\n8\timport { Restaurant, fetchFavoris } from \"../api\";\n9\timport RestoCard from \"../components/RestoCard\";\n10\t\n11\texport default function FavorisPage() {\n12\t const { me, favs } = useAccount();\n13\t const [restos, setRestos] = useState<Restaurant[]>([]);\n14\t const [loading, setLoading] = useState(true);\n15\t\n16\t useEffect(() => {\n17\t if (!me) { setLoading(false); return; }\n18\t fetchFavoris()\n19\t .then((f) => setRestos(f.restaurants))\n20\t .catch(() => {})\n21\t .finally(() => setLoading(false));\n22\t }, [me, favs.size]);\n23\t\n24\t if (!me) {\n25\t return (\n26\t <div className=\"notice container\">\n27\t <h2>Connectez-vous pour vos favoris</h2>\n28\t <p>\n29\t <a className=\"login-btn\" href=\"/api/auth/ka/login\" style={{ display: \"inline-flex\" }}>\n30\t <span className=\"ka-badge\" aria-hidden=\"true\">KA</span>\n31\t Se connecter avec KA\n32\t </a>\n33\t </p>\n34\t <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p>\n35\t </div>\n36\t );\n37\t }\n38\t\n39\t return (\n40\t <div className=\"container sources\">\n41\t <span className=\"kicker\">Mon compte — {me.ka_id}</span>\n42\t <h1>Mes restos favoris</h1>\n43\t <p className=\"sub\">Les restos que vous avez épinglés, sur tous vos appareils.</p>\n44\t {loading ? (\n45\t <p>Chargement…</p>\n46\t ) : restos.length === 0 ? (\n47\t <div className=\"notice\">\n48\t <h2>Aucun favori pour l'instant</h2>\n49\t <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p>\n50\t </div>\n51\t ) : (\n52\t <div className=\"grid\">\n53\t {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n54\t </div>\n55\t )}\n56\t </div>\n57\t );\n58\t}\n59\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e99ae954-6e7e-45b7-9036-85f19fbe2edc","timestamp":"2026-08-22T23:02:28.507Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Favoris.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Favoris.tsx\n// Desc: Restos favoris du membre connecté (« Se connecter avec KA »).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { useAccount } from \"../account\";\nimport { Restaurant, fetchFavoris } from \"../api\";\nimport RestoCard from \"../components/RestoCard\";\n\nexport default function FavorisPage() {\n const { me, favs } = useAccount();\n const [restos, setRestos] = useState<Restaurant[]>([]);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n if (!me) { setLoading(false); return; }\n fetchFavoris()\n .then((f) => setRestos(f.restaurants))\n .catch(() => {})\n .finally(() => setLoading(false));\n }, [me, favs.size]);\n\n if (!me) {\n return (\n <div className=\"notice container\">\n <h2>Connectez-vous pour vos favoris</h2>\n <p>\n <a className=\"login-btn\" href=\"/api/auth/ka/login\" style={{ display: \"inline-flex\" }}>\n <span className=\"ka-badge\" aria-hidden=\"true\">KA</span>\n Se connecter avec KA\n </a>\n </p>\n <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p>\n </div>\n );\n }\n\n return (\n <div className=\"container sources\">\n <span className=\"kicker\">Mon compte — {me.ka_id}</span>\n <h1>Mes restos favoris</h1>\n <p className=\"sub\">Les restos que vous avez épinglés, sur tous vos appareils.</p>\n {loading ? (\n <p>Chargement…</p>\n ) : restos.length === 0 ? (\n <div className=\"notice\">\n <h2>Aucun favori pour l'instant</h2>\n <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p>\n </div>\n ) : (\n <div className=\"grid\">\n {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n )}\n </div>\n );\n}\n","numLines":59,"startLine":1,"totalLines":59}}}}20{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0196WrkD2RWj6j1e8tbPmAHb","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"27a19826-58bf-466d-90c8-00174903e838"}}21{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0196WrkD2RWj6j1e8tbPmAHb","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File: api.ts\n4\t// Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR\n5\t// (cuisines, contextes de prix, régions). Même patron que Lou·Ka.\n6\t// ==============================================================================\n7\t\n8\texport interface MenuOptionChoice { name: string; price_delta: number }\n9\texport interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }\n10\t\n11\texport interface MenuItem {\n12\t id?: string;\n13\t name: string;\n14\t description?: string;\n15\t price: number | null;\n16\t currency?: string;\n17\t tags?: string[];\n18\t options?: MenuOption[];\n19\t image?: string;\n20\t}\n21\t\n22\texport interface MenuSection { name: string; items: MenuItem[]; image?: string }\n23\t\n24\texport interface Menu {\n25\t price_context: \"dine-in\" | \"takeout\" | \"delivery\";\n26\t price_source: string;\n27\t currency: string;\n28\t captured_at: string;\n29\t item_count: number;\n30\t sections: MenuSection[];\n31\t}\n32\t\n33\texport interface MenuSummary {\n34\t image?: string | null;\n35\t price_context: string;\n36\t price_source: string;\n37\t captured_at: string;\n38\t item_count: number;\n39\t price_min: number | null;\n40\t price_median: number | null;\n41\t}\n42\t\n43\texport interface Restaurant {\n44\t uid: string;\n45\t source: string;\n46\t external_id: string;\n47\t name: string;\n48\t chain: string | null;\n49\t cuisines: string[];\n50\t establishment_type: string;\n51\t price_range: string;\n52\t address: string;\n53\t city: string;\n54\t region: string;\n55\t postal_code: string;\n56\t lat: number | null;\n57\t lng: number | null;\n58\t phone: string;\n59\t website: string;\n60\t url: string;\n61\t hours: Record<string, string>;\n62\t services: string[];\n63\t dietary_options: string[];\n64\t languages: string[];\n65\t images: string[];\n66\t status: string;\n67\t menu_summary?: MenuSummary | null;\n68\t menus?: Menu[];\n69\t recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];\n70\t dup_sources?: string[];\n71\t first_seen?: number;\n72\t last_seen: number;\n73\t updated_at: number;\n74\t active: number;\n75\t}\n76\t\n77\texport interface Facets {\n78\t regions: { region: string; n: number }[];\n79\t all_regions: string[];\n80\t cities: string[];\n81\t cuisines: { cuisine: string; n: number }[];\n82\t diets: { diet: string; n: number }[];\n83\t establishment_types: { t: string; n: number }[];\n84\t chains: string[];\n85\t sources: { source: string; n: number }[];\n86\t}\n87\t\n88\texport interface Source {\n89\t id: string;\n90\t name: string;\n91\t url: string;\n92\t platform?: string;\n93\t tier?: number;\n94\t price_context?: string;\n95\t extraction?: string;\n96\t cadence?: string;\n97\t status: string;\n98\t active_restaurants: number;\n99\t menu_items: number;\n100\t last_sync: number | null;\n101\t integrations?: { label: string; status: string }[];\n102\t}\n103\t\n104\texport interface Stats {\n105\t restaurants: number;\n106\t with_menu: number;\n107\t chains: number;\n108\t regions: number;\n109\t sources: number;\n110\t menus: number;\n111\t items: number;\n112\t by_region: { region: string; n: number }[];\n113\t by_context: { price_context: string; n: number }[];\n114\t recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];\n115\t}\n116\t\n117\t// --- libellés français --------------------------------------------------------\n118\t\n119\texport const CUISINE_LABELS: Record<string, string> = {\n120\t \"quebecois\": \"Québécois\", \"francais\": \"Français\", \"italien\": \"Italien\",\n121\t \"pizza\": \"Pizza\", \"burgers\": \"Burgers\", \"poulet\": \"Poulet\",\n122\t \"bbq-grillades\": \"BBQ & grillades\", \"fruits-de-mer\": \"Fruits de mer\",\n123\t \"sushi-japonais\": \"Sushi & japonais\", \"chinois\": \"Chinois\", \"thai\": \"Thaï\",\n124\t \"vietnamien\": \"Vietnamien\", \"coreen\": \"Coréen\", \"indien\": \"Indien\",\n125\t \"libanais-moyen-orient\": \"Libanais & Moyen-Orient\", \"mexicain\": \"Mexicain\",\n126\t \"grec\": \"Grec\", \"mediterraneen\": \"Méditerranéen\",\n127\t \"dejeuner-brunch\": \"Déjeuner & brunch\", \"cafe-dessert\": \"Café & dessert\",\n128\t \"vegetarien-vegan\": \"Végé & vegan\", \"fast-food\": \"Fast-food\", \"autre\": \"Autre\",\n129\t};\n130\t\n131\texport const CONTEXT_LABELS: Record<string, string> = {\n132\t \"dine-in\": \"Prix en salle\",\n133\t \"takeout\": \"Prix pour emporter\",\n134\t \"delivery\": \"Prix livraison (majoré 25-30 %)\",\n135\t};\n136\t\n137\texport const CONTEXT_SHORT: Record<string, string> = {\n138\t \"dine-in\": \"Salle\", \"takeout\": \"Emporter\", \"delivery\": \"Livraison\",\n139\t};\n140\t\n141\texport const TYPE_LABELS: Record<string, string> = {\n142\t \"restaurant\": \"Restaurant\", \"fast-food\": \"Fast-food\", \"cafe\": \"Café\",\n143\t \"bar\": \"Bar & pub\", \"food-truck\": \"Food truck\", \"traiteur\": \"Traiteur\",\n144\t \"boulangerie-patisserie\": \"Boulangerie-pâtisserie\",\n145\t \"casse-croute\": \"Casse-croûte\", \"microbrasserie\": \"Microbrasserie\",\n146\t \"hotel\": \"Resto d'hôtel\", \"ghost-kitchen\": \"Cuisine fantôme\",\n147\t};\n148\t\n149\texport const DIET_LABELS: Record<string, string> = {\n150\t \"vegan\": \"Vegan\", \"vegetarien\": \"Végétarien\", \"sans-gluten\": \"Sans gluten\",\n151\t \"halal\": \"Halal\", \"casher\": \"Casher\", \"sans-noix\": \"Sans noix\", \"epice\": \"Épicé\",\n152\t};\n153\t\n154\texport const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;\n155\texport const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;\n156\texport const dietLabel = (d: string) => DIET_LABELS[d] ?? d;\n157\t\n158\tconst SOURCE_NAMES: Record<string, string> = {\n159\t ueat: \"UEAT (commande en ligne)\",\n160\t osm: \"OpenStreetMap\",\n161\t};\n162\texport function registerSourceNames(sources: Source[]) {\n163\t for (const s of sources) SOURCE_NAMES[s.id] = s.name;\n164\t}\n165\texport const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;\n166\t\n167\t/** 16.5 -> « 16,50 $ » */\n168\texport const fmtPrice = (p: number | null | undefined): string =>\n169\t p == null ? \"—\" : `${p.toFixed(2).replace(\".\", \",\")} $`;\n170\t\n171\texport const fmtDate = (iso: string | null | undefined): string => {\n172\t if (!iso) return \"—\";\n173\t const d = new Date(iso);\n174\t return isNaN(d.getTime()) ? iso : d.toLocaleDateString(\"fr-CA\");\n175\t};\n176\t\n177\texport const fmtTs = (ts: number | null | undefined): string =>\n178\t ts ? new Date(ts * 1000).toLocaleString(\"fr-CA\") : \"—\";\n179\t\n180\t// --- client -------------------------------------------------------------------\n181\t\n182\tasync function get<T>(path: string): Promise<T> {\n183\t const ctl = new AbortController();\n184\t const timer = setTimeout(() => ctl.abort(), 20000);\n185\t try {\n186\t const resp = await fetch(path, { signal: ctl.signal });\n187\t if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);\n188\t return (await resp.json()) as T;\n189\t } finally {\n190\t clearTimeout(timer);\n191\t }\n192\t}\n193\t\n194\texport interface Dish {\n195\t item: string;\n196\t description: string;\n197\t price: number | null;\n198\t currency: string;\n199\t image?: string | null;\n200\t tags: string[];\n201\t section: string;\n202\t price_context: string;\n203\t price_source: string;\n204\t captured_at: string;\n205\t brand: string;\n206\t restaurant: string;\n207\t uid: string;\n208\t city: string;\n209\t region: string;\n210\t locations: number;\n211\t}\n212\t\n213\texport interface DishQuery {\n214\t q: string; region?: string; city?: string; cuisine?: string;\n215\t price_max?: number; limit?: number; offset?: number;\n216\t}\n217\t\n218\texport function fetchDishes(query: DishQuery) {\n219\t const params = new URLSearchParams();\n220\t for (const [k, v] of Object.entries(query))\n221\t if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n222\t return get<{ total: number; count: number; dishes: Dish[] }>(\n223\t `/api/dishes?${params.toString()}`);\n224\t}\n225\t\n226\texport interface RestaurantQuery {\n227\t region?: string; city?: string; cuisine?: string; establishment_type?: string;\n228\t diet?: string; service?: string; price_range?: string; chain?: string;\n229\t source?: string; q?: string; has_menu?: string; sort?: string;\n230\t limit?: number; offset?: number;\n231\t}\n232\t\n233\texport function fetchRestaurants(query: RestaurantQuery = {}) {\n234\t const params = new URLSearchParams();\n235\t for (const [k, v] of Object.entries(query))\n236\t if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n237\t return get<{ total: number; count: number; restaurants: Restaurant[] }>(\n238\t `/api/restaurants?${params.toString()}`);\n239\t}\n240\t\n241\texport const fetchRestaurant = (uid: string) =>\n242\t get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n243\t\n244\texport const fetchFacets = (region?: string) =>\n245\t get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);\n246\t\n247\texport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n248\t\n249\texport const fetchStats = () => get<Stats>(\"/api/stats\");\n250\t\n251\t// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------\n252\t\n253\texport interface Me {\n254\t uid: number;\n255\t ka_id: string;\n256\t email: string;\n257\t name: string;\n258\t picture: string;\n259\t bio?: string;\n260\t city?: string;\n261\t public_url?: string;\n262\t profile_source: string;\n263\t}\n264\t\n265\texport const fetchAuthConfig = () => get<{ ka: boolean }>(\"/api/auth/config\");\n266\t\n267\texport async function fetchMe(): Promise<Me | null> {\n268\t try {\n269\t return await get<Me>(\"/api/me\");\n270\t } catch {\n271\t return null;\n272\t }\n273\t}\n274\t\n275\texport async function logout(): Promise<void> {\n276\t await fetch(\"/api/auth/logout\", { method: \"POST\" });\n277\t}\n278\t\n279\texport const fetchFavoris = () =>\n280\t get<{ count: number; restaurants: Restaurant[]; uids: string[] }>(\"/api/favoris\");\n281\t\n282\texport async function toggleFavori(uid: string): Promise<boolean> {\n283\t const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: \"POST\" });\n284\t if (!resp.ok) throw new Error(`API ${resp.status}`);\n285\t return (await resp.json()).favori as boolean;\n286\t}\n287\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"04bed392-c6d4-45ef-ac49-7717d435fda1","timestamp":"2026-08-22T23:02:28.577Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: api.ts\n// Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR\n// (cuisines, contextes de prix, régions). Même patron que Lou·Ka.\n// ==============================================================================\n\nexport interface MenuOptionChoice { name: string; price_delta: number }\nexport interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }\n\nexport interface MenuItem {\n id?: string;\n name: string;\n description?: string;\n price: number | null;\n currency?: string;\n tags?: string[];\n options?: MenuOption[];\n image?: string;\n}\n\nexport interface MenuSection { name: string; items: MenuItem[]; image?: string }\n\nexport interface Menu {\n price_context: \"dine-in\" | \"takeout\" | \"delivery\";\n price_source: string;\n currency: string;\n captured_at: string;\n item_count: number;\n sections: MenuSection[];\n}\n\nexport interface MenuSummary {\n image?: string | null;\n price_context: string;\n price_source: string;\n captured_at: string;\n item_count: number;\n price_min: number | null;\n price_median: number | null;\n}\n\nexport interface Restaurant {\n uid: string;\n source: string;\n external_id: string;\n name: string;\n chain: string | null;\n cuisines: string[];\n establishment_type: string;\n price_range: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n lat: number | null;\n lng: number | null;\n phone: string;\n website: string;\n url: string;\n hours: Record<string, string>;\n services: string[];\n dietary_options: string[];\n languages: string[];\n images: string[];\n status: string;\n menu_summary?: MenuSummary | null;\n menus?: Menu[];\n recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];\n dup_sources?: string[];\n first_seen?: number;\n last_seen: number;\n updated_at: number;\n active: number;\n}\n\nexport interface Facets {\n regions: { region: string; n: number }[];\n all_regions: string[];\n cities: string[];\n cuisines: { cuisine: string; n: number }[];\n diets: { diet: string; n: number }[];\n establishment_types: { t: string; n: number }[];\n chains: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n platform?: string;\n tier?: number;\n price_context?: string;\n extraction?: string;\n cadence?: string;\n status: string;\n active_restaurants: number;\n menu_items: number;\n last_sync: number | null;\n integrations?: { label: string; status: string }[];\n}\n\nexport interface Stats {\n restaurants: number;\n with_menu: number;\n chains: number;\n regions: number;\n sources: number;\n menus: number;\n items: number;\n by_region: { region: string; n: number }[];\n by_context: { price_context: string; n: number }[];\n recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];\n}\n\n// --- libellés français --------------------------------------------------------\n\nexport const CUISINE_LABELS: Record<string, string> = {\n \"quebecois\": \"Québécois\", \"francais\": \"Français\", \"italien\": \"Italien\",\n \"pizza\": \"Pizza\", \"burgers\": \"Burgers\", \"poulet\": \"Poulet\",\n \"bbq-grillades\": \"BBQ & grillades\", \"fruits-de-mer\": \"Fruits de mer\",\n \"sushi-japonais\": \"Sushi & japonais\", \"chinois\": \"Chinois\", \"thai\": \"Thaï\",\n \"vietnamien\": \"Vietnamien\", \"coreen\": \"Coréen\", \"indien\": \"Indien\",\n \"libanais-moyen-orient\": \"Libanais & Moyen-Orient\", \"mexicain\": \"Mexicain\",\n \"grec\": \"Grec\", \"mediterraneen\": \"Méditerranéen\",\n \"dejeuner-brunch\": \"Déjeuner & brunch\", \"cafe-dessert\": \"Café & dessert\",\n \"vegetarien-vegan\": \"Végé & vegan\", \"fast-food\": \"Fast-food\", \"autre\": \"Autre\",\n};\n\nexport const CONTEXT_LABELS: Record<string, string> = {\n \"dine-in\": \"Prix en salle\",\n \"takeout\": \"Prix pour emporter\",\n \"delivery\": \"Prix livraison (majoré 25-30 %)\",\n};\n\nexport const CONTEXT_SHORT: Record<string, string> = {\n \"dine-in\": \"Salle\", \"takeout\": \"Emporter\", \"delivery\": \"Livraison\",\n};\n\nexport const TYPE_LABELS: Record<string, string> = {\n \"restaurant\": \"Restaurant\", \"fast-food\": \"Fast-food\", \"cafe\": \"Café\",\n \"bar\": \"Bar & pub\", \"food-truck\": \"Food truck\", \"traiteur\": \"Traiteur\",\n \"boulangerie-patisserie\": \"Boulangerie-pâtisserie\",\n \"casse-croute\": \"Casse-croûte\", \"microbrasserie\": \"Microbrasserie\",\n \"hotel\": \"Resto d'hôtel\", \"ghost-kitchen\": \"Cuisine fantôme\",\n};\n\nexport const DIET_LABELS: Record<string, string> = {\n \"vegan\": \"Vegan\", \"vegetarien\": \"Végétarien\", \"sans-gluten\": \"Sans gluten\",\n \"halal\": \"Halal\", \"casher\": \"Casher\", \"sans-noix\": \"Sans noix\", \"epice\": \"Épicé\",\n};\n\nexport const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;\nexport const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;\nexport const dietLabel = (d: string) => DIET_LABELS[d] ?? d;\n\nconst SOURCE_NAMES: Record<string, string> = {\n ueat: \"UEAT (commande en ligne)\",\n osm: \"OpenStreetMap\",\n};\nexport function registerSourceNames(sources: Source[]) {\n for (const s of sources) SOURCE_NAMES[s.id] = s.name;\n}\nexport const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;\n\n/** 16.5 -> « 16,50 $ » */\nexport const fmtPrice = (p: number | null | undefined): string =>\n p == null ? \"—\" : `${p.toFixed(2).replace(\".\", \",\")} $`;\n\nexport const fmtDate = (iso: string | null | undefined): string => {\n if (!iso) return \"—\";\n const d = new Date(iso);\n return isNaN(d.getTime()) ? iso : d.toLocaleDateString(\"fr-CA\");\n};\n\nexport const fmtTs = (ts: number | null | undefined): string =>\n ts ? new Date(ts * 1000).toLocaleString(\"fr-CA\") : \"—\";\n\n// --- client -------------------------------------------------------------------\n\nasync function get<T>(path: string): Promise<T> {\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(path, { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface Dish {\n item: string;\n description: string;\n price: number | null;\n currency: string;\n image?: string | null;\n tags: string[];\n section: string;\n price_context: string;\n price_source: string;\n captured_at: string;\n brand: string;\n restaurant: string;\n uid: string;\n city: string;\n region: string;\n locations: number;\n}\n\nexport interface DishQuery {\n q: string; region?: string; city?: string; cuisine?: string;\n price_max?: number; limit?: number; offset?: number;\n}\n\nexport function fetchDishes(query: DishQuery) {\n const params = new URLSearchParams();\n for (const [k, v] of Object.entries(query))\n if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n return get<{ total: number; count: number; dishes: Dish[] }>(\n `/api/dishes?${params.toString()}`);\n}\n\nexport interface RestaurantQuery {\n region?: string; city?: string; cuisine?: string; establishment_type?: string;\n diet?: string; service?: string; price_range?: string; chain?: string;\n source?: string; q?: string; has_menu?: string; sort?: string;\n limit?: number; offset?: number;\n}\n\nexport function fetchRestaurants(query: RestaurantQuery = {}) {\n const params = new URLSearchParams();\n for (const [k, v] of Object.entries(query))\n if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n return get<{ total: number; count: number; restaurants: Restaurant[] }>(\n `/api/restaurants?${params.toString()}`);\n}\n\nexport const fetchRestaurant = (uid: string) =>\n get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\nexport const fetchFacets = (region?: string) =>\n get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get<Stats>(\"/api/stats\");\n\n// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------\n\nexport interface Me {\n uid: number;\n ka_id: string;\n email: string;\n name: string;\n picture: string;\n bio?: string;\n city?: string;\n public_url?: string;\n profile_source: string;\n}\n\nexport const fetchAuthConfig = () => get<{ ka: boolean }>(\"/api/auth/config\");\n\nexport async function fetchMe(): Promise<Me | null> {\n try {\n return await get<Me>(\"/api/me\");\n } catch {\n return null;\n }\n}\n\nexport async function logout(): Promise<void> {\n await fetch(\"/api/auth/logout\", { method: \"POST\" });\n}\n\nexport const fetchFavoris = () =>\n get<{ count: number; restaurants: Restaurant[]; uids: string[] }>(\"/api/favoris\");\n\nexport async function toggleFavori(uid: string): Promise<boolean> {\n const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: \"POST\" });\n if (!resp.ok) throw new Error(`API ${resp.status}`);\n return (await resp.json()).favori as boolean;\n}\n","numLines":287,"startLine":1,"totalLines":287}}}}22{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d5a5f967-3dc7-4d64-b3e2-341095e4d6fe","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}23{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIShwcKpwEIERgCKkDlPPs2Iw84p7VAl3lsuf+QDPJ077fRIx9WMbG3e19VFKBtMgvWCrEvmaTbMlTbgCWWNco4RiIIDUpHTATbSY/tMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGN1ajUBrABAhIMh6l44+3FLFUAzt6TGgz+AaP13y+Xhl/fa98iMMxhD4t3z3YawADnjNzokpKZMByuqEkNgaeAq7ypfiu8/AeRXkijPqth41IeO+JMDiqMBbsbD/zwz6Ksk5dFcQ6NCE6g3gVGgPwJmEf254at07B5AA7TksW6xTIH3TN81ODYL5CZdzzOetBXjhDT3PZWb9UxjYSTgBAN/vZGK+MaJAiEzwJYwdeTCkmM8CGMdSu9Ykd3EXPQipCCDnMoTtA6rNUG4r3H14UFsNtwJArDwCC7z2ONjip3SxBphsaEL2CQZJd1vY1hde5B1Ry/sB1jfISea6HehOzG0sL+Yj8WgbYLTMyvkUs4kyBK92vALGUGh2iwWofxH/0o/4MmIQ+7ZAQNT0chWi2fDix6597IYWfWKodvUleBWapUHTZLvO8SaLzcRicutUzi8aFxTfAXB6uE11bs96pES/G0uWXz/2Wdfw/f6Sow3AFm9c5Sq1uE8Z7VSmKqvL64VBnNn0XOlyPd29awZMTr2IWtN6N58SNYNg8dkiiGGe0xeEqx25aCZVOjJsaJC+dfWiBtKdhgumZVu8DGIENSWkqF6bHCZ2vSBmjVWh97/0c3qm15Ha1yb4ywjoLB9+jajuWu4Wb6Vk8N4xNg7H4Y/wNIFt6oKivyKYUOoVkAgztp7MOCoC2+v8HQn75rqoGQlz5Yq+DnDmoVP8bw/Rq+IcxirOd3heWe/cnaLRkmqCDdgy0S1apxTtGNphelnw+u4saST2u33xRW4SOpZHflW3vCsXBY3HOrh+uNb6hkWeZkbthYnuUPO/9oKMzeXGt8IYB4mZjYgc+7yW/HVFoygEk6S7lMrkTEjXnqy29eg7D0TJNfPxJn74OJJWjX4PXTVpL9Dsb9Uf6E8TQYywrfPx9KPAHLYKNwQHxU2do3FvW+yNjnS6cAuWxCs/1KTlwPJFtxm927pOdzTbSnfvW+GunpU80YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9c1608c5-8f69-4c48-91e8-b9451a9465b1"}}24{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjAQKqAEIERgCKkAKb3pdSrxUGpKRN9hYBDA1fuQaAbj06Pq3xcxBRpUgWiH+jfJnZg/i1eW0q5bPSgj4d/EgVz5xNEodTtSycscmMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQi06A1NuRRJo41amXnK2lOYgBAagBjdWo1AawAQISDF8ZYuuW9jSuulUGBRoMqrVGFaOQzrfWSbz5IjAyiamw6MRDN7vfDZQKDboCeFmOnXT52nDAgmSGT8YG14HHTIkrL7iuBMrCg5iXYYYqkAK8XHQmQuVG4T18yVAiqW7fuoI/8PPGOGv2l2ohctJgdAktfZ4pQIjoZrmApWtGUx8pxga+6/+KEYwzfQHAGVxgzahGHKCa7tl6Huqq4YwA01+jYdpv5JCxhlrErY85TGmvrIO+xZAe5zGQ0WxvzJt6y9GPLqBfe609+9Iow52aF3I8f98CIN+LhhCkFC70jg/zKIDDnOg4PJ4xDT3YpXXoE89DHilGt99ppvwEP+6TZuQ4u8Yt6CqPLsYYXJ+0yVjJtGx6CV++IQzC87vOvOzNyGrXh0QAATFsTMnmEbHLBBVqro/ae/jqYjSvt3tzEczC2tRIV03ay89U5hIATpOlbB8og/GZVCvSC/ZZxV5FkRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"892551cf-0ac6-4b97-9e10-e740d6820f15"}}25{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RybvL1vESbCEsKuD2fGrsJ","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","limit":80},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ef3ab84b-4018-4821-8f0f-fa45fd1cdbab"}}26{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RybvL1vESbCEsKuD2fGrsJ","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File: pages/Resto.tsx\n4\t// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n5\t// + menu complet par sections avec prix, options et badge de CONTEXTE\n6\t// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n7\t// est inutilisable ; delivery affiché clairement étiqueté).\n8\t// ==============================================================================\n9\timport { useEffect, useState } from \"react\";\n10\timport { Link, useParams } from \"react-router-dom\";\n11\timport {\n12\t CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n13\t fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n14\t} from \"../api\";\n15\timport { IcoCompass } from \"../components/Icons\";\n16\t\n17\tfunction MenuBlock({ menu }: { menu: Menu }) {\n18\t const [openSections, setOpenSections] = useState<Set<number>>(\n19\t new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n20\t const toggle = (i: number) => {\n21\t const next = new Set(openSections);\n22\t if (next.has(i)) next.delete(i);\n23\t else next.add(i);\n24\t setOpenSections(next);\n25\t };\n26\t return (\n27\t <section className=\"menu-block\">\n28\t <div className={`ctx-banner ctx-${menu.price_context}`}>\n29\t <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n30\t <span>\n31\t source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n32\t {\" · \"}{menu.item_count} plats\n33\t </span>\n34\t </div>\n35\t {menu.sections.map((sec, i) => (\n36\t <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n37\t <button className=\"menu-section-head\" onClick={() => toggle(i)}\n38\t aria-expanded={openSections.has(i)}>\n39\t {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n40\t <h3>{sec.name}</h3>\n41\t <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n42\t </button>\n43\t {openSections.has(i) && (\n44\t <ul className=\"menu-items\">\n45\t {sec.items.map((it, j) => (\n46\t <li className=\"menu-item\" key={`${it.name}-${j}`}>\n47\t <div className=\"mi-row\">\n48\t <div className=\"mi-main\">\n49\t <div className=\"mi-line\">\n50\t <span className=\"mi-name\">\n51\t {it.name}\n52\t {(it.tags ?? []).map((t) => (\n53\t <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n54\t ))}\n55\t </span>\n56\t <span className=\"mi-dots\" aria-hidden=\"true\" />\n57\t <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n58\t </div>\n59\t {it.description && <p className=\"mi-desc\">{it.description}</p>}\n60\t </div>\n61\t {it.image && (\n62\t <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n63\t )}\n64\t </div>\n65\t {(it.options ?? []).length > 0 && (\n66\t <div className=\"mi-optgroups\">\n67\t {(it.options ?? []).map((op, k) => (\n68\t <div className=\"og\" key={`${op.group}-${k}`}>\n69\t <div className=\"og-head\">\n70\t <span className=\"og-name\">{op.group}</span>\n71\t <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n72\t {op.required ? \"Choix requis\" : \"Optionnel\"}\n73\t </span>\n74\t </div>\n75\t <div className=\"og-choices\">\n76\t {op.choices.map((c, m) => (\n77\t <span className=\"og-choice\" key={`${c.name}-${m}`}>\n78\t {c.name}\n79\t {c.price_delta !== 0 && (\n80\t <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d98795a8-dd3f-48be-b745-7dce732d6392","timestamp":"2026-08-22T23:02:38.620Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Resto.tsx\n// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n// + menu complet par sections avec prix, options et badge de CONTEXTE\n// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n// est inutilisable ; delivery affiché clairement étiqueté).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoCompass } from \"../components/Icons\";\n\nfunction MenuBlock({ menu }: { menu: Menu }) {\n const [openSections, setOpenSections] = useState<Set<number>>(\n new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n const toggle = (i: number) => {\n const next = new Set(openSections);\n if (next.has(i)) next.delete(i);\n else next.add(i);\n setOpenSections(next);\n };\n return (\n <section className=\"menu-block\">\n <div className={`ctx-banner ctx-${menu.price_context}`}>\n <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n <span>\n source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n {\" · \"}{menu.item_count} plats\n </span>\n </div>\n {menu.sections.map((sec, i) => (\n <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n <button className=\"menu-section-head\" onClick={() => toggle(i)}\n aria-expanded={openSections.has(i)}>\n {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n <h3>{sec.name}</h3>\n <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n </button>\n {openSections.has(i) && (\n <ul className=\"menu-items\">\n {sec.items.map((it, j) => (\n <li className=\"menu-item\" key={`${it.name}-${j}`}>\n <div className=\"mi-row\">\n <div className=\"mi-main\">\n <div className=\"mi-line\">\n <span className=\"mi-name\">\n {it.name}\n {(it.tags ?? []).map((t) => (\n <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n ))}\n </span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n </div>\n {it.description && <p className=\"mi-desc\">{it.description}</p>}\n </div>\n {it.image && (\n <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n )}\n </div>\n {(it.options ?? []).length > 0 && (\n <div className=\"mi-optgroups\">\n {(it.options ?? []).map((op, k) => (\n <div className=\"og\" key={`${op.group}-${k}`}>\n <div className=\"og-head\">\n <span className=\"og-name\">{op.group}</span>\n <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n {op.required ? \"Choix requis\" : \"Optionnel\"}\n </span>\n </div>\n <div className=\"og-choices\">\n {op.choices.map((c, m) => (\n <span className=\"og-choice\" key={`${c.name}-${m}`}>\n {c.name}\n {c.price_delta !== 0 && (\n <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>","numLines":80,"startLine":1,"totalLines":242}}}}27{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011kaTNC7SHuY9ZssT8Vv6r3","name":"Grep","input":{"pattern":"uids|/api/restaurants","path":"/Users/simon-pierreboucher/apps/resto-ka/restoka","output_mode":"content","-n":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3034a17f-18fe-4af9-8959-5651bb46157a"}}28{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011kaTNC7SHuY9ZssT8Vv6r3","type":"tool_result","content":"restoka/permits.py:119: seen_uids: set[str] = set()\nrestoka/permits.py:165: if hit is None or hit in seen_uids:\nrestoka/permits.py:167: seen_uids.add(hit)\nrestoka/auth.py:270: \"uids\": [r[\"uid\"] for r in out]}\nrestoka/dedup.py:107: for uids in groups.values():\nrestoka/dedup.py:108: uids = sorted(uids)\nrestoka/dedup.py:109: for other in uids[1:]:\nrestoka/dedup.py:110: union(uids[0], other)\nrestoka/web.py:68:def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\nrestoka/web.py:70: if not uids:\nrestoka/web.py:72: qmarks = \",\".join(\"?\" * len(uids))\nrestoka/web.py:76: f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\nrestoka/web.py:107:@app.get(\"/api/restaurants\")\nrestoka/web.py:251:@app.get(\"/api/restaurants/{uid:path}/prices\")\nrestoka/web.py:262:@app.get(\"/api/restaurants/{uid:path}/inspections\")\nrestoka/web.py:284:@app.get(\"/api/restaurants/{uid:path}\")\nrestoka/db.py:325: seen_uids = set()\nrestoka/db.py:345: seen_uids.add(resto.uid)\nrestoka/db.py:410: if r[\"uid\"] in seen_uids:\nrestoka/stats.py:564: seen_uids: set[str] = set()\nrestoka/stats.py:572: if r[\"uid\"] in seen_uids:\nrestoka/stats.py:574: seen_uids.add(r[\"uid\"])"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1f7d9705-d154-4c5e-bf1d-147764e9f107","timestamp":"2026-08-22T23:02:39.313Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"restoka/permits.py:119: seen_uids: set[str] = set()\nrestoka/permits.py:165: if hit is None or hit in seen_uids:\nrestoka/permits.py:167: seen_uids.add(hit)\nrestoka/auth.py:270: \"uids\": [r[\"uid\"] for r in out]}\nrestoka/dedup.py:107: for uids in groups.values():\nrestoka/dedup.py:108: uids = sorted(uids)\nrestoka/dedup.py:109: for other in uids[1:]:\nrestoka/dedup.py:110: union(uids[0], other)\nrestoka/web.py:68:def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\nrestoka/web.py:70: if not uids:\nrestoka/web.py:72: qmarks = \",\".join(\"?\" * len(uids))\nrestoka/web.py:76: f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\nrestoka/web.py:107:@app.get(\"/api/restaurants\")\nrestoka/web.py:251:@app.get(\"/api/restaurants/{uid:path}/prices\")\nrestoka/web.py:262:@app.get(\"/api/restaurants/{uid:path}/inspections\")\nrestoka/web.py:284:@app.get(\"/api/restaurants/{uid:path}\")\nrestoka/db.py:325: seen_uids = set()\nrestoka/db.py:345: seen_uids.add(resto.uid)\nrestoka/db.py:410: if r[\"uid\"] in seen_uids:\nrestoka/stats.py:564: seen_uids: set[str] = set()\nrestoka/stats.py:572: if r[\"uid\"] in seen_uids:\nrestoka/stats.py:574: seen_uids.add(r[\"uid\"])","numLines":22}}}29{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5917c3dc-583f-462d-91c1-896aed0a42d3","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}30{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcxooQhvAma6Kh7zww6","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjwMKpwEIERgCKkDKVQTwY07b1i65UETHTn/nG8zwl8CBaxvAsXls96A8CpjtYZefdPkNKDtwEb6GM2hOH4oL+tvFBh1dgF+8aFUfMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGT1ajUBrABAhIM9fjm/FA3sO60o6Y7GgwhPe+fKBsFECdFp2siMPp7cIzmVrJ01szqRs9dunfU+lKwBE9e1FkFDR6Mpw8tnAzKV2L6LBuzLTfkgVeHUCqUAYE9cP+rI3SkvsTX4Vj4SBqLNIbSnZCVpF3J+VgPljQen4470SP00P1YrqgdIiGryyEU+sQbgMFlVER/gjV0cgFbjeYUPZFK1dq3lCKjfcE8BnOAK/Ttqumv/BoyZ55995r26FgTuoVh/U/7t6RHOmjajD7SEwWkAc8nvJnA8Km0v8Uq6ULKqFT2bmYvfsQ933ONeU0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2861,"cache_read_input_tokens":58710,"cache_creation":{"ephemeral_5m_input_tokens":2861,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3e23d32d-47f5-401a-bab8-4896f7f849b0"}}31{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcxooQhvAma6Kh7zww6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G83XTyDDAF797VKvgvbgAv","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","offset":100,"limit":110},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2861,"cache_read_input_tokens":58710,"cache_creation":{"ephemeral_5m_input_tokens":2861,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"219cbc96-4f88-4ba4-b27e-665c7f70c20a"}}32{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G83XTyDDAF797VKvgvbgAv","type":"tool_result","content":"100\t\n101\t\n102\t@app.get(\"/healthz\")\n103\tdef healthz():\n104\t return {\"status\": \"ok\"}\n105\t\n106\t\n107\t@app.get(\"/api/restaurants\")\n108\tdef list_restaurants(\n109\t region: str | None = None,\n110\t city: str | None = None,\n111\t cuisine: str | None = None,\n112\t establishment_type: str | None = None,\n113\t diet: str | None = None, # vegan, sans-gluten…\n114\t service: str | None = None, # takeout, delivery, salle\n115\t price_range: str | None = None, # $ à $$$$\n116\t chain: str | None = None,\n117\t source: str | None = None,\n118\t q: str | None = None,\n119\t has_menu: int | None = None, # 1 = seulement les restos avec menu\n120\t active: int = 1,\n121\t sort: str = \"menu\", # menu | name | price | recent\n122\t limit: int = Query(60, le=500),\n123\t offset: int = 0,\n124\t):\n125\t con = db.connect()\n126\t sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\" # doublons masqués\n127\t args: list = []\n128\t if active in (0, 1):\n129\t sql += \" AND active=?\"; args.append(active)\n130\t if region:\n131\t sql += \" AND region=?\"; args.append(region)\n132\t if city:\n133\t sql += \" AND city=?\"; args.append(city)\n134\t if cuisine:\n135\t sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n136\t if establishment_type:\n137\t sql += \" AND establishment_type=?\"; args.append(establishment_type)\n138\t if diet:\n139\t sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n140\t if service:\n141\t sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n142\t if price_range:\n143\t sql += \" AND price_range=?\"; args.append(price_range)\n144\t if chain:\n145\t sql += \" AND chain=?\"; args.append(chain)\n146\t if source:\n147\t sql += \" AND source=?\"; args.append(source)\n148\t if q:\n149\t sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n150\t args += [f\"%{q}%\"] * 4\n151\t if has_menu == 1:\n152\t sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n153\t elif has_menu == 0:\n154\t sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n155\t total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n156\t if sort == \"recent\":\n157\t sql += \" ORDER BY updated_at DESC\"\n158\t elif sort == \"price\":\n159\t sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n160\t elif sort == \"name\":\n161\t sql += \" ORDER BY name COLLATE NOCASE ASC\"\n162\t else: # défaut « menu » : les restos avec menu complet d'abord\n163\t sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n164\t \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n165\t sql += \" LIMIT ? OFFSET ?\"\n166\t args += [limit, offset]\n167\t rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n168\t menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n169\t for r in rows:\n170\t r[\"menu_summary\"] = menus.get(r[\"uid\"])\n171\t con.close()\n172\t return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n173\t\n174\t\n175\t@app.get(\"/api/dishes\")\n176\tdef search_dishes(\n177\t q: str = Query(..., min_length=2),\n178\t region: str | None = None,\n179\t city: str | None = None,\n180\t cuisine: str | None = None,\n181\t price_max: float | None = None,\n182\t limit: int = Query(30, le=100),\n183\t offset: int = 0,\n184\t):\n185\t \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n186\t\n187\t Les succursales d'une même chaîne partagent le même menu : les résultats\n188\t sont regroupés par (marque, plat, prix, contexte) avec le nombre\n189\t d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n190\t \"\"\"\n191\t con = db.connect()\n192\t needle = q.strip().lower()\n193\t sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n194\t \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n195\t \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n196\t \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n197\t args: list = [f\"%{needle}%\"]\n198\t if region:\n199\t sql += \" AND r.region=?\"; args.append(region)\n200\t if city:\n201\t sql += \" AND r.city=?\"; args.append(city)\n202\t if cuisine:\n203\t sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n204\t\n205\t groups: dict[tuple, dict] = {}\n206\t for row in con.execute(sql, args):\n207\t try:\n208\t sections = json.loads(row[\"sections\"] or \"[]\")\n209\t except ValueError:"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"6a4f8dbd-1d7e-4da4-9c84-fad0737850a6","timestamp":"2026-08-22T23:02:44.640Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","content":"\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/restaurants\")\ndef list_restaurants(\n region: str | None = None,\n city: str | None = None,\n cuisine: str | None = None,\n establishment_type: str | None = None,\n diet: str | None = None, # vegan, sans-gluten…\n service: str | None = None, # takeout, delivery, salle\n price_range: str | None = None, # $ à $$$$\n chain: str | None = None,\n source: str | None = None,\n q: str | None = None,\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,\n sort: str = \"menu\", # menu | name | price | recent\n limit: int = Query(60, le=500),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if cuisine:\n sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n if establishment_type:\n sql += \" AND establishment_type=?\"; args.append(establishment_type)\n if diet:\n sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n if service:\n sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n if price_range:\n sql += \" AND price_range=?\"; args.append(price_range)\n if chain:\n sql += \" AND chain=?\"; args.append(chain)\n if source:\n sql += \" AND source=?\"; args.append(source)\n if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4\n if has_menu == 1:\n sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n elif has_menu == 0:\n sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"recent\":\n sql += \" ORDER BY updated_at DESC\"\n elif sort == \"price\":\n sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n elif sort == \"name\":\n sql += \" ORDER BY name COLLATE NOCASE ASC\"\n else: # défaut « menu » : les restos avec menu complet d'abord\n sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n for r in rows:\n r[\"menu_summary\"] = menus.get(r[\"uid\"])\n con.close()\n return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n\n\n@app.get(\"/api/dishes\")\ndef search_dishes(\n q: str = Query(..., min_length=2),\n region: str | None = None,\n city: str | None = None,\n cuisine: str | None = None,\n price_max: float | None = None,\n limit: int = Query(30, le=100),\n offset: int = 0,\n):\n \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n\n Les succursales d'une même chaîne partagent le même menu : les résultats\n sont regroupés par (marque, plat, prix, contexte) avec le nombre\n d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n \"\"\"\n con = db.connect()\n needle = q.strip().lower()\n sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n args: list = [f\"%{needle}%\"]\n if region:\n sql += \" AND r.region=?\"; args.append(region)\n if city:\n sql += \" AND r.city=?\"; args.append(city)\n if cuisine:\n sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n\n groups: dict[tuple, dict] = {}\n for row in con.execute(sql, args):\n try:\n sections = json.loads(row[\"sections\"] or \"[]\")\n except ValueError:","numLines":110,"startLine":100,"totalLines":517}}}}33{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"53b0d38e-1cb2-413b-b10c-1b7b2752e06d","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}34{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4xQKpwEIERgCKkCWfnmbJnUiQUMLM0Z6CmvJKqpUx6aJtrEVhgGUzCvolz5LOQkTt9HPLCMA8iRj7RSzA4+itTbW0IM51XtvtCTcMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGo1ajUBrABAhIM1aKrocwZBejRA/WtGgwL8zDz3PL/4c0cGHEiMFvon3gW8ZrgHF6jhuAowMcIQHGPeFOHoaXKYOCjxBHptr1OuPrRxfBhzp2Q4W36tSroEgoRAPn0bRVpTYh2EICVGcNhD9ANgAJ4b8iWG6HBwY72fN7iP6Pcfs0rccKGx2C8fRfSUDR7nUSOyyL98LH9CyA/iix6i0PbRJL7zb92Dzl0VUVb2yzyLEVQLPaXxRfoo0euN0wzL4h7pdOOaSPEcOIVgummByWx7WpDC7927UUcTvFLDk0sb426O1vs0h32gpBTjuJUu8+Y4B795/lrtxlWg32glPzgMhES6acir/m6cxc6gG+dhVD9RtA7M+FtUTP5EPZr2VCBb059+9DyN+wAYSn3fSD/2JXhHtFc3jMCRcPnGQ2RAamBTwMYuDYCZ2n1snoSZVZoGCQyEYImQNApvceoKtXXvFwJfF7MOqcFoq089AgruRPhgM/l42QgtYRkAZhVPu9IHTrwnr2QaXXx4bdmNCbtX8XqY6I1KsqIWLdUcZIv1x9J72y/tcdQWU/s2y6FJSvOfx+3SZyPFYljROhTwps0faJoyKsph8vU22t8UP8UJqbPmrqRPW1A5glLWT7lKwHn8evjMKb0KSdUT4vytal/9wQwIR4uAciqVggCDefvX+laMdNy2xEAp9WFJcv1z8L0pmrjUHkTfpvXUTU/MbgV6zvUbUpm6ltkOfkez/f+yBxatT9ZpuQjU5Elu004v0YdgG1MRNvC3srivDyUoh9WRQLlmWQqnDEwQWAZARYulrmeWjeVK9MWUdn25xSEIoJKnlCpuuO/i0GtvjiCjtnA9naRpwDkKZetMYnf2EY/KlACAl1kx21Jh9ycyJ7OM0rl1DLjlvaYtGoVdzag0Y2TZxzvQV8Ns8fEVoIqcWVKv2DP87Gg/trZNJQRamnJT/1xBr86IZGO+rRlDbQ1YnY9Le5b1cCwu4Uh4T9qm7Qx7eK5eflqyaQa1lglMZhtLs9MTfJVa7Mr4QywMP8wzhQ5LlBLjj9CGonGg1PFlB1SEsyYtGjvPeFE1vLbTbQwX+ED3hayRR54AHYergvewB7UGxY1z6xg7qypoC15nUp/DovWC9OwC5h+uU871xTsMV9jkT2V+lScbiy4mj/WdfFeRIJg5r2008lvFkMY7bkPqrozaXFWd1q28HjndWdDan881q8T2L2RUHml1dt24kwjcULW+ULUKmAnSY1F3aFEhmX0x+WaWFCjZsdZbnvgCA+cIehGcw13Aa3lYh7pWgdl8/Kauaw/0SqRv8ADaLINyq7q6gf4/tt6DjRi6bWah2ESRg58nHCPcIaG3iRgL7mRShXm4EAtzgLoF20VpeMvqSqChHPn7op8AZF/9xaGNcXpkgVaF7cvQesmGdaApPzpB44R8GYRDX3Rj2rqovx6javhfXIJgCNbOgz5IG/t8mxdptk2rvZn0O9T9mu/e1enrAMVdEtqVSE3ceLuuzKMeSHX8EadFvtTJKxjUksNymw7kmAlFQ5fhCE3aXLPAtcg2byjshOYqnHhuJfHdA3eDDpwrGbU7ZsgxeNJHhUnaAn4IVCpblal63v9rm+B/5DdZ+MogO/Y3oL8uYFKf7q8D/9TPE+HCZGqsjvvYRBZjq//h/jxk1T/TyXfdzGcBOIH1mtCAuXvjcuSXANnpbitxDIqtt56gYjJ/kRit+GNh/UrUcYdaocL3S6EKlqx+zKHzex2Mt/687tk5RVdNl00YNvJYPNp+52g49SDofG4ALmhIhB8rRcydKzLe7rukY5Dpiwmnan0VFFTV0FJ3remJoqfHAFdvWEzN39B8Dw1KsedJdA3W5D5ztZh/Vsq4U0dpIWvTULq+TAL9jiKj+v43LeNsjPllhMXM3QUdixAIwbY1wK7yTc1Glmmwk9/gdKCi5sWCX7q8SYMi7OXUGK51/M2fA8RZoPm68ucon23VTwjsHHDACqBFHmEwfLSb86HwKu6Wf7mcHGbEieJAT9pYS2kMhHGj/lLjPFdYweMQqVnFEXxU8BQLTEf6KgRqzabHZ7TmOS/ZL1BYZ9jjJOsSFwenMT6Cx0ITihz/p9gyA1+8IDrl0uPEOph/1TNj1AFgN4K3plkWQMYq4fkYFHtrzg2LFn3u4a0f7670BbYpIj7PL/Ku0mG7yX+ms4+jpTN44zIET4VtEI+t7s24Ij3XOlAEsw+GrG38FdaBqEmD89rClvs7bHyJ2ZOxhYWldXaF1CfJ4H2BYq71jnjKjG8vZyGiL1aRGhCpccjq+AObpnsDRS5Iv2nqEzLhRtB7fsQhKZXjCUAES1kvJLG1/5/q3QTpzGO4h8E8o123zs0CDlNBbKZrwzHViyMbHGqwnx6CoAgsMZMZ+pXMbILy8ibrIk8trWnBjB8opqBzGir64BdFLcD0lUhlzkVvDnm9tHLJTfIPhlBRaFEqwOqfCu0vxX99YYNsRzc6W4omA3YBLb1Fdf0TEt10gd7YcNEFNfDV3iR1qNMB3F+WmPWnDEKJxnuYT0gLYIV4Xgw8kg/+ccJu/LDZ+sykLHZFScAu7o7RcLVs3Y02rL0sV8/KKhhZxrv0WPcrV2VkwOzNPPjDuE8kwzZVWoVf5jlRZGz9OKv0ND2j2QkX62XOmPdkNh3Cdmi9H5Zi1hksxYdvjRvrN+XELTCeXpxuz8AjqqrlT/MAg7JuJ0UO2dSHDV1cVIVCJ1NzOAqPJu1ZVL4tWlveKJ7m1Zf1cMV/NKPoiHd2rfzrnggyCpi/WERDktuyopw83fVjhmoHsbs/ErfMjwrvCUuX5/tmSDm2j+OUVbaWVdSnv30EkmV9uuhBP65462vEfjXY+jji5C+jxABVuCFH+qTMAkG5pyX4uLk2C26IOkvDjdx1KfwGBZOPZCL2Z1zY3iiEM1HTmCzX4FTwujpNyoEZfxHRBtq+vbP/1ChN/HaKYYXqiLKIlE+8wBA97gXuujprYL5rxhIkYfJIwUCbmdP/zQpMZ9vsFJBqGOccwWYrM080adIVUlib8yZCtW0u1B4OkC/HvQU5v1O+jnFjL3G1FdycE0eNpM6lkkUTamgUN+d+uF2zcpK+hm7rPVknaxb1w1YXYXbQvYJuyl1q93dCilEGn1FDL69nh4s+0Q15ZjmKUqRDsHA2MticjMF+Y9e7tH4ZFaPQegLk6SDYPBKZ0u5YWazOoGYrxJCILIgalAh2ylg5LTpKH+E62dxKJo6hQC1Oqsf6P1v0QqMLHfvGUspjlU51rqLpnHiB9JHdV/qvKx3QKYtL+C1hQGzyi/YCX0GxTW2zvihJJMBb4LbGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"2ecd571f-2542-445a-a403-1780ffdd2d96"}}35{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISwQQKqAEIERgCKkCMdXJYr7sjvJbfnYQnWKKIclj6cWVRj8AuJAbFfN2VaH5APXmNzFWqAT3HgIsDStjXlP+0YzovQ3GI8WxKVwVjMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQi06A1NuRRJo41amXnK2lOYgBAagBqNWo1AawAQISDGUiZNacajsOUq2RhxoM1VhA/71DJPl1kKC+IjAS58a3sW27L4u0UvrkhMoc1nOR8fo0QQOgzWpeP9hykPFl0gATxT5k5gef7xZZVVsqxQIWyV4e0FbfTOJ6Wnt2gBW1e2zu9S//sFo95pputcO2jStsJmOdiARRNHGiJfKiDqOV5Yhv8AEW3t9agw4opCaZevTi+3jhgi7MRCDqps7lj1AFjiXxgxrKUTdMfuZXhMHLns0SeywI0Q8h1i/VGuxhue1y1y5gXoa+6OLm8o4o6qNLYUqwuIU85mxG2gqasYULmB7e8GKFZFsCi1u4Y26uempRK5Osk6UtQlStAkDcLAP34+npLYgfpcC7WFPNpvMVO+YuZEat6ge1WHDiPjahwoEotMcas4II/1dA/sEMlyothR+AdsLpJAde4b2oldgxFZu8zDDSJv1KtypFrxFFH+WurKcGAtLvgqy/dZXSFZFqDk5wzpqFF4qSfFJYh7DCMYfMFZUHhnvB0IzLmANydGdiDzpa+1XTvbWfhv8kEny3uW7SGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"a8b83c85-7d76-4653-b131-3c825edbbd93"}}36{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LrQTPp29dsDE3cawcNghWL","name":"Grep","input":{"pattern":"^\\.grid|\\.results-head","path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","output_mode":"content","-n":true,"-A":6},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"69e5f5fa-b239-4a61-bad9-26445275ed4b"}}37{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LrQTPp29dsDE3cawcNghWL","type":"tool_result","content":"217:.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n218:.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n219:.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n220:.grid {\n221- display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n222- gap: 22px; padding-bottom: 30px;\n223-}\n224-@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n225-\n226-.card {\n--\n650: .results-head { margin-top: 20px; }\n651- .chips { margin-right: -16px; padding-right: 16px; }\n652- .notice { padding: 48px 16px; }\n653- .menu-items { padding: 0 14px 12px; }\n654- .menu-section-head { padding: 13px 14px; }\n655- .mi-thumb { width: 56px; height: 56px; }\n656-}"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"51dfc704-9389-4996-b146-c0d7c5e371ee","timestamp":"2026-08-22T23:03:05.477Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"217:.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n218:.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n219:.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n220:.grid {\n221- display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n222- gap: 22px; padding-bottom: 30px;\n223-}\n224-@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n225-\n226-.card {\n--\n650: .results-head { margin-top: 20px; }\n651- .chips { margin-right: -16px; padding-right: 16px; }\n652- .notice { padding: 48px 16px; }\n653- .menu-items { padding: 0 14px 12px; }\n654- .menu-section-head { padding: 13px 14px; }\n655- .mi-thumb { width: 56px; height: 56px; }\n656-}","numLines":18}}}38{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013mtW5zRiywigpnfa7MvyCb","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components/RestoCard.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"63869124-4f74-4565-96c0-1208a73fb302"}}39{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013mtW5zRiywigpnfa7MvyCb","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File: components/RestoCard.tsx\n4\t// Desc: Carte de restaurant (grille de résultats) — même patron que la\n5\t// ListingCard de Lou·Ka : prix en tête, badge de contexte de prix\n6\t// TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.\n7\t// ==============================================================================\n8\timport { Link } from \"react-router-dom\";\n9\timport { useAccount } from \"../account\";\n10\timport {\n11\t CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,\n12\t} from \"../api\";\n13\timport { IcoPlate } from \"./Icons\";\n14\t\n15\texport default function RestoCard({ r }: { r: Restaurant }) {\n16\t const ms = r.menu_summary;\n17\t const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord\n18\t const logo = (r.images ?? [])[0] || null;\n19\t const { me, favs, toggleFav } = useAccount();\n20\t const fav = favs.has(r.uid);\n21\t return (\n22\t <Link to={`/resto/${encodeURIComponent(r.uid)}`} className=\"card\">\n23\t <div className=\"card-img\">\n24\t {img ? (\n25\t <img src={img} alt={r.name} loading=\"lazy\" />\n26\t ) : logo ? (\n27\t <div className=\"noimg\"><img className=\"card-logo\" src={logo} alt={r.name} loading=\"lazy\" /></div>\n28\t ) : (\n29\t <div className=\"noimg\"><IcoPlate size={38} strokeWidth={1.6} /></div>\n30\t )}\n31\t {r.price_range && <span className=\"badge type\">{r.price_range}</span>}\n32\t {ms && (\n33\t <span className={`badge right ctx ctx-${ms.price_context}`}>\n34\t {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}\n35\t </span>\n36\t )}\n37\t {me && (\n38\t <button\n39\t className={`fav-btn ${fav ? \"on\" : \"\"}`}\n40\t aria-label={fav ? \"Retirer des favoris\" : \"Ajouter aux favoris\"}\n41\t aria-pressed={fav}\n42\t onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }}\n43\t >\n44\t {fav ? \"♥\" : \"♡\"}\n45\t </button>\n46\t )}\n47\t </div>\n48\t <div className=\"card-body\">\n49\t <div className=\"card-price\">\n50\t {ms?.price_median != null ? (\n51\t <>\n52\t {fmtPrice(ms.price_median)} <small>plat médian</small>\n53\t </>\n54\t ) : (\n55\t <span className=\"no-menu\">Menu à venir</span>\n56\t )}\n57\t </div>\n58\t <div className=\"card-title\">{r.name}</div>\n59\t <div className=\"card-meta\">\n60\t <span>{typeLabel(r.establishment_type)}</span>\n61\t {r.cuisines.slice(0, 2).map((c) => (\n62\t <span key={c}>\n63\t <span className=\"sep\" /> {cuisineLabel(c)}\n64\t </span>\n65\t ))}\n66\t </div>\n67\t <div className=\"card-meta\">\n68\t {r.city && <span>{r.city}</span>}\n69\t {r.city && r.region && <span className=\"sep\" />}\n70\t {r.region && <span>{r.region}</span>}\n71\t </div>\n72\t <div className=\"card-foot\">\n73\t <span className=\"source-tag\">{sourceName(r.source)}</span>\n74\t {ms && <span className=\"avail\">{ms.item_count} plats au menu</span>}\n75\t </div>\n76\t </div>\n77\t </Link>\n78\t );\n79\t}\n80\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d3b860dd-fe78-4c0f-9e01-2dff76b83c91","timestamp":"2026-08-22T23:03:05.763Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components/RestoCard.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: components/RestoCard.tsx\n// Desc: Carte de restaurant (grille de résultats) — même patron que la\n// ListingCard de Lou·Ka : prix en tête, badge de contexte de prix\n// TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.\n// ==============================================================================\nimport { Link } from \"react-router-dom\";\nimport { useAccount } from \"../account\";\nimport {\n CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoPlate } from \"./Icons\";\n\nexport default function RestoCard({ r }: { r: Restaurant }) {\n const ms = r.menu_summary;\n const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord\n const logo = (r.images ?? [])[0] || null;\n const { me, favs, toggleFav } = useAccount();\n const fav = favs.has(r.uid);\n return (\n <Link to={`/resto/${encodeURIComponent(r.uid)}`} className=\"card\">\n <div className=\"card-img\">\n {img ? (\n <img src={img} alt={r.name} loading=\"lazy\" />\n ) : logo ? (\n <div className=\"noimg\"><img className=\"card-logo\" src={logo} alt={r.name} loading=\"lazy\" /></div>\n ) : (\n <div className=\"noimg\"><IcoPlate size={38} strokeWidth={1.6} /></div>\n )}\n {r.price_range && <span className=\"badge type\">{r.price_range}</span>}\n {ms && (\n <span className={`badge right ctx ctx-${ms.price_context}`}>\n {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}\n </span>\n )}\n {me && (\n <button\n className={`fav-btn ${fav ? \"on\" : \"\"}`}\n aria-label={fav ? \"Retirer des favoris\" : \"Ajouter aux favoris\"}\n aria-pressed={fav}\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }}\n >\n {fav ? \"♥\" : \"♡\"}\n </button>\n )}\n </div>\n <div className=\"card-body\">\n <div className=\"card-price\">\n {ms?.price_median != null ? (\n <>\n {fmtPrice(ms.price_median)} <small>plat médian</small>\n </>\n ) : (\n <span className=\"no-menu\">Menu à venir</span>\n )}\n </div>\n <div className=\"card-title\">{r.name}</div>\n <div className=\"card-meta\">\n <span>{typeLabel(r.establishment_type)}</span>\n {r.cuisines.slice(0, 2).map((c) => (\n <span key={c}>\n <span className=\"sep\" /> {cuisineLabel(c)}\n </span>\n ))}\n </div>\n <div className=\"card-meta\">\n {r.city && <span>{r.city}</span>}\n {r.city && r.region && <span className=\"sep\" />}\n {r.region && <span>{r.region}</span>}\n </div>\n <div className=\"card-foot\">\n <span className=\"source-tag\">{sourceName(r.source)}</span>\n {ms && <span className=\"avail\">{ms.item_count} plats au menu</span>}\n </div>\n </div>\n </Link>\n );\n}\n","numLines":80,"startLine":1,"totalLines":80}}}}40{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7fdafa64-84dd-46a5-ad58-d4e650780618","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}41{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJczmzKhJhaYCodDfMLu","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3yQKpwEIERgCKkB/Lb3sugs6YuwTkwFJdd2eM2cneW+vXwpT2sJEbcyr8QmSNLKdtVzXlBmyiXD+1oNVdjqWWyP4r8PN4RkqLdlGMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHE1ajUBrABAhIMZ4MVcIQXTXVXc/yfGgzLJxr9aodfkDHUfq0iMBm8NIPwcKrviXe/FTLXi4IgSe5ywNIDPSQKWhhQqFFqeiizE1Q+8JTELorPO4Bj5yrkIilFBtag2GONZG/sMNouhekwWjD47QdhKIsn8I0aeq4ei3BIXe/5BS/Scy2Tg5P4oYPKgDxVWK5DTkPkkX3b9thYVLhSeGhu1ELl6j/68SArQ6aOsSWWYkWEy/i/Ohn+7AXgPW/vL8TEr9d0JLt4vcpxuYrjQQ9hQiIOphHxcM5Qiz6g34pp/0Wk+TESW5dkFT8G9JVF3HsTxg7e0EZwokafHlfTeL5kVe2u7jDQQnSTNcAgiw/f0Hd40pbwU4n7Qnmu5Xlh8qBPXF4qA2qOnHlFf6n11QN1L+qLf38m4+/UFwUP6KvOotIo+qGo9jP5cnnkuVpvLwEowgc6s1GegA8YKWCd+ePvP9NFEh9qeJX35Ym+zsQd0/1Bh8NvY4mV1lpL2iVdDqLK5P+j1azWgwVQVXNep5SyBykn5PnxCJH4KvWGpD1DEyfXMTabbG5hD1nIM7+KBa49cgme5bnMu30mxz9u6r/DWn9aQt7ru/KSfkM3UCTkaydW6PnU8FRJZuuXCUtgErLUCAY2ltZ+K4wKI4ARUifyIAuOSf58iVFuZJDPKI+L69PYsLOhhuMpVUAjpUxNKs/2EjMqe8bkgz6qb3Sa5tgDekR2vFOUj/e0HfJ2Z0CBu2W2FAfrrirHZY8EMh87ws+wXVRwZw9jA+JkpSjJSzWU7cWrJ2QJiYuNgespZAxpK9wBHdWtX6Whwvf7xLknJEjwMoGn042ImB1R2zNI0MZgdUoaqcal91RBjbRKHwIc/Cdj2I6QmO1lgX1dVjRpEDKSJ/tOAeKxH5VKBg2KlVWkW8F1ElaoBRFKWao6sYqTOOqMkChNFNZxA/PyHEr7EV1hY5mk00KkPekf+fD7wNNfJRCsKwMGm8WlMmeg4LoGt+vI8o1o0+egJzb7jalv+9aACJKR+LPagoIinr8RetmbuASnRLJEGU0P68IwWQYLLEKAcCi4NTBe/2xrslbjmk04cj/ono93daZUGRSReeVkAkO9e4sDreyhUxvKHfyrRWmOhSs3JIk4f0p6/7+MravhxgmENNKKFpFytJTZMqzIpjg+KuwRU3Zvt7vwjiL8jcoKVy2iWY1tKM+JlnxeOmtVcpZPvjRHjfKqAAZ8EpSJFOMgKoTbhDRrAUpqhnx9Qt8ofa4UvQI8tbJLJjjhRiSYvxmJkRlJW+RlC4N/nGu1Q73GiWlOUu8wHXY5fPpuUqcJFpXzYkeets0bsZbcYTa0KQLuHn7AGCUMp995YXujRAe4dbOJ9QCV//ScobJeUqboFdsZfBIbsnw0jItfGadNMTUa6gJN+9/Ihj7fA8bzKjIEgY4aEthNGMCSnk0Z+7nYy0k8S3TA6VFtJPlaA6QaCxSyb8UuPvlD8D0V/nzgHdaE7tkxTU+FoqgJSXLv2i7kZevU/1zCFQkMbbE8MWpurrHAY4OlXO8bj4jX8VNcYrTGcFbPewTS3GtrZsSkXiTTG0pMf8JMMHjwrs10yhedNKp+BnaHM2fjSokE9jq4epo86yoGpi1hbtFnHSYHn9S3G94GIs3NqRpSJxu7QP1lZWb5l/kDj1FwijoWZCYLLvR9PAQgrymrTMomw1lYoJmmA8T1c0pxSgV1Ll4vrY3PUIQITua8qEeAntU6S+J41HfzbsV6uxAwNa0Nn5sC4EdAx5UVVL9VBbXNJtmIXOc31wl3xJYUD1wsRO9XuL1YzOXv8RGEgGKml6UBDL7hQQxRUD/+BPGr3VLubAUSezEQ+uXg6fdetROIZfQcnKw07EQUR2LUsrEgLRLoGk/iaPxNBHBcDWdCqWu8K1ghTds1VcCl6GvrECROJ8bnq99KMieY6nPDTLWVmexxbDaLzVDJRTW6rBtDkI2Y/UUNRJMWRTQJUwoIpC0BWM3cik9UKauL1vtCTSkGPAuf+1FSH5bmILeDB739FMwDMVhnehUoRtuttRhQH0kVwcuQ8k7czmpV2QKBG5a9m5iLnqa3Rqtz60haCo/S3XxoC6+YXC8qUtRYIV887D5k5Ag+Ohnezr5LLGBND+HNwiGTM3kx+/VSMloc2lTUtwpRq9s+eLquYg9N60RlpiX/Q225ffU6LrprZ7BBEwfG3sVMk7a5iGdFtKiEYxQmerI+KjncG5EHvRGE3yuoBgwnb49Rff5tr66UXMT3if9PFqi32sVjUQfXrYnQZEDAIVVrpYzg2Pks3mlp8odyR81jZ8mrhtYi3kfNWEQ5mpxKFIoVAB5vcUUsBOKkneyIJQY5QvJWN1OqAvaY262BpVWhEHDA/sJk0MTGuKphLy/z5vXmdJcypxsTHPfk2RZJqSc1POVDQN6UpbhRDLbP/Ag8r8eDzG/QqyTnZ38j90u2SHELGDVDRTvW3Vv7ceo0MQi3FIAve3IvAnrS0w9BSFycJbGYWfrAamWW+prtrvv75G2pOj1wS0xgCaal0IALa8AaoZjsqroaTJ9MUX4LsmFzKNLSyVI53TAziPeI24jsltinktsa9RzX8b/+rDTRgNpShN6KssTv5Y7IV+pW4Y+NgyiSWu63OtnXQHlxX8iCSNDMTkJXKHhd1DwoctrxEd4d0QLAAeMY2NoXAS9E8mMJnOr2QIjKHmnqyXDnefqojJ5KQ2pZvjm8Of3hiNVFnlL3JiFdCrF2r6+0qTcmcpSGC0aNsp72XpThhYp42qNL9QaAVI5MUITFFp6v35nNbEYIz8PKERfy/0rIulftZbDVgEWD2NJwx28niup8iz6zV51gZvfjKqc+GsAdMHjiAEiMoph+aZOKUFFrgVylypFFBmWHzyCmt2Bxe3ukDtpE64j+/U5vfOTHbJGmX4nmrzEmiXBrSct7KP2Bvh15F0Q2kz/gzwr1673lqMzqA0WXkEkbiksh6qqSgK6HDdlzc40k2WoAf185SCzy/iti7o6DurpXxNltLfFvFrmD9+7o44eAu/np/m2f5BHNwE44ir2mTsFnWlE65IZBCehQ2xuyn5HVw3//ZrS2gf2w6WeGUCr04EcgtNGnR5l2H9w9xIJyZOVAzmi5ZQYVGUAQn1Q/RDJEteoRQq66EEY8Y1gOVZFhjVzWWYBo7BM6Sy35fJCFvsw5kjHwHQarrEGv7oe6UyCZrw/v6SnXT/JaewCuh2VcAExdG/JjVcnq9PiKA8jqQU4yw3/955xi61VAX/fUJSOBSov0jctGRryxjSR/6wp7R8dPiqKd/rghZDoTM7W/JU93TXSNxi9pZRDMBgnneXB7/KsVmQxX7+SmQ244q59I2OyKJxCQ3DzZ2npbgS2mIjrp4oG2DCYqUa0hXHamhKBBRp+TUr1raZv4VAOUbSumXW1aK9qTfTemJ25Qw6GBILfXv81WSCIGb8ba9ut6dR4lAS0PPaaod4lKHzfSlUPFKmwV7Wb60P7GZKxce2hBFDl7f15vffa6+DVd25uUN3Y0lab1TQbAIO0I0mEPIlbWXrZ4pGlblv73gH5TIDZ4qJOdr8umPpOsdXQBLaFodPTR7pVcUN95gZ1WfWY5blGNnvtcgsZLcKWDPfaYnc/rCle9S36xxoBw1IR9a29d4kGKz8aYCkuPtjn5rB3QM5pXCxSG2cnJ8Ez+0J6/KOtMkswocrDgdDSR9ydXQAnUgvQwkAeNlHZGQWXFYMItPo/uWJ7cjKVyvbo155VphnsJ04wpORv3V5KVBVQMIV9JD7Xb5hbvVFjyEsVq/bhoaCylpwwcjmbXOihXbiZZh5uHfe1GtT3PlTkOj7U2n4N/pwmbkN0Ee4SPgZVMTblFoP3HP9adeV6ItfnQ/nTBDlVSNAfr++UrhrRLNUQqmKDAJCJCd+5o5urt5+32rKlzWVULRgv6MR4QU1vKrKTLhdDUCoWeoWU2/wtxq5GkWFF1QYArjRUvRZ+rhk5E4tipmjzO+YOdQBDCoKqZUGxDIxjC+ObaYp9IHbQd2kWYgQO2EGyJgVO8kQoC9y7OC9KnQ0KaqTyFjWc4sOCVfuF0HwwFWwa9Hkqr1TWa0usMw4dXf2KG0jH/pHw3gST7jA5DT35Y+78o4f+VtxDQTRsBeAxxr24LrJrqNTGX9iwmYtcpDOKYKZnyaImen06OVCIjmkW6Jg+72JdtptsompU4k1bNGt8dEoKvtl6qCB+jSnXPLnQPlyVuGIMtF4UTbfZ+RLUJfHBhrT+zN98COguhlaHb5jDkCDu54zAi8EJLHiIklGyafRK7n8VXByQWSvIRj4aA8BSLiYSwLi2bHTrvMgmZt2OvfBlIMnLiPDvNJM3GOQcTNC27yyolASDtxdyc+oKVSC1KyR84gPUQA5QZkGS4V8aWdy2JiJ18xL/OvHEe2IVq3K2yHPvlVKosx03S2eHJhKjzze/k3NYa115OHqquNLsQv/vCd9WJtq8+rtp0OlikLyPFINl27CeIP4kjLYr57W1EcjyhzOhLmuwlsM2om2ZsLS/N29ZElPBvVNTg55uJMbYGshp+LPYCUozIvuLYdIu1bhQS9Y28u/ExZw6xQuqwURGWh2y8UCCZEOTztDFUQUIS1BGWrQCZz3VwlSTUGBaq6mwsfqS72ypGbO2WD9Iant1qajIHk4zChRu1Rets9RxAGeVJsRA9yiPEFLLHkYZV69UnjQNGx2+jBrZiqlYs0yCDzsFzaAHDQ+pfyXRF7y8Z8PXAqEljKivh3vmWbPhccCI2FhvdPzHaWYEod4t/9P5tBs6gN5i6v7rdYwZaEV0nE2dqak3NDNzWciDRIhQ3qq+e1oRQodMEixCNQmgKfGP/EFD1DNC4tVHe4M24bM497ey0SRE5QjlLVJwE2KR1pXrX7Gij85PU3shZ19AvmZgOzwcC2GHmmuIj24Cz0Ok52HRhIWlGo5eCk27obB7RBPVoSjnkHtczKO0j7a+KuCiLn4eLen465CfLUmORJADaDiQvuRVqdvAZblDA7e3r6oreylpuFwDuQLatZptG6AVHfQ2Jna2/L6sna1kmj7xeKsHgLW+T0X826qg9xP/R7jJgGxjejvG4D5foILybqk+aazDVe0ShyUC6a/kL2WdEkdAeef8ja+ituXumryBNI+dMykZ58xItruUn8YstFdMjD8UAHEo02noILlwUJzKH+xE14w/sF98Pjtvo4J8UMR0pi1A1z1zFCbT1vUx9Vcz38YoTFUdYe7ZgO6xmkscZSsT1xOW4gDHcYJ0ZjTuFuNF9FHqBxImnpotFI4n5nrlKoq2sUG72W4FVtAAwzZCWgLdGZ+d9jzf14FkudSx33vYtyH60skaHtqfkivs8ocBNK7WQKYkREUxPx5DGiX5fjA/Rn8LF7ggMjyT1ESH54l8PCG0ez7a/4beNa+1cGoNGTWUW35ueNa//8hl7byjmQsmrR5JawouigHBclipHsUGeoDR/HPJuly0MsTLHyNfBdQAUFV+CiiCSYc7MWyBgPzrGUJbGR9Rrk7tYcWlxD07Q7SRBdyPg5bqoVcUiT9afmMPtrHYTLF6R6NpVkhyjE99EdT79cJvxZMbDw0du9vQoxZveb7Svwbl9h5Wyjl6gpKQ4L/qiHxCImsUu+WFsqmlam3YLdG9HGS942ztc6D3eRl4+qzn2/YUuSu831ZL4SWBMJy++bMTCvmVODMwju+r0cZmlyjugegRK9j+QGtj7YmcDcsLvdImskWv1yV81y22Cv5QwXqjTlV2aEQ988bpJFt1O1kU9CYJuQWXS79pA6TkV9kFCxX34J9aBbj0Ee0UmO563GJkNsNHvD6ZScmk6Umcer8hNFH4lvR0fpMuQwP5wpLePTFA3kAfA1mWjJJIke4iJYOUyV+JwKQDJ2r/SpgURDRghLUQ92DhRowDIaJ6sBQCaRogzfaJzU5faa06qNAOhF38cj+yAEar9yLhS38NazfiP4ll0BgcHSXxv2JfZ4tKO+qSVUM+EKt/A1IQCxAfqu+bGzPYJY6kG3AqV03x6Gz+GQ44oYxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3189,"cache_read_input_tokens":63952,"cache_creation":{"ephemeral_5m_input_tokens":3189,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"392a6ebe-b2c4-4ee9-8fd5-ec0507d6c41d"}}42{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJczmzKhJhaYCodDfMLu","type":"message","role":"assistant","content":[{"type":"text","text":"Je passe à l'implémentation. D'abord la partie fiche resto (traçage des consultations) — je lis le useEffect de chargement."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3189,"cache_read_input_tokens":63952,"cache_creation":{"ephemeral_5m_input_tokens":3189,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d95c1ab3-b056-4536-a292-a4a87b0f3e43"}}43{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJczmzKhJhaYCodDfMLu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EpaCWRZdaFxCTftPfLwruf","name":"Grep","input":{"pattern":"useEffect|fetchRestaurant\\(","path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","output_mode":"content","-n":true,"-A":10},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3189,"cache_read_input_tokens":63952,"cache_creation":{"ephemeral_5m_input_tokens":3189,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"99b6a564-ff50-4dff-9612-07c3815755da"}}44{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EpaCWRZdaFxCTftPfLwruf","type":"tool_result","content":"9:import { useEffect, useState } from \"react\";\n10-import { Link, useParams } from \"react-router-dom\";\n11-import {\n12- CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n13- fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n14-} from \"../api\";\n15-import { IcoCompass } from \"../components/Icons\";\n16-\n17-function MenuBlock({ menu }: { menu: Menu }) {\n18- const [openSections, setOpenSections] = useState<Set<number>>(\n19- new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n--\n108: useEffect(() => {\n109- if (!uid) return;\n110- setResto(null);\n111- setNotFound(false);\n112: fetchRestaurant(uid)\n113- .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n114- .catch(() => setNotFound(true));\n115- window.scrollTo({ top: 0 });\n116- }, [uid]);\n117-\n118- if (notFound) {\n119- return (\n120- <div className=\"notice container\">\n121- <div className=\"big\"><IcoCompass size={40} /></div>\n122- <h2>Restaurant introuvable</h2>"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"bb1334ac-4b6c-4227-ac8c-74026d0b7d3a","timestamp":"2026-08-22T23:03:34.450Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"9:import { useEffect, useState } from \"react\";\n10-import { Link, useParams } from \"react-router-dom\";\n11-import {\n12- CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n13- fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n14-} from \"../api\";\n15-import { IcoCompass } from \"../components/Icons\";\n16-\n17-function MenuBlock({ menu }: { menu: Menu }) {\n18- const [openSections, setOpenSections] = useState<Set<number>>(\n19- new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n--\n108: useEffect(() => {\n109- if (!uid) return;\n110- setResto(null);\n111- setNotFound(false);\n112: fetchRestaurant(uid)\n113- .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n114- .catch(() => setNotFound(true));\n115- window.scrollTo({ top: 0 });\n116- }, [uid]);\n117-\n118- if (notFound) {\n119- return (\n120- <div className=\"notice container\">\n121- <div className=\"big\"><IcoCompass size={40} /></div>\n122- <h2>Restaurant introuvable</h2>","numLines":27}}}45{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d8ebb08d-e5da-4769-8a52-f12b83ba8cda","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}46{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzgMKpwEIERgCKkB6Xqkf+T+LuZHEy+lj44VN5z0Uw4hknKsJCeXQj2fNXIMtNksXEYdDuNO82e3mc9uH1WtruZfQcoLRc+5ahEd5Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHM1ajUBrABAhIM9xEE2yWAiK9JbexoGgzp1OmAxPaHfu1P7xwiMNvy5mxN57Tw8/CgXwX6KLraHoCeyoj1atRP8t4RO39VLC5xroiVb9tOP+GiZNUpLirTAZ46HDRZDtw3FLqfHmnQ2tUViC4RIHpKon82uHtD0W8dkVt0E7d0uFP8g8nANfsk1KPFEI1jYMwkzNpTWh2LkPf+phzVhENAfKsJVLUa2PR+rLazT1o7xf9pYzCOxOTZppyUjGxtOfmcJxRF+nj2uY+YU0jWOKu5+BnYUxnBUITAZOjX0a5S9Q16NQS8XI7MrUaOoBWhoyVnSYXK9SYxFb2mR6M+uGw3fQ2Su7939eT+pBkBjcxjE7MSOuNu2HvGwVLFxqQkTYasMug8Br+s374Kxn0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"83434518-3952-46d8-a8d2-b57c03652eb1"}}47{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"text","text":"Tout est clair. J'applique les modifications : API backend (`uids`), helpers localStorage dans api.ts, traçage dans Resto.tsx, section « Récemment consultés » dans Home.tsx + CSS."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"24d06d6f-4337-4788-97d4-49b0eabc08ab"}}48{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EgLrTHft5oVwZjtRZvCxvd","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","old_string":" q: str | None = None,\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,","new_string":" q: str | None = None,\n uids: str | None = None, # liste d'uids séparés par des virgules\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"bb9ab7ff-654c-4ec7-ad54-e538e1968b6c"}}49{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EgLrTHft5oVwZjtRZvCxvd","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/restoka/web.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"385ba3c6-8710-4bc2-9f2e-34e310a36834","timestamp":"2026-08-22T23:03:43.104Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","oldString":" q: str | None = None,\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,","newString":" q: str | None = None,\n uids: str | None = None, # liste d'uids séparés par des virgules\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: restoka/web.py\n# Desc: API FastAPI (JSON) + service du frontend React (frontend/dist).\n# Recherche des restaurants (région, ville, cuisine, diète, prix, texte),\n# fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >\n# delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport statistics\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\n\nfrom . import db, ingest\nfrom .normalize import PRICE_CONTEXTS\nfrom .regions import REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\n# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)\n_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}\n\napp = FastAPI(title=\"Resto-Ka API\", version=\"1.0\",\n description=\"Agrégateur des restaurants du Québec — menus & prix\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n_sync_lock = threading.Lock()\n\n# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py\nfrom . import auth # noqa: E402 (import tardif : évite le cycle web<->auth)\napp.include_router(auth.router)\n\n# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py\n# inclus AVANT le fallback SPA pour que ces routes aient priorité\nfrom . import seo # noqa: E402\napp.include_router(seo.router)\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n for col in (\"cuisines\", \"services\", \"dietary_options\", \"languages\", \"images\"):\n d[col] = json.loads(d.get(col) or \"[]\")\n d[\"hours\"] = json.loads(d.get(\"hours\") or \"{}\")\n try: # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n except (ValueError, TypeError):\n d[\"details\"] = {}\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n return d\n\n\ndef _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\n \"\"\"Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes.\"\"\"\n if not uids:\n return {}\n qmarks = \",\".join(\"?\" * len(uids))\n best: dict[str, dict] = {}\n for r in con.execute(\n f\"SELECT uid, price_context, price_source, captured_at, item_count,\"\n f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\n cur = best.get(r[\"uid\"])\n if cur and _CTX_ORDER.get(cur[\"price_context\"], 9) <= \\\n _CTX_ORDER.get(r[\"price_context\"], 9):\n continue\n sections = json.loads(r[\"sections\"] or \"[]\")\n prices = [it.get(\"price\")\n for sec in sections\n for it in sec.get(\"items\") or []\n if isinstance(it.get(\"price\"), (int, float)) and it[\"price\"] > 0]\n image = next((it[\"image\"] for sec in sections\n for it in sec.get(\"items\") or [] if it.get(\"image\")),\n next((sec[\"image\"] for sec in sections\n if sec.get(\"image\")), None))\n best[r[\"uid\"]] = {\n \"image\": image,\n \"price_context\": r[\"price_context\"],\n \"price_source\": r[\"price_source\"],\n \"captured_at\": r[\"captured_at\"],\n \"item_count\": r[\"item_count\"],\n \"price_min\": min(prices) if prices else None,\n \"price_median\": round(statistics.median(prices), 2) if prices else None,\n }\n return best\n\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/restaurants\")\ndef list_restaurants(\n region: str | None = None,\n city: str | None = None,\n cuisine: str | None = None,\n establishment_type: str | None = None,\n diet: str | None = None, # vegan, sans-gluten…\n service: str | None = None, # takeout, delivery, salle\n price_range: str | None = None, # $ à $$$$\n chain: str | None = None,\n source: str | None = None,\n q: str | None = None,\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,\n sort: str = \"menu\", # menu | name | price | recent\n limit: int = Query(60, le=500),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if cuisine:\n sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n if establishment_type:\n sql += \" AND establishment_type=?\"; args.append(establishment_type)\n if diet:\n sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n if service:\n sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n if price_range:\n sql += \" AND price_range=?\"; args.append(price_range)\n if chain:\n sql += \" AND chain=?\"; args.append(chain)\n if source:\n sql += \" AND source=?\"; args.append(source)\n if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4\n if has_menu == 1:\n sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n elif has_menu == 0:\n sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"recent\":\n sql += \" ORDER BY updated_at DESC\"\n elif sort == \"price\":\n sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n elif sort == \"name\":\n sql += \" ORDER BY name COLLATE NOCASE ASC\"\n else: # défaut « menu » : les restos avec menu complet d'abord\n sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n for r in rows:\n r[\"menu_summary\"] = menus.get(r[\"uid\"])\n con.close()\n return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n\n\n@app.get(\"/api/dishes\")\ndef search_dishes(\n q: str = Query(..., min_length=2),\n region: str | None = None,\n city: str | None = None,\n cuisine: str | None = None,\n price_max: float | None = None,\n limit: int = Query(30, le=100),\n offset: int = 0,\n):\n \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n\n Les succursales d'une même chaîne partagent le même menu : les résultats\n sont regroupés par (marque, plat, prix, contexte) avec le nombre\n d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n \"\"\"\n con = db.connect()\n needle = q.strip().lower()\n sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n args: list = [f\"%{needle}%\"]\n if region:\n sql += \" AND r.region=?\"; args.append(region)\n if city:\n sql += \" AND r.city=?\"; args.append(city)\n if cuisine:\n sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n\n groups: dict[tuple, dict] = {}\n for row in con.execute(sql, args):\n try:\n sections = json.loads(row[\"sections\"] or \"[]\")\n except ValueError:\n continue\n brand = row[\"chain\"] or row[\"rname\"]\n for sec in sections:\n for it in sec.get(\"items\") or []:\n hay = f\"{it.get('name', '')} {it.get('description', '')}\".lower()\n if needle not in hay:\n continue\n price = it.get(\"price\")\n if price_max is not None and (price is None or price > price_max):\n continue\n key = (brand, it.get(\"name\"), price, row[\"price_context\"])\n g = groups.get(key)\n if g is None:\n groups[key] = {\n \"item\": it.get(\"name\"),\n \"description\": it.get(\"description\") or \"\",\n \"price\": price,\n \"currency\": it.get(\"currency\", \"CAD\"),\n \"image\": it.get(\"image\"),\n \"tags\": it.get(\"tags\") or [],\n \"section\": sec.get(\"name\"),\n \"price_context\": row[\"price_context\"],\n \"price_source\": row[\"price_source\"],\n \"captured_at\": row[\"captured_at\"],\n \"brand\": brand,\n \"restaurant\": row[\"rname\"],\n \"uid\": row[\"uid\"],\n \"city\": row[\"city\"],\n \"region\": row[\"region\"],\n \"locations\": 1,\n }\n else:\n g[\"locations\"] += 1\n con.close()\n results = sorted(groups.values(),\n key=lambda g: (g[\"price\"] is None, g[\"price\"] or 0))\n total = len(results)\n return {\"total\": total, \"count\": min(limit, max(0, total - offset)),\n \"dishes\": results[offset:offset + limit]}\n\n\n@app.get(\"/api/restaurants/{uid:path}/prices\")\ndef price_history(uid: str):\n \"\"\"Historique des prix par item (série temporelle, détection des hausses).\"\"\"\n con = db.connect()\n rows = [dict(r) for r in con.execute(\n \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n \" WHERE uid=? ORDER BY ts DESC LIMIT 500\", (uid,))]\n con.close()\n return {\"uid\": uid, \"prices\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}/inspections\")\ndef restaurant_inspections(uid: str):\n \"\"\"Condamnations MAPAQ croisées avec ce restaurant (inspections\n alimentaires — Données Québec, licence CC-BY 4.0). Croisement\n conservateur : voir restoka/inspections.py.\"\"\"\n con = db.connect()\n if con.execute(\"SELECT 1 FROM restaurants WHERE uid=?\",\n (uid,)).fetchone() is None:\n con.close()\n raise HTTPException(404, \"Restaurant introuvable\")\n rows = [dict(r) for r in con.execute(\n \"SELECT etablissement, exploitant, description, adresse,\"\n \" date_infraction, date_jugement, montant_amende, motif, matched_by\"\n \" FROM inspections WHERE uid=? ORDER BY date_jugement DESC\", (uid,))]\n con.close()\n return {\"uid\": uid, \"total\": len(rows),\n \"total_amendes\": round(sum(r[\"montant_amende\"] or 0 for r in rows), 2),\n \"source\": \"MAPAQ — Condamnations des établissements alimentaires \"\n \"(Données Québec, CC-BY 4.0)\",\n \"inspections\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}\")\ndef get_restaurant(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM restaurants WHERE uid=?\", (uid,)).fetchone()\n if row is None:\n con.close()\n raise HTTPException(404, \"Restaurant introuvable\")\n d = _row_to_dict(row)\n # tous les contextes de prix disponibles, meilleur en premier — un menu\n # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)\n menus = []\n for m in con.execute(\n \"SELECT price_context, price_source, currency, captured_at,\"\n \" item_count, sections FROM menus WHERE uid=?\", (uid,)):\n menus.append({\n \"price_context\": m[\"price_context\"],\n \"price_source\": m[\"price_source\"],\n \"currency\": m[\"currency\"],\n \"captured_at\": m[\"captured_at\"],\n \"item_count\": m[\"item_count\"],\n \"sections\": json.loads(m[\"sections\"] or \"[]\"),\n })\n menus.sort(key=lambda m: _CTX_ORDER.get(m[\"price_context\"], 9))\n d[\"menus\"] = menus\n # items dont le prix a changé récemment (crédibilité, §12.2)\n d[\"recent_price_changes\"] = [dict(r) for r in con.execute(\n \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n \" WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)\"\n \" GROUP BY item_key HAVING COUNT(*) > 1\"\n \" ORDER BY ts DESC LIMIT 20\", (uid, uid))]\n con.close()\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(region: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n city_sql = (\"SELECT DISTINCT city FROM restaurants\"\n \" WHERE active=1 AND dup_of IS NULL AND city<>''\")\n city_args: list = []\n if region:\n city_sql += \" AND region=?\"\n city_args.append(region)\n cuisines: dict[str, int] = {}\n diets: dict[str, int] = {}\n for r in con.execute(\"SELECT cuisines, dietary_options FROM restaurants\"\n \" WHERE active=1 AND dup_of IS NULL\"):\n for c in json.loads(r[\"cuisines\"] or \"[]\"):\n cuisines[c] = cuisines.get(c, 0) + 1\n for dd in json.loads(r[\"dietary_options\"] or \"[]\"):\n diets[dd] = diets.get(dd, 0) + 1\n out = {\n \"regions\": [dict(r) for r in con.execute(\n \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")],\n \"all_regions\": REGIONS,\n \"cities\": [r[\"city\"] for r in con.execute(\n city_sql + \" ORDER BY city\", city_args)],\n \"cuisines\": [{\"cuisine\": c, \"n\": n} for c, n in\n sorted(cuisines.items(), key=lambda kv: -kv[1])],\n \"diets\": [{\"diet\": d, \"n\": n} for d, n in\n sorted(diets.items(), key=lambda kv: -kv[1])],\n \"establishment_types\": [dict(r) for r in con.execute(\n \"SELECT establishment_type t, COUNT(*) n FROM restaurants\"\n \" WHERE active=1 AND dup_of IS NULL AND establishment_type<>''\"\n \" GROUP BY establishment_type ORDER BY n DESC\")],\n \"chains\": [r[\"chain\"] for r in con.execute(\n \"SELECT DISTINCT chain FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain\")],\n \"sources\": [dict(r) for r in con.execute(\n \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source\")}\n items = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT r.source, SUM(m.item_count) n FROM menus m\"\n \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_restaurants\"] = counts.get(s[\"id\"], 0)\n s[\"menu_items\"] = items.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n # ne pas exposer les clés d'intégration dans l'API publique\n if \"integrations\" in s:\n s[\"integrations\"] = [{\"label\": i.get(\"label\"),\n \"status\": i.get(\"status\", \"actif\")}\n for i in s[\"integrations\"]]\n return {\"sources\": registry}\n\n\n# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------\n_SITE = {\n \"wordmark\": \"Resto·Ka\",\n \"accent\": \"#f08c00\", # safran — on-accent encre #141814\n \"domain\": \"www.resto-ka.com\",\n \"tagline\": \"Chaque resto, chaque plat, chaque prix.\",\n}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(\n period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = Query(None),\n):\n \"\"\"Tableau de bord analytique (contrat commun Groupe KA, cache 5 min).\"\"\"\n from . import stats as kastats\n if period not in kastats.PERIOD_LABELS and not (from_ and to):\n raise HTTPException(400, \"période inconnue\")\n return kastats.dashboard(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(\n period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = Query(None),\n mode: str = \"complet\",\n):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :\n complet | synthese | tendances | repartitions | donnees.\n Un mode inconnu retombe sur complet (rétrocompatible v1).\"\"\"\n from . import kapdf\n from . import stats as kastats\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n if period not in kastats.PERIOD_LABELS and not (from_ and to):\n raise HTTPException(400, \"période inconnue\")\n dash = kastats.dashboard(period, from_, to)\n pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()\n fname = kapdf.filename(\"resto-ka\", period, mode)\n return Response(content=pdf, media_type=\"application/pdf\",\n headers={\"Content-Disposition\":\n f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats\")\ndef stats():\n con = db.connect()\n head = dict(con.execute(\n \"\"\"SELECT COUNT(*) restaurants,\n SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))\n with_menu,\n COUNT(DISTINCT chain) chains,\n COUNT(DISTINCT region) regions,\n COUNT(DISTINCT source) sources\n FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n m = dict(con.execute(\n \"\"\"SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items\n FROM menus m JOIN restaurants r ON r.uid=m.uid\n WHERE r.active=1 AND r.dup_of IS NULL\"\"\").fetchone())\n by_region = [dict(r) for r in con.execute(\n \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")]\n by_context = [dict(r) for r in con.execute(\n \"SELECT m.price_context, COUNT(*) n FROM menus m\"\n \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1\"\n \" GROUP BY m.price_context\")]\n log = [dict(r) for r in con.execute(\n \"SELECT source, ts, found, added, updated, removed, ok, message\"\n \" FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n insp = dict(con.execute(\n \"\"\"SELECT COUNT(*) inspections,\n SUM(uid IS NOT NULL) inspections_matched,\n COUNT(DISTINCT uid) restaurants_with_inspections\n FROM inspections\"\"\").fetchone())\n enr = dict(con.execute(\n \"\"\"SELECT SUM(details LIKE '%\"yelp\":%') with_yelp_rating,\n SUM(details LIKE '%\"ubereats\":%') with_ubereats,\n SUM(details LIKE '%\"permis_alcool\"%') with_alcohol_permit\n FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n con.close()\n try: # nb de sources au registre (data/sources.json), actives ou en attente\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n registered = len(registry)\n except (ValueError, OSError, KeyError):\n registered = None\n return {**head, **m, **insp, **enr, \"sources_registry\": registered,\n \"by_region\": by_region, \"by_context\": by_context,\n \"recent_syncs\": log}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n ingest.enrich()\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Frontend React (build Vite) --------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"),\n name=\"assets\")\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés immuables ; index.html toujours revalidé.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n _CLIENT_PREFIXES = (\"resto/\", \"sources\", \"stats\", \"favoris\", \"contact\")\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n known = (full_path == \"\" or full_path in _CLIENT_PREFIXES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":116,"oldLines":6,"newStart":116,"newLines":7,"lines":[" chain: str | None = None,"," source: str | None = None,"," q: str | None = None,","+ uids: str | None = None, # liste d'uids séparés par des virgules"," has_menu: int | None = None, # 1 = seulement les restos avec menu"," active: int = 1,"," sort: str = \"menu\", # menu | name | price | recent"]}],"userModified":false,"replaceAll":false}}}50{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MXGmRsUFRZNqkockvPaG7X","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","old_string":" if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4","new_string":" if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4\n if uids:\n lst = [u.strip() for u in uids.split(\",\") if u.strip()][:24]\n if lst:\n sql += f\" AND uid IN ({','.join('?' * len(lst))})\"\n args += lst"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"eedf531d-7770-41f9-bc83-7cd6820e9e22"}}51{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MXGmRsUFRZNqkockvPaG7X","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/restoka/web.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"c3f9826e-515f-4e92-acfd-3aaa0969de64","timestamp":"2026-08-22T23:03:45.514Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","oldString":" if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4","newString":" if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4\n if uids:\n lst = [u.strip() for u in uids.split(\",\") if u.strip()][:24]\n if lst:\n sql += f\" AND uid IN ({','.join('?' * len(lst))})\"\n args += lst","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: restoka/web.py\n# Desc: API FastAPI (JSON) + service du frontend React (frontend/dist).\n# Recherche des restaurants (région, ville, cuisine, diète, prix, texte),\n# fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >\n# delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport statistics\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\n\nfrom . import db, ingest\nfrom .normalize import PRICE_CONTEXTS\nfrom .regions import REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\n# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)\n_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}\n\napp = FastAPI(title=\"Resto-Ka API\", version=\"1.0\",\n description=\"Agrégateur des restaurants du Québec — menus & prix\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n_sync_lock = threading.Lock()\n\n# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py\nfrom . import auth # noqa: E402 (import tardif : évite le cycle web<->auth)\napp.include_router(auth.router)\n\n# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py\n# inclus AVANT le fallback SPA pour que ces routes aient priorité\nfrom . import seo # noqa: E402\napp.include_router(seo.router)\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n for col in (\"cuisines\", \"services\", \"dietary_options\", \"languages\", \"images\"):\n d[col] = json.loads(d.get(col) or \"[]\")\n d[\"hours\"] = json.loads(d.get(\"hours\") or \"{}\")\n try: # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n except (ValueError, TypeError):\n d[\"details\"] = {}\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n return d\n\n\ndef _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\n \"\"\"Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes.\"\"\"\n if not uids:\n return {}\n qmarks = \",\".join(\"?\" * len(uids))\n best: dict[str, dict] = {}\n for r in con.execute(\n f\"SELECT uid, price_context, price_source, captured_at, item_count,\"\n f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\n cur = best.get(r[\"uid\"])\n if cur and _CTX_ORDER.get(cur[\"price_context\"], 9) <= \\\n _CTX_ORDER.get(r[\"price_context\"], 9):\n continue\n sections = json.loads(r[\"sections\"] or \"[]\")\n prices = [it.get(\"price\")\n for sec in sections\n for it in sec.get(\"items\") or []\n if isinstance(it.get(\"price\"), (int, float)) and it[\"price\"] > 0]\n image = next((it[\"image\"] for sec in sections\n for it in sec.get(\"items\") or [] if it.get(\"image\")),\n next((sec[\"image\"] for sec in sections\n if sec.get(\"image\")), None))\n best[r[\"uid\"]] = {\n \"image\": image,\n \"price_context\": r[\"price_context\"],\n \"price_source\": r[\"price_source\"],\n \"captured_at\": r[\"captured_at\"],\n \"item_count\": r[\"item_count\"],\n \"price_min\": min(prices) if prices else None,\n \"price_median\": round(statistics.median(prices), 2) if prices else None,\n }\n return best\n\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/restaurants\")\ndef list_restaurants(\n region: str | None = None,\n city: str | None = None,\n cuisine: str | None = None,\n establishment_type: str | None = None,\n diet: str | None = None, # vegan, sans-gluten…\n service: str | None = None, # takeout, delivery, salle\n price_range: str | None = None, # $ à $$$$\n chain: str | None = None,\n source: str | None = None,\n q: str | None = None,\n uids: str | None = None, # liste d'uids séparés par des virgules\n has_menu: int | None = None, # 1 = seulement les restos avec menu\n active: int = 1,\n sort: str = \"menu\", # menu | name | price | recent\n limit: int = Query(60, le=500),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if cuisine:\n sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n if establishment_type:\n sql += \" AND establishment_type=?\"; args.append(establishment_type)\n if diet:\n sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n if service:\n sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n if price_range:\n sql += \" AND price_range=?\"; args.append(price_range)\n if chain:\n sql += \" AND chain=?\"; args.append(chain)\n if source:\n sql += \" AND source=?\"; args.append(source)\n if q:\n sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n args += [f\"%{q}%\"] * 4\n if has_menu == 1:\n sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n elif has_menu == 0:\n sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"recent\":\n sql += \" ORDER BY updated_at DESC\"\n elif sort == \"price\":\n sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n elif sort == \"name\":\n sql += \" ORDER BY name COLLATE NOCASE ASC\"\n else: # défaut « menu » : les restos avec menu complet d'abord\n sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n for r in rows:\n r[\"menu_summary\"] = menus.get(r[\"uid\"])\n con.close()\n return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n\n\n@app.get(\"/api/dishes\")\ndef search_dishes(\n q: str = Query(..., min_length=2),\n region: str | None = None,\n city: str | None = None,\n cuisine: str | None = None,\n price_max: float | None = None,\n limit: int = Query(30, le=100),\n offset: int = 0,\n):\n \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n\n Les succursales d'une même chaîne partagent le même menu : les résultats\n sont regroupés par (marque, plat, prix, contexte) avec le nombre\n d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n \"\"\"\n con = db.connect()\n needle = q.strip().lower()\n sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n args: list = [f\"%{needle}%\"]\n if region:\n sql += \" AND r.region=?\"; args.append(region)\n if city:\n sql += \" AND r.city=?\"; args.append(city)\n if cuisine:\n sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n\n groups: dict[tuple, dict] = {}\n for row in con.execute(sql, args):\n try:\n sections = json.loads(row[\"sections\"] or \"[]\")\n except ValueError:\n continue\n brand = row[\"chain\"] or row[\"rname\"]\n for sec in sections:\n for it in sec.get(\"items\") or []:\n hay = f\"{it.get('name', '')} {it.get('description', '')}\".lower()\n if needle not in hay:\n continue\n price = it.get(\"price\")\n if price_max is not None and (price is None or price > price_max):\n continue\n key = (brand, it.get(\"name\"), price, row[\"price_context\"])\n g = groups.get(key)\n if g is None:\n groups[key] = {\n \"item\": it.get(\"name\"),\n \"description\": it.get(\"description\") or \"\",\n \"price\": price,\n \"currency\": it.get(\"currency\", \"CAD\"),\n \"image\": it.get(\"image\"),\n \"tags\": it.get(\"tags\") or [],\n \"section\": sec.get(\"name\"),\n \"price_context\": row[\"price_context\"],\n \"price_source\": row[\"price_source\"],\n \"captured_at\": row[\"captured_at\"],\n \"brand\": brand,\n \"restaurant\": row[\"rname\"],\n \"uid\": row[\"uid\"],\n \"city\": row[\"city\"],\n \"region\": row[\"region\"],\n \"locations\": 1,\n }\n else:\n g[\"locations\"] += 1\n con.close()\n results = sorted(groups.values(),\n key=lambda g: (g[\"price\"] is None, g[\"price\"] or 0))\n total = len(results)\n return {\"total\": total, \"count\": min(limit, max(0, total - offset)),\n \"dishes\": results[offset:offset + limit]}\n\n\n@app.get(\"/api/restaurants/{uid:path}/prices\")\ndef price_history(uid: str):\n \"\"\"Historique des prix par item (série temporelle, détection des hausses).\"\"\"\n con = db.connect()\n rows = [dict(r) for r in con.execute(\n \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n \" WHERE uid=? ORDER BY ts DESC LIMIT 500\", (uid,))]\n con.close()\n return {\"uid\": uid, \"prices\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}/inspections\")\ndef restaurant_inspections(uid: str):\n \"\"\"Condamnations MAPAQ croisées avec ce restaurant (inspections\n alimentaires — Données Québec, licence CC-BY 4.0). Croisement\n conservateur : voir restoka/inspections.py.\"\"\"\n con = db.connect()\n if con.execute(\"SELECT 1 FROM restaurants WHERE uid=?\",\n (uid,)).fetchone() is None:\n con.close()\n raise HTTPException(404, \"Restaurant introuvable\")\n rows = [dict(r) for r in con.execute(\n \"SELECT etablissement, exploitant, description, adresse,\"\n \" date_infraction, date_jugement, montant_amende, motif, matched_by\"\n \" FROM inspections WHERE uid=? ORDER BY date_jugement DESC\", (uid,))]\n con.close()\n return {\"uid\": uid, \"total\": len(rows),\n \"total_amendes\": round(sum(r[\"montant_amende\"] or 0 for r in rows), 2),\n \"source\": \"MAPAQ — Condamnations des établissements alimentaires \"\n \"(Données Québec, CC-BY 4.0)\",\n \"inspections\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}\")\ndef get_restaurant(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM restaurants WHERE uid=?\", (uid,)).fetchone()\n if row is None:\n con.close()\n raise HTTPException(404, \"Restaurant introuvable\")\n d = _row_to_dict(row)\n # tous les contextes de prix disponibles, meilleur en premier — un menu\n # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)\n menus = []\n for m in con.execute(\n \"SELECT price_context, price_source, currency, captured_at,\"\n \" item_count, sections FROM menus WHERE uid=?\", (uid,)):\n menus.append({\n \"price_context\": m[\"price_context\"],\n \"price_source\": m[\"price_source\"],\n \"currency\": m[\"currency\"],\n \"captured_at\": m[\"captured_at\"],\n \"item_count\": m[\"item_count\"],\n \"sections\": json.loads(m[\"sections\"] or \"[]\"),\n })\n menus.sort(key=lambda m: _CTX_ORDER.get(m[\"price_context\"], 9))\n d[\"menus\"] = menus\n # items dont le prix a changé récemment (crédibilité, §12.2)\n d[\"recent_price_changes\"] = [dict(r) for r in con.execute(\n \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n \" WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)\"\n \" GROUP BY item_key HAVING COUNT(*) > 1\"\n \" ORDER BY ts DESC LIMIT 20\", (uid, uid))]\n con.close()\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(region: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n city_sql = (\"SELECT DISTINCT city FROM restaurants\"\n \" WHERE active=1 AND dup_of IS NULL AND city<>''\")\n city_args: list = []\n if region:\n city_sql += \" AND region=?\"\n city_args.append(region)\n cuisines: dict[str, int] = {}\n diets: dict[str, int] = {}\n for r in con.execute(\"SELECT cuisines, dietary_options FROM restaurants\"\n \" WHERE active=1 AND dup_of IS NULL\"):\n for c in json.loads(r[\"cuisines\"] or \"[]\"):\n cuisines[c] = cuisines.get(c, 0) + 1\n for dd in json.loads(r[\"dietary_options\"] or \"[]\"):\n diets[dd] = diets.get(dd, 0) + 1\n out = {\n \"regions\": [dict(r) for r in con.execute(\n \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")],\n \"all_regions\": REGIONS,\n \"cities\": [r[\"city\"] for r in con.execute(\n city_sql + \" ORDER BY city\", city_args)],\n \"cuisines\": [{\"cuisine\": c, \"n\": n} for c, n in\n sorted(cuisines.items(), key=lambda kv: -kv[1])],\n \"diets\": [{\"diet\": d, \"n\": n} for d, n in\n sorted(diets.items(), key=lambda kv: -kv[1])],\n \"establishment_types\": [dict(r) for r in con.execute(\n \"SELECT establishment_type t, COUNT(*) n FROM restaurants\"\n \" WHERE active=1 AND dup_of IS NULL AND establishment_type<>''\"\n \" GROUP BY establishment_type ORDER BY n DESC\")],\n \"chains\": [r[\"chain\"] for r in con.execute(\n \"SELECT DISTINCT chain FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain\")],\n \"sources\": [dict(r) for r in con.execute(\n \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source\")}\n items = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT r.source, SUM(m.item_count) n FROM menus m\"\n \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_restaurants\"] = counts.get(s[\"id\"], 0)\n s[\"menu_items\"] = items.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n # ne pas exposer les clés d'intégration dans l'API publique\n if \"integrations\" in s:\n s[\"integrations\"] = [{\"label\": i.get(\"label\"),\n \"status\": i.get(\"status\", \"actif\")}\n for i in s[\"integrations\"]]\n return {\"sources\": registry}\n\n\n# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------\n_SITE = {\n \"wordmark\": \"Resto·Ka\",\n \"accent\": \"#f08c00\", # safran — on-accent encre #141814\n \"domain\": \"www.resto-ka.com\",\n \"tagline\": \"Chaque resto, chaque plat, chaque prix.\",\n}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(\n period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = Query(None),\n):\n \"\"\"Tableau de bord analytique (contrat commun Groupe KA, cache 5 min).\"\"\"\n from . import stats as kastats\n if period not in kastats.PERIOD_LABELS and not (from_ and to):\n raise HTTPException(400, \"période inconnue\")\n return kastats.dashboard(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(\n period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = Query(None),\n mode: str = \"complet\",\n):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :\n complet | synthese | tendances | repartitions | donnees.\n Un mode inconnu retombe sur complet (rétrocompatible v1).\"\"\"\n from . import kapdf\n from . import stats as kastats\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n if period not in kastats.PERIOD_LABELS and not (from_ and to):\n raise HTTPException(400, \"période inconnue\")\n dash = kastats.dashboard(period, from_, to)\n pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()\n fname = kapdf.filename(\"resto-ka\", period, mode)\n return Response(content=pdf, media_type=\"application/pdf\",\n headers={\"Content-Disposition\":\n f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats\")\ndef stats():\n con = db.connect()\n head = dict(con.execute(\n \"\"\"SELECT COUNT(*) restaurants,\n SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))\n with_menu,\n COUNT(DISTINCT chain) chains,\n COUNT(DISTINCT region) regions,\n COUNT(DISTINCT source) sources\n FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n m = dict(con.execute(\n \"\"\"SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items\n FROM menus m JOIN restaurants r ON r.uid=m.uid\n WHERE r.active=1 AND r.dup_of IS NULL\"\"\").fetchone())\n by_region = [dict(r) for r in con.execute(\n \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")]\n by_context = [dict(r) for r in con.execute(\n \"SELECT m.price_context, COUNT(*) n FROM menus m\"\n \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1\"\n \" GROUP BY m.price_context\")]\n log = [dict(r) for r in con.execute(\n \"SELECT source, ts, found, added, updated, removed, ok, message\"\n \" FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n insp = dict(con.execute(\n \"\"\"SELECT COUNT(*) inspections,\n SUM(uid IS NOT NULL) inspections_matched,\n COUNT(DISTINCT uid) restaurants_with_inspections\n FROM inspections\"\"\").fetchone())\n enr = dict(con.execute(\n \"\"\"SELECT SUM(details LIKE '%\"yelp\":%') with_yelp_rating,\n SUM(details LIKE '%\"ubereats\":%') with_ubereats,\n SUM(details LIKE '%\"permis_alcool\"%') with_alcohol_permit\n FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n con.close()\n try: # nb de sources au registre (data/sources.json), actives ou en attente\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n registered = len(registry)\n except (ValueError, OSError, KeyError):\n registered = None\n return {**head, **m, **insp, **enr, \"sources_registry\": registered,\n \"by_region\": by_region, \"by_context\": by_context,\n \"recent_syncs\": log}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n ingest.enrich()\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Frontend React (build Vite) --------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"),\n name=\"assets\")\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés immuables ; index.html toujours revalidé.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n _CLIENT_PREFIXES = (\"resto/\", \"sources\", \"stats\", \"favoris\", \"contact\")\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n known = (full_path == \"\" or full_path in _CLIENT_PREFIXES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":149,"oldLines":6,"newStart":149,"newLines":11,"lines":[" if q:"," sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\""," args += [f\"%{q}%\"] * 4","+ if uids:","+ lst = [u.strip() for u in uids.split(\",\") if u.strip()][:24]","+ if lst:","+ sql += f\" AND uid IN ({','.join('?' * len(lst))})\"","+ args += lst"," if has_menu == 1:"," sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\""," elif has_menu == 0:"]}],"userModified":false,"replaceAll":false}}}52{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9de48342-e427-42ba-abda-96a846e65f11","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}53{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd3h4jSuLXSdhKnj9dF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WXoYCRVCHqgvtqBxcAqyNq","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts","old_string":"export const fetchRestaurant = (uid: string) =>\n get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);","new_string":"export const fetchRestaurant = (uid: string) =>\n get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\n// --- récemment consultés (localStorage, par appareil) ---------------------------\n\nconst RECENT_KEY = \"restoka:recent\";\nconst RECENT_MAX = 12;\n\nexport function getRecentUids(): string[] {\n try {\n const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? \"[]\");\n return Array.isArray(raw) ? raw.filter((u) => typeof u === \"string\") : [];\n } catch {\n return [];\n }\n}\n\nexport function pushRecentUid(uid: string) {\n try {\n const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);\n localStorage.setItem(RECENT_KEY, JSON.stringify(next));\n } catch { /* stockage indisponible (navigation privée) */ }\n}\n\n/** Fiches des restos consultés, remises dans l'ordre de consultation. */\nexport async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {\n if (uids.length === 0) return [];\n const params = new URLSearchParams({ uids: uids.join(\",\"), limit: String(uids.length) });\n const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);\n const by = new Map(r.restaurants.map((x) => [x.uid, x]));\n return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":922,"cache_read_input_tokens":69747,"cache_creation":{"ephemeral_5m_input_tokens":922,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"8b716c47-531e-4f86-8ba2-06da5c7ca58c"}}54{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WXoYCRVCHqgvtqBxcAqyNq","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"320f5873-fbdf-4574-9b51-e3c55a90b165","timestamp":"2026-08-22T23:03:56.385Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts","oldString":"export const fetchRestaurant = (uid: string) =>\n get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);","newString":"export const fetchRestaurant = (uid: string) =>\n get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\n// --- récemment consultés (localStorage, par appareil) ---------------------------\n\nconst RECENT_KEY = \"restoka:recent\";\nconst RECENT_MAX = 12;\n\nexport function getRecentUids(): string[] {\n try {\n const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? \"[]\");\n return Array.isArray(raw) ? raw.filter((u) => typeof u === \"string\") : [];\n } catch {\n return [];\n }\n}\n\nexport function pushRecentUid(uid: string) {\n try {\n const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);\n localStorage.setItem(RECENT_KEY, JSON.stringify(next));\n } catch { /* stockage indisponible (navigation privée) */ }\n}\n\n/** Fiches des restos consultés, remises dans l'ordre de consultation. */\nexport async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {\n if (uids.length === 0) return [];\n const params = new URLSearchParams({ uids: uids.join(\",\"), limit: String(uids.length) });\n const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);\n const by = new Map(r.restaurants.map((x) => [x.uid, x]));\n return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);\n}","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: api.ts\n// Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR\n// (cuisines, contextes de prix, régions). Même patron que Lou·Ka.\n// ==============================================================================\n\nexport interface MenuOptionChoice { name: string; price_delta: number }\nexport interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }\n\nexport interface MenuItem {\n id?: string;\n name: string;\n description?: string;\n price: number | null;\n currency?: string;\n tags?: string[];\n options?: MenuOption[];\n image?: string;\n}\n\nexport interface MenuSection { name: string; items: MenuItem[]; image?: string }\n\nexport interface Menu {\n price_context: \"dine-in\" | \"takeout\" | \"delivery\";\n price_source: string;\n currency: string;\n captured_at: string;\n item_count: number;\n sections: MenuSection[];\n}\n\nexport interface MenuSummary {\n image?: string | null;\n price_context: string;\n price_source: string;\n captured_at: string;\n item_count: number;\n price_min: number | null;\n price_median: number | null;\n}\n\nexport interface Restaurant {\n uid: string;\n source: string;\n external_id: string;\n name: string;\n chain: string | null;\n cuisines: string[];\n establishment_type: string;\n price_range: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n lat: number | null;\n lng: number | null;\n phone: string;\n website: string;\n url: string;\n hours: Record<string, string>;\n services: string[];\n dietary_options: string[];\n languages: string[];\n images: string[];\n status: string;\n menu_summary?: MenuSummary | null;\n menus?: Menu[];\n recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];\n dup_sources?: string[];\n first_seen?: number;\n last_seen: number;\n updated_at: number;\n active: number;\n}\n\nexport interface Facets {\n regions: { region: string; n: number }[];\n all_regions: string[];\n cities: string[];\n cuisines: { cuisine: string; n: number }[];\n diets: { diet: string; n: number }[];\n establishment_types: { t: string; n: number }[];\n chains: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n platform?: string;\n tier?: number;\n price_context?: string;\n extraction?: string;\n cadence?: string;\n status: string;\n active_restaurants: number;\n menu_items: number;\n last_sync: number | null;\n integrations?: { label: string; status: string }[];\n}\n\nexport interface Stats {\n restaurants: number;\n with_menu: number;\n chains: number;\n regions: number;\n sources: number;\n menus: number;\n items: number;\n by_region: { region: string; n: number }[];\n by_context: { price_context: string; n: number }[];\n recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];\n}\n\n// --- libellés français --------------------------------------------------------\n\nexport const CUISINE_LABELS: Record<string, string> = {\n \"quebecois\": \"Québécois\", \"francais\": \"Français\", \"italien\": \"Italien\",\n \"pizza\": \"Pizza\", \"burgers\": \"Burgers\", \"poulet\": \"Poulet\",\n \"bbq-grillades\": \"BBQ & grillades\", \"fruits-de-mer\": \"Fruits de mer\",\n \"sushi-japonais\": \"Sushi & japonais\", \"chinois\": \"Chinois\", \"thai\": \"Thaï\",\n \"vietnamien\": \"Vietnamien\", \"coreen\": \"Coréen\", \"indien\": \"Indien\",\n \"libanais-moyen-orient\": \"Libanais & Moyen-Orient\", \"mexicain\": \"Mexicain\",\n \"grec\": \"Grec\", \"mediterraneen\": \"Méditerranéen\",\n \"dejeuner-brunch\": \"Déjeuner & brunch\", \"cafe-dessert\": \"Café & dessert\",\n \"vegetarien-vegan\": \"Végé & vegan\", \"fast-food\": \"Fast-food\", \"autre\": \"Autre\",\n};\n\nexport const CONTEXT_LABELS: Record<string, string> = {\n \"dine-in\": \"Prix en salle\",\n \"takeout\": \"Prix pour emporter\",\n \"delivery\": \"Prix livraison (majoré 25-30 %)\",\n};\n\nexport const CONTEXT_SHORT: Record<string, string> = {\n \"dine-in\": \"Salle\", \"takeout\": \"Emporter\", \"delivery\": \"Livraison\",\n};\n\nexport const TYPE_LABELS: Record<string, string> = {\n \"restaurant\": \"Restaurant\", \"fast-food\": \"Fast-food\", \"cafe\": \"Café\",\n \"bar\": \"Bar & pub\", \"food-truck\": \"Food truck\", \"traiteur\": \"Traiteur\",\n \"boulangerie-patisserie\": \"Boulangerie-pâtisserie\",\n \"casse-croute\": \"Casse-croûte\", \"microbrasserie\": \"Microbrasserie\",\n \"hotel\": \"Resto d'hôtel\", \"ghost-kitchen\": \"Cuisine fantôme\",\n};\n\nexport const DIET_LABELS: Record<string, string> = {\n \"vegan\": \"Vegan\", \"vegetarien\": \"Végétarien\", \"sans-gluten\": \"Sans gluten\",\n \"halal\": \"Halal\", \"casher\": \"Casher\", \"sans-noix\": \"Sans noix\", \"epice\": \"Épicé\",\n};\n\nexport const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;\nexport const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;\nexport const dietLabel = (d: string) => DIET_LABELS[d] ?? d;\n\nconst SOURCE_NAMES: Record<string, string> = {\n ueat: \"UEAT (commande en ligne)\",\n osm: \"OpenStreetMap\",\n};\nexport function registerSourceNames(sources: Source[]) {\n for (const s of sources) SOURCE_NAMES[s.id] = s.name;\n}\nexport const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;\n\n/** 16.5 -> « 16,50 $ » */\nexport const fmtPrice = (p: number | null | undefined): string =>\n p == null ? \"—\" : `${p.toFixed(2).replace(\".\", \",\")} $`;\n\nexport const fmtDate = (iso: string | null | undefined): string => {\n if (!iso) return \"—\";\n const d = new Date(iso);\n return isNaN(d.getTime()) ? iso : d.toLocaleDateString(\"fr-CA\");\n};\n\nexport const fmtTs = (ts: number | null | undefined): string =>\n ts ? new Date(ts * 1000).toLocaleString(\"fr-CA\") : \"—\";\n\n// --- client -------------------------------------------------------------------\n\nasync function get<T>(path: string): Promise<T> {\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(path, { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface Dish {\n item: string;\n description: string;\n price: number | null;\n currency: string;\n image?: string | null;\n tags: string[];\n section: string;\n price_context: string;\n price_source: string;\n captured_at: string;\n brand: string;\n restaurant: string;\n uid: string;\n city: string;\n region: string;\n locations: number;\n}\n\nexport interface DishQuery {\n q: string; region?: string; city?: string; cuisine?: string;\n price_max?: number; limit?: number; offset?: number;\n}\n\nexport function fetchDishes(query: DishQuery) {\n const params = new URLSearchParams();\n for (const [k, v] of Object.entries(query))\n if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n return get<{ total: number; count: number; dishes: Dish[] }>(\n `/api/dishes?${params.toString()}`);\n}\n\nexport interface RestaurantQuery {\n region?: string; city?: string; cuisine?: string; establishment_type?: string;\n diet?: string; service?: string; price_range?: string; chain?: string;\n source?: string; q?: string; has_menu?: string; sort?: string;\n limit?: number; offset?: number;\n}\n\nexport function fetchRestaurants(query: RestaurantQuery = {}) {\n const params = new URLSearchParams();\n for (const [k, v] of Object.entries(query))\n if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n return get<{ total: number; count: number; restaurants: Restaurant[] }>(\n `/api/restaurants?${params.toString()}`);\n}\n\nexport const fetchRestaurant = (uid: string) =>\n get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\nexport const fetchFacets = (region?: string) =>\n get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get<Stats>(\"/api/stats\");\n\n// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------\n\nexport interface Me {\n uid: number;\n ka_id: string;\n email: string;\n name: string;\n picture: string;\n bio?: string;\n city?: string;\n public_url?: string;\n profile_source: string;\n}\n\nexport const fetchAuthConfig = () => get<{ ka: boolean }>(\"/api/auth/config\");\n\nexport async function fetchMe(): Promise<Me | null> {\n try {\n return await get<Me>(\"/api/me\");\n } catch {\n return null;\n }\n}\n\nexport async function logout(): Promise<void> {\n await fetch(\"/api/auth/logout\", { method: \"POST\" });\n}\n\nexport const fetchFavoris = () =>\n get<{ count: number; restaurants: Restaurant[]; uids: string[] }>(\"/api/favoris\");\n\nexport async function toggleFavori(uid: string): Promise<boolean> {\n const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: \"POST\" });\n if (!resp.ok) throw new Error(`API ${resp.status}`);\n return (await resp.json()).favori as boolean;\n}\n","structuredPatch":[{"oldStart":241,"oldLines":6,"newStart":241,"newLines":36,"lines":[" export const fetchRestaurant = (uid: string) =>"," get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);"," ","+// --- récemment consultés (localStorage, par appareil) ---------------------------","+","+const RECENT_KEY = \"restoka:recent\";","+const RECENT_MAX = 12;","+","+export function getRecentUids(): string[] {","+ try {","+ const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? \"[]\");","+ return Array.isArray(raw) ? raw.filter((u) => typeof u === \"string\") : [];","+ } catch {","+ return [];","+ }","+}","+","+export function pushRecentUid(uid: string) {","+ try {","+ const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);","+ localStorage.setItem(RECENT_KEY, JSON.stringify(next));","+ } catch { /* stockage indisponible (navigation privée) */ }","+}","+","+/** Fiches des restos consultés, remises dans l'ordre de consultation. */","+export async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {","+ if (uids.length === 0) return [];","+ const params = new URLSearchParams({ uids: uids.join(\",\"), limit: String(uids.length) });","+ const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);","+ const by = new Map(r.restaurants.map((x) => [x.uid, x]));","+ return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);","+}","+"," export const fetchFacets = (region?: string) =>"," get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);"," "]}],"userModified":false,"replaceAll":false}}}55{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd3h4jSuLXSdhKnj9dF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VwsG2nLSQFpTye9wnkAo5Z","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","old_string":" CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";","new_string":" CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,\n} from \"../api\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":922,"cache_read_input_tokens":69747,"cache_creation":{"ephemeral_5m_input_tokens":922,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"105cc677-311d-40c9-bf84-1d519a5757c6"}}56{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VwsG2nLSQFpTye9wnkAo5Z","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"2be50eb9-808c-45f2-9db0-84deb2de1048","timestamp":"2026-08-22T23:03:58.472Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","oldString":" CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";","newString":" CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,\n} from \"../api\";","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Resto.tsx\n// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n// + menu complet par sections avec prix, options et badge de CONTEXTE\n// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n// est inutilisable ; delivery affiché clairement étiqueté).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoCompass } from \"../components/Icons\";\n\nfunction MenuBlock({ menu }: { menu: Menu }) {\n const [openSections, setOpenSections] = useState<Set<number>>(\n new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n const toggle = (i: number) => {\n const next = new Set(openSections);\n if (next.has(i)) next.delete(i);\n else next.add(i);\n setOpenSections(next);\n };\n return (\n <section className=\"menu-block\">\n <div className={`ctx-banner ctx-${menu.price_context}`}>\n <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n <span>\n source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n {\" · \"}{menu.item_count} plats\n </span>\n </div>\n {menu.sections.map((sec, i) => (\n <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n <button className=\"menu-section-head\" onClick={() => toggle(i)}\n aria-expanded={openSections.has(i)}>\n {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n <h3>{sec.name}</h3>\n <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n </button>\n {openSections.has(i) && (\n <ul className=\"menu-items\">\n {sec.items.map((it, j) => (\n <li className=\"menu-item\" key={`${it.name}-${j}`}>\n <div className=\"mi-row\">\n <div className=\"mi-main\">\n <div className=\"mi-line\">\n <span className=\"mi-name\">\n {it.name}\n {(it.tags ?? []).map((t) => (\n <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n ))}\n </span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n </div>\n {it.description && <p className=\"mi-desc\">{it.description}</p>}\n </div>\n {it.image && (\n <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n )}\n </div>\n {(it.options ?? []).length > 0 && (\n <div className=\"mi-optgroups\">\n {(it.options ?? []).map((op, k) => (\n <div className=\"og\" key={`${op.group}-${k}`}>\n <div className=\"og-head\">\n <span className=\"og-name\">{op.group}</span>\n <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n {op.required ? \"Choix requis\" : \"Optionnel\"}\n </span>\n </div>\n <div className=\"og-choices\">\n {op.choices.map((c, m) => (\n <span className=\"og-choice\" key={`${c.name}-${m}`}>\n {c.name}\n {c.price_delta !== 0 && (\n <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>\n {c.price_delta > 0 ? \"+\" : \"−\"}\n {Math.abs(c.price_delta).toFixed(2).replace(\".\", \",\")} $\n </b>\n )}\n </span>\n ))}\n </div>\n </div>\n ))}\n </div>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n ))}\n </section>\n );\n}\n\nexport default function RestoPage() {\n const { uid } = useParams<{ uid: string }>();\n const [resto, setResto] = useState<Restaurant | null>(null);\n const [notFound, setNotFound] = useState(false);\n const [ctx, setCtx] = useState<string | null>(null);\n\n useEffect(() => {\n if (!uid) return;\n setResto(null);\n setNotFound(false);\n fetchRestaurant(uid)\n .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n .catch(() => setNotFound(true));\n window.scrollTo({ top: 0 });\n }, [uid]);\n\n if (notFound) {\n return (\n <div className=\"notice container\">\n <div className=\"big\"><IcoCompass size={40} /></div>\n <h2>Restaurant introuvable</h2>\n <p><Link to=\"/\" className=\"link-btn\">Retour à la recherche</Link></p>\n </div>\n );\n }\n if (!resto) {\n return (\n <div className=\"container detail\">\n <div className=\"skel\"><div className=\"sk-img\" /><div className=\"sk-line\" /><div className=\"sk-line short\" /></div>\n </div>\n );\n }\n\n const menus = resto.menus ?? [];\n const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null;\n\n return (\n <div className=\"container detail\">\n <nav className=\"crumbs\" aria-label=\"Fil d'Ariane\">\n <Link to=\"/\">Restos</Link> ›\n {resto.region && (\n <>\n <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> ›\n </>\n )}\n <span>{resto.name}</span>\n </nav>\n\n <div className=\"detail-grid\">\n {/* Panneau resto (nom, prix, adresse) EN PREMIER dans le DOM : c'est le\n héro de la fiche — l'ordre du DOM est l'ordre visuel sur mobile\n (standard Groupe Ka « Ordre des sections — pages détail »).\n Sur desktop, le CSS le place explicitement en colonne 2. */}\n <aside className=\"panel\">\n <div className=\"panel-head\">\n {(resto.images ?? [])[0] && (\n <img className=\"panel-logo\" src={resto.images[0]} alt=\"\" loading=\"lazy\" />\n )}\n {resto.price_range && <div className=\"price\">{resto.price_range}</div>}\n </div>\n <h1>{resto.name}</h1>\n <div className=\"loc\">\n {resto.address}{resto.address && resto.city ? \", \" : \"\"}{resto.city}\n {resto.postal_code ? ` (${resto.postal_code})` : \"\"}\n </div>\n <div className=\"kv\">\n <div className=\"cell\">\n <div className=\"k\">Région</div>\n <div className=\"v\">{resto.region || \"—\"}</div>\n </div>\n <div className=\"cell\">\n <div className=\"k\">Type</div>\n <div className=\"v\">{typeLabel(resto.establishment_type)}</div>\n </div>\n {resto.chain && (\n <div className=\"cell\">\n <div className=\"k\">Chaîne</div>\n <div className=\"v\">{resto.chain}</div>\n </div>\n )}\n <div className=\"cell\">\n <div className=\"k\">Statut</div>\n <div className=\"v\">{resto.status === \"open\" ? \"Ouvert\" : \"Fermé temporairement\"}</div>\n </div>\n </div>\n {resto.cuisines.length > 0 && (\n <>\n <div className=\"klabel\">Cuisines</div>\n <div className=\"amenity-row\">\n {resto.cuisines.map((c) => (\n <span className=\"amenity\" key={c}>{cuisineLabel(c)}</span>\n ))}\n </div>\n </>\n )}\n {resto.dietary_options.length > 0 && (\n <>\n <div className=\"klabel\">Diètes offertes</div>\n <div className=\"amenity-row\">\n {resto.dietary_options.map((d) => (\n <span className=\"amenity\" key={d}>{dietLabel(d)}</span>\n ))}\n </div>\n </>\n )}\n {(resto.website || resto.url) && (\n <a className=\"cta\" href={resto.url || resto.website}\n target=\"_blank\" rel=\"noopener noreferrer\">\n Commander / voir chez le resto ↗\n </a>\n )}\n <div className=\"fine\">\n Source : {sourceName(resto.source)} — les prix appartiennent au\n resto et peuvent changer sans préavis.\n </div>\n </aside>\n\n <div className=\"detail-main\">\n {menus.length > 1 && (\n <div className=\"view-toggle ctx-toggle\" role=\"tablist\" aria-label=\"Contexte de prix\">\n {menus.map((m) => (\n <button key={m.price_context}\n className={m.price_context === menu?.price_context ? \"on\" : \"\"}\n onClick={() => setCtx(m.price_context)}>\n {CONTEXT_SHORT[m.price_context] ?? m.price_context}\n </button>\n ))}\n </div>\n )}\n {menu ? <MenuBlock menu={menu} /> : (\n <div className=\"notice\">\n <h2>Menu à venir</h2>\n <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p>\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n","structuredPatch":[{"oldStart":10,"oldLines":7,"newStart":10,"newLines":7,"lines":[" import { Link, useParams } from \"react-router-dom\";"," import {"," CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,","- fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,","+ fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,"," } from \"../api\";"," import { IcoCompass } from \"../components/Icons\";"," "]}],"userModified":false,"replaceAll":false}}}57{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd3h4jSuLXSdhKnj9dF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Mzzo8dxfqnZ3mAWcgGKusY","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","old_string":" fetchRestaurant(uid)\n .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n .catch(() => setNotFound(true));","new_string":" fetchRestaurant(uid)\n .then((r) => {\n setResto(r);\n setCtx(r.menus?.[0]?.price_context ?? null);\n pushRecentUid(r.uid);\n })\n .catch(() => setNotFound(true));"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":922,"cache_read_input_tokens":69747,"cache_creation":{"ephemeral_5m_input_tokens":922,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"889b767c-5357-4ce8-9605-2d7667595fc3"}}58{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Mzzo8dxfqnZ3mAWcgGKusY","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"7464e903-23bb-4cee-9f25-e0301c05749a","timestamp":"2026-08-22T23:04:00.891Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","oldString":" fetchRestaurant(uid)\n .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n .catch(() => setNotFound(true));","newString":" fetchRestaurant(uid)\n .then((r) => {\n setResto(r);\n setCtx(r.menus?.[0]?.price_context ?? null);\n pushRecentUid(r.uid);\n })\n .catch(() => setNotFound(true));","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Resto.tsx\n// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n// + menu complet par sections avec prix, options et badge de CONTEXTE\n// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n// est inutilisable ; delivery affiché clairement étiqueté).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoCompass } from \"../components/Icons\";\n\nfunction MenuBlock({ menu }: { menu: Menu }) {\n const [openSections, setOpenSections] = useState<Set<number>>(\n new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n const toggle = (i: number) => {\n const next = new Set(openSections);\n if (next.has(i)) next.delete(i);\n else next.add(i);\n setOpenSections(next);\n };\n return (\n <section className=\"menu-block\">\n <div className={`ctx-banner ctx-${menu.price_context}`}>\n <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n <span>\n source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n {\" · \"}{menu.item_count} plats\n </span>\n </div>\n {menu.sections.map((sec, i) => (\n <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n <button className=\"menu-section-head\" onClick={() => toggle(i)}\n aria-expanded={openSections.has(i)}>\n {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n <h3>{sec.name}</h3>\n <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n </button>\n {openSections.has(i) && (\n <ul className=\"menu-items\">\n {sec.items.map((it, j) => (\n <li className=\"menu-item\" key={`${it.name}-${j}`}>\n <div className=\"mi-row\">\n <div className=\"mi-main\">\n <div className=\"mi-line\">\n <span className=\"mi-name\">\n {it.name}\n {(it.tags ?? []).map((t) => (\n <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n ))}\n </span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n </div>\n {it.description && <p className=\"mi-desc\">{it.description}</p>}\n </div>\n {it.image && (\n <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n )}\n </div>\n {(it.options ?? []).length > 0 && (\n <div className=\"mi-optgroups\">\n {(it.options ?? []).map((op, k) => (\n <div className=\"og\" key={`${op.group}-${k}`}>\n <div className=\"og-head\">\n <span className=\"og-name\">{op.group}</span>\n <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n {op.required ? \"Choix requis\" : \"Optionnel\"}\n </span>\n </div>\n <div className=\"og-choices\">\n {op.choices.map((c, m) => (\n <span className=\"og-choice\" key={`${c.name}-${m}`}>\n {c.name}\n {c.price_delta !== 0 && (\n <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>\n {c.price_delta > 0 ? \"+\" : \"−\"}\n {Math.abs(c.price_delta).toFixed(2).replace(\".\", \",\")} $\n </b>\n )}\n </span>\n ))}\n </div>\n </div>\n ))}\n </div>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n ))}\n </section>\n );\n}\n\nexport default function RestoPage() {\n const { uid } = useParams<{ uid: string }>();\n const [resto, setResto] = useState<Restaurant | null>(null);\n const [notFound, setNotFound] = useState(false);\n const [ctx, setCtx] = useState<string | null>(null);\n\n useEffect(() => {\n if (!uid) return;\n setResto(null);\n setNotFound(false);\n fetchRestaurant(uid)\n .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n .catch(() => setNotFound(true));\n window.scrollTo({ top: 0 });\n }, [uid]);\n\n if (notFound) {\n return (\n <div className=\"notice container\">\n <div className=\"big\"><IcoCompass size={40} /></div>\n <h2>Restaurant introuvable</h2>\n <p><Link to=\"/\" className=\"link-btn\">Retour à la recherche</Link></p>\n </div>\n );\n }\n if (!resto) {\n return (\n <div className=\"container detail\">\n <div className=\"skel\"><div className=\"sk-img\" /><div className=\"sk-line\" /><div className=\"sk-line short\" /></div>\n </div>\n );\n }\n\n const menus = resto.menus ?? [];\n const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null;\n\n return (\n <div className=\"container detail\">\n <nav className=\"crumbs\" aria-label=\"Fil d'Ariane\">\n <Link to=\"/\">Restos</Link> ›\n {resto.region && (\n <>\n <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> ›\n </>\n )}\n <span>{resto.name}</span>\n </nav>\n\n <div className=\"detail-grid\">\n {/* Panneau resto (nom, prix, adresse) EN PREMIER dans le DOM : c'est le\n héro de la fiche — l'ordre du DOM est l'ordre visuel sur mobile\n (standard Groupe Ka « Ordre des sections — pages détail »).\n Sur desktop, le CSS le place explicitement en colonne 2. */}\n <aside className=\"panel\">\n <div className=\"panel-head\">\n {(resto.images ?? [])[0] && (\n <img className=\"panel-logo\" src={resto.images[0]} alt=\"\" loading=\"lazy\" />\n )}\n {resto.price_range && <div className=\"price\">{resto.price_range}</div>}\n </div>\n <h1>{resto.name}</h1>\n <div className=\"loc\">\n {resto.address}{resto.address && resto.city ? \", \" : \"\"}{resto.city}\n {resto.postal_code ? ` (${resto.postal_code})` : \"\"}\n </div>\n <div className=\"kv\">\n <div className=\"cell\">\n <div className=\"k\">Région</div>\n <div className=\"v\">{resto.region || \"—\"}</div>\n </div>\n <div className=\"cell\">\n <div className=\"k\">Type</div>\n <div className=\"v\">{typeLabel(resto.establishment_type)}</div>\n </div>\n {resto.chain && (\n <div className=\"cell\">\n <div className=\"k\">Chaîne</div>\n <div className=\"v\">{resto.chain}</div>\n </div>\n )}\n <div className=\"cell\">\n <div className=\"k\">Statut</div>\n <div className=\"v\">{resto.status === \"open\" ? \"Ouvert\" : \"Fermé temporairement\"}</div>\n </div>\n </div>\n {resto.cuisines.length > 0 && (\n <>\n <div className=\"klabel\">Cuisines</div>\n <div className=\"amenity-row\">\n {resto.cuisines.map((c) => (\n <span className=\"amenity\" key={c}>{cuisineLabel(c)}</span>\n ))}\n </div>\n </>\n )}\n {resto.dietary_options.length > 0 && (\n <>\n <div className=\"klabel\">Diètes offertes</div>\n <div className=\"amenity-row\">\n {resto.dietary_options.map((d) => (\n <span className=\"amenity\" key={d}>{dietLabel(d)}</span>\n ))}\n </div>\n </>\n )}\n {(resto.website || resto.url) && (\n <a className=\"cta\" href={resto.url || resto.website}\n target=\"_blank\" rel=\"noopener noreferrer\">\n Commander / voir chez le resto ↗\n </a>\n )}\n <div className=\"fine\">\n Source : {sourceName(resto.source)} — les prix appartiennent au\n resto et peuvent changer sans préavis.\n </div>\n </aside>\n\n <div className=\"detail-main\">\n {menus.length > 1 && (\n <div className=\"view-toggle ctx-toggle\" role=\"tablist\" aria-label=\"Contexte de prix\">\n {menus.map((m) => (\n <button key={m.price_context}\n className={m.price_context === menu?.price_context ? \"on\" : \"\"}\n onClick={() => setCtx(m.price_context)}>\n {CONTEXT_SHORT[m.price_context] ?? m.price_context}\n </button>\n ))}\n </div>\n )}\n {menu ? <MenuBlock menu={menu} /> : (\n <div className=\"notice\">\n <h2>Menu à venir</h2>\n <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p>\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n","structuredPatch":[{"oldStart":110,"oldLines":7,"newStart":110,"newLines":11,"lines":[" setResto(null);"," setNotFound(false);"," fetchRestaurant(uid)","- .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })","+ .then((r) => {","+ setResto(r);","+ setCtx(r.menus?.[0]?.price_context ?? null);","+ pushRecentUid(r.uid);","+ })"," .catch(() => setNotFound(true));"," window.scrollTo({ top: 0 });"," }, [uid]);"]}],"userModified":false,"replaceAll":false}}}59{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7db04391-b405-4550-a22b-93bdad10e917","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}60{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant la section sur l'accueil et le CSS."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"f4902605-fde2-4707-a01c-c5ded0021732"}}61{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PNpfRZ4pT2i4pqwjCozSTF","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","old_string":"import {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";","new_string":"import {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3fd23029-56a5-4396-8564-c5eefcbd36e0"}}62{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PNpfRZ4pT2i4pqwjCozSTF","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"2d5294d1-8182-43f0-97d7-b2bdeb82abc5","timestamp":"2026-08-22T23:04:08.096Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","oldString":"import {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";","newString":"import {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Home.tsx\n// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n// tous les menus), chips cuisines, barre de filtres (région, ville,\n// type, diète, prix), grilles de résultats + pagination numérotée.\n// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n// Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n return (\n <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n {d.image ? (\n <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n ) : (\n <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n )}\n <div className=\"dish-main\">\n <div className=\"dish-line\">\n <span className=\"dish-name\">{d.item}</span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n </div>\n {d.description && <p className=\"dish-desc\">{d.description}</p>}\n <div className=\"dish-meta\">\n <b>{d.brand}</b>\n <span className=\"sep\" />\n <span>{d.section}</span>\n <span className=\"sep\" />\n {d.locations > 1\n ? <span>{d.locations} emplacements</span>\n : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n <span className={`ctx-pill ctx-${d.price_context}`}>\n {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n </span>\n </div>\n </div>\n </Link>\n );\n}\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState<Facets | null>(null);\n const [restos, setRestos] = useState<Restaurant[]>([]);\n const [dishes, setDishes] = useState<Dish[]>([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(false);\n const [page, setPage] = useState(0);\n const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);\n\n // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n // Escape (même pattern que le menu hamburger d'App.tsx)\n useEffect(() => {\n document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n window.addEventListener(\"keydown\", onKey);\n return () => {\n document.body.style.overflow = \"\";\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [sheetOpen]);\n\n const filters = useMemo(() => {\n const f: Partial<Record<FilterKey, string>> = {};\n for (const k of FILTER_KEYS) {\n const v = params.get(k);\n if (v) f[k] = v;\n }\n return f;\n }, [params]);\n\n const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n const setFilter = (key: FilterKey, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value);\n else next.delete(key);\n setParams(next, { replace: true });\n setPage(0);\n };\n\n useEffect(() => {\n fetchStats()\n .then((s) => setStatChips([\n { label: \"restaurants\", value: s.restaurants },\n { label: \"avec menu complet\", value: s.with_menu },\n { label: \"plats avec prix\", value: s.items },\n { label: \"régions couvertes\", value: s.regions },\n ]))\n .catch(() => {});\n }, []);\n\n useEffect(() => {\n fetchFacets(filters.region).then(setFacets).catch(() => {});\n }, [filters.region]);\n\n useEffect(() => {\n setLoading(true);\n setError(false);\n if (mode === \"plats\") {\n const q = (filters.q ?? \"\").trim();\n if (q.length < 2) {\n setDishes([]); setTotal(0); setLoading(false);\n return;\n }\n fetchDishes({\n q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n })\n .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n } else {\n const { mode: _m, ...rest } = filters;\n fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n }\n }, [filters, page, mode]);\n\n const activePills: { key: FilterKey; label: string }[] = [];\n if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n const filterbar = (\n <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n <div className=\"sheet-head\">\n Filtres\n <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n </div>\n <div className=\"f-primary\">\n <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"restos\"}\n onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"plats\"}\n onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n </div>\n <label className=\"f-search\">\n <IcoSearch size={16} />\n <input\n type=\"search\"\n placeholder={mode === \"plats\"\n ? \"Poutine, tartare, pad thaï, sushi…\"\n : \"Resto, chaîne, ville…\"}\n value={filters.q ?? \"\"}\n onChange={(e) => setFilter(\"q\", e.target.value)}\n aria-label=\"Recherche\"\n />\n {filters.q && (\n <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n )}\n </label>\n <label className=\"f-ctl\">\n <span>Région</span>\n <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n <option value=\"\">Toutes</option>\n {(facets?.regions ?? []).map((r) => (\n <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Ville</span>\n <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.cities ?? []).map((c) => (\n <option key={c} value={c}>{c}</option>\n ))}\n </select>\n </label>\n {mode === \"restos\" && (\n <>\n <label className=\"f-ctl\">\n <span>Type</span>\n <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n <option value=\"\">Tous</option>\n {(facets?.establishment_types ?? []).map((t) => (\n <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Diète</span>\n <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.diets ?? []).map((d) => (\n <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Prix</span>\n <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n <option value=\"\">Tous</option>\n {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n <option key={p} value={p}>{p}</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Menu</span>\n <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n <option value=\"\">Tous</option>\n <option value=\"1\">Avec menu complet</option>\n <option value=\"0\">Fiche seulement</option>\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Tri</span>\n <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n <option value=\"menu\">Menus d'abord</option>\n <option value=\"name\">Nom</option>\n <option value=\"price\">Fourchette de prix</option>\n <option value=\"recent\">Récents</option>\n </select>\n </label>\n </>\n )}\n </div>\n <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n Voir les {total} résultats\n </button>\n </div>\n );\n\n return (\n <div className=\"container\">\n <section className=\"hero\">\n <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n <h1>\n Tous les restos. Tous les menus.<br />\n <span className=\"hl\">Tous les prix.</span>\n </h1>\n <p className=\"lede\">\n Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n le contexte du prix (salle, emporter, livraison) est toujours affiché.\n </p>\n <div className=\"stat-row\">\n <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n {statChips.map((s) => (\n <div className=\"stat-chip\" key={s.label}>\n <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n </div>\n ))}\n </div>\n <div className=\"benefits\">\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n <span className=\"benefit-label\">17 régions du Québec</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n <span className=\"benefit-label\">Recherche par plat</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n <span className=\"benefit-label\">Filtres diète & cuisine</span>\n </div>\n </div>\n </section>\n\n {/* chips cuisines (défilement horizontal) */}\n <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n <button\n key={c.cuisine}\n className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n >\n {cuisineLabel(c.cuisine)} · {c.n}\n </button>\n ))}\n </div>\n\n {filterbar}\n {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n <IcoSearch size={16} /> Filtres\n </button>\n\n {activePills.length > 0 && (\n <div className=\"pills\">\n {activePills.map((p) => (\n <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n {p.label} <span className=\"pill-x\">✕</span>\n </button>\n ))}\n <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n </div>\n )}\n\n <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n <span>\n {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n ? \"tapez un plat (min. 2 lettres)\"\n : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n </span>\n </div>\n\n {error && (\n <div className=\"notice\">\n <h2>Impossible de charger les résultats</h2>\n <p>Réessayez dans un instant.</p>\n </div>\n )}\n\n {loading ? (\n <div className=\"grid\">\n {Array.from({ length: 8 }).map((_, i) => (\n <div className=\"skel\" key={i}>\n <div className=\"sk-img\" />\n <div className=\"sk-line\" />\n <div className=\"sk-line short\" />\n </div>\n ))}\n </div>\n ) : mode === \"plats\" ? (\n (filters.q ?? \"\").trim().length < 2 ? (\n <div className=\"notice\">\n <h2>Cherchez n'importe quel plat</h2>\n <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n du Québec sont indexés, avec leurs prix réels.</p>\n </div>\n ) : dishes.length === 0 ? (\n <div className=\"notice\">\n <h2>Aucun plat trouvé</h2>\n <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"dish-list\">\n {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )\n ) : restos.length === 0 && !error ? (\n <div className=\"notice\">\n <h2>Aucun resto ne correspond</h2>\n <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"grid\">\n {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )}\n </div>\n );\n}\n","structuredPatch":[{"oldStart":11,"oldLines":7,"newStart":11,"newLines":8,"lines":[" import { Link, useSearchParams } from \"react-router-dom\";"," import {"," CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,","- fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,","+ fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,","+ fetchStats, fmtPrice, getRecentUids, typeLabel,"," } from \"../api\";"," import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";"," import Pager from \"../components/Pager\";"]}],"userModified":false,"replaceAll":false}}}63{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NMvPt7FXW6GJrKzLXi76H2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","old_string":" const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);","new_string":" const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);\n const [recent, setRecent] = useState<Restaurant[]>([]);\n\n useEffect(() => {\n fetchRecentRestaurants(getRecentUids().slice(0, 8))\n .then(setRecent)\n .catch(() => {});\n }, []);"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"4f376696-a6f6-49d1-8742-f8ca12f67d70"}}64{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NMvPt7FXW6GJrKzLXi76H2","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"31b62d8c-16db-4185-aa70-ce72826fad1d","timestamp":"2026-08-22T23:04:10.914Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","oldString":" const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);","newString":" const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);\n const [recent, setRecent] = useState<Restaurant[]>([]);\n\n useEffect(() => {\n fetchRecentRestaurants(getRecentUids().slice(0, 8))\n .then(setRecent)\n .catch(() => {});\n }, []);","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Home.tsx\n// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n// tous les menus), chips cuisines, barre de filtres (région, ville,\n// type, diète, prix), grilles de résultats + pagination numérotée.\n// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n// Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n return (\n <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n {d.image ? (\n <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n ) : (\n <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n )}\n <div className=\"dish-main\">\n <div className=\"dish-line\">\n <span className=\"dish-name\">{d.item}</span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n </div>\n {d.description && <p className=\"dish-desc\">{d.description}</p>}\n <div className=\"dish-meta\">\n <b>{d.brand}</b>\n <span className=\"sep\" />\n <span>{d.section}</span>\n <span className=\"sep\" />\n {d.locations > 1\n ? <span>{d.locations} emplacements</span>\n : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n <span className={`ctx-pill ctx-${d.price_context}`}>\n {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n </span>\n </div>\n </div>\n </Link>\n );\n}\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState<Facets | null>(null);\n const [restos, setRestos] = useState<Restaurant[]>([]);\n const [dishes, setDishes] = useState<Dish[]>([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(false);\n const [page, setPage] = useState(0);\n const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);\n\n // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n // Escape (même pattern que le menu hamburger d'App.tsx)\n useEffect(() => {\n document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n window.addEventListener(\"keydown\", onKey);\n return () => {\n document.body.style.overflow = \"\";\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [sheetOpen]);\n\n const filters = useMemo(() => {\n const f: Partial<Record<FilterKey, string>> = {};\n for (const k of FILTER_KEYS) {\n const v = params.get(k);\n if (v) f[k] = v;\n }\n return f;\n }, [params]);\n\n const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n const setFilter = (key: FilterKey, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value);\n else next.delete(key);\n setParams(next, { replace: true });\n setPage(0);\n };\n\n useEffect(() => {\n fetchStats()\n .then((s) => setStatChips([\n { label: \"restaurants\", value: s.restaurants },\n { label: \"avec menu complet\", value: s.with_menu },\n { label: \"plats avec prix\", value: s.items },\n { label: \"régions couvertes\", value: s.regions },\n ]))\n .catch(() => {});\n }, []);\n\n useEffect(() => {\n fetchFacets(filters.region).then(setFacets).catch(() => {});\n }, [filters.region]);\n\n useEffect(() => {\n setLoading(true);\n setError(false);\n if (mode === \"plats\") {\n const q = (filters.q ?? \"\").trim();\n if (q.length < 2) {\n setDishes([]); setTotal(0); setLoading(false);\n return;\n }\n fetchDishes({\n q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n })\n .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n } else {\n const { mode: _m, ...rest } = filters;\n fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n }\n }, [filters, page, mode]);\n\n const activePills: { key: FilterKey; label: string }[] = [];\n if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n const filterbar = (\n <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n <div className=\"sheet-head\">\n Filtres\n <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n </div>\n <div className=\"f-primary\">\n <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"restos\"}\n onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"plats\"}\n onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n </div>\n <label className=\"f-search\">\n <IcoSearch size={16} />\n <input\n type=\"search\"\n placeholder={mode === \"plats\"\n ? \"Poutine, tartare, pad thaï, sushi…\"\n : \"Resto, chaîne, ville…\"}\n value={filters.q ?? \"\"}\n onChange={(e) => setFilter(\"q\", e.target.value)}\n aria-label=\"Recherche\"\n />\n {filters.q && (\n <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n )}\n </label>\n <label className=\"f-ctl\">\n <span>Région</span>\n <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n <option value=\"\">Toutes</option>\n {(facets?.regions ?? []).map((r) => (\n <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Ville</span>\n <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.cities ?? []).map((c) => (\n <option key={c} value={c}>{c}</option>\n ))}\n </select>\n </label>\n {mode === \"restos\" && (\n <>\n <label className=\"f-ctl\">\n <span>Type</span>\n <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n <option value=\"\">Tous</option>\n {(facets?.establishment_types ?? []).map((t) => (\n <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Diète</span>\n <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.diets ?? []).map((d) => (\n <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Prix</span>\n <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n <option value=\"\">Tous</option>\n {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n <option key={p} value={p}>{p}</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Menu</span>\n <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n <option value=\"\">Tous</option>\n <option value=\"1\">Avec menu complet</option>\n <option value=\"0\">Fiche seulement</option>\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Tri</span>\n <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n <option value=\"menu\">Menus d'abord</option>\n <option value=\"name\">Nom</option>\n <option value=\"price\">Fourchette de prix</option>\n <option value=\"recent\">Récents</option>\n </select>\n </label>\n </>\n )}\n </div>\n <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n Voir les {total} résultats\n </button>\n </div>\n );\n\n return (\n <div className=\"container\">\n <section className=\"hero\">\n <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n <h1>\n Tous les restos. Tous les menus.<br />\n <span className=\"hl\">Tous les prix.</span>\n </h1>\n <p className=\"lede\">\n Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n le contexte du prix (salle, emporter, livraison) est toujours affiché.\n </p>\n <div className=\"stat-row\">\n <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n {statChips.map((s) => (\n <div className=\"stat-chip\" key={s.label}>\n <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n </div>\n ))}\n </div>\n <div className=\"benefits\">\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n <span className=\"benefit-label\">17 régions du Québec</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n <span className=\"benefit-label\">Recherche par plat</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n <span className=\"benefit-label\">Filtres diète & cuisine</span>\n </div>\n </div>\n </section>\n\n {/* chips cuisines (défilement horizontal) */}\n <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n <button\n key={c.cuisine}\n className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n >\n {cuisineLabel(c.cuisine)} · {c.n}\n </button>\n ))}\n </div>\n\n {filterbar}\n {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n <IcoSearch size={16} /> Filtres\n </button>\n\n {activePills.length > 0 && (\n <div className=\"pills\">\n {activePills.map((p) => (\n <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n {p.label} <span className=\"pill-x\">✕</span>\n </button>\n ))}\n <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n </div>\n )}\n\n <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n <span>\n {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n ? \"tapez un plat (min. 2 lettres)\"\n : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n </span>\n </div>\n\n {error && (\n <div className=\"notice\">\n <h2>Impossible de charger les résultats</h2>\n <p>Réessayez dans un instant.</p>\n </div>\n )}\n\n {loading ? (\n <div className=\"grid\">\n {Array.from({ length: 8 }).map((_, i) => (\n <div className=\"skel\" key={i}>\n <div className=\"sk-img\" />\n <div className=\"sk-line\" />\n <div className=\"sk-line short\" />\n </div>\n ))}\n </div>\n ) : mode === \"plats\" ? (\n (filters.q ?? \"\").trim().length < 2 ? (\n <div className=\"notice\">\n <h2>Cherchez n'importe quel plat</h2>\n <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n du Québec sont indexés, avec leurs prix réels.</p>\n </div>\n ) : dishes.length === 0 ? (\n <div className=\"notice\">\n <h2>Aucun plat trouvé</h2>\n <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"dish-list\">\n {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )\n ) : restos.length === 0 && !error ? (\n <div className=\"notice\">\n <h2>Aucun resto ne correspond</h2>\n <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"grid\">\n {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )}\n </div>\n );\n}\n","structuredPatch":[{"oldStart":68,"oldLines":7,"newStart":68,"newLines":14,"lines":[" const [page, setPage] = useState(0);"," const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);"," const [sheetOpen, setSheetOpen] = useState(false);","+ const [recent, setRecent] = useState<Restaurant[]>([]);"," ","+ useEffect(() => {","+ fetchRecentRestaurants(getRecentUids().slice(0, 8))","+ .then(setRecent)","+ .catch(() => {});","+ }, []);","+"," // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à"," // Escape (même pattern que le menu hamburger d'App.tsx)"," useEffect(() => {"]}],"userModified":false,"replaceAll":false}}}65{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HoCBdzdnYdagH9LLdvjgW1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","old_string":" <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","new_string":" {page === 0 && recent.length > 0 && (\n <section aria-label=\"Récemment consultés\">\n <div className=\"results-head\">\n <h2>Récemment consultés</h2>\n <span>vos dernières fiches</span>\n </div>\n <div className=\"recent-row\">\n {recent.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n </section>\n )}\n\n <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"8eb82533-e1a7-4fcd-a255-c2482ed9de7c"}}66{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HoCBdzdnYdagH9LLdvjgW1","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"894bbbc6-888f-42e2-bca4-ec0b89f9ee0a","timestamp":"2026-08-22T23:04:13.785Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","oldString":" <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","newString":" {page === 0 && recent.length > 0 && (\n <section aria-label=\"Récemment consultés\">\n <div className=\"results-head\">\n <h2>Récemment consultés</h2>\n <span>vos dernières fiches</span>\n </div>\n <div className=\"recent-row\">\n {recent.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n </section>\n )}\n\n <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File: pages/Home.tsx\n// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n// tous les menus), chips cuisines, barre de filtres (région, ville,\n// type, diète, prix), grilles de résultats + pagination numérotée.\n// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n// Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n return (\n <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n {d.image ? (\n <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n ) : (\n <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n )}\n <div className=\"dish-main\">\n <div className=\"dish-line\">\n <span className=\"dish-name\">{d.item}</span>\n <span className=\"mi-dots\" aria-hidden=\"true\" />\n <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n </div>\n {d.description && <p className=\"dish-desc\">{d.description}</p>}\n <div className=\"dish-meta\">\n <b>{d.brand}</b>\n <span className=\"sep\" />\n <span>{d.section}</span>\n <span className=\"sep\" />\n {d.locations > 1\n ? <span>{d.locations} emplacements</span>\n : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n <span className={`ctx-pill ctx-${d.price_context}`}>\n {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n </span>\n </div>\n </div>\n </Link>\n );\n}\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState<Facets | null>(null);\n const [restos, setRestos] = useState<Restaurant[]>([]);\n const [dishes, setDishes] = useState<Dish[]>([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(false);\n const [page, setPage] = useState(0);\n const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n const [sheetOpen, setSheetOpen] = useState(false);\n const [recent, setRecent] = useState<Restaurant[]>([]);\n\n useEffect(() => {\n fetchRecentRestaurants(getRecentUids().slice(0, 8))\n .then(setRecent)\n .catch(() => {});\n }, []);\n\n // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n // Escape (même pattern que le menu hamburger d'App.tsx)\n useEffect(() => {\n document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n window.addEventListener(\"keydown\", onKey);\n return () => {\n document.body.style.overflow = \"\";\n window.removeEventListener(\"keydown\", onKey);\n };\n }, [sheetOpen]);\n\n const filters = useMemo(() => {\n const f: Partial<Record<FilterKey, string>> = {};\n for (const k of FILTER_KEYS) {\n const v = params.get(k);\n if (v) f[k] = v;\n }\n return f;\n }, [params]);\n\n const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n const setFilter = (key: FilterKey, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value);\n else next.delete(key);\n setParams(next, { replace: true });\n setPage(0);\n };\n\n useEffect(() => {\n fetchStats()\n .then((s) => setStatChips([\n { label: \"restaurants\", value: s.restaurants },\n { label: \"avec menu complet\", value: s.with_menu },\n { label: \"plats avec prix\", value: s.items },\n { label: \"régions couvertes\", value: s.regions },\n ]))\n .catch(() => {});\n }, []);\n\n useEffect(() => {\n fetchFacets(filters.region).then(setFacets).catch(() => {});\n }, [filters.region]);\n\n useEffect(() => {\n setLoading(true);\n setError(false);\n if (mode === \"plats\") {\n const q = (filters.q ?? \"\").trim();\n if (q.length < 2) {\n setDishes([]); setTotal(0); setLoading(false);\n return;\n }\n fetchDishes({\n q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n })\n .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n } else {\n const { mode: _m, ...rest } = filters;\n fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n .catch(() => setError(true))\n .finally(() => setLoading(false));\n }\n }, [filters, page, mode]);\n\n const activePills: { key: FilterKey; label: string }[] = [];\n if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n const filterbar = (\n <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n <div className=\"sheet-head\">\n Filtres\n <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n </div>\n <div className=\"f-primary\">\n <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"restos\"}\n onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n aria-selected={mode === \"plats\"}\n onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n </div>\n <label className=\"f-search\">\n <IcoSearch size={16} />\n <input\n type=\"search\"\n placeholder={mode === \"plats\"\n ? \"Poutine, tartare, pad thaï, sushi…\"\n : \"Resto, chaîne, ville…\"}\n value={filters.q ?? \"\"}\n onChange={(e) => setFilter(\"q\", e.target.value)}\n aria-label=\"Recherche\"\n />\n {filters.q && (\n <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n )}\n </label>\n <label className=\"f-ctl\">\n <span>Région</span>\n <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n <option value=\"\">Toutes</option>\n {(facets?.regions ?? []).map((r) => (\n <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Ville</span>\n <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.cities ?? []).map((c) => (\n <option key={c} value={c}>{c}</option>\n ))}\n </select>\n </label>\n {mode === \"restos\" && (\n <>\n <label className=\"f-ctl\">\n <span>Type</span>\n <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n <option value=\"\">Tous</option>\n {(facets?.establishment_types ?? []).map((t) => (\n <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Diète</span>\n <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n <option value=\"\">Toutes</option>\n {(facets?.diets ?? []).map((d) => (\n <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Prix</span>\n <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n <option value=\"\">Tous</option>\n {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n <option key={p} value={p}>{p}</option>\n ))}\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Menu</span>\n <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n <option value=\"\">Tous</option>\n <option value=\"1\">Avec menu complet</option>\n <option value=\"0\">Fiche seulement</option>\n </select>\n </label>\n <label className=\"f-ctl\">\n <span>Tri</span>\n <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n <option value=\"menu\">Menus d'abord</option>\n <option value=\"name\">Nom</option>\n <option value=\"price\">Fourchette de prix</option>\n <option value=\"recent\">Récents</option>\n </select>\n </label>\n </>\n )}\n </div>\n <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n Voir les {total} résultats\n </button>\n </div>\n );\n\n return (\n <div className=\"container\">\n <section className=\"hero\">\n <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n <h1>\n Tous les restos. Tous les menus.<br />\n <span className=\"hl\">Tous les prix.</span>\n </h1>\n <p className=\"lede\">\n Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n le contexte du prix (salle, emporter, livraison) est toujours affiché.\n </p>\n <div className=\"stat-row\">\n <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n {statChips.map((s) => (\n <div className=\"stat-chip\" key={s.label}>\n <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n </div>\n ))}\n </div>\n <div className=\"benefits\">\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n <span className=\"benefit-label\">17 régions du Québec</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n <span className=\"benefit-label\">Recherche par plat</span>\n </div>\n <div className=\"benefit\">\n <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n <span className=\"benefit-label\">Filtres diète & cuisine</span>\n </div>\n </div>\n </section>\n\n {/* chips cuisines (défilement horizontal) */}\n <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n <button\n key={c.cuisine}\n className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n >\n {cuisineLabel(c.cuisine)} · {c.n}\n </button>\n ))}\n </div>\n\n {filterbar}\n {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n <IcoSearch size={16} /> Filtres\n </button>\n\n {activePills.length > 0 && (\n <div className=\"pills\">\n {activePills.map((p) => (\n <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n {p.label} <span className=\"pill-x\">✕</span>\n </button>\n ))}\n <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n </div>\n )}\n\n <div className=\"results-head\">\n <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n <span>\n {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n ? \"tapez un plat (min. 2 lettres)\"\n : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n </span>\n </div>\n\n {error && (\n <div className=\"notice\">\n <h2>Impossible de charger les résultats</h2>\n <p>Réessayez dans un instant.</p>\n </div>\n )}\n\n {loading ? (\n <div className=\"grid\">\n {Array.from({ length: 8 }).map((_, i) => (\n <div className=\"skel\" key={i}>\n <div className=\"sk-img\" />\n <div className=\"sk-line\" />\n <div className=\"sk-line short\" />\n </div>\n ))}\n </div>\n ) : mode === \"plats\" ? (\n (filters.q ?? \"\").trim().length < 2 ? (\n <div className=\"notice\">\n <h2>Cherchez n'importe quel plat</h2>\n <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n du Québec sont indexés, avec leurs prix réels.</p>\n </div>\n ) : dishes.length === 0 ? (\n <div className=\"notice\">\n <h2>Aucun plat trouvé</h2>\n <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"dish-list\">\n {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )\n ) : restos.length === 0 && !error ? (\n <div className=\"notice\">\n <h2>Aucun resto ne correspond</h2>\n <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n </div>\n ) : (\n <>\n <div className=\"grid\">\n {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n </div>\n <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n </>\n )}\n </div>\n );\n}\n","structuredPatch":[{"oldStart":338,"oldLines":6,"newStart":338,"newLines":18,"lines":[" </div>"," )}"," ","+ {page === 0 && recent.length > 0 && (","+ <section aria-label=\"Récemment consultés\">","+ <div className=\"results-head\">","+ <h2>Récemment consultés</h2>","+ <span>vos dernières fiches</span>","+ </div>","+ <div className=\"recent-row\">","+ {recent.map((r) => <RestoCard key={r.uid} r={r} />)}","+ </div>","+ </section>","+ )}","+"," <div className=\"results-head\">"," <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>"," <span>"]}],"userModified":false,"replaceAll":false}}}67{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f23ca9f1-02e2-4a2a-a0fb-ac8032a35ca0","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}68{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd5moo5CYQen1aciGnM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XnZvWSpzTkCGVac5zfT5vS","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","offset":217,"limit":12},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1345,"cache_read_input_tokens":72475,"cache_creation":{"ephemeral_5m_input_tokens":1345,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"6636843b-68a0-4d91-b831-9e9dc1daacb8"}}69{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XnZvWSpzTkCGVac5zfT5vS","type":"tool_result","content":"217\t.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n218\t.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n219\t.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n220\t.grid {\n221\t display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n222\t gap: 22px; padding-bottom: 30px;\n223\t}\n224\t@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n225\t\n226\t.card {\n227\t background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n228\t overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0f609dea-cbd2-4cf2-ac90-c3b165c23aeb","timestamp":"2026-08-22T23:04:18.239Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","content":".results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n.grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n gap: 22px; padding-bottom: 30px;\n}\n@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n.card {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);","numLines":12,"startLine":217,"totalLines":907}}}}70{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"059cee3c-e4cc-43a0-942d-644f2de514ee","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}71{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd67E6oAG5NogZz4TaR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PcK2PJqbnJxmw3au1NuGHh","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","old_string":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }","new_string":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n/* récemment consultés — bande horizontale de cartes, défilement latéral */\n.recent-row {\n display: grid; grid-auto-flow: column; grid-auto-columns: 290px;\n gap: 18px; overflow-x: auto; padding-bottom: 12px;\n scroll-snap-type: x proximity;\n}\n.recent-row > .card { scroll-snap-align: start; }\n@media (max-width: 640px) {\n .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; }\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":498,"cache_read_input_tokens":73820,"cache_creation":{"ephemeral_5m_input_tokens":498,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0bf8752e-00ee-4a0c-8bae-383b8d5b5d4b"}}72{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PcK2PJqbnJxmw3au1NuGHh","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"7104059a-9770-4720-ab5c-f83d68ea3c75","timestamp":"2026-08-22T23:04:25.516Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","oldString":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }","newString":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n/* récemment consultés — bande horizontale de cartes, défilement latéral */\n.recent-row {\n display: grid; grid-auto-flow: column; grid-auto-columns: 290px;\n gap: 18px; overflow-x: auto; padding-bottom: 12px;\n scroll-snap-type: x proximity;\n}\n.recent-row > .card { scroll-snap-align: start; }\n@media (max-width: 640px) {\n .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; }\n}","originalFile":"/* -----------------------------------------------------------------------------\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: styles.css\n Desc: Identité visuelle Resto·Ka — système de design « éditorial sharp »\n du GROUPE KA. Le socle (palette fixe, typo, grain, boutons, cartes…)\n vient de ka-ui (src/ka/tokens.css, importé AVANT ce fichier) ; ici on\n ne définit que l'ACCENT SAFRAN de Resto-Ka + les classes métier.\n · Accent Resto-Ka : safran #f08c00 (soft #fdeed7 · deep #b96a00)\n · Baseline : « Chaque resto, chaque plat, chaque prix. »\n · 100 % adaptatif mobile (safe-areas iOS)\n----------------------------------------------------------------------------- */\n:root {\n /* ACCENT du site — seule surcharge de tokens.css (ka-ui) */\n --accent: #f08c00;\n --accent-soft: #fdeed7;\n --accent-deep: #b96a00;\n --on-accent: #141814;\n\n /* alias rétro-compatibles (le code historique utilise --lime) */\n --lime: var(--accent);\n --lime-soft: var(--accent-soft);\n}\n\nh1, h2, h3, h4 { font-weight: 700; margin: 0; }\na { text-decoration: none; }\nbutton { font-family: inherit; }\nimg { display: block; }\n\n.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }\n@media (max-width: 640px) { .container { padding: 0 16px; } }\n\n/* ================= Header ================= */\n.header {\n position: sticky; top: 0; z-index: var(--z-header, 500);\n background: var(--paper);\n border-bottom: 1.5px solid var(--ink);\n}\n:root { --header-h: 64px; } /* hauteur du header (ancre du menu compte fixed) */\n.header-inner { display: flex; align-items: center; gap: 20px; height: var(--header-h); }\n.brand {\n font-family: var(--font-display); font-weight: 700; font-size: 24px;\n letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;\n color: var(--ink);\n}\n.brand .logo-ico { margin-right: 11px; flex: none; }\n/* « Ka » en pastille lime inclinée — même geste que la carte membre Groupe KA */\n.brand .ka {\n background: var(--accent); color: var(--ink);\n padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px;\n display: inline-block; transform: rotate(-2deg);\n}\n.brand-tag {\n font-family: var(--font-mono); font-size: 10px; font-weight: 500;\n text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);\n margin-left: 14px;\n}\n@media (max-width: 900px) { .brand-tag { display: none; } }\n.nav { margin-left: auto; display: flex; gap: 6px; }\n.nav a {\n padding: 8px 16px; border: 1.5px solid transparent; border-radius: var(--r-ctl);\n font-family: var(--font-display); font-weight: 700; font-size: 14px;\n color: var(--ink); transition: all 0.13s ease; min-height: 38px;\n display: inline-flex; align-items: center;\n}\n.nav a:hover { background: var(--accent); border-color: var(--ink); }\n.nav a.active { background: var(--ink); color: var(--accent); border-color: var(--ink); }\n\n/* ================= Ticker ================= */\n.ticker {\n background: var(--ink); color: rgba(245, 243, 238, 0.85); overflow: hidden;\n font-family: var(--font-mono); font-size: 11px; font-weight: 500;\n letter-spacing: 0.08em; text-transform: uppercase; padding: 7px 0;\n white-space: nowrap; border-bottom: 1.5px solid var(--ink);\n}\n.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }\n.ticker span { padding: 0 26px; position: relative; }\n.ticker span::after { content: \"◆\"; position: absolute; right: -6px; color: var(--accent); opacity: 0.9; font-size: 8px; top: 3px; }\n@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }\n@media (prefers-reduced-motion: reduce) {\n .ticker-track { animation: none; }\n * { transition-duration: 0.01ms !important; }\n}\n\n/* ================= Hero ================= */\n.hero { padding: 58px 0 22px; }\n@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }\n.hero h1 {\n font-size: clamp(36px, 5.4vw, 58px); font-weight: 700; line-height: 1.04;\n letter-spacing: -0.04em; max-width: 920px; margin-top: 16px; color: var(--ink);\n}\n.hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; }\n.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }\n.stat-chip {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n padding: 7px 14px; font-size: 12.5px; font-weight: 500;\n color: var(--ink-2); display: flex; gap: 8px; align-items: center;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n}\n.stat-chip b { color: var(--ink); font-weight: 700; font-family: var(--font-display); }\n.stat-chip .pulse {\n width: 8px; height: 8px; border-radius: 50%; background: var(--green);\n box-shadow: 0 0 0 4px var(--accent-soft); animation: pulse 2.4s ease infinite;\n}\n@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(28, 92, 65, 0.18); } }\n\n/* ================= Rangée d'avantages ================= */\n.benefits {\n display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n gap: 14px; margin-top: 30px;\n}\n.benefit { display: flex; align-items: center; gap: 12px; }\n.benefit-ico {\n width: 42px; height: 42px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n flex: none; display: flex; align-items: center; justify-content: center;\n background: var(--accent); color: var(--ink); box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);\n}\n.benefit-ico.blue { background: var(--surface); color: var(--green); }\n.benefit-label {\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink);\n}\n@media (max-width: 640px) { .benefits { grid-template-columns: 1fr 1fr; gap: 12px; } }\n\n/* ================= Filter bar ================= */\n.filterbar {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n box-shadow: var(--shadow-off-soft); padding: 14px; margin: 20px 0 6px;\n display: flex; flex-direction: column; gap: 0; min-width: 0;\n}\n.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }\n.f-search {\n flex: 1 1 220px; min-width: 0; max-width: 440px; align-self: center;\n display: flex; align-items: center; gap: 8px;\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface-2);\n padding: 0 13px; min-height: 46px; color: var(--ink-2);\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);\n transition: box-shadow 0.15s ease, background 0.15s ease;\n}\n.f-search:focus-within {\n background: var(--white, #fff); outline: 2px solid var(--green); outline-offset: 1px;\n box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25);\n}\n.f-search input {\n border: none; background: none; outline: none; flex: 1; min-width: 0;\n font-size: 14px; color: var(--ink); font-family: inherit;\n}\n.f-clear {\n border: 1px solid var(--line); background: var(--paper); color: var(--ink-2);\n border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer;\n flex: none; display: grid; place-items: center;\n}\n.f-ctl {\n flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;\n gap: 2px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface);\n padding: 7px 14px 6px; min-height: 52px; cursor: pointer;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);\n transition: box-shadow 0.15s ease;\n}\n.f-ctl:focus-within { outline: 2px solid var(--green); outline-offset: 1px; box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25); }\n.f-ctl > span {\n font-family: var(--font-mono); font-size: 9px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);\n}\n.f-ctl select {\n border: none; background: transparent; outline: none; font-family: var(--font-display);\n font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;\n appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;\n text-overflow: ellipsis;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E\");\n background-repeat: no-repeat; background-position: right center;\n}\n\n/* pastilles de filtres actifs */\n.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; }\n.pill {\n display: inline-flex; align-items: center; gap: 7px;\n border: 1.5px solid var(--ink); background: var(--accent); color: var(--ink);\n border-radius: var(--r-ctl); padding: 5px 12px; font-size: 12.5px; font-weight: 700;\n font-family: var(--font-display); cursor: pointer;\n box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2); transition: all 0.12s ease;\n}\n.pill:hover { background: var(--ink); color: var(--accent); }\n.pill-x { font-size: 10px; opacity: 0.65; }\n.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }\n.pill-clear:hover { background: var(--danger); color: #fff; }\n.link-btn {\n background: none; border: none; padding: 0; color: var(--green); font: inherit;\n text-decoration: underline; cursor: pointer;\n}\n.btn {\n display: inline-flex; align-items: center; justify-content: center; gap: 8px;\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 10px 20px;\n font-family: var(--font-display); font-weight: 700; font-size: 14px;\n cursor: pointer; min-height: 44px;\n transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.1s ease;\n}\n.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }\n.btn-primary { background: var(--ink); color: var(--accent); box-shadow: var(--shadow-off-mid); }\n.btn-primary:hover { background: var(--green-deep); }\n.btn-ghost { background: transparent; color: var(--ink); }\n.btn-ghost:hover { background: var(--accent); }\n.btn:disabled { opacity: 0.4; cursor: default; }\n\n/* ================= Chips ================= */\n.chips { display: flex; gap: 8px; margin: 18px 0 4px; overflow-x: auto; padding: 4px 4px 8px 0; scrollbar-width: none; }\n.chips::-webkit-scrollbar { display: none; }\n.chip {\n border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);\n border-radius: var(--r-ctl); padding: 8px 16px; font-size: 13px; font-weight: 700;\n cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); transition: all 0.13s ease;\n}\n.chip:hover { background: var(--accent-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.15); }\n.chip.on { background: var(--ink); color: var(--accent); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.3); }\n\n/* ================= Results ================= */\n.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n.grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n gap: 22px; padding-bottom: 30px;\n}\n@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n.card {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);\n transition: transform 0.18s ease, box-shadow 0.18s ease;\n}\n.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }\n.card:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }\n.card-img { position: relative; aspect-ratio: 16/9; background: var(--surface-2); overflow: hidden; border-bottom: 1.5px solid var(--ink); }\n.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }\n.card:hover .card-img img { transform: scale(1.04); }\n.card-img .noimg {\n display: flex; align-items: center; justify-content: center; height: 100%;\n color: var(--ink-3);\n background:\n radial-gradient(circle at 82% 20%, var(--accent-soft) 0, transparent 46%),\n radial-gradient(circle at 12% 85%, var(--amber-soft) 0, transparent 42%),\n var(--surface-2);\n}\n.card-logo { max-width: 46%; max-height: 68%; object-fit: contain; }\n.card:hover .card-logo { transform: none; }\n.badge {\n position: absolute; top: 10px; left: 10px; border: 1.5px solid var(--ink);\n border-radius: var(--r-ctl); padding: 3px 10px;\n font-family: var(--font-mono); font-size: 10px; font-weight: 700; letter-spacing: 0.05em;\n background: rgba(255, 255, 255, 0.95); color: var(--ink);\n box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.25);\n}\n.badge.type { background: var(--ink); color: var(--accent); }\n.badge.right { left: auto; right: 10px; }\n/* badge de contexte de prix — TOUJOURS visible (un prix sans contexte est inutilisable) */\n.badge.ctx-takeout, .ctx-pill.ctx-takeout { background: var(--accent); color: var(--ink); }\n.badge.ctx-dine-in, .ctx-pill.ctx-dine-in { background: var(--green); color: #fff; }\n.badge.ctx-delivery, .ctx-pill.ctx-delivery { background: var(--amber); color: var(--ink); }\n.card-body { padding: 15px 17px; display: flex; flex-direction: column; gap: 6px; flex: 1; }\n.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.03em; color: var(--ink); }\n.card-price small { font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }\n.card-price .no-menu { font-size: 14px; font-weight: 600; color: var(--ink-3); }\n.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); }\n.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }\n.card-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; }\n.card-foot { margin-top: auto; padding-top: 11px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }\n.source-tag {\n font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.06em; color: var(--green);\n background: var(--accent-soft); border: 1px solid rgba(28, 92, 65, 0.35);\n border-radius: var(--r-ctl); padding: 3px 9px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;\n}\n.avail { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); font-weight: 500; text-align: right; }\n\n/* ================= Bascule Restos / Plats ================= */\n.mode-toggle {\n display: inline-flex; align-self: center; border: 1.5px solid var(--ink);\n border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); flex: none;\n}\n.mode-toggle button {\n border: 0; background: transparent; padding: 12px 20px; cursor: pointer;\n font-family: var(--font-display); font-size: 14px; font-weight: 700; color: var(--ink-2);\n min-height: 46px;\n}\n.mode-toggle button + button { border-left: 1.5px solid var(--ink); }\n.mode-toggle button.on { background: var(--ink); color: var(--accent); }\n\n/* ================= Résultats par plat ================= */\n.dish-list {\n display: flex; flex-direction: column; gap: 12px; padding-bottom: 10px;\n}\n.dish-row {\n display: flex; gap: 14px; align-items: flex-start;\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); padding: 14px 16px;\n box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.08);\n transition: transform 0.15s ease, box-shadow 0.15s ease;\n}\n.dish-row:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }\n.dish-img {\n width: 62px; height: 62px; flex: none; object-fit: cover;\n border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2);\n}\n.dish-noimg { display: flex; align-items: center; justify-content: center; color: var(--ink-3); }\n.dish-main { flex: 1; min-width: 0; }\n.dish-line { display: flex; align-items: baseline; gap: 10px; }\n.dish-name { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); }\n.dish-price { font-family: var(--font-display); font-weight: 700; font-size: 16.5px; color: var(--ink); white-space: nowrap; }\n.dish-desc {\n margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 620px;\n display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\n}\n.dish-meta {\n display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\n margin-top: 7px; font-size: 12px; color: var(--ink-2);\n}\n.dish-meta b { color: var(--ink); }\n.dish-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; }\n.dish-meta .ctx-pill {\n border: 1px solid var(--ink); border-radius: 4px; padding: 2px 8px;\n font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.05em;\n}\n\n/* ================= Pager (numéroté) ================= */\n.pager {\n display: flex; align-items: center; justify-content: center; gap: 6px;\n flex-wrap: wrap; padding: 18px 0 60px;\n}\n.pg-btn {\n min-width: 40px; min-height: 40px; padding: 0 12px;\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n background: var(--surface); color: var(--ink); cursor: pointer;\n font-family: var(--font-display); font-weight: 700; font-size: 13.5px;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);\n transition: all 0.12s ease;\n}\n.pg-btn:hover:not(:disabled) { background: var(--accent-soft); }\n.pg-btn.on { background: var(--ink); color: var(--accent); }\n.pg-btn:disabled { opacity: 0.35; cursor: default; box-shadow: none; }\n.pg-btn:active:not(:disabled) { transform: translate(2px, 2px); box-shadow: none; }\n.pg-ellipsis { color: var(--ink-3); font-family: var(--font-mono); padding: 0 4px; }\n\n/* ================= Skeletons ================= */\n@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }\n.skel { border-radius: var(--r-card); border: 1.5px solid var(--line-strong); overflow: hidden; background: var(--surface); }\n.skel .sk-img, .skel .sk-line {\n background: linear-gradient(90deg, #eceae4 25%, #f6f4ef 50%, #eceae4 75%);\n background-size: 800px 100%; animation: shimmer 1.4s infinite linear;\n}\n.skel .sk-img { aspect-ratio: 16/9; }\n.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }\n.skel .sk-line.short { width: 45%; }\n\n/* ================= Empty / error ================= */\n.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }\n.notice .big { font-size: 44px; margin-bottom: 10px; color: var(--green); }\n.notice h2 { color: var(--ink); }\n\n/* ================= Fiche resto ================= */\n.detail { padding: 30px 0 90px; }\n.crumbs {\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;\n letter-spacing: 0.08em; text-transform: uppercase;\n color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;\n}\n.crumbs a { border-bottom: 1.5px solid transparent; }\n.crumbs a:hover { color: var(--green); border-color: var(--green); }\n/* Ordre DOM = ordre visuel (standard Groupe Ka « Ordre des sections — pages\n détail ») : le panneau héro (nom/prix/adresse) est 1er dans le DOM ; sur\n desktop il est placé en colonne 2 via grid-column — pas de `order` ni de\n `column-reverse` pour réordonner entre breakpoints. */\n.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }\n@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }\n.detail-grid > * { min-width: 0; }\n@media (min-width: 901px) {\n .detail-grid > .panel { grid-column: 2; grid-row: 1; }\n .detail-grid > .detail-main { grid-column: 1; grid-row: 1; }\n}\n\n.panel {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px;\n}\n@media (max-width: 900px) { .panel { position: static; } }\n.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n.panel-logo { max-width: 110px; max-height: 54px; object-fit: contain; border-radius: var(--r-ctl); }\n.panel .price {\n font-family: var(--font-display); font-size: 30px; font-weight: 700;\n letter-spacing: -0.03em; color: var(--ink);\n background: var(--accent); border-radius: 8px; padding: 0 10px 2px;\n display: inline-block; transform: rotate(-1deg);\n}\n.panel h1 { font-size: 23px; margin: 10px 0 2px; }\n.panel .loc { color: var(--ink-2); font-size: 14px; }\n.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }\n@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }\n.kv .cell { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 10px 14px; }\n.kv .cell .k { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); font-weight: 700; }\n.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); color: var(--ink); }\n.klabel { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; margin-bottom: 8px; }\n.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }\n.amenity {\n background: var(--accent-soft); color: var(--green); font-size: 12px; font-weight: 600;\n border: 1px solid rgba(28, 92, 65, 0.35); border-radius: var(--r-ctl); padding: 4px 11px;\n}\n.cta {\n display: block; text-align: center; background: var(--ink); color: var(--accent);\n font-weight: 700; font-family: var(--font-display); border: 1.5px solid var(--ink);\n border-radius: var(--r-ctl); padding: 14px; min-height: 48px;\n box-shadow: var(--shadow-off-mid); transition: all 0.14s ease;\n}\n.cta:hover { background: var(--green-deep); }\n.cta:active { transform: translate(2px, 2px); box-shadow: none; }\n.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; }\n\n/* --- menu (sections, items, options) --------------------------------------- */\n.view-toggle {\n display: inline-flex; border: 1.5px solid var(--ink);\n border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n}\n.view-toggle button {\n border: 0; background: transparent; padding: 8px 16px; cursor: pointer;\n font-family: var(--font-display); font-size: 12.5px; font-weight: 700; color: var(--ink-2);\n}\n.view-toggle button + button { border-left: 1.5px solid var(--ink); }\n.view-toggle button.on { background: var(--ink); color: var(--accent); }\n.ctx-toggle { margin-bottom: 14px; }\n\n.menu-block { display: flex; flex-direction: column; gap: 16px; }\n.ctx-banner {\n border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 12px 18px;\n display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;\n font-size: 12.5px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);\n}\n.ctx-banner b { font-family: var(--font-display); font-size: 14px; }\n.ctx-banner span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); }\n.ctx-banner.ctx-takeout { background: var(--accent-soft); }\n.ctx-banner.ctx-takeout b { color: var(--green); }\n.ctx-banner.ctx-dine-in { background: var(--surface); }\n.ctx-banner.ctx-dine-in b { color: var(--green-deep); }\n.ctx-banner.ctx-delivery { background: var(--amber-soft); }\n.ctx-banner.ctx-delivery b { color: #9a6414; }\n\n.menu-section {\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden;\n}\n.menu-section-head {\n width: 100%; display: flex; align-items: center; gap: 12px;\n border: none; background: none; cursor: pointer;\n padding: 14px 18px; text-align: left;\n}\n.menu-section-head h3 { font-size: 17px; color: var(--ink); letter-spacing: -0.02em; flex: 1; }\n.menu-section-head:hover { background: var(--accent-soft); }\n.ms-img {\n width: 36px; height: 36px; object-fit: cover; border-radius: var(--r-ctl);\n border: 1.5px solid var(--ink); flex: none;\n}\n.ms-meta { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 600; white-space: nowrap; }\n.menu-items { list-style: none; margin: 0; padding: 0 18px 14px; }\n.menu-item { padding: 12px 0; border-top: 1px solid var(--line); }\n.mi-row { display: flex; gap: 14px; align-items: flex-start; }\n.mi-main { flex: 1; min-width: 0; }\n.mi-line { display: flex; align-items: baseline; gap: 10px; }\n.mi-name { font-weight: 600; font-size: 14.5px; color: var(--ink); }\n.mi-dots { flex: 1; border-bottom: 2px dotted var(--line-strong); opacity: 0.35; transform: translateY(-3px); min-width: 20px; }\n.mi-price { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); white-space: nowrap; }\n.mi-desc { margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 560px; }\n.mi-thumb {\n width: 68px; height: 68px; flex: none; object-fit: cover;\n border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2);\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n}\n.mi-tag {\n font-style: normal; font-family: var(--font-mono); font-size: 9px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.06em; border-radius: 4px;\n padding: 2px 7px; margin-left: 7px;\n background: var(--accent-soft); color: var(--green); border: 1px solid rgba(28, 92, 65, 0.3);\n vertical-align: 2px;\n}\n.mi-tag-epice { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.5); }\n.mi-tag-rupture { background: var(--surface-2); color: var(--ink-3); border-color: var(--line); }\n\n/* groupes d'options : blocs structurés, choix en pastilles avec ± prix */\n.mi-optgroups {\n margin-top: 10px; display: flex; flex-direction: column; gap: 8px;\n background: var(--surface-2); border: 1px solid var(--line);\n border-radius: var(--r-ctl); padding: 10px 12px;\n}\n.og-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }\n.og-name {\n font-family: var(--font-mono); font-weight: 700; color: var(--ink);\n font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;\n}\n.og-req {\n font-family: var(--font-mono); font-size: 9px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.05em; border-radius: 4px; padding: 2px 7px;\n background: var(--surface); color: var(--ink-3); border: 1px solid var(--line);\n}\n.og-req.on { background: var(--accent); color: var(--ink); border-color: var(--ink); }\n.og-choices { display: flex; flex-wrap: wrap; gap: 6px; }\n.og-choice {\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--surface); border: 1px solid var(--line-strong);\n border-radius: var(--r-ctl); padding: 4px 10px;\n font-size: 12px; color: var(--ink-2);\n}\n.og-choice b { font-family: var(--font-mono); font-weight: 700; font-size: 11px; }\n.og-choice b.plus { color: #9a6414; }\n.og-choice b.moins { color: var(--green); }\n\n/* ================= Sources page ================= */\n.sources { padding: 44px 0 90px; }\n.sources h1 { font-size: clamp(28px, 4vw, 42px); margin: 12px 0 6px; color: var(--ink); }\n.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }\n.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }\n.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }\n.src-table th {\n text-align: left; font-family: var(--font-mono); font-size: 10px;\n text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-2);\n padding: 13px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2);\n}\n.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }\n.src-table tr:last-child td { border-bottom: none; }\n.src-table tr:hover td { background: var(--accent-soft); }\n.src-table a { color: var(--green); font-weight: 600; border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); }\n.src-integrations { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; max-width: 380px; }\n.src-table .pill, .pill.ok, .pill.todo {\n display: inline-block; border-radius: 4px; padding: 3px 9px;\n font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--ink);\n box-shadow: none; cursor: default; transform: none;\n}\n.pill.ok { background: var(--accent); color: var(--ink); }\n.pill.todo { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.6); }\n.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; color: var(--ink); }\n\n/* ================= Page Statistiques ================= */\n.stats-page { padding: 44px 0 90px; }\n.stats-title { font-size: clamp(30px, 4.6vw, 48px); margin: 12px 0 6px; color: var(--ink); }\n.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }\n.tile {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n padding: 18px 20px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);\n}\n.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; color: var(--ink); }\n.tile-k { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }\n.hero-tile { background: var(--ink); color: var(--paper); border-color: var(--ink); box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.25); }\n.hero-tile .tile-v { color: var(--accent); }\n.hero-tile .tile-k { color: rgba(245, 243, 238, 0.6); }\n\n.viz-card {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;\n}\n.viz-card h2 { font-size: 20px; letter-spacing: -0.02em; color: var(--ink); }\n.viz-sub { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }\n.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }\n@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }\n\n.hbars { display: flex; flex-direction: column; gap: 7px; }\n.hbar-row {\n display: grid; grid-template-columns: minmax(96px, 190px) 1fr auto;\n gap: 12px; align-items: center; min-height: 26px; cursor: default;\n}\n.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.hbar-label a { border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); }\n.hbar-label a:hover { color: var(--green); }\n.hbar-track { background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 4px; height: 18px; overflow: hidden; }\n.hbar-fill {\n display: block; height: 100%; background: var(--green); border-radius: 0;\n min-width: 2px; transition: background 0.12s ease;\n}\n.hbar-row:hover .hbar-fill { background: var(--accent); }\n.hbar-value { font-family: var(--font-mono); font-size: 11px; font-weight: 700; white-space: nowrap; color: var(--ink); }\n@media (max-width: 640px) {\n .hbar-row { grid-template-columns: minmax(80px, 120px) 1fr auto; gap: 8px; }\n .viz-card { padding: 20px 16px 14px; }\n}\n.stats-foot { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.02em; margin-top: 6px; }\n\n/* ================= Mobile : feuille de filtres + FAB ================= */\n.sheet-head { display: none; }\n.sheet-apply { display: none; }\n.sheet-backdrop { display: none; }\n.fab { display: none; }\n\n@media (max-width: 640px) {\n :root { --header-h: 56px; }\n .brand { font-size: 20px; }\n .nav a { padding: 8px 13px; font-size: 13.5px; }\n .ticker { font-size: 10px; padding: 6px 0; }\n .hero h1 { font-size: clamp(30px, 8.6vw, 42px); }\n .hero p.lede { font-size: 15px; }\n .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }\n .stat-row::-webkit-scrollbar { display: none; }\n .stat-chip { flex: 0 0 auto; white-space: nowrap; }\n\n /* la barre de filtres devient une feuille coulissante (bottom sheet) */\n .filterbar { display: none; }\n .filterbar.open .f-primary { flex-direction: column; }\n .filterbar.open .f-search { align-self: stretch; max-width: none; min-height: 46px; }\n .filterbar.open .f-search input { font-size: 16px; } /* anti-zoom iOS */\n .filterbar.open .f-ctl select { max-width: none; width: 100%; }\n .filterbar.open {\n display: flex; flex-direction: column; gap: 12px;\n position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);\n margin: 0; border-radius: 16px 16px 0 0; border: none; border-top: 2px solid var(--ink);\n max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;\n padding: 16px 18px calc(18px + env(safe-area-inset-bottom));\n box-shadow: 0 -16px 48px rgba(20, 24, 20, 0.28);\n animation: sheet-up 0.22s ease;\n }\n @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }\n .filterbar.open .sheet-head {\n display: flex; justify-content: space-between; align-items: center;\n font-family: var(--font-display); font-weight: 700; font-size: 17px;\n letter-spacing: -0.01em; color: var(--ink);\n position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;\n border-bottom: 1px solid var(--line); margin-bottom: 2px; z-index: 1;\n }\n .sheet-close {\n border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl);\n width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; color: var(--ink);\n }\n .filterbar.open .sheet-apply { display: flex; width: 100%; }\n .sheet-backdrop {\n display: block; position: fixed; inset: 0; z-index: var(--z-overlay, 800);\n background: rgba(20, 24, 20, 0.45); backdrop-filter: blur(2px);\n }\n .fab {\n display: flex; align-items: center; gap: 6px;\n position: fixed; left: 50%; transform: translateX(-50%);\n bottom: calc(74px + env(safe-area-inset-bottom)); z-index: 80;\n background: var(--ink); color: var(--accent); border: 1.5px solid var(--ink);\n border-radius: var(--r-ctl); padding: 13px 24px; font-family: var(--font-display);\n font-weight: 700; font-size: 15px; cursor: pointer;\n box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.3);\n }\n .fab:active { transform: translateX(-50%) translate(2px, 2px); box-shadow: none; }\n\n .detail { padding-top: 20px; }\n .panel { padding: 20px; }\n .panel .price { font-size: 26px; }\n .results-head { margin-top: 20px; }\n .chips { margin-right: -16px; padding-right: 16px; }\n .notice { padding: 48px 16px; }\n .menu-items { padding: 0 14px 12px; }\n .menu-section-head { padding: 13px 14px; }\n .mi-thumb { width: 56px; height: 56px; }\n}\n\n/* ================= Barre de navigation inférieure (mobile) ================= */\n.tabbar { display: none; }\n@media (max-width: 760px) {\n .tabbar {\n display: grid;\n position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-bottombar, 600);\n background: rgba(245, 243, 238, 0.94);\n backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);\n border-top: 1.5px solid var(--ink);\n padding: 6px 4px calc(6px + env(safe-area-inset-bottom));\n }\n .tabbar-3 { grid-template-columns: repeat(3, 1fr); }\n .tabbar a {\n display: flex; flex-direction: column; align-items: center; gap: 3px;\n padding: 5px 2px; min-height: 48px; justify-content: center;\n font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--ink-3);\n border-radius: var(--r-ctl); transition: color 0.12s ease;\n }\n .tabbar a.active { color: var(--green); }\n .tabbar a svg { display: block; }\n body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }\n .ka-footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); }\n}\n\n/* Anti-zoom iOS : les champs doivent faire >= 16px\n (filet global : tokens.css force aussi 16 px au doigt) */\n@media (max-width: 900px) {\n .f-search input, .f-ctl select { font-size: 16px; }\n}\n\n/* Cible tactile ≥44 px du logo (138×30 rendus) : zone étendue invisible */\n@media (pointer: coarse) {\n .brand { position: relative; }\n .brand::after { content: \"\"; position: absolute; inset: -8px; }\n}\n\n/* ================= Footer (KaFooter — ka-ui) ================= */\n/* Le footer commun vient de tokens.css (.ka-footer) + KaFooter.tsx. On garde\n ici seulement la rangée de nav locale posée au-dessus. */\n.prefoot { border-top: 2px solid var(--ink); padding: 22px 0; margin-top: 40px; background: var(--surface-2); }\n.ka-footer { margin-top: 0; }\n.prefoot-inner { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; }\n.prefoot-nav { display: flex; flex-wrap: wrap; gap: 4px 8px; }\n.prefoot-nav a {\n display: inline-flex; align-items: center; min-height: var(--touch);\n padding: 8px 12px; border-radius: var(--r-ctl);\n font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);\n}\n.prefoot-nav a:hover { background: var(--accent); color: var(--on-accent); }\n.prefoot-osm {\n margin-left: auto; font-family: var(--font-mono); font-size: 10.5px;\n letter-spacing: 0.03em; color: var(--ink-3);\n}\n.prefoot-osm a { color: var(--green); border-bottom: 1px solid rgba(28, 92, 65, 0.4); }\n@media (max-width: 640px) { .prefoot-osm { margin-left: 0; } }\n\n/* --- Compte membre (« Se connecter avec KA ») ------------------------------- */\n.login-btn {\n display: inline-flex; align-items: center; gap: 8px;\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n background: var(--surface); color: var(--ink);\n padding: 8px 14px; font-family: var(--font-display);\n font-weight: 700; font-size: 13.5px; min-height: 38px;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n transition: background 0.13s ease, transform 0.1s ease, box-shadow 0.1s ease;\n}\n.login-btn:hover { background: var(--accent-soft); }\n.login-btn:active { transform: translate(2px, 2px); box-shadow: none; }\n.ka-badge {\n display: inline-flex; align-items: center; justify-content: center;\n background: var(--accent); color: var(--ink); border: 1px solid var(--ink);\n padding: 1px 6px 2px; border-radius: 4px;\n font-family: var(--font-display); font-weight: 700; font-size: 11px;\n transform: rotate(-2deg);\n}\n.account { position: relative; }\n.account-btn {\n width: 40px; height: 40px; border-radius: 50%; overflow: hidden;\n border: 1.5px solid var(--ink); background: var(--accent);\n cursor: pointer; padding: 0;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);\n}\n.account-btn img { width: 100%; height: 100%; object-fit: cover; }\n.account-initial {\n display: flex; align-items: center; justify-content: center;\n width: 100%; height: 100%;\n font-family: var(--font-display); font-weight: 700; font-size: 17px; color: var(--ink);\n}\n/* Backdrop + menu rendus à la racine de <body> via createPortal (un fixed\n descendant du header blurré se re-parentait, et le stacking context z-50 du\n header plaquait le menu sous la tabbar z-85 / le fab z-80). Échelle commune\n tokens.css : voile = --z-overlay (800), menu = --z-modal (900). */\n.account-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay); }\n.account-menu {\n position: fixed;\n right: max(12px, env(safe-area-inset-right));\n top: calc(var(--header-h, 66px) + 10px);\n z-index: var(--z-modal);\n min-width: 230px; background: var(--surface);\n border: 1.5px solid var(--ink); border-radius: var(--r-card);\n box-shadow: var(--shadow-off); padding: 8px; display: flex; flex-direction: column;\n}\n.account-id { padding: 8px 10px 10px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }\n.account-id b { font-family: var(--font-display); font-size: 14.5px; }\n.account-id span { font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; }\n.account-kaid {\n font-family: var(--font-mono) !important; font-size: 11px !important;\n color: var(--green) !important; letter-spacing: 0.06em;\n}\n.account-link, .account-menu button {\n text-align: left; border: none; background: none; cursor: pointer;\n padding: 10px; border-radius: var(--r-ctl);\n font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);\n}\n.account-link:hover, .account-menu button:hover { background: var(--accent-soft); }\n\n/* cœur favori sur les cartes */\n.fav-btn {\n position: absolute; bottom: 10px; right: 10px;\n width: 34px; height: 34px; border-radius: 50%;\n border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.95);\n color: var(--ink-2); font-size: 16px; line-height: 1; cursor: pointer;\n box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2);\n transition: transform 0.12s ease, background 0.12s ease;\n}\n.fav-btn:hover { transform: scale(1.08); }\n.fav-btn.on { background: var(--accent); color: var(--ink); }\n\n/* --- Menu déroulant mobile ------------------------------------------------- */\n.menu-btn {\n display: none; position: relative; z-index: 60;\n width: 44px; height: 44px; margin-left: auto;\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n background: var(--surface); cursor: pointer; padding: 0;\n flex-direction: column; align-items: center; justify-content: center; gap: 5px;\n box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);\n transition: box-shadow 0.15s ease, transform 0.15s ease;\n}\n.menu-btn:active { transform: translate(2px, 2px); box-shadow: none; }\n.menu-btn span {\n display: block; width: 18px; height: 2px; background: var(--ink);\n border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease;\n}\n.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }\n.menu-btn.open span:nth-child(2) { opacity: 0; }\n.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }\n\n.mobile-menu {\n display: none; position: absolute; left: 0; right: 0; top: 100%;\n background: var(--paper); border-bottom: 2px solid var(--ink);\n box-shadow: 0 18px 30px rgba(20, 24, 20, 0.14);\n padding: 8px 16px calc(16px + env(safe-area-inset-bottom));\n max-height: 0; overflow: hidden; opacity: 0;\n transition: max-height 0.3s ease, opacity 0.22s ease;\n}\n.mobile-menu { z-index: var(--z-modal, 900); }\n.mobile-menu.open { max-height: 480px; opacity: 1; }\n/* Le drawer vit DANS le header : quand il est ouvert, le header monte au niveau\n modal pour peindre au-dessus du .mm-backdrop (frère du header, overlay 800). */\n.header:has(.mobile-menu.open) { z-index: var(--z-modal, 900); }\n.mm-link {\n display: flex; align-items: center; gap: 12px;\n padding: 15px 10px; min-height: 52px;\n font-family: var(--font-display); font-weight: 700; font-size: 19px;\n letter-spacing: -0.02em; border-bottom: 1px solid var(--line);\n color: var(--ink);\n opacity: 0; transform: translateY(-8px);\n transition: opacity 0.25s ease, transform 0.25s ease;\n}\n.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }\n.mm-link.active { color: var(--green); }\n.mm-link.active .mm-arrow { opacity: 1; color: var(--green); }\n.mm-ico { width: 26px; text-align: center; font-size: 17px; }\n.mm-arrow { margin-left: auto; opacity: 0.25; color: var(--green); transition: opacity 0.15s ease; }\n.mm-link:active { background: var(--accent-soft); border-radius: var(--r-ctl); }\n.mm-foot {\n padding: 12px 10px 4px;\n font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.02em;\n}\n.mm-backdrop {\n position: fixed; inset: 0; z-index: var(--z-overlay, 800);\n background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px);\n}\n@media (max-width: 760px) {\n .menu-btn { display: flex; }\n .nav { display: none; }\n .mobile-menu { display: block; }\n}\n\n/* ================= Badge « Un service Groupe KA » (ka-ui) ================= */\n.hdr-badge { display: none; }\n@media (min-width: 900px) { .hdr-badge { display: inline-flex; } }\n.mm-badge { padding: 14px 10px 2px; }\n\n/* ================= Page Contact ================= */\n.contact-page { padding: 44px 0 90px; }\n.contact-page h1 { font-size: clamp(30px, 4.6vw, 48px); margin: 12px 0 6px; color: var(--ink); }\n.contact-page .sub { color: var(--ink-2); max-width: 640px; margin: 0 0 30px; }\n.contact-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; margin-bottom: 26px; }\n.contact-card {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n box-shadow: var(--shadow-off-soft); padding: 20px;\n display: flex; flex-direction: column; gap: 6px;\n}\n.contact-card .role {\n font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);\n}\n.contact-card a.mail {\n display: inline-flex; align-items: center; min-height: var(--touch);\n font-family: var(--font-display); font-weight: 700; font-size: 16.5px;\n letter-spacing: -0.01em; color: var(--ink); word-break: break-all;\n}\n.contact-card a.mail:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }\n.contact-note {\n border: 1.5px solid var(--ink); border-left: 6px solid var(--accent);\n border-radius: var(--r-card); background: var(--accent-soft);\n padding: 16px 18px; max-width: 760px; margin-bottom: 26px; font-size: 14px;\n box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);\n}\n.contact-hub { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 38px; }\n.contact-hub .hint { font-size: 12.5px; color: var(--ink-2); max-width: 460px; }\n.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.contact-sites a {\n display: inline-flex; align-items: center; gap: 7px; min-height: var(--touch);\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface);\n padding: 8px 14px; font-family: var(--font-display); font-weight: 700; font-size: 13.5px;\n color: var(--ink); box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);\n transition: all 0.13s ease;\n}\n.contact-sites a:hover { background: var(--accent); color: var(--on-accent); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); }\n.contact-sites .dot { width: 9px; height: 9px; border-radius: 50%; border: 1px solid rgba(20, 24, 20, 0.35); flex: none; }\n\n/* ================= Stats — tableau de bord Groupe KA (SPEC ka-ui/stats) ==== */\n.stats-page { display: grid; gap: 26px; }\n.stats-page .sub { margin-bottom: 0; }\n.stats-head { display: flex; flex-wrap: wrap; gap: 18px; justify-content: space-between; align-items: flex-end; }\n.stats-actions { display: grid; gap: 10px; justify-items: start; }\n.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); gap: 14px; }\n.period-bar { padding: 12px 14px; }\n.stats-grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); gap: 18px; align-items: start; }\n.records-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 12px; margin-top: 12px; }\n.stats-foot { margin-top: 12px; }\n@media (max-width: 767px) {\n .stats-page { gap: 20px; }\n .kpi-grid { grid-template-columns: 1fr 1fr; }\n .stats-head { align-items: flex-start; flex-direction: column; }\n}\n@media (max-width: 420px) { .kpi-grid { grid-template-columns: 1fr; } }\n","structuredPatch":[{"oldStart":223,"oldLines":6,"newStart":223,"newLines":17,"lines":[" }"," @media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }"," ","+/* récemment consultés — bande horizontale de cartes, défilement latéral */","+.recent-row {","+ display: grid; grid-auto-flow: column; grid-auto-columns: 290px;","+ gap: 18px; overflow-x: auto; padding-bottom: 12px;","+ scroll-snap-type: x proximity;","+}","+.recent-row > .card { scroll-snap-align: start; }","+@media (max-width: 640px) {","+ .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; }","+}","+"," .card {"," background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);"," overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);"]}],"userModified":false,"replaceAll":false}}}73{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a6aec4a8-1e81-4e00-b932-103fc7a6ca59","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}74{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8QIKpwEIERgCKkAkN00v9bOz6ka4EcxVmxwShmSWsV4nTJlbr6fXwYuKeP0BB8HuSV+FLvM6eZ6Bq77SfOgtwhssEM8qg/aP1QG/Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAH+1ajUBrABAhIMrt5CKsEf+7f2fqIjGgxCNGhCfu+9oEdiRQ0iMEnlLwWsHz0F7UlQuBiuMEcH2mguI6S8LKJRV+yfY6Ogy7jjZ4moK4TX35JU6FlB/ip3796us5tmm17UqXKgPaIbX1aVF3pmlaNI9GiPKtTHge8sOIV2HElLScKaEyidIUJgIyhA62hTLeCwnxnq7OJn9Vtm+I02TRaejbk8V+nSVlFSzHnOm9XGrvIUFLNL8kcH3Tstu0mmIuzH54bfGiEDpFadpxgld4YYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d20f8ce9-ad57-449d-bbe7-2d0afbc7041c"}}75{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"text","text":"Je build le frontend et vérifie le nom PM2 de l'app."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"081e187e-c8d4-4311-a8c7-7947e50a0950"}}76{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01N7PAWYwyLYvNmZDVYCER3x","name":"Bash","input":{"command":"cd /Users/simon-pierreboucher/apps/resto-ka/frontend && npm run build 2>&1 | tail -15","description":"Build frontend Vite","timeout":180000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"5f3d336b-e31b-4e0c-be9e-f41d035f35b0"}}77{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WzE3AgjjtfL82VirT9Su3u","name":"Bash","input":{"command":"pm2 ls | head -20","description":"List PM2 apps"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"b32a152b-910f-4ec9-9da6-2937d7fd1c83"}}78{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01N7PAWYwyLYvNmZDVYCER3x","type":"tool_result","content":"> resto-ka-frontend@1.0.0 build\n> tsc -b && vite build\n\nvite v5.4.21 building for production...\ntransforming...\n✓ 51 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 2.16 kB │ gzip: 0.89 kB\ndist/assets/index-CjaZ2C7m.css 47.50 kB │ gzip: 9.49 kB\ndist/assets/index-CDRAbc8P.js 239.01 kB │ gzip: 74.71 kB\n✓ built in 419ms","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"be6be758-3ec0-49f4-9da5-7bbdf5cda395","timestamp":"2026-08-22T23:04:33.554Z","tool_use_result":{"stdout":"> resto-ka-frontend@1.0.0 build\n> tsc -b && vite build\n\nvite v5.4.21 building for production...\ntransforming...\n✓ 51 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 2.16 kB │ gzip: 0.89 kB\ndist/assets/index-CjaZ2C7m.css 47.50 kB │ gzip: 9.49 kB\ndist/assets/index-CDRAbc8P.js 239.01 kB │ gzip: 74.71 kB\n✓ built in 419ms","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}79{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WzE3AgjjtfL82VirT9Su3u","type":"tool_result","content":"┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 17D │ 0 │ online │ 0% │ 171.9mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 17D │ 13 │ online │ 0% │ 185.7mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 10D │ 14 │ online │ 0% │ 74.3mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 10D │ 0 │ online │ 0% │ 73.1mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 3D │ 21 │ online │ 0% │ 702.3mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 6D │ 0 │ online │ 0% │ 131.2mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 4D │ 6 │ online │ 0% │ 11.5gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 16D │ 6 │ online │ 0% │ 219.7mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 16D │ 3 │ online │ 0% │ 812.6mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 16D │ 0 │ online │ 0% │ 151.4mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 5D │ 0 │ online │ 0% │ 175.3mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 81871 │ 4h │ 11 │ online │ 0% │ 521.7mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 77232 │ 3h │ 33 │ online │ 0% │ 249.3mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 13D │ 0 │ online │ 0% │ 41.3mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 13D │ 0 │ online │ 0% │ 62.0mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 13D │ 0 │ online │ 0% │ 80.0mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 13D │ 0 │ online │ 0% │ 77.0mb │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3dc1b281-d9f0-4a8b-914b-d25e6b1b0fea","timestamp":"2026-08-22T23:04:33.690Z","tool_use_result":{"stdout":"┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 17D │ 0 │ online │ 0% │ 171.9mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 17D │ 13 │ online │ 0% │ 185.7mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 10D │ 14 │ online │ 0% │ 74.3mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 10D │ 0 │ online │ 0% │ 73.1mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 3D │ 21 │ online │ 0% │ 702.3mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 6D │ 0 │ online │ 0% │ 131.2mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 4D │ 6 │ online │ 0% │ 11.5gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 16D │ 6 │ online │ 0% │ 219.7mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 16D │ 3 │ online │ 0% │ 812.6mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 16D │ 0 │ online │ 0% │ 151.4mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 5D │ 0 │ online │ 0% │ 175.3mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 81871 │ 4h │ 11 │ online │ 0% │ 521.7mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 77232 │ 3h │ 33 │ online │ 0% │ 249.3mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 13D │ 0 │ online │ 0% │ 41.3mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 13D │ 0 │ online │ 0% │ 62.0mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 13D │ 0 │ online │ 0% │ 80.0mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 13D │ 0 │ online │ 0% │ 77.0mb │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}80{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"74c1d36f-fbe1-4e1e-84f3-8a365fcb6179","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}81{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd7F2RwghoHBRmEKJgn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PkxhJLugVwsc8zpVRvdk7C","name":"Bash","input":{"command":"pm2 ls | grep -i resto","description":"Find resto-ka PM2 process"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2113,"cache_read_input_tokens":74803,"cache_creation":{"ephemeral_5m_input_tokens":2113,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"36d5895e-d061-4f95-ad6e-839c5afafe66"}}82{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PkxhJLugVwsc8zpVRvdk7C","type":"tool_result","content":"│ 84 │ resto-ka │ default │ N/A │ fork │ 78693 │ 3h │ 20 │ online │ 0% │ 104.2mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 5D │ 0 │ online │ 0% │ 139.7mb │ simon-p… │ disabled │\n│ 86 │ resto-ka-sync │ default │ N/A │ fork │ 81889 │ 4h │ 7 │ online │ 0% │ 282.3mb │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"5019f5ed-b5a1-48da-953f-3890d9f97def","timestamp":"2026-08-22T23:04:38.333Z","tool_use_result":{"stdout":"│ 84 │ resto-ka │ default │ N/A │ fork │ 78693 │ 3h │ 20 │ online │ 0% │ 104.2mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 5D │ 0 │ online │ 0% │ 139.7mb │ simon-p… │ disabled │\n│ 86 │ resto-ka-sync │ default │ N/A │ fork │ 81889 │ 4h │ 7 │ online │ 0% │ 282.3mb │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}83{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5f4a141b-4ca5-4ca8-b680-af11a7dee172","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}84{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd7aePGDGZTEvygutey","type":"message","role":"assistant","content":[{"type":"text","text":"Build OK. Je redémarre l'app et je teste l'endpoint `uids` en live."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":494,"cache_read_input_tokens":76916,"cache_creation":{"ephemeral_5m_input_tokens":494,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"14077308-a399-4076-9fe4-2019d8882d53"}}85{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd7aePGDGZTEvygutey","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NYUZtUfkofFa27SQPo8s3G","name":"Bash","input":{"command":"pm2 restart resto-ka --update-env >/dev/null && sleep 2 && curl -s -o /dev/null -w \"%{http_code}\" http://localhost:$(pm2 describe resto-ka | grep -o 'port[^,]*' | head -1 | grep -o '[0-9]*' || echo \"\")/healthz 2>/dev/null; pm2 describe resto-ka | grep -E \"script|args\" | head -4","description":"Restart resto-ka and inspect config"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":494,"cache_read_input_tokens":76916,"cache_creation":{"ephemeral_5m_input_tokens":494,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0e22294c-da25-475a-948a-129d526f9ab0"}}86{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NYUZtUfkofFa27SQPo8s3G","type":"tool_result","content":"000│ script path │ /Users/simon-pierreboucher/apps/resto-ka/.venv/bin/python │\n│ script args │ run.py serve 8115 │\n│ interpreter args │ N/A │\n│ script id │ 84 │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"878e64d7-2867-4ebf-b0e5-1876c841c342","timestamp":"2026-08-22T23:04:47.268Z","tool_use_result":{"stdout":"000│ script path │ /Users/simon-pierreboucher/apps/resto-ka/.venv/bin/python │\n│ script args │ run.py serve 8115 │\n│ interpreter args │ N/A │\n│ script id │ 84 │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}87{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"79585a83-0e01-4f05-a30d-d29aa470e266","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}88{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd8FAtfQnxE5FimUomf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017mAfqzJPJvXtxtpcFW7Gu9","name":"Bash","input":{"command":"sleep 2; curl -s http://localhost:8115/healthz; echo; UID1=$(curl -s \"http://localhost:8115/api/restaurants?limit=2\" | python3 -c \"import json,sys; d=json.load(sys.stdin); print(','.join(r['uid'] for r in d['restaurants']))\"); echo \"uids=$UID1\"; curl -s \"http://localhost:8115/api/restaurants?uids=$UID1&limit=5\" | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d['total'], [r['name'] for r in d['restaurants']])\"","description":"Healthcheck + test uids filter live"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":354,"cache_read_input_tokens":77410,"cache_creation":{"ephemeral_5m_input_tokens":354,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"20675be7-c5ec-4ae5-bbb3-7afb094d6306"}}89{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017mAfqzJPJvXtxtpcFW7Gu9","type":"tool_result","content":"{\"status\":\"ok\"}\nuids=site-resto:node/2861312352,site-resto:node/4907803404\n2 ['3 Brasseurs', '3 Madames Dumpling']","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e4d9e9d5-d634-49b0-9fe4-32ae770a88b2","timestamp":"2026-08-22T23:04:58.122Z","tool_use_result":{"stdout":"{\"status\":\"ok\"}\nuids=site-resto:node/2861312352,site-resto:node/4907803404\n2 ['3 Brasseurs', '3 Madames Dumpling']","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}90{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"17e4ca73-7e8d-4ecf-9b2f-34d46397c4e8","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}91{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd93f7t28B4FEieANv5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISgAUKpwEIERgCKkDHIOLCPzhp+0jx70TNv0DX54279HJlYYEoDuedM+MRJ2J6SDkIna9ITrqRi4H5OUl24Ydd3d2UXdpn77ZmK3zMMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGh1qjUBrABAhIMW10c3FR3pMGFiLG+GgyYmXWfL9pHkpVHFvEiMBk+SjvTfrIbA+qDNwaku6bERihY2XDdKzNF0xod7VWA53j8OkMrA6a24Fv3+mddUyqFA9VFX2zDUBIW7cFEOyx4tFZHoZ6FQLXPj9DjKZxZuawuc2zgZFZnsG2QVUg3pNkGxQTDr8qE4p5vaS8w1qt+yAnswwZ4Fu9+/teeL1juCPLY1F2QOfdWh/WqEMxR7P/GZ29Wb8Irj4v9d7gzKs8438yRldUENFoP5WuoUab0fT0y0J8N5Q7JTjCP+NHBM0np5N1evP9+NeHr8Ywua4dDibjYWmECD0MjgpxXWqV/10kyW7/G9HY5p7G3rdXwDpwrtnMhFV+nrxwSXxTi1rdeJSRfYJIMnYwK3u4ce2gFLCKw5g05oxyfufr89v3kUakHoms4zc2G8xA7/r5gch/5jkwfex+a6ARjBV1IZqKQDwISxmoCGR6dj3SSbqygZ5CW4ktnzyO2XePaujRCShP8bcE9NOe8U2CMrOxcoI8ize0ijv9w/6jcVPDh8n+r/gwEAtLRo8YIa5gJLy+dREPXXS1gEJDxiC6lFCpzicZ57kqlb0XLcbsn44K113NGm3hLT7mOtCzHGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":352,"cache_read_input_tokens":77764,"cache_creation":{"ephemeral_5m_input_tokens":352,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"7f5cf586-5b67-4ec5-8a50-9c1f0443cd45"}}92{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd93f7t28B4FEieANv5","type":"message","role":"assistant","content":[{"type":"text","text":"Le backend répond et le filtre `uids` fonctionne (ordre remis côté client). Je vérifie que le site sert le nouveau build."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":352,"cache_read_input_tokens":77764,"cache_creation":{"ephemeral_5m_input_tokens":352,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"08fa28ad-1eba-4584-ba4b-054bbf208398"}}93{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd93f7t28B4FEieANv5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01L5Co665chw4XKUXzWFtmJZ","name":"Bash","input":{"command":"curl -s http://localhost:8115/ | grep -o 'index-[^\"]*\\.js' | head -1; curl -s -o /dev/null -w \"%{http_code}\\n\" https://resto-ka.com/healthz","description":"Verify new bundle served + public healthcheck"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":352,"cache_read_input_tokens":77764,"cache_creation":{"ephemeral_5m_input_tokens":352,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"41722ee6-999d-45e8-821e-375b2c5e7707"}}94{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 6\nindex-CDRAbc8P.js\n000","is_error":true,"tool_use_id":"toolu_01L5Co665chw4XKUXzWFtmJZ"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ef8cf4cf-ed0a-4742-8fce-cd351da0b68a","timestamp":"2026-08-22T23:05:05.992Z","tool_use_result":"Error: Exit code 6\nindex-CDRAbc8P.js\n000"}}95{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c3af226f-fe20-43ea-80c2-25d72df30a6f","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}96{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd9d8mUZ9oju1sFDFG6","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvQMKpwEIERgCKkClIhwf4ziMqotF00Ut3/pl1DXp95WbnVd1YS7Pvo2MZXpKeVo3rT/FTEzndd3z8B4wCtgXEOVqkuH/BB3d56cEMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGn1qjUBrABAhIMdh4rmzj65HRk+Mj3Ggzxr4ceimng+gJCyNMiML6BocOQCo3TMLj98RcdeNZyC7Xk9I9xIRk3BnWbqo+icMvUqA7V0DudYWCzQRFMmCrCATNJhReR2kOPsP3sb+Y54rxOTWEPDs/rBGood9HMNdsbtCSg/vcU9YfgkMa3I/5Ex5G+E2PbmLA49nePkZlJgQEwl4VPZlDsxYFESmK2Z1hFETXq43X2xQ0Rf0OeooXvVwNqAKV3uZhLQXVZLyW7dIwLKAkMcd6keV65ZhWhkuwCzRYvQPU0zYwKnz3i/RpDOwrlfRBYOimeFcpp6L7HXD12I0UWCNAquG0C+7kX7NlwQMsUSIUdCcawA+f9+Ij15ZHmGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":78116,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"58aa7d81-0c16-4ce6-bed8-fd6549c1e878"}}97{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd9d8mUZ9oju1sFDFG6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ae6Hk88LSA213xrRYLV8GW","name":"Bash","input":{"command":"pm2 describe resto-ka-ngrok | grep -E \"script args\" ","description":"Check ngrok domain for resto-ka"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":78116,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9536e241-4e74-46df-bb26-f8d4ccbdd96a"}}98{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ae6Hk88LSA213xrRYLV8GW","type":"tool_result","content":"│ script args │ http --url=www.resto-ka.com 8115 │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1a7f1644-5391-4672-b306-da77f8c5c03f","timestamp":"2026-08-22T23:05:12.445Z","tool_use_result":{"stdout":"│ script args │ http --url=www.resto-ka.com 8115 │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}99{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4057d8d7-6d24-45c5-a5ce-69e405974f32","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}100{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdA6aGiMaEk98DRbGwa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JrKXRBLYSbf1wkTN8ZG45q","name":"Bash","input":{"command":"curl -s -o /dev/null -w \"%{http_code}\\n\" https://www.resto-ka.com/healthz","description":"Public healthcheck www.resto-ka.com"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":221,"cache_read_input_tokens":78493,"cache_creation":{"ephemeral_5m_input_tokens":221,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1dc9cab5-686a-45e6-8669-9371f80bf4e4"}}101{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JrKXRBLYSbf1wkTN8ZG45q","type":"tool_result","content":"200","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e65e7595-e057-490a-a496-e0215206f61b","timestamp":"2026-08-22T23:05:18.491Z","tool_use_result":{"stdout":"200","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}102{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6f3613bf-f086-40bc-8e5a-03319ecf8fcd","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}103{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdAZUmyamkMP6p6N96C","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzwQKpwEIERgCKkBT0Xtd93TAz/CgI8jZSWjhqZAFGR3o/tcrik5SxtXsZ5kNJQWt+nStNDgI7JTbBqtNkrryH756L94u/oqt79gGMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAG21qjUBrABAhIM033+e+aMLiSsVdESGgxa0IGIwpOs6IZgWG4iMOLJnhfElbmcdlPew8yAvFbUbXhOIJ82Y4b11QHkvm0GmjThHTQDA+nj4Wonj0BqgSrUArM6HJGbRo0Nax9lVNSVhnCbhvixPTexwqvuU6byfnUYQzAg4dDhRfn8uGFIA4uISGVKMUzTZjp19Tiih/J9pfDQYu2GxfMRcjtczvJp66RDSdygVMRuMuMAZU6Bmi1mZo5F4BfP4T3HEt5p/NZQQEF+yF9FdEhEN10Hhr/fgOjr0CiLDLsqHS7f03bmQLqbGtYrZmA1uN62P2WHVdXUr15JN8MCbbWXSJQBxdHVfXkk0lWvJzOJna0YzaBIJb+7tQnw9TvPEXQPd2lx2nXCmgnBOVya352TUMobmTQV3mlRpaP/U3xX1ubq3oeRxlwkLi85JPeYxVZSmneWiRZ28wWM5rNGRy9G43rGFAjMaxIypAfzTkTCl5A5cm7zgAtw2g6/o6OxxlD93Psx3epwIinDAslx6iEC+4DPPnYgwwoVzMEYEcgfUozfZg1ItfnE1ZmXDRgYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":78714,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"a33c1577-11bd-4638-86d3-2afa0eaa031f"}}104{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdAZUmyamkMP6p6N96C","type":"message","role":"assistant","content":[{"type":"text","text":"Tout est en ligne. Je committe et pousse (seulement mes fichiers — `data/ueat-discovered.json` est modifié par le sync, je n'y touche pas)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":78714,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"5038a88c-3b97-4f91-afbf-dfbe0c628e6f"}}105{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdAZUmyamkMP6p6N96C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G773q1g1yb1EJWEmdsND3Z","name":"Bash","input":{"command":"git add restoka/web.py frontend/src/api.ts frontend/src/pages/Resto.tsx frontend/src/pages/Home.tsx frontend/src/styles.css && git commit -m \"$(cat <<'EOF'\naccueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>\nEOF\n)\" && git push origin main && git status --short | head -5","description":"Commit and push to spbgit origin main"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":78714,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"4186ff38-0313-45e1-a42b-a1728257d539"}}106{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 128\nfatal: pathspec 'restoka/web.py' did not match any files","is_error":true,"tool_use_id":"toolu_01G773q1g1yb1EJWEmdsND3Z"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"f211afc7-6023-4ae5-8a53-20a5a609abc9","timestamp":"2026-08-22T23:05:29.040Z","tool_use_result":"Error: Exit code 128\nfatal: pathspec 'restoka/web.py' did not match any files"}}107{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"53f8b9e0-c1d7-4231-9282-350e7aceae7e","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}108{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdBKgLp9MxdfCJj5CVe","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011SraJZMU1e8PdFH2vCLTWq","name":"Bash","input":{"command":"pwd; git -C /Users/simon-pierreboucher/apps/resto-ka status --short | head","description":"Check cwd and git status"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":472,"cache_read_input_tokens":78840,"cache_creation":{"ephemeral_5m_input_tokens":472,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"8ec4f867-6776-4e0e-ac73-dab036aa382f"}}109{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011SraJZMU1e8PdFH2vCLTWq","type":"tool_result","content":"/Users/simon-pierreboucher/apps/resto-ka/frontend\n M data/ueat-discovered.json\n M frontend/src/api.ts\n M frontend/src/pages/Home.tsx\n M frontend/src/pages/Resto.tsx\n M frontend/src/styles.css\n M restoka/web.py","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9aebfe70-ae26-419d-a50b-0f3cdd6ac897","timestamp":"2026-08-22T23:05:33.779Z","tool_use_result":{"stdout":"/Users/simon-pierreboucher/apps/resto-ka/frontend\n M data/ueat-discovered.json\n M frontend/src/api.ts\n M frontend/src/pages/Home.tsx\n M frontend/src/pages/Resto.tsx\n M frontend/src/styles.css\n M restoka/web.py","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}110{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"39c5729e-c597-45af-9f23-16da962d83b2","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}111{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdBfmpZ8MJU6q8qP1ML","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrQIKpwEIERgCKkDoUHz8FUWHmJYbgeWGtHOmq8AMDJfJs5OqYvR9og+5yoV3PfzIDsvwNXOZgYqiJibWOP2keQ0hRS42RJqj8P0aMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHC1qjUBrABAhIMsskVaf6yggaPPj4oGgzG/u/L9UoCURXcX9kiMCDZCeVQ7wcfgMtz5Vq0SkiZg8ybWHI6zod+2nggp1q5vyjKqpc8BwAjkHdZJvwVdCozJ1GFeYFVZdCKoR9dBJxOBYfV0Wmugl9b7/MivnhWhKgeQyYroi5l6LLMpL7jek/ZVQKYGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":79312,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ff6d5347-9efa-4222-b7e8-1d82493e01c2"}}112{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdBfmpZ8MJU6q8qP1ML","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AVfDYDjapVib9kaRKHeeQP","name":"Bash","input":{"command":"cd /Users/simon-pierreboucher/apps/resto-ka && git add restoka/web.py frontend/src/api.ts frontend/src/pages/Resto.tsx frontend/src/pages/Home.tsx frontend/src/styles.css && git commit -m \"$(cat <<'EOF'\naccueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>\nEOF\n)\" && git push origin main","description":"Commit and push to origin main (spbgit)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":79312,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"51d36152-d710-4a9b-82c6-26e233959bee"}}113{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AVfDYDjapVib9kaRKHeeQP","type":"tool_result","content":"[main 10ba8a7] accueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n 5 files changed, 74 insertions(+), 3 deletions(-)\nTo gitsrv:srv/git/resto-ka.git\n 6a43314..10ba8a7 main -> main","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"02ec9c7e-9e35-42dd-a617-bf4fdcd5e63e","timestamp":"2026-08-22T23:05:42.045Z","tool_use_result":{"stdout":"[main 10ba8a7] accueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n 5 files changed, 74 insertions(+), 3 deletions(-)\nTo gitsrv:srv/git/resto-ka.git\n 6a43314..10ba8a7 main -> main","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}114{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"242049ba-f0dd-4d19-942a-5803bede5306","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}115{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdCH767VRoWTsE6qbHP","type":"message","role":"assistant","content":[{"type":"text","text":"Fait — les restos/plats récemment consultés s'affichent maintenant en premier sur l'accueil.\n\n**Fonctionnement**\n- Chaque fiche resto ouverte (y compris via un plat, qui mène à la fiche) est mémorisée en localStorage (`restoka:recent`, max 12, plus récent en tête) — `frontend/src/pages/Resto.tsx:117`.\n- L'accueil affiche une bande horizontale **« Récemment consultés »** (jusqu'à 8 cartes, ordre de consultation) juste au-dessus des résultats, en modes Restos et Plats, sur la première page seulement — `frontend/src/pages/Home.tsx`.\n- Côté API, `/api/restaurants` accepte un paramètre `uids` (liste d'uids, plafonné à 24) pour récupérer ces fiches en un seul appel — `restoka/web.py:151`.\n\n**Déploiement** : build Vite OK, `pm2 restart resto-ka`, healthcheck local et https://www.resto-ka.com → 200, filtre `uids` testé en live, commit `10ba8a7` poussé sur spbgit."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":426,"cache_read_input_tokens":79694,"cache_creation":{"ephemeral_5m_input_tokens":426,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e4eb6a25-6a20-4bba-b886-567c508a9461"}}116{"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":234490,"duration_api_ms":227810,"num_turns":33,"result":"Fait — les restos/plats récemment consultés s'affichent maintenant en premier sur l'accueil.\n\n**Fonctionnement**\n- Chaque fiche resto ouverte (y compris via un plat, qui mène à la fiche) est mémorisée en localStorage (`restoka:recent`, max 12, plus récent en tête) — `frontend/src/pages/Resto.tsx:117`.\n- L'accueil affiche une bande horizontale **« Récemment consultés »** (jusqu'à 8 cartes, ordre de consultation) juste au-dessus des résultats, en modes Restos et Plats, sur la première page seulement — `frontend/src/pages/Home.tsx`.\n- Côté API, `/api/restaurants` accepte un paramètre `uids` (liste d'uids, plafonné à 24) pour récupérer ces fiches en un seul appel — `restoka/web.py:151`.\n\n**Déploiement** : build Vite OK, `pm2 restart resto-ka`, healthcheck local et https://www.resto-ka.com → 200, filtre `uids` testé en live, commit `10ba8a7` poussé sur spbgit.","stop_reason":"end_turn","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","total_cost_usd":0.9551052000000002,"usage":{"input_tokens":48,"cache_creation_input_tokens":80120,"cache_read_input_tokens":1580334,"output_tokens":11999,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":80120},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":395,"cache_read_input_tokens":79694,"cache_creation_input_tokens":426,"cache_creation":{"ephemeral_5m_input_tokens":426,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":356,"outputTokens":14,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.00042599999999999995,"contextWindow":200000,"maxOutputTokens":32000},"claude-fable-5":{"inputTokens":48,"outputTokens":11999,"cacheReadInputTokens":1580334,"cacheCreationInputTokens":80120,"webSearchRequests":0,"costUSD":0.9546792000000002,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"8b90a74a-9b4d-42f3-a3da-346f568ff92e"}}117{"type":"status","state":"idle"}118