SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
9.8 KB · 248 lines tsx
Raw Blame History
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