Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1// ==============================================================================2// Author: Simon-Pierre Boucher <contact@spboucher.ai>3// File: pages/Resto.tsx4// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)5// + menu complet par sections avec prix, options et badge de CONTEXTE6// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte7// est inutilisable ; delivery affiché clairement étiqueté).8// ==============================================================================9import { useEffect, useState } from "react";10import { Link, useParams } from "react-router-dom";11import {12 CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,13 fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,14} from "../api";15import { IcoCompass } from "../components/Icons";16import { FavButton } from "../favorites";1718function MenuBlock({ menu }: { menu: Menu }) {19 const [openSections, setOpenSections] = useState<Set<number>>(20 new Set(menu.sections.map((_, i) => i).slice(0, 4)));21 const toggle = (i: number) => {22 const next = new Set(openSections);23 if (next.has(i)) next.delete(i);24 else next.add(i);25 setOpenSections(next);26 };27 return (28 <section className="menu-block">29 <div className={`ctx-banner ctx-${menu.price_context}`}>30 <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>31 <span>32 source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}33 {" · "}{menu.item_count} plats34 </span>35 </div>36 {menu.sections.map((sec, i) => (37 <div className="menu-section" key={`${sec.name}-${i}`}>38 <button className="menu-section-head" onClick={() => toggle(i)}39 aria-expanded={openSections.has(i)}>40 {sec.image && <img className="ms-img" src={sec.image} alt="" loading="lazy" />}41 <h3>{sec.name}</h3>42 <span className="ms-meta">{sec.items.length} · {openSections.has(i) ? "−" : "+"}</span>43 </button>44 {openSections.has(i) && (45 <ul className="menu-items">46 {sec.items.map((it, j) => (47 <li className="menu-item" key={`${it.name}-${j}`}>48 <div className="mi-row">49 <div className="mi-main">50 <div className="mi-line">51 <span className="mi-name">52 {it.name}53 {(it.tags ?? []).map((t) => (54 <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>55 ))}56 </span>57 <span className="mi-dots" aria-hidden="true" />58 <span className="mi-price">{fmtPrice(it.price)}</span>59 </div>60 {it.description && <p className="mi-desc">{it.description}</p>}61 </div>62 {it.image && (63 <img className="mi-thumb" src={it.image} alt={it.name} loading="lazy" />64 )}65 </div>66 {(it.options ?? []).length > 0 && (67 <div className="mi-optgroups">68 {(it.options ?? []).map((op, k) => (69 <div className="og" key={`${op.group}-${k}`}>70 <div className="og-head">71 <span className="og-name">{op.group}</span>72 <span className={`og-req ${op.required ? "on" : ""}`}>73 {op.required ? "Choix requis" : "Optionnel"}74 </span>75 </div>76 <div className="og-choices">77 {op.choices.map((c, m) => (78 <span className="og-choice" key={`${c.name}-${m}`}>79 {c.name}80 {c.price_delta !== 0 && (81 <b className={c.price_delta > 0 ? "plus" : "moins"}>82 {c.price_delta > 0 ? "+" : "−"}83 {Math.abs(c.price_delta).toFixed(2).replace(".", ",")} $84 </b>85 )}86 </span>87 ))}88 </div>89 </div>90 ))}91 </div>92 )}93 </li>94 ))}95 </ul>96 )}97 </div>98 ))}99 </section>100 );101}102103export default function RestoPage() {104 const { uid } = useParams<{ uid: string }>();105 const [resto, setResto] = useState<Restaurant | null>(null);106 const [notFound, setNotFound] = useState(false);107 const [ctx, setCtx] = useState<string | null>(null);108109 useEffect(() => {110 if (!uid) return;111 setResto(null);112 setNotFound(false);113 fetchRestaurant(uid)114 .then((r) => {115 setResto(r);116 setCtx(r.menus?.[0]?.price_context ?? null);117 pushRecentUid(r.uid);118 })119 .catch(() => setNotFound(true));120 window.scrollTo({ top: 0 });121 }, [uid]);122123 if (notFound) {124 return (125 <div className="notice container">126 <div className="big"><IcoCompass size={40} /></div>127 <h2>Restaurant introuvable</h2>128 <p><Link to="/" className="link-btn">Retour à la recherche</Link></p>129 </div>130 );131 }132 if (!resto) {133 return (134 <div className="container detail">135 <div className="skel"><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>136 </div>137 );138 }139140 const menus = resto.menus ?? [];141 const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null;142143 return (144 <div className="container detail">145 <nav className="crumbs" aria-label="Fil d'Ariane">146 <Link to="/">Restos</Link> ›147 {resto.region && (148 <>149 <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> ›150 </>151 )}152 <span>{resto.name}</span>153 </nav>154155 <div className="detail-grid">156 {/* Panneau resto (nom, prix, adresse) EN PREMIER dans le DOM : c'est le157 héro de la fiche — l'ordre du DOM est l'ordre visuel sur mobile158 (standard Groupe Ka « Ordre des sections — pages détail »).159 Sur desktop, le CSS le place explicitement en colonne 2. */}160 <aside className="panel">161 <div className="panel-head">162 {(resto.images ?? [])[0] && (163 <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" />164 )}165 {resto.price_range && <div className="price">{resto.price_range}</div>}166 <FavButton r={resto} big />167 </div>168 <h1>{resto.name}</h1>169 <div className="loc">170 {resto.address}{resto.address && resto.city ? ", " : ""}{resto.city}171 {resto.postal_code ? ` (${resto.postal_code})` : ""}172 </div>173 <div className="kv">174 <div className="cell">175 <div className="k">Région</div>176 <div className="v">{resto.region || "—"}</div>177 </div>178 <div className="cell">179 <div className="k">Type</div>180 <div className="v">{typeLabel(resto.establishment_type)}</div>181 </div>182 {resto.chain && (183 <div className="cell">184 <div className="k">Chaîne</div>185 <div className="v">{resto.chain}</div>186 </div>187 )}188 <div className="cell">189 <div className="k">Statut</div>190 <div className="v">{resto.status === "open" ? "Ouvert" : "Fermé temporairement"}</div>191 </div>192 </div>193 {resto.cuisines.length > 0 && (194 <>195 <div className="klabel">Cuisines</div>196 <div className="amenity-row">197 {resto.cuisines.map((c) => (198 <span className="amenity" key={c}>{cuisineLabel(c)}</span>199 ))}200 </div>201 </>202 )}203 {resto.dietary_options.length > 0 && (204 <>205 <div className="klabel">Diètes offertes</div>206 <div className="amenity-row">207 {resto.dietary_options.map((d) => (208 <span className="amenity" key={d}>{dietLabel(d)}</span>209 ))}210 </div>211 </>212 )}213 {(resto.website || resto.url) && (214 <a className="cta" href={resto.url || resto.website}215 target="_blank" rel="noopener noreferrer">216 Commander / voir chez le resto ↗217 </a>218 )}219 <div className="fine">220 Source : {sourceName(resto.source)} — les prix appartiennent au221 resto et peuvent changer sans préavis.222 </div>223 </aside>224225 <div className="detail-main">226 {menus.length > 1 && (227 <div className="view-toggle ctx-toggle" role="tablist" aria-label="Contexte de prix">228 {menus.map((m) => (229 <button key={m.price_context}230 className={m.price_context === menu?.price_context ? "on" : ""}231 onClick={() => setCtx(m.price_context)}>232 {CONTEXT_SHORT[m.price_context] ?? m.price_context}233 </button>234 ))}235 </div>236 )}237 {menu ? <MenuBlock menu={menu} /> : (238 <div className="notice">239 <h2>Menu à venir</h2>240 <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p>241 </div>242 )}243 </div>244 </div>245 </div>246 );247}248