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%

Resto·Ka v1 — agrégateur des restaurants du Québec (menus & prix réels)

- Pipeline complet calqué sur Lou·Ka : connecteurs auto-découverts, schéma
  Restaurant + menu imbriqué (sections → items → prix → options), validation
  stricte (price_context/price_source obligatoires), menus conservés PAR
  contexte, historique des prix par item, alertes de dérive, délai de grâce.
- Connecteur templatisé UEAT (API GraphQL, prix takeout réels) : 19 marques,
  542 restos, 61 330 plats (98 % avec photo, 69 % avec options), 14/17 régions.
- Géocodage Nominatim + Adresses Québec (96 %), régions ville→région,
  déduplication inter-sources (succursales jamais fusionnées).
- Frontend Vite/React 18/TS — design « éditorial sharp » Groupe KA, recherche
  par resto ET par plat (groupée par marque), pagination numérotée, images,
  options en pastilles avec suppléments, badges de contexte de prix.
- 41 tests (fixtures UEAT réelles hors ligne). Déployé : www.resto-ka.com
  (M3U96b:8115, PM2 + ngrok + sync hebdo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 17, 2026)

44 changed files +7,899 −0

added .gitignore +16 −0
@@ -0,0 +1,16 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: .gitignore
4 +# Desc: Resto·Ka — fichiers exclus du dépôt (secrets, base, builds).
5 +# ==============================================================================
6 +.env
7 +__pycache__/
8 +*.pyc
9 +.venv/
10 +.pytest_cache/
11 +data/*.db
12 +data/*.db-journal
13 +frontend/node_modules/
14 +frontend/dist/
15 +frontend/tsconfig.tsbuildinfo
16 +.DS_Store
added CLAUDE.md +572 −0
@@ -0,0 +1,572 @@
1 +<!--
2 +================================================================================
3 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 + File: CLAUDE.md
5 + Project: Resto·Ka — Agrégateur québécois de tous les restaurants (menus + prix)
6 +================================================================================
7 +-->
8 +
9 +# CLAUDE.md — Resto·Ka
10 +
11 +> **Agrégateur exhaustif de tous les restaurants du Québec, avec leurs menus
12 +> complets et leurs prix.**
13 +> Chaque resto, chaque plat, chaque prix — dans les 17 régions, au même endroit,
14 +> comparable et cherchable.
15 +
16 +Ce fichier est la source de vérité pour tout agent Claude (ou humain) qui travaille
17 +sur ce dépôt. Lis-le **en entier** avant d'écrire une seule ligne de code.
18 +
19 +---
20 +
21 +## 0. À LIRE EN PREMIER — Règles non négociables
22 +
23 +1. **En-tête d'auteur obligatoire.** **Chaque fichier** du dépôt (code, config,
24 + doc, script) doit commencer par un en-tête mentionnant l'auteur :
25 + `Simon-Pierre Boucher <contact@spboucher.ai>`. Voir la **section 17** pour le
26 + format exact selon le type de fichier. Aucun fichier ne doit être commité sans
27 + cet en-tête.
28 +
29 +2. **Inspire-toi du dépôt Lou·Ka.** Avant de concevoir ou d'écrire **le moindre
30 + connecteur**, va lire le **dépôt de Lou·Ka** (le site jumeau, agrégateur de
31 + logements locatifs). Ses connecteurs sont le patron de référence : structure
32 + des modules, extraction (Firecrawl / Scrapfly), normalisation, déduplication,
33 + gestion des mises à jour, planification des tâches. **Ne réinvente rien** :
34 + reprends les mêmes conventions, la même architecture de connecteur, les mêmes
35 + utilitaires partagés. Resto·Ka doit être cohérent avec l'écosystème `·Ka`
36 + (Lou·Ka, Immo·Ka, Auto·Ka, Food·Ka, Fabri·Ka, Sorti·Ka). Voir **section 3**.
37 +
38 +3. **Travail de moine.** L'agrégation de menus est un travail minutieux, source par
39 + source, plat par plat, sans rien précipiter. Un menu mal parsé (mauvais prix,
40 + plat manquant, option ignorée) détruit la confiance dans toute la base. Mieux
41 + vaut un connecteur robuste de plus que dix fragiles.
42 +
43 +4. **Vérifie l'existant avant d'ajouter.** Avant d'intégrer un resto ou une source,
44 + confirme qu'il/elle n'est **pas déjà couvert(e)** (voir le registre des sources
45 + et l'index des restaurants, sections 9 et 12). Ne duplique jamais un connecteur
46 + ni une fiche resto. Un restaurant = une fiche canonique, quelles que soient les
47 + sources qui l'alimentent.
48 +
49 +5. **Chaque source a son connecteur sur mesure**, mais les sources qui partagent
50 + une **même plateforme** (livraison, commande en ligne, POS, réservation)
51 + doivent être couvertes par un **connecteur générique templatisé** réutilisable.
52 + Voir **section 8**.
53 +
54 +6. **La provenance du prix est sacrée.** Un prix de livraison (Uber Eats, DoorDash,
55 + Skip) est **majoré de 25-30 %** par rapport à la salle à manger. Ne **jamais**
56 + présenter un prix sans son contexte (`price_context`). Voir **sections 5 et 11**.
57 +
58 +---
59 +
60 +## 1. Mission
61 +
62 +Rendre visible, en un seul endroit, **tous les restaurants du Québec avec leurs
63 +menus et leurs prix réels**, dans les 17 régions — pour que quiconque puisse
64 +répondre à *« qu'est-ce que je mange, où, et combien ça coûte ? »* sans ouvrir
65 +quinze applis.
66 +
67 +### Ce qui nous distingue
68 +
69 +Les plateformes existantes sont soit des **apps de livraison** (Uber Eats, Skip,
70 +DoorDash — menus complets mais prix majorés, périmètre limité aux restos inscrits
71 +et payants), soit des **annuaires** (Google, Yelp, TripAdvisor — avis et coordonnées,
72 +mais **pas de menus structurés ni de prix comparables**), soit des **médias food**
73 +(Tastet, RestoMontréal — éditorial, pas exhaustif).
74 +
75 +Personne n'offre **le menu complet + les prix de tous les restos, comparables entre
76 +eux, partout au Québec.** C'est le créneau de Resto·Ka.
77 +
78 +---
79 +
80 +## 2. Périmètre — TOUS les établissements où l'on mange
81 +
82 +Périmètre maximaliste. Si on peut y commander à manger, ça doit finir dans Resto·Ka :
83 +
84 +- **Restaurants** (tous services : gastronomique, décontracté, familial).
85 +- **Fast-food & chaînes** (menus standardisés, multi-succursales).
86 +- **Cafés & salons de thé** (avec offre alimentaire).
87 +- **Bars & pubs** avec cuisine.
88 +- **Comptoirs pour emporter, cantines, casse-croûtes, food trucks.**
89 +- **Pizzérias, sushis, poulet, burgers, shawarma**, etc.
90 +- **Boulangeries-pâtisseries et traiteurs** avec menu et prix.
91 +- **Microbrasseries et établissements** avec menu de bouffe.
92 +- **Restaurants d'hôtel**, cuisines fantômes (ghost kitchens).
93 +
94 +> Règle d'or : en cas de doute sur l'inclusion, **on inclut** et on catégorise par
95 +> type d'établissement. Le périmètre est large par conception.
96 +
97 +---
98 +
99 +## 3. Dépôts de référence — étudier Lou·Ka AVANT de coder
100 +
101 +Resto·Ka fait partie de la famille `·Ka`. **Le dépôt de Lou·Ka est la référence
102 +canonique pour l'architecture des connecteurs.** Avant tout développement, l'agent
103 +Claude doit :
104 +
105 +1. **Ouvrir le dépôt de Lou·Ka** et localiser le dossier des connecteurs
106 + (typiquement `connectors/`, `scrapers/` ou `sources/`).
107 +2. **Lire 2 ou 3 connecteurs complets** de bout en bout pour comprendre :
108 + - la structure d'un module connecteur (interface commune, méthodes attendues) ;
109 + - l'**extraction** (Firecrawl / Scrapfly, endpoints JSON, sitemaps) ;
110 + - la **normalisation** vers le schéma commun ;
111 + - la **déduplication** (annonces publiées sur plusieurs plateformes) ;
112 + - la gestion des **mises à jour** (nouveautés, changements, retraits) ;
113 + - la **planification** (cadence, files, retries, backoff), les **erreurs** et le
114 + **logging**.
115 +3. **Réutiliser** les utilitaires partagés de Lou·Ka (client Firecrawl/Scrapfly,
116 + helpers de normalisation, déduplication, géocodage) plutôt que d'en réécrire.
117 +4. **Calquer** les conventions de nommage, la structure des dossiers et le style
118 + de code de Lou·Ka. Un dev qui connaît Lou·Ka doit se retrouver immédiatement.
119 +
120 +> Adaptation propre à Resto·Ka : là où Lou·Ka normalise une **annonce**, Resto·Ka
121 +> normalise un **restaurant + un menu imbriqué (catégories → plats → prix →
122 +> options)**. Le schéma est plus profond ; le reste du patron s'applique tel quel.
123 +
124 +> Si tu ne trouves pas le dépôt Lou·Ka ou l'accès aux connecteurs, **arrête-toi et
125 +> demande** plutôt que d'inventer une architecture divergente.
126 +
127 +Écosystème complet, pour contexte :
128 +
129 +| Site | Domaine | Agrège |
130 +|-------------|------------------|-------------------------------------------|
131 +| Lou·Ka | lou-ka.com | Logements à louer |
132 +| Immo·Ka | immo-ka.com | Propriétés à vendre |
133 +| Auto·Ka | auto-ka.com | Véhicules usagés |
134 +| Food·Ka | food-ka.com | Prix d'épicerie & circulaires |
135 +| Fabri·Ka | fabri-ka.com | Produits québécois |
136 +| Sorti·Ka | sorti-ka.com | Sorties & événements |
137 +| **Resto·Ka**| **resto-ka.com** | **Restaurants, menus & prix (ce dépôt)** |
138 +
139 +---
140 +
141 +## 4. Architecture
142 +
143 +Pipeline standard de la famille `·Ka`, adapté aux restaurants et menus :
144 +
145 +```
146 + ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ ┌──────────┐
147 + │ CONNECTEUR │──▶│ EXTRACTION │──▶│ NORMALISATION │──▶│ DÉDUPLICATION │──▶│ STOCKAGE │
148 + │ (par source)│ │ FC / Scrapfly│ │ resto + menu │ │ identité resto│ │ (DB) │
149 + │ │ │ + parse PDF │ │ (schéma commun)│ │ + items │ │ │
150 + └─────────────┘ └──────────────┘ └────────────────┘ └──────────────┘ └────┬─────┘
151 + │
152 + ┌──────────▼──────────┐
153 + │ API / RECHERCHE / │
154 + │ UI (menus & prix) │
155 + └─────────────────────┘
156 +```
157 +
158 +Composants :
159 +
160 +- **Connecteurs** : un module par source (ou par plateforme), interface commune.
161 +- **Orchestrateur** : planifie et exécute (cadence, retries, backoff).
162 +- **Extraction** : Firecrawl & Scrapfly + clients d'API + **parsing de menus PDF /
163 + images** (OCR) pour les menus non structurés.
164 +- **Normalisation** : mappe le brut vers le **schéma Restaurant + Menu** (section 5).
165 +- **Déduplication** : fusionne les restos et les items en double inter-sources.
166 +- **Stockage** : base restos + menus + **historique des prix** (suivi des hausses).
167 +- **API / UI** : recherche géolocalisée, filtres (cuisine, prix, diète), comparaison.
168 +
169 +---
170 +
171 +## 5. Modèle de données — schéma Restaurant + Menu
172 +
173 +### 5.1 Restaurant
174 +
175 +```jsonc
176 +{
177 + "id": "string", // * id interne canonique (post-déduplication)
178 + "source_ids": ["ubereats:abc"], // * id(s) d'origine par source (préfixés)
179 + "name": "string", // * nom normalisé
180 + "chain": "string|null", // marque/chaîne si applicable
181 + "cuisines": ["italien", "pizza"], // * taxonomie cuisine (section 6)
182 + "establishment_type": "restaurant",// restaurant|fast-food|cafe|bar|food-truck|traiteur...
183 + "price_range": "$$", // $ à $$$$ (estimé à partir des prix)
184 + "address": "string", // *
185 + "city": "string", // *
186 + "region": "string", // * une des 17 régions (section 7)
187 + "postal_code": "string",
188 + "lat": 0.0, // * géolocalisation
189 + "lng": 0.0, // *
190 + "phone": "string",
191 + "website": "string",
192 + "hours": { /* horaires par jour */ },
193 + "services": ["salle", "emporter", "livraison"], // dine-in|takeout|delivery
194 + "dietary_options": ["vegan", "sans-gluten", "halal"],
195 + "languages": ["fr", "en"],
196 + "menu": { /* voir 5.2 */ },
197 + "status": "open", // open|temporarily_closed|closed
198 + "first_seen": "ISO-8601", // *
199 + "last_seen": "ISO-8601", // *
200 + "updated_at": "ISO-8601" // *
201 +}
202 +```
203 +
204 +### 5.2 Menu (imbriqué)
205 +
206 +```jsonc
207 +"menu": {
208 + "currency": "CAD",
209 + "price_context": "delivery", // * dine-in | takeout | delivery (voir §6)
210 + "price_source": "ubereats", // * source du prix (traçabilité)
211 + "captured_at": "ISO-8601", // * quand ce menu/prix a été capté
212 + "sections": [
213 + {
214 + "name": "Pizzas",
215 + "items": [
216 + {
217 + "id": "string",
218 + "name": "Margherita", // *
219 + "description": "string",
220 + "price": 16.50, // * prix de l'item (dans price_context)
221 + "currency": "CAD",
222 + "tags": ["vegetarien"],
223 + "options": [ // modificateurs / variantes
224 + {
225 + "group": "Format",
226 + "required": true,
227 + "choices": [
228 + { "name": "10 pouces", "price_delta": 0.00 },
229 + { "name": "14 pouces", "price_delta": 6.00 }
230 + ]
231 + }
232 + ]
233 + }
234 + ]
235 + }
236 + ]
237 +}
238 +```
239 +
240 +Règles :
241 +- `price_context` et `price_source` sont **obligatoires** : un prix sans contexte
242 + est inutilisable (livraison ≠ salle).
243 +- `lat`/`lng` obligatoires → **géocoder** l'adresse si absente.
244 +- Capturer les **options/modificateurs** (formats, suppléments, choix) : ils
245 + changent le prix réel.
246 +- Conserver la **devise** partout (CAD). Au QC, TPS/TVQ s'ajoutent ; ne pas les
247 + inclure dans `price` sauf si la source le fait, et alors le **documenter**.
248 +
249 +---
250 +
251 +## 6. Taxonomies
252 +
253 +### 6.1 Cuisines (multi-valué)
254 +`quebecois` · `francais` · `italien` · `pizza` · `burgers` · `poulet` ·
255 +`bbq-grillades` · `fruits-de-mer` · `sushi-japonais` · `chinois` · `thai` ·
256 +`vietnamien` · `coreen` · `indien` · `libanais-moyen-orient` · `mexicain` ·
257 +`grec` · `mediterraneen` · `dejeuner-brunch` · `cafe-dessert` · `vegetarien-vegan` ·
258 +`fast-food` · `autre`
259 +
260 +### 6.2 Contexte de prix (`price_context`) — CRITIQUE
261 +- `dine-in` — prix en salle (référence idéale).
262 +- `takeout` — prix pour emporter (souvent = salle).
263 +- `delivery` — prix sur plateforme de livraison, **majoré de 25-30 %**.
264 +
265 +> Toujours privilégier `dine-in`/`takeout` (menu du resto lui-même ou sa plateforme
266 +> de commande en ligne) plutôt que `delivery` quand les deux existent. Si on n'a que
267 +> `delivery`, on l'affiche **clairement étiqueté**.
268 +
269 +### 6.3 Diètes / tags
270 +`vegan` · `vegetarien` · `sans-gluten` · `halal` · `casher` · `sans-noix` · `epice`
271 +
272 +---
273 +
274 +## 7. Régions du Québec (obligatoire pour chaque restaurant)
275 +
276 +Bas-Saint-Laurent · Saguenay–Lac-Saint-Jean · Capitale-Nationale · Mauricie ·
277 +Estrie · Montréal · Outaouais · Abitibi-Témiscamingue · Côte-Nord ·
278 +Nord-du-Québec · Gaspésie–Îles-de-la-Madeleine · Chaudière-Appalaches · Laval ·
279 +Lanaudière · Laurentides · Montérégie · Centre-du-Québec.
280 +
281 +Rattacher chaque resto à sa région via la ville/adresse (table de correspondance
282 +ville → région, comme dans Lou·Ka).
283 +
284 +---
285 +
286 +## 8. Philosophie des connecteurs
287 +
288 +- **Un connecteur par source**, taillé sur mesure.
289 +- **Connecteurs templatisés par plateforme** : c'est le levier d'échelle nº 1. Une
290 + plateforme de commande en ligne ou de livraison couvre des **milliers** de restos
291 + avec la même structure → un seul connecteur paramétré les couvre tous (exactement
292 + comme les connecteurs par plateforme d'Auto·Ka et Fabri·Ka).
293 +- **Deux types de sources à combiner** :
294 + 1. **Découverte** (liste des restos + métadonnées) : annuaires, cartes, tourisme.
295 + 2. **Menus + prix** (le cœur) : commande en ligne, livraison, sites de restos, PDF.
296 +- Interface commune (à calquer sur Lou·Ka) : `fetch()` → `normalize()` → `emit()`.
297 + Idempotent et incrémental quand c'est possible.
298 +- **Robustesse** : timeouts, retries + backoff, tolérance aux changements mineurs,
299 + alerte si le nombre d'items d'un menu chute anormalement (connecteur cassé).
300 +
301 +---
302 +
303 +## 9. Catalogue des sources à connecter (priorisé)
304 +
305 +> ⚠️ Vérifie le registre des sources et l'index des restos **avant** d'ajouter.
306 +
307 +### Palier 1 — Plateformes de commande en ligne des restos (MEILLEUR prix + volume)
308 +
309 +Ces plateformes propulsent les **sites/apps des restos eux-mêmes** → menus complets
310 +à des **prix réels (dine-in/takeout, non majorés)**. À templatiser par plateforme :
311 +
312 +- **UEAT** (foodtech de Québec, marque blanche, **très répandue au QC**).
313 +- **Square Online Ordering**, **Toast**, **Clover**, **GloriaFood (Oracle)**,
314 + **Flipdish**, **Foodiv**, **ChowNow**, **Bite (DoorDash)**.
315 +
316 +### Palier 2 — Plateformes de livraison (menus complets, prix MAJORÉS)
317 +
318 +Menus riches et structurés, mais prix `delivery` (+25-30 %), anti-bot fort et CGU
319 +restrictives (voir **section 15** — risque légal réel) :
320 +
321 +- **Uber Eats**, **SkipTheDishes**, **DoorDash**.
322 +- Plateformes québécoises : **RestoLoco** et autres services locaux régionaux.
323 +
324 +> Politique : utiliser la livraison surtout pour la **découverte** et pour les
325 +> restos qu'on ne trouve nulle part ailleurs. Toujours étiqueter `price_context:
326 +> delivery`. Préférer une source `dine-in` dès qu'elle existe.
327 +
328 +### Palier 3 — Sites web des restaurants (menus « maison »)
329 +
330 +- Menus en **HTML** (parse direct) ou en **PDF / image** (→ parsing PDF + OCR,
331 + voir section 10). Détecter la plateforme de commande embarquée (souvent Palier 1).
332 +
333 +### Palier 4 — Découverte & métadonnées (liste des restos)
334 +
335 +- **Google Places / Maps**, **Yelp**, **TripAdvisor** (coordonnées, horaires, type
336 + de cuisine, fourchette de prix — **rarement les menus** ; respecter les CGU/API).
337 +- **Associations touristiques régionales (ATR)**, Tourisme Québec.
338 +- Médias food : **Tastet**, **RestoMontréal** et annuaires régionaux (découverte).
339 +
340 +### Palier 5 — Réservation (métadonnées + parfois menus)
341 +
342 +- **OpenTable**, **LibroReserve** (Québec), **Resy**, **Zenchef** — horaires,
343 + services, parfois menus.
344 +
345 +### Palier 6 — Chaînes (menus standardisés)
346 +
347 +- Sites des grandes chaînes : menus et prix souvent uniformes par bannière et
348 + parfois par région → un connecteur par chaîne couvre toutes les succursales.
349 +
350 +---
351 +
352 +## 10. Extraction — Firecrawl, Scrapfly & parsing de menus
353 +
354 +Les connecteurs s'appuient **souvent sur Firecrawl et Scrapfly** (comme tout
355 +l'écosystème `·Ka`). Règle de choix :
356 +
357 +- **API / endpoint JSON de la plateforme** → toujours privilégié (données
358 + structurées, stables). Beaucoup de plateformes de commande chargent leur menu
359 + via un appel JSON interne : **le trouver et le consommer directement.**
360 +- **Firecrawl** → crawl et extraction en volume de sites relativement ouverts
361 + (sites de restos, annuaires), extraction structurée (markdown/JSON).
362 +- **Scrapfly** → sites protégés, JavaScript lourd, anti-bot, rendu navigateur,
363 + rotation d'IP. **Requis** pour les plateformes de livraison (Uber Eats, Skip,
364 + DoorDash) qui bloquent agressivement.
365 +- **Parsing de menus PDF / images** : de nombreux restos publient un menu en PDF ou
366 + en photo. Prévoir extraction de texte PDF + **OCR** + un post-traitement (LLM) qui
367 + transforme le texte brut en structure `sections → items → price → options`.
368 + Valider systématiquement (un prix mal OCR-isé est pire que pas de prix).
369 +
370 +Chaque connecteur doit **documenter** en tête de fichier son mode d'extraction et
371 +pourquoi. Réutiliser les clients partagés de Lou·Ka.
372 +
373 +---
374 +
375 +## 11. Normalisation
376 +
377 +- Nettoyer noms de restos, sections et plats (HTML résiduel, majuscules, doublons).
378 +- Mapper vers le **schéma Restaurant + Menu** (section 5) intégralement.
379 +- **Cuisines** et **type d'établissement** classés dès la normalisation (section 6).
380 +- **Géocoder** (lat/lng obligatoires) et **rattacher la région** (section 7).
381 +- **Prix** : renseigner **obligatoirement** `price_context` et `price_source` ;
382 + capturer les **options/modificateurs**. Ne jamais mélanger des prix de contextes
383 + différents dans un même menu.
384 +- **Devise** CAD ; documenter le traitement des taxes (TPS/TVQ hors prix au QC).
385 +- Conserver les **`source_ids`** préfixés (ex. `ueat:1234`, `ubereats:abcd`).
386 +
387 +---
388 +
389 +## 12. Déduplication — restaurants ET items
390 +
391 +Un même resto apparaît sur Uber Eats + DoorDash + Skip + son propre site + Google.
392 +
393 +### 12.1 Identité du restaurant
394 +1. **Clé** : nom normalisé + adresse (géoloc arrondie) + téléphone → empreinte.
395 +2. **Approché** : noms proches + même adresse → fusion candidate (journaliser).
396 +3. **Fusion** : un enregistrement canonique, agréger les `source_ids`, choisir la
397 + meilleure source par champ.
398 +4. **Multi-succursales / chaînes** : chaque **adresse** est une fiche resto
399 + distincte, reliée par `chain`. Ne pas fusionner deux succursales.
400 +
401 +### 12.2 Menus & prix
402 +- Ne pas écraser un menu `dine-in` par un menu `delivery` : **conserver les deux
403 + contextes** et présenter le meilleur (dine-in prioritaire), en gardant delivery
404 + étiqueté.
405 +- Dédupliquer les **items** au sein d'un même contexte (même plat listé deux fois).
406 +- **Historiser** les prix : à chaque capture, si le prix d'un item change, garder la
407 + série temporelle (utile pour détecter les hausses et pour la crédibilité).
408 +
409 +S'inspirer de la déduplication de **Lou·Ka** (multi-plateformes) et d'Auto·Ka
410 +(identité forte par clé).
411 +
412 +---
413 +
414 +## 13. Fraîcheur des menus & états
415 +
416 +- **Menus changeants** : prix et plats évoluent (saisonnier, table d'hôte,
417 + spéciaux). Rafraîchir régulièrement et **horodater** chaque capture (`captured_at`).
418 +- **Fermetures** : resto fermé temporairement / définitivement → `status` mis à jour
419 + plutôt que suppression brutale (politique de grâce via `last_seen`, comme Lou·Ka).
420 +- **Items retirés** : disparition d'un plat → le marquer inactif, conserver
421 + l'historique.
422 +- **Spéciaux / table d'hôte** : capturer si structurés, avec leur période de validité.
423 +
424 +---
425 +
426 +## 14. Cadence de rafraîchissement
427 +
428 +- Grandes plateformes (commande en ligne, livraison, chaînes) : **hebdomadaire** au
429 + minimum ; **quotidien** pour les prix sur les gros volumes urbains.
430 +- Sites de restos indépendants : **hebdomadaire à mensuel** (menus stables).
431 +- Découverte (annuaires) : **mensuel** pour repérer nouveaux restos / fermetures.
432 +- Toujours mettre à jour `last_seen` et `captured_at` à chaque passage réussi.
433 +
434 +---
435 +
436 +## 15. Conformité & aspects légaux — À PRENDRE AU SÉRIEUX
437 +
438 +> C'est le point le plus sensible de ce projet. Les plateformes de livraison
439 +> **protègent activement** leurs données (menus/prix) et l'interdisent dans leurs
440 +> CGU. Ne pas traiter ça à la légère.
441 +
442 +- Respecter les **CGU** et `robots.txt` de chaque source. Les plateformes de
443 + livraison sont explicitement restrictives : **minimiser**, privilégier d'autres
444 + sources pour les mêmes restos, et **demander** en cas de doute avant d'industrialiser.
445 +- Privilégier les **sources de première partie** : le **site du resto** et sa
446 + **plateforme de commande** (UEAT, etc.) sont la voie la plus légitime pour le
447 + menu et le prix réel.
448 +- **Rate-limiting** raisonnable, identification honnête, pas de surcharge.
449 +- Utiliser les **API officielles** quand elles existent (Google Places, Yelp) et
450 + en respecter les quotas et conditions d'affichage/attribution.
451 +- Ne republier que ce qui est nécessaire à la découverte (nom, plats, prix, infos
452 + pratiques, lien vers la source). **Attribuer** les sources requises.
453 +- Consigner par source la base légale/mode d'accès retenu (API vs scraping) en tête
454 + de connecteur.
455 +
456 +---
457 +
458 +## 16. Conventions de code
459 +
460 +- Suivre **les mêmes conventions que Lou·Ka** (langage, structure de dossiers,
461 + nommage, linting, formatage). Cohérence > préférences personnelles.
462 +- Un connecteur = un module isolé, testable indépendamment.
463 +- Secrets (clés Firecrawl/Scrapfly, API Google/Yelp) via variables d'environnement /
464 + gestionnaire de secrets. Jamais en clair.
465 +- Logging structuré (source, nb de restos, nb d'items, erreurs, durée).
466 +- Commits atomiques, messages clairs.
467 +
468 +---
469 +
470 +## 17. En-tête d'auteur — OBLIGATOIRE dans chaque fichier
471 +
472 +**Tout fichier** du dépôt doit débuter par un en-tête d'auteur. L'auteur est
473 +toujours **Simon-Pierre Boucher \<contact@spboucher.ai\>**.
474 +
475 +**Python / Shell / YAML / TOML / Dockerfile :**
476 +```python
477 +# ==============================================================================
478 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
479 +# File: <nom_du_fichier>
480 +# Desc: <brève description>
481 +# ==============================================================================
482 +```
483 +
484 +**JavaScript / TypeScript / Go / Rust / Java / C :**
485 +```javascript
486 +// ==============================================================================
487 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
488 +// File: <nom_du_fichier>
489 +// Desc: <brève description>
490 +// ==============================================================================
491 +```
492 +
493 +**Markdown / HTML / XML :**
494 +```html
495 +<!--
496 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
497 + File: <nom_du_fichier>
498 + Desc: <brève description>
499 +-->
500 +```
501 +
502 +**CSS / SCSS :**
503 +```css
504 +/*
505 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
506 + File: <nom_du_fichier>
507 +*/
508 +```
509 +
510 +Règles :
511 +- **Aucun** fichier commité sans cet en-tête.
512 +- Pour un connecteur, indiquer dans `Desc:` la **source/plateforme couverte**, le
513 + **mode d'extraction** (API / Firecrawl / Scrapfly / PDF-OCR) et le **contexte de
514 + prix** produit (dine-in / takeout / delivery).
515 +- Vérifier la présence de l'en-tête en revue de code (idéalement hook de pré-commit).
516 +
517 +---
518 +
519 +## 18. Tests & qualité
520 +
521 +- Chaque connecteur : **tests unitaires** de normalisation sur des fixtures réelles
522 + (réponses JSON, HTML, PDF de menus figés).
523 +- Tests de **parsing de menus** : vérifier prix, options et sections sur des
524 + échantillons connus (les prix sont l'actif le plus critique).
525 +- Tests de **déduplication** resto + items multi-sources.
526 +- **Validation de schéma** : tout resto/menu émis doit passer la validation avant
527 + stockage ; rejeter un menu sans `price_context`/`price_source`.
528 +- **Monitoring** : alerte si le nb d'items d'un resto ou le volume d'une source
529 + chute anormalement (connecteur probablement cassé).
530 +
531 +---
532 +
533 +## 19. Checklist — ajouter un nouveau connecteur / restaurant
534 +
535 +1. [ ] Lire les connecteurs de référence dans le **dépôt Lou·Ka** (section 3).
536 +2. [ ] Vérifier que la source/le resto **n'est pas déjà couvert** (registres).
537 +3. [ ] Identifier la **plateforme** : peut-on templatiser/réutiliser un connecteur ?
538 +4. [ ] Choisir le **mode d'extraction** : API > site du resto > Firecrawl > Scrapfly ;
539 + PDF/OCR si menu non structuré (section 10).
540 +5. [ ] Déterminer le **`price_context`** produit et le documenter.
541 +6. [ ] Implémenter `fetch()` → `normalize()` → `emit()` (interface commune).
542 +7. [ ] Mapper **tout** le schéma resto + menu, géocoder, région, cuisines, options.
543 +8. [ ] Brancher la **déduplication** resto + items (section 12).
544 +9. [ ] Gérer **fraîcheur, fermetures, items retirés, historique des prix** (§13).
545 +10. [ ] Vérifier la **conformité** CGU/robots et consigner la base d'accès (§15).
546 +11. [ ] Ajouter l'**en-tête d'auteur** (section 17).
547 +12. [ ] Écrire les **tests** + fixtures, dont le parsing de prix (section 18).
548 +13. [ ] Enregistrer la source dans le **registre** avec sa cadence.
549 +14. [ ] Valider volume et qualité sur un premier run, journaliser.
550 +
551 +---
552 +
553 +## 20. Roadmap / MVP
554 +
555 +**MVP** : templatiser **un** connecteur pour la plateforme de commande en ligne la
556 +plus répandue au QC (**UEAT**) → menus complets à prix réels (`dine-in`/`takeout`)
557 +pour des milliers de restos d'un coup. Ajouter la découverte via Google Places/Yelp
558 +pour la liste et les métadonnées, et le parsing des menus PDF des sites de restos.
559 +UI de recherche géolocalisée avec filtres (cuisine, prix, diète, service) et
560 +affichage clair du **contexte de prix**.
561 +
562 +**Ensuite** : plateformes de livraison (avec étiquetage strict), chaînes,
563 +réservation, régions moins couvertes. Puis historique des prix, comparaison entre
564 +restos, et cross-linking avec les autres sites `·Ka`.
565 +
566 +**Nord stratégique** : être **le seul** à offrir menus **et** prix **comparables**
567 +de **tous** les restos du Québec — là où la livraison est chère et partielle, les
568 +annuaires sans menus, et les médias food non exhaustifs.
569 +
570 +---
571 +
572 +*Fin de CLAUDE.md — Resto·Ka. Auteur : Simon-Pierre Boucher \<contact@spboucher.ai\>.*
added README.md +106 −0
@@ -0,0 +1,106 @@
1 +<!--
2 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 + File: README.md
4 + Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix).
5 +-->
6 +
7 +# Resto·Ka — [resto-ka.com](https://www.resto-ka.com)
8 +
9 +![Restaurants](https://img.shields.io/badge/restaurants-542-141814?labelColor=d9f26b&style=for-the-badge)
10 +![Plats avec prix](https://img.shields.io/badge/plats_avec_prix-61_330-141814?labelColor=d9f26b&style=for-the-badge)
11 +![Régions](https://img.shields.io/badge/r%C3%A9gions-14%2F17-141814?labelColor=d9f26b&style=for-the-badge)
12 +![Chaînes](https://img.shields.io/badge/cha%C3%AEnes_suivies-15-141814?labelColor=d9f26b&style=for-the-badge)
13 +
14 +![Prix](https://img.shields.io/badge/prix-takeout_r%C3%A9els,_non_major%C3%A9s-1c5c41?style=flat-square)
15 +![Images](https://img.shields.io/badge/items_avec_photo-98%25-1c5c41?style=flat-square)
16 +![Options](https://img.shields.io/badge/items_avec_options-69%25-1c5c41?style=flat-square)
17 +![Géocodage](https://img.shields.io/badge/g%C3%A9ocod%C3%A9-96%25-1c5c41?style=flat-square)
18 +![Tests](https://img.shields.io/badge/tests-41_pass-1c5c41?style=flat-square)
19 +![Python](https://img.shields.io/badge/python-3.14_%C2%B7_FastAPI-141814?style=flat-square)
20 +![React](https://img.shields.io/badge/react-18_%C2%B7_Vite_%C2%B7_TS-141814?style=flat-square)
21 +
22 +> **Agrégateur exhaustif de tous les restaurants du Québec, avec leurs menus
23 +> complets et leurs prix.** Chaque resto, chaque plat, chaque prix — dans les
24 +> 17 régions, au même endroit, comparable et cherchable — **par resto ou par
25 +> plat**.
26 +
27 +Plateforme de la famille **·Ka** (Lou·Ka, Immo·Ka, Auto·Ka, Food·Ka, Fabri·Ka,
28 +Sorti·Ka). Architecture des connecteurs **calquée sur Lou·Ka**, identité
29 +visuelle **« éditorial sharp » du Groupe KA** (Space Grotesk / Inter /
30 +JetBrains Mono, papier-encre-lime, bordures encre + ombres décalées).
31 +`CLAUDE.md` est la source de vérité du projet.
32 +
33 +## L'app
34 +
35 +| Accueil — recherche de restos | Recherche par plat |
36 +|---|---|
37 +| ![Accueil](docs/screenshots/accueil.png) | ![Plats](docs/screenshots/plats.png) |
38 +
39 +| Fiche resto — menu, photos, options & prix | Couverture en direct |
40 +|---|---|
41 +| ![Fiche](docs/screenshots/fiche.png) | ![Stats](docs/screenshots/stats.png) |
42 +
43 +## Ce qui rend Resto·Ka différent
44 +
45 +- **Prix réels** : captés sur la plateforme de commande des restos eux-mêmes
46 + (Palier 1), pas sur les apps de livraison (+25-30 %).
47 +- **Un prix n'est JAMAIS présenté sans son contexte** (`dine-in` / `takeout` /
48 + `delivery`) — un menu sans `price_context`/`price_source` est rejeté à
49 + l'ingestion, et un menu salle n'est jamais écrasé par un menu livraison.
50 +- **Recherche par plat** dans les 61 330 items (groupée par marque pour ne pas
51 + répéter 76 fois la même poutine de chaîne).
52 +- **Options & formats capturés** avec leurs suppléments (« Mini +3,70 $ ») —
53 + ils changent le prix réel.
54 +- **Historique des prix par item** (`item_price_log`) à chaque changement.
55 +- **Alerte de dérive** : chute anormale du volume d'une source ou du nombre
56 + d'items d'un menu → retraits suspendus / ancien menu conservé.
57 +
58 +## Démarrage
59 +
60 +```bash
61 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
62 +cd frontend && npm install && npm run build && cd ..
63 +
64 +.venv/bin/python run.py sync # synchronise (+ géocode + dédup)
65 +.venv/bin/python run.py serve 8115 # API + frontend sur :8115
66 +.venv/bin/python run.py watch 168 # boucle hebdomadaire
67 +.venv/bin/python -m pytest tests/ # 41 tests (fixtures réelles hors ligne)
68 +```
69 +
70 +## Architecture (patron Lou·Ka)
71 +
72 +```
73 +restoka/
74 +├── connectors/ # un module par source/plateforme, auto-enregistrés
75 +│ ├── base.py # requests direct / Firecrawl / Scrapfly + cache détail
76 +│ └── ueat.py # UEAT (templatisé) — API GraphQL, prix TAKEOUT réels
77 +├── schema.py # Restaurant + menu imbriqué (sections→items→options) + finalize()
78 +├── normalize.py # prix, cuisines, type d'établissement, diètes, $-$$$$
79 +├── regions.py # ville → 17 régions (+ repli postal, + repli lat/lng)
80 +├── db.py # SQLite : restos, menus PAR CONTEXTE, historique de prix
81 +├── ingest.py # pipeline sync → geocode → dedup (délai de grâce, alertes)
82 +├── dedup.py # identité resto inter-sources (succursales jamais fusionnées)
83 +├── geocode.py # Nominatim + Adresses Québec, cache persistant
84 +└── web.py # FastAPI + frontend React (frontend/dist)
85 +frontend/ # Vite + React 18 + TS — design éditorial sharp Groupe KA
86 +```
87 +
88 +## Sources connectées
89 +
90 +Registre : `data/sources.json` (à consulter AVANT d'ajouter quoi que ce soit).
91 +
92 +| Source | Palier | Contexte de prix | Couverture |
93 +|--------|--------|------------------|------------|
94 +| **UEAT** (commande en ligne, API GraphQL anonyme) | 1 | `takeout` (prix réels) | 19 intégrations : Sushi Shop (141), Poulet Rouge (82), Valentine (76), Normandin (44), Chocolats Favoris (41), Allô mon Coco (34), Thaïzone (26), Scores (25), Chez Ashton (23), Pacini (20), Tutti Frutti, Mandy's, Panda Boba-T, Ryu Sushi, Impérial, Pizza Mia, Station W, Le Super Qualité, Château Frontenac |
95 +| St-Hubert | 6 | `takeout` (visé) | à faire — anti-bot Akamai, voir `data/sources.json` |
96 +
97 +## Déploiement
98 +
99 +Node **M3U96b** (MacLustr), `~/apps/resto-ka`, PM2 : `resto-ka` (:8115) +
100 +`resto-ka-ngrok` (→ https://www.resto-ka.com) + `resto-ka-sync` (hebdo).
101 +Registre cross-session : `~/Desktop/cluster-skill/cluster-deployments.json`.
102 +
103 +---
104 +
105 +*Resto·Ka — plateforme du [Groupe KA](https://www.groupe-ka.com). Auteur :
106 +Simon-Pierre Boucher \<contact@spboucher.ai\>.*
added data/sources.json +54 −0
@@ -0,0 +1,54 @@
1 +{
2 + "_comment": "Resto·Ka — Registre des sources (restaurants du Québec, menus & prix). Auteur : Simon-Pierre Boucher — contact@spboucher.ai. Vérifier ce registre AVANT d'ajouter une source ou un resto (CLAUDE.md §0.4).",
3 + "sources": [
4 + {
5 + "id": "ueat",
6 + "name": "UEAT (commande en ligne)",
7 + "url": "https://ueat.io",
8 + "platform": "ueat",
9 + "connector": "ueat",
10 + "tier": 1,
11 + "price_context": "takeout",
12 + "extraction": "API GraphQL interne (api.ueat.io/graphql), auth anonyme par GUID d'intégration — la même API que le widget officiel de commande",
13 + "acces_legal": "Source de première partie : plateforme de commande des restos eux-mêmes, prix réels non majorés (CLAUDE.md §9 Palier 1, §15). API anonyme publique, throttling poli 0,35 s, User-Agent identifiable RestoKaBot.",
14 + "cadence": "hebdomadaire",
15 + "status": "actif",
16 + "integrations": [
17 + { "key": "4268100d-6470-40b9-a755-e11342c91d6d", "label": "Poulet Rouge (chaîne)" },
18 + { "key": "01d1c391-59cf-45a3-b051-7b2f9a623858", "label": "Fairmont Le Château Frontenac (Québec)" },
19 + { "key": "b7282ce3-95cc-43ff-80b5-0cca8a7e4778", "label": "Allô mon Coco (chaîne, déjeuner-brunch)" },
20 + { "key": "b1c2c004-bcff-4eb0-b3b9-d3bc5c8799a3", "label": "Restaurant Impérial (Brossard, Pointe-Claire)" },
21 + { "key": "2f51961a-f378-4812-a31a-a0b45da3f87e", "label": "Pizza Mia (Laval)" },
22 + { "key": "1b432c57-98d6-4681-a560-d1b7bfafdd6f", "label": "Station W (Montréal, café)" },
23 + { "key": "ff376cab-30e2-4cce-9999-ed755583c7f5", "label": "Chocolats Favoris (chaîne, dessert)" },
24 + { "key": "c5548a05-1776-49ac-9c9f-2ac8c998eed4", "label": "Le Super Qualité (Montréal, indien)" },
25 + { "key": "d84801b5-66f5-44b3-b0dc-52c0365f571c", "label": "Ryu Sushi (Montréal)" },
26 + { "key": "87c690ed-be3c-4a5d-b58b-676235faa9c1", "label": "Panda Boba-T (chaîne, bubble tea)" },
27 + { "key": "970d8e90-55ca-4655-9631-a8f9af08f4b7", "label": "Chez Ashton (23 succ., poutine)" },
28 + { "key": "e473d88d-b405-41ee-9ee8-2aa628eee604", "label": "Thaïzone (26 succ. QC)" },
29 + { "key": "f89d74e1-c2ab-4ace-8bce-39069900f5b4", "label": "Normandin (44 succ., familial)" },
30 + { "key": "e15b2545-2f2a-4e5f-baa6-00ab849cfa16", "label": "Valentine (76 succ. QC, casse-croûte)" },
31 + { "key": "657f8ce3-d288-4245-aecf-7ca017364b8e", "label": "Tutti Frutti (9 succ. QC, déjeuner)" },
32 + { "key": "00f8d52b-7aaf-4bcf-8722-6d538aad5a9a", "label": "Pacini (20 succ. QC, italien)" },
33 + { "key": "d679a49f-0a25-4694-82f2-56073a8b8790", "label": "Scores (25 succ., rôtisserie)" },
34 + { "key": "1696ecd3-d4ea-4224-9f98-9299ebcb339c", "label": "Mandy's (9 succ. QC, salades)" },
35 + { "key": "dce3e009-8038-47bc-8d5e-f336458e573f", "label": "Sushi Shop (141 succ. QC)" },
36 + { "key": "e8015de3-09ac-4cd2-a18d-0820dbe356ed", "label": "(intégration retirée chez UEAT)", "status": "inactif" },
37 + { "key": "dfde098b-ee0f-46a3-b1ad-09ec2acb54b9", "label": "(intégration retirée chez UEAT)", "status": "inactif" }
38 + ]
39 + },
40 + {
41 + "id": "sthubert",
42 + "name": "St-Hubert (commande en ligne)",
43 + "url": "https://commande.st-hubert.com",
44 + "platform": "st-hubert",
45 + "connector": null,
46 + "tier": 6,
47 + "price_context": "takeout",
48 + "extraction": "À déterminer — Akamai anti-bot sur st-hubert.com ; commande.st-hubert.com rend une page vide même via Scrapfly ASP (flux JS multi-étapes, sélection de succursale requise). Prochain essai : rejouer les XHR internes après sélection d'une succursale (js_scenario Scrapfly).",
49 + "acces_legal": "Site de commande de la chaîne elle-même (prix réels). À industrialiser prudemment (CLAUDE.md §15).",
50 + "cadence": "hebdomadaire (visé)",
51 + "status": "à faire"
52 + }
53 + ]
54 +}
added docs/screenshots/accueil.png +0 −0

Binary file not shown.

added docs/screenshots/fiche.png +0 −0

Binary file not shown.

added docs/screenshots/plats.png +0 −0

Binary file not shown.

added docs/screenshots/stats.png +0 −0

Binary file not shown.

added frontend/index.html +27 −0
@@ -0,0 +1,27 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 + File: index.html
5 + Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix).
6 +---------------------------------------------------------------------------- -->
7 +<html lang="fr">
8 + <head>
9 + <meta charset="UTF-8" />
10 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
11 + <title>Resto-Ka — Tous les restaurants du Québec, menus et prix</title>
12 + <meta name="description" content="Resto-Ka agrège les restaurants du Québec avec leurs menus complets et leurs prix réels — comparables, cherchables, dans les 17 régions. Chaque resto, chaque plat, chaque prix." />
13 + <meta name="theme-color" content="#ffffff" />
14 + <meta name="mobile-web-app-capable" content="yes" />
15 + <meta name="apple-mobile-web-app-capable" content="yes" />
16 + <meta name="apple-mobile-web-app-status-bar-style" content="default" />
17 + <meta name="apple-mobile-web-app-title" content="Resto-Ka" />
18 + <link rel="preconnect" href="https://fonts.googleapis.com" />
19 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
20 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
21 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Ccircle cx='32' cy='32' r='21' fill='none' stroke='%23d9f26b' stroke-width='5'/%3E%3Ccircle cx='32' cy='32' r='9.5' fill='%23d9f26b'/%3E%3Cpath d='M13 20 v10 M9.5 20 v6 M16.5 20 v6' stroke='%23f5f3ee' stroke-width='2.6' stroke-linecap='round'/%3E%3Cpath d='M51.5 20 v24' stroke='%23f5f3ee' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E" />
22 + </head>
23 + <body>
24 + <div id="root"></div>
25 + <script type="module" src="/src/main.tsx"></script>
26 + </body>
27 +</html>
added frontend/package-lock.json +1834 −0
@@ -0,0 +1,1834 @@
1 +{
2 + "name": "resto-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "resto-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "react": "^18.3.1",
12 + "react-dom": "^18.3.1",
13 + "react-router-dom": "^6.26.0"
14 + },
15 + "devDependencies": {
16 + "@types/react": "^18.3.3",
17 + "@types/react-dom": "^18.3.0",
18 + "@vitejs/plugin-react": "^4.3.1",
19 + "typescript": "^5.5.4",
20 + "vite": "^5.4.0"
21 + }
22 + },
23 + "node_modules/@babel/code-frame": {
24 + "version": "7.29.7",
25 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
26 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
27 + "dev": true,
28 + "license": "MIT",
29 + "dependencies": {
30 + "@babel/helper-validator-identifier": "^7.29.7",
31 + "js-tokens": "^4.0.0",
32 + "picocolors": "^1.1.1"
33 + },
34 + "engines": {
35 + "node": ">=6.9.0"
36 + }
37 + },
38 + "node_modules/@babel/compat-data": {
39 + "version": "7.29.7",
40 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
41 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
42 + "dev": true,
43 + "license": "MIT",
44 + "engines": {
45 + "node": ">=6.9.0"
46 + }
47 + },
48 + "node_modules/@babel/core": {
49 + "version": "7.29.7",
50 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
51 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
52 + "dev": true,
53 + "license": "MIT",
54 + "dependencies": {
55 + "@babel/code-frame": "^7.29.7",
56 + "@babel/generator": "^7.29.7",
57 + "@babel/helper-compilation-targets": "^7.29.7",
58 + "@babel/helper-module-transforms": "^7.29.7",
59 + "@babel/helpers": "^7.29.7",
60 + "@babel/parser": "^7.29.7",
61 + "@babel/template": "^7.29.7",
62 + "@babel/traverse": "^7.29.7",
63 + "@babel/types": "^7.29.7",
64 + "@jridgewell/remapping": "^2.3.5",
65 + "convert-source-map": "^2.0.0",
66 + "debug": "^4.1.0",
67 + "gensync": "^1.0.0-beta.2",
68 + "json5": "^2.2.3",
69 + "semver": "^6.3.1"
70 + },
71 + "engines": {
72 + "node": ">=6.9.0"
73 + },
74 + "funding": {
75 + "type": "opencollective",
76 + "url": "https://opencollective.com/babel"
77 + }
78 + },
79 + "node_modules/@babel/generator": {
80 + "version": "7.29.8",
81 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
82 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
83 + "dev": true,
84 + "license": "MIT",
85 + "dependencies": {
86 + "@babel/parser": "^7.29.8",
87 + "@babel/types": "^7.29.8",
88 + "@jridgewell/gen-mapping": "^0.3.12",
89 + "@jridgewell/trace-mapping": "^0.3.28",
90 + "jsesc": "^3.0.2"
91 + },
92 + "engines": {
93 + "node": ">=6.9.0"
94 + }
95 + },
96 + "node_modules/@babel/helper-compilation-targets": {
97 + "version": "7.29.7",
98 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
99 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
100 + "dev": true,
101 + "license": "MIT",
102 + "dependencies": {
103 + "@babel/compat-data": "^7.29.7",
104 + "@babel/helper-validator-option": "^7.29.7",
105 + "browserslist": "^4.24.0",
106 + "lru-cache": "^5.1.1",
107 + "semver": "^6.3.1"
108 + },
109 + "engines": {
110 + "node": ">=6.9.0"
111 + }
112 + },
113 + "node_modules/@babel/helper-globals": {
114 + "version": "7.29.7",
115 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
116 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
117 + "dev": true,
118 + "license": "MIT",
119 + "engines": {
120 + "node": ">=6.9.0"
121 + }
122 + },
123 + "node_modules/@babel/helper-module-imports": {
124 + "version": "7.29.7",
125 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
126 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
127 + "dev": true,
128 + "license": "MIT",
129 + "dependencies": {
130 + "@babel/traverse": "^7.29.7",
131 + "@babel/types": "^7.29.7"
132 + },
133 + "engines": {
134 + "node": ">=6.9.0"
135 + }
136 + },
137 + "node_modules/@babel/helper-module-transforms": {
138 + "version": "7.29.7",
139 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
140 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
141 + "dev": true,
142 + "license": "MIT",
143 + "dependencies": {
144 + "@babel/helper-module-imports": "^7.29.7",
145 + "@babel/helper-validator-identifier": "^7.29.7",
146 + "@babel/traverse": "^7.29.7"
147 + },
148 + "engines": {
149 + "node": ">=6.9.0"
150 + },
151 + "peerDependencies": {
152 + "@babel/core": "^7.0.0"
153 + }
154 + },
155 + "node_modules/@babel/helper-plugin-utils": {
156 + "version": "7.29.7",
157 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
158 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
159 + "dev": true,
160 + "license": "MIT",
161 + "engines": {
162 + "node": ">=6.9.0"
163 + }
164 + },
165 + "node_modules/@babel/helper-string-parser": {
166 + "version": "7.29.7",
167 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
168 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
169 + "dev": true,
170 + "license": "MIT",
171 + "engines": {
172 + "node": ">=6.9.0"
173 + }
174 + },
175 + "node_modules/@babel/helper-validator-identifier": {
176 + "version": "7.29.7",
177 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
178 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
179 + "dev": true,
180 + "license": "MIT",
181 + "engines": {
182 + "node": ">=6.9.0"
183 + }
184 + },
185 + "node_modules/@babel/helper-validator-option": {
186 + "version": "7.29.7",
187 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
188 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
189 + "dev": true,
190 + "license": "MIT",
191 + "engines": {
192 + "node": ">=6.9.0"
193 + }
194 + },
195 + "node_modules/@babel/helpers": {
196 + "version": "7.29.7",
197 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
198 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
199 + "dev": true,
200 + "license": "MIT",
201 + "dependencies": {
202 + "@babel/template": "^7.29.7",
203 + "@babel/types": "^7.29.7"
204 + },
205 + "engines": {
206 + "node": ">=6.9.0"
207 + }
208 + },
209 + "node_modules/@babel/parser": {
210 + "version": "7.29.8",
211 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
212 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
213 + "dev": true,
214 + "license": "MIT",
215 + "dependencies": {
216 + "@babel/types": "^7.29.8"
217 + },
218 + "bin": {
219 + "parser": "bin/babel-parser.js"
220 + },
221 + "engines": {
222 + "node": ">=6.0.0"
223 + }
224 + },
225 + "node_modules/@babel/plugin-transform-react-jsx-self": {
226 + "version": "7.29.7",
227 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
228 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
229 + "dev": true,
230 + "license": "MIT",
231 + "dependencies": {
232 + "@babel/helper-plugin-utils": "^7.29.7"
233 + },
234 + "engines": {
235 + "node": ">=6.9.0"
236 + },
237 + "peerDependencies": {
238 + "@babel/core": "^7.0.0-0"
239 + }
240 + },
241 + "node_modules/@babel/plugin-transform-react-jsx-source": {
242 + "version": "7.29.7",
243 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
244 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
245 + "dev": true,
246 + "license": "MIT",
247 + "dependencies": {
248 + "@babel/helper-plugin-utils": "^7.29.7"
249 + },
250 + "engines": {
251 + "node": ">=6.9.0"
252 + },
253 + "peerDependencies": {
254 + "@babel/core": "^7.0.0-0"
255 + }
256 + },
257 + "node_modules/@babel/template": {
258 + "version": "7.29.7",
259 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
260 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
261 + "dev": true,
262 + "license": "MIT",
263 + "dependencies": {
264 + "@babel/code-frame": "^7.29.7",
265 + "@babel/parser": "^7.29.7",
266 + "@babel/types": "^7.29.7"
267 + },
268 + "engines": {
269 + "node": ">=6.9.0"
270 + }
271 + },
272 + "node_modules/@babel/traverse": {
273 + "version": "7.29.8",
274 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
275 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
276 + "dev": true,
277 + "license": "MIT",
278 + "dependencies": {
279 + "@babel/code-frame": "^7.29.7",
280 + "@babel/generator": "^7.29.8",
281 + "@babel/helper-globals": "^7.29.7",
282 + "@babel/parser": "^7.29.8",
283 + "@babel/template": "^7.29.7",
284 + "@babel/types": "^7.29.8",
285 + "debug": "^4.3.1"
286 + },
287 + "engines": {
288 + "node": ">=6.9.0"
289 + }
290 + },
291 + "node_modules/@babel/types": {
292 + "version": "7.29.8",
293 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
294 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
295 + "dev": true,
296 + "license": "MIT",
297 + "dependencies": {
298 + "@babel/helper-string-parser": "^7.29.7",
299 + "@babel/helper-validator-identifier": "^7.29.7"
300 + },
301 + "engines": {
302 + "node": ">=6.9.0"
303 + }
304 + },
305 + "node_modules/@esbuild/aix-ppc64": {
306 + "version": "0.21.5",
307 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
308 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
309 + "cpu": [
310 + "ppc64"
311 + ],
312 + "dev": true,
313 + "license": "MIT",
314 + "optional": true,
315 + "os": [
316 + "aix"
317 + ],
318 + "engines": {
319 + "node": ">=12"
320 + }
321 + },
322 + "node_modules/@esbuild/android-arm": {
323 + "version": "0.21.5",
324 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
325 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
326 + "cpu": [
327 + "arm"
328 + ],
329 + "dev": true,
330 + "license": "MIT",
331 + "optional": true,
332 + "os": [
333 + "android"
334 + ],
335 + "engines": {
336 + "node": ">=12"
337 + }
338 + },
339 + "node_modules/@esbuild/android-arm64": {
340 + "version": "0.21.5",
341 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
342 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
343 + "cpu": [
344 + "arm64"
345 + ],
346 + "dev": true,
347 + "license": "MIT",
348 + "optional": true,
349 + "os": [
350 + "android"
351 + ],
352 + "engines": {
353 + "node": ">=12"
354 + }
355 + },
356 + "node_modules/@esbuild/android-x64": {
357 + "version": "0.21.5",
358 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
359 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
360 + "cpu": [
361 + "x64"
362 + ],
363 + "dev": true,
364 + "license": "MIT",
365 + "optional": true,
366 + "os": [
367 + "android"
368 + ],
369 + "engines": {
370 + "node": ">=12"
371 + }
372 + },
373 + "node_modules/@esbuild/darwin-arm64": {
374 + "version": "0.21.5",
375 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
376 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
377 + "cpu": [
378 + "arm64"
379 + ],
380 + "dev": true,
381 + "license": "MIT",
382 + "optional": true,
383 + "os": [
384 + "darwin"
385 + ],
386 + "engines": {
387 + "node": ">=12"
388 + }
389 + },
390 + "node_modules/@esbuild/darwin-x64": {
391 + "version": "0.21.5",
392 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
393 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
394 + "cpu": [
395 + "x64"
396 + ],
397 + "dev": true,
398 + "license": "MIT",
399 + "optional": true,
400 + "os": [
401 + "darwin"
402 + ],
403 + "engines": {
404 + "node": ">=12"
405 + }
406 + },
407 + "node_modules/@esbuild/freebsd-arm64": {
408 + "version": "0.21.5",
409 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
410 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
411 + "cpu": [
412 + "arm64"
413 + ],
414 + "dev": true,
415 + "license": "MIT",
416 + "optional": true,
417 + "os": [
418 + "freebsd"
419 + ],
420 + "engines": {
421 + "node": ">=12"
422 + }
423 + },
424 + "node_modules/@esbuild/freebsd-x64": {
425 + "version": "0.21.5",
426 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
427 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
428 + "cpu": [
429 + "x64"
430 + ],
431 + "dev": true,
432 + "license": "MIT",
433 + "optional": true,
434 + "os": [
435 + "freebsd"
436 + ],
437 + "engines": {
438 + "node": ">=12"
439 + }
440 + },
441 + "node_modules/@esbuild/linux-arm": {
442 + "version": "0.21.5",
443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
444 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
445 + "cpu": [
446 + "arm"
447 + ],
448 + "dev": true,
449 + "license": "MIT",
450 + "optional": true,
451 + "os": [
452 + "linux"
453 + ],
454 + "engines": {
455 + "node": ">=12"
456 + }
457 + },
458 + "node_modules/@esbuild/linux-arm64": {
459 + "version": "0.21.5",
460 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
461 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
462 + "cpu": [
463 + "arm64"
464 + ],
465 + "dev": true,
466 + "license": "MIT",
467 + "optional": true,
468 + "os": [
469 + "linux"
470 + ],
471 + "engines": {
472 + "node": ">=12"
473 + }
474 + },
475 + "node_modules/@esbuild/linux-ia32": {
476 + "version": "0.21.5",
477 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
478 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
479 + "cpu": [
480 + "ia32"
481 + ],
482 + "dev": true,
483 + "license": "MIT",
484 + "optional": true,
485 + "os": [
486 + "linux"
487 + ],
488 + "engines": {
489 + "node": ">=12"
490 + }
491 + },
492 + "node_modules/@esbuild/linux-loong64": {
493 + "version": "0.21.5",
494 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
495 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
496 + "cpu": [
497 + "loong64"
498 + ],
499 + "dev": true,
500 + "license": "MIT",
501 + "optional": true,
502 + "os": [
503 + "linux"
504 + ],
505 + "engines": {
506 + "node": ">=12"
507 + }
508 + },
509 + "node_modules/@esbuild/linux-mips64el": {
510 + "version": "0.21.5",
511 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
512 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
513 + "cpu": [
514 + "mips64el"
515 + ],
516 + "dev": true,
517 + "license": "MIT",
518 + "optional": true,
519 + "os": [
520 + "linux"
521 + ],
522 + "engines": {
523 + "node": ">=12"
524 + }
525 + },
526 + "node_modules/@esbuild/linux-ppc64": {
527 + "version": "0.21.5",
528 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
529 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
530 + "cpu": [
531 + "ppc64"
532 + ],
533 + "dev": true,
534 + "license": "MIT",
535 + "optional": true,
536 + "os": [
537 + "linux"
538 + ],
539 + "engines": {
540 + "node": ">=12"
541 + }
542 + },
543 + "node_modules/@esbuild/linux-riscv64": {
544 + "version": "0.21.5",
545 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
546 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
547 + "cpu": [
548 + "riscv64"
549 + ],
550 + "dev": true,
551 + "license": "MIT",
552 + "optional": true,
553 + "os": [
554 + "linux"
555 + ],
556 + "engines": {
557 + "node": ">=12"
558 + }
559 + },
560 + "node_modules/@esbuild/linux-s390x": {
561 + "version": "0.21.5",
562 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
563 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
564 + "cpu": [
565 + "s390x"
566 + ],
567 + "dev": true,
568 + "license": "MIT",
569 + "optional": true,
570 + "os": [
571 + "linux"
572 + ],
573 + "engines": {
574 + "node": ">=12"
575 + }
576 + },
577 + "node_modules/@esbuild/linux-x64": {
578 + "version": "0.21.5",
579 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
580 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
581 + "cpu": [
582 + "x64"
583 + ],
584 + "dev": true,
585 + "license": "MIT",
586 + "optional": true,
587 + "os": [
588 + "linux"
589 + ],
590 + "engines": {
591 + "node": ">=12"
592 + }
593 + },
594 + "node_modules/@esbuild/netbsd-x64": {
595 + "version": "0.21.5",
596 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
597 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
598 + "cpu": [
599 + "x64"
600 + ],
601 + "dev": true,
602 + "license": "MIT",
603 + "optional": true,
604 + "os": [
605 + "netbsd"
606 + ],
607 + "engines": {
608 + "node": ">=12"
609 + }
610 + },
611 + "node_modules/@esbuild/openbsd-x64": {
612 + "version": "0.21.5",
613 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
614 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
615 + "cpu": [
616 + "x64"
617 + ],
618 + "dev": true,
619 + "license": "MIT",
620 + "optional": true,
621 + "os": [
622 + "openbsd"
623 + ],
624 + "engines": {
625 + "node": ">=12"
626 + }
627 + },
628 + "node_modules/@esbuild/sunos-x64": {
629 + "version": "0.21.5",
630 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
631 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
632 + "cpu": [
633 + "x64"
634 + ],
635 + "dev": true,
636 + "license": "MIT",
637 + "optional": true,
638 + "os": [
639 + "sunos"
640 + ],
641 + "engines": {
642 + "node": ">=12"
643 + }
644 + },
645 + "node_modules/@esbuild/win32-arm64": {
646 + "version": "0.21.5",
647 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
648 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
649 + "cpu": [
650 + "arm64"
651 + ],
652 + "dev": true,
653 + "license": "MIT",
654 + "optional": true,
655 + "os": [
656 + "win32"
657 + ],
658 + "engines": {
659 + "node": ">=12"
660 + }
661 + },
662 + "node_modules/@esbuild/win32-ia32": {
663 + "version": "0.21.5",
664 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
665 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
666 + "cpu": [
667 + "ia32"
668 + ],
669 + "dev": true,
670 + "license": "MIT",
671 + "optional": true,
672 + "os": [
673 + "win32"
674 + ],
675 + "engines": {
676 + "node": ">=12"
677 + }
678 + },
679 + "node_modules/@esbuild/win32-x64": {
680 + "version": "0.21.5",
681 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
682 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
683 + "cpu": [
684 + "x64"
685 + ],
686 + "dev": true,
687 + "license": "MIT",
688 + "optional": true,
689 + "os": [
690 + "win32"
691 + ],
692 + "engines": {
693 + "node": ">=12"
694 + }
695 + },
696 + "node_modules/@jridgewell/gen-mapping": {
697 + "version": "0.3.13",
698 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
699 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
700 + "dev": true,
701 + "license": "MIT",
702 + "dependencies": {
703 + "@jridgewell/sourcemap-codec": "^1.5.0",
704 + "@jridgewell/trace-mapping": "^0.3.24"
705 + }
706 + },
707 + "node_modules/@jridgewell/remapping": {
708 + "version": "2.3.5",
709 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
710 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
711 + "dev": true,
712 + "license": "MIT",
713 + "dependencies": {
714 + "@jridgewell/gen-mapping": "^0.3.5",
715 + "@jridgewell/trace-mapping": "^0.3.24"
716 + }
717 + },
718 + "node_modules/@jridgewell/resolve-uri": {
719 + "version": "3.1.2",
720 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
721 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
722 + "dev": true,
723 + "license": "MIT",
724 + "engines": {
725 + "node": ">=6.0.0"
726 + }
727 + },
728 + "node_modules/@jridgewell/sourcemap-codec": {
729 + "version": "1.5.5",
730 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
731 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
732 + "dev": true,
733 + "license": "MIT"
734 + },
735 + "node_modules/@jridgewell/trace-mapping": {
736 + "version": "0.3.31",
737 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
738 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
739 + "dev": true,
740 + "license": "MIT",
741 + "dependencies": {
742 + "@jridgewell/resolve-uri": "^3.1.0",
743 + "@jridgewell/sourcemap-codec": "^1.4.14"
744 + }
745 + },
746 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
747 + "version": "1.5.1",
748 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
749 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
750 + "cpu": [
751 + "x64"
752 + ],
753 + "dev": true,
754 + "libc": [
755 + "glibc"
756 + ],
757 + "license": "MIT",
758 + "optional": true,
759 + "os": [
760 + "linux"
761 + ],
762 + "engines": {
763 + "node": "^22.20 || ^24.12 || >=25"
764 + }
765 + },
766 + "node_modules/@remix-run/router": {
767 + "version": "1.23.3",
768 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
769 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
770 + "license": "MIT",
771 + "engines": {
772 + "node": ">=14.0.0"
773 + }
774 + },
775 + "node_modules/@rolldown/pluginutils": {
776 + "version": "1.0.0-beta.27",
777 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
778 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
779 + "dev": true,
780 + "license": "MIT"
781 + },
782 + "node_modules/@rollup/rollup-android-arm-eabi": {
783 + "version": "4.62.4",
784 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
785 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
786 + "cpu": [
787 + "arm"
788 + ],
789 + "dev": true,
790 + "license": "MIT",
791 + "optional": true,
792 + "os": [
793 + "android"
794 + ]
795 + },
796 + "node_modules/@rollup/rollup-android-arm64": {
797 + "version": "4.62.4",
798 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
799 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
800 + "cpu": [
801 + "arm64"
802 + ],
803 + "dev": true,
804 + "license": "MIT",
805 + "optional": true,
806 + "os": [
807 + "android"
808 + ]
809 + },
810 + "node_modules/@rollup/rollup-darwin-arm64": {
811 + "version": "4.62.4",
812 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
813 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
814 + "cpu": [
815 + "arm64"
816 + ],
817 + "dev": true,
818 + "license": "MIT",
819 + "optional": true,
820 + "os": [
821 + "darwin"
822 + ]
823 + },
824 + "node_modules/@rollup/rollup-darwin-x64": {
825 + "version": "4.62.4",
826 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
827 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
828 + "cpu": [
829 + "x64"
830 + ],
831 + "dev": true,
832 + "license": "MIT",
833 + "optional": true,
834 + "os": [
835 + "darwin"
836 + ]
837 + },
838 + "node_modules/@rollup/rollup-freebsd-arm64": {
839 + "version": "4.62.4",
840 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
841 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
842 + "cpu": [
843 + "arm64"
844 + ],
845 + "dev": true,
846 + "license": "MIT",
847 + "optional": true,
848 + "os": [
849 + "freebsd"
850 + ]
851 + },
852 + "node_modules/@rollup/rollup-freebsd-x64": {
853 + "version": "4.62.4",
854 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
855 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
856 + "cpu": [
857 + "x64"
858 + ],
859 + "dev": true,
860 + "license": "MIT",
861 + "optional": true,
862 + "os": [
863 + "freebsd"
864 + ]
865 + },
866 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
867 + "version": "4.62.4",
868 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
869 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
870 + "cpu": [
871 + "arm"
872 + ],
873 + "dev": true,
874 + "libc": [
875 + "glibc"
876 + ],
877 + "license": "MIT",
878 + "optional": true,
879 + "os": [
880 + "linux"
881 + ]
882 + },
883 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
884 + "version": "4.62.4",
885 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
886 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
887 + "cpu": [
888 + "arm"
889 + ],
890 + "dev": true,
891 + "libc": [
892 + "musl"
893 + ],
894 + "license": "MIT",
895 + "optional": true,
896 + "os": [
897 + "linux"
898 + ]
899 + },
900 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
901 + "version": "4.62.4",
902 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
903 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
904 + "cpu": [
905 + "arm64"
906 + ],
907 + "dev": true,
908 + "libc": [
909 + "glibc"
910 + ],
911 + "license": "MIT",
912 + "optional": true,
913 + "os": [
914 + "linux"
915 + ]
916 + },
917 + "node_modules/@rollup/rollup-linux-arm64-musl": {
918 + "version": "4.62.4",
919 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
920 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
921 + "cpu": [
922 + "arm64"
923 + ],
924 + "dev": true,
925 + "libc": [
926 + "musl"
927 + ],
928 + "license": "MIT",
929 + "optional": true,
930 + "os": [
931 + "linux"
932 + ]
933 + },
934 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
935 + "version": "4.62.4",
936 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
937 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
938 + "cpu": [
939 + "loong64"
940 + ],
941 + "dev": true,
942 + "libc": [
943 + "glibc"
944 + ],
945 + "license": "MIT",
946 + "optional": true,
947 + "os": [
948 + "linux"
949 + ]
950 + },
951 + "node_modules/@rollup/rollup-linux-loong64-musl": {
952 + "version": "4.62.4",
953 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
954 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
955 + "cpu": [
956 + "loong64"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "musl"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ]
967 + },
968 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
969 + "version": "4.62.4",
970 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
971 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
972 + "cpu": [
973 + "ppc64"
974 + ],
975 + "dev": true,
976 + "libc": [
977 + "glibc"
978 + ],
979 + "license": "MIT",
980 + "optional": true,
981 + "os": [
982 + "linux"
983 + ]
984 + },
985 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
986 + "version": "4.62.4",
987 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
988 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
989 + "cpu": [
990 + "ppc64"
991 + ],
992 + "dev": true,
993 + "libc": [
994 + "musl"
995 + ],
996 + "license": "MIT",
997 + "optional": true,
998 + "os": [
999 + "linux"
1000 + ]
1001 + },
1002 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1003 + "version": "4.62.4",
1004 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1005 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1006 + "cpu": [
1007 + "riscv64"
1008 + ],
1009 + "dev": true,
1010 + "libc": [
1011 + "glibc"
1012 + ],
1013 + "license": "MIT",
1014 + "optional": true,
1015 + "os": [
1016 + "linux"
1017 + ]
1018 + },
1019 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1020 + "version": "4.62.4",
1021 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1022 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1023 + "cpu": [
1024 + "riscv64"
1025 + ],
1026 + "dev": true,
1027 + "libc": [
1028 + "musl"
1029 + ],
1030 + "license": "MIT",
1031 + "optional": true,
1032 + "os": [
1033 + "linux"
1034 + ]
1035 + },
1036 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1037 + "version": "4.62.4",
1038 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1039 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1040 + "cpu": [
1041 + "s390x"
1042 + ],
1043 + "dev": true,
1044 + "libc": [
1045 + "glibc"
1046 + ],
1047 + "license": "MIT",
1048 + "optional": true,
1049 + "os": [
1050 + "linux"
1051 + ]
1052 + },
1053 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1054 + "version": "4.62.4",
1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1056 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1057 + "cpu": [
1058 + "x64"
1059 + ],
1060 + "dev": true,
1061 + "libc": [
1062 + "glibc"
1063 + ],
1064 + "license": "MIT",
1065 + "optional": true,
1066 + "os": [
1067 + "linux"
1068 + ]
1069 + },
1070 + "node_modules/@rollup/rollup-linux-x64-musl": {
1071 + "version": "4.62.4",
1072 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1073 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1074 + "cpu": [
1075 + "x64"
1076 + ],
1077 + "dev": true,
1078 + "libc": [
1079 + "musl"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "linux"
1085 + ]
1086 + },
1087 + "node_modules/@rollup/rollup-openbsd-x64": {
1088 + "version": "4.62.4",
1089 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1090 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1091 + "cpu": [
1092 + "x64"
1093 + ],
1094 + "dev": true,
1095 + "license": "MIT",
1096 + "optional": true,
1097 + "os": [
1098 + "openbsd"
1099 + ]
1100 + },
1101 + "node_modules/@rollup/rollup-openharmony-arm64": {
1102 + "version": "4.62.4",
1103 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1104 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1105 + "cpu": [
1106 + "arm64"
1107 + ],
1108 + "dev": true,
1109 + "license": "MIT",
1110 + "optional": true,
1111 + "os": [
1112 + "openharmony"
1113 + ]
1114 + },
1115 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1116 + "version": "4.62.4",
1117 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1118 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1119 + "cpu": [
1120 + "arm64"
1121 + ],
1122 + "dev": true,
1123 + "license": "MIT",
1124 + "optional": true,
1125 + "os": [
1126 + "win32"
1127 + ]
1128 + },
1129 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1130 + "version": "4.62.4",
1131 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1132 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1133 + "cpu": [
1134 + "ia32"
1135 + ],
1136 + "dev": true,
1137 + "license": "MIT",
1138 + "optional": true,
1139 + "os": [
1140 + "win32"
1141 + ]
1142 + },
1143 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1144 + "version": "4.62.4",
1145 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1146 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1147 + "cpu": [
1148 + "x64"
1149 + ],
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "optional": true,
1153 + "os": [
1154 + "win32"
1155 + ]
1156 + },
1157 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1158 + "version": "4.62.4",
1159 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1160 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1161 + "cpu": [
1162 + "x64"
1163 + ],
1164 + "dev": true,
1165 + "license": "MIT",
1166 + "optional": true,
1167 + "os": [
1168 + "win32"
1169 + ]
1170 + },
1171 + "node_modules/@types/babel__core": {
1172 + "version": "7.20.5",
1173 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1174 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1175 + "dev": true,
1176 + "license": "MIT",
1177 + "dependencies": {
1178 + "@babel/parser": "^7.20.7",
1179 + "@babel/types": "^7.20.7",
1180 + "@types/babel__generator": "*",
1181 + "@types/babel__template": "*",
1182 + "@types/babel__traverse": "*"
1183 + }
1184 + },
1185 + "node_modules/@types/babel__generator": {
1186 + "version": "7.27.0",
1187 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1188 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1189 + "dev": true,
1190 + "license": "MIT",
1191 + "dependencies": {
1192 + "@babel/types": "^7.0.0"
1193 + }
1194 + },
1195 + "node_modules/@types/babel__template": {
1196 + "version": "7.4.4",
1197 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1198 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1199 + "dev": true,
1200 + "license": "MIT",
1201 + "dependencies": {
1202 + "@babel/parser": "^7.1.0",
1203 + "@babel/types": "^7.0.0"
1204 + }
1205 + },
1206 + "node_modules/@types/babel__traverse": {
1207 + "version": "7.28.0",
1208 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1209 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1210 + "dev": true,
1211 + "license": "MIT",
1212 + "dependencies": {
1213 + "@babel/types": "^7.28.2"
1214 + }
1215 + },
1216 + "node_modules/@types/estree": {
1217 + "version": "1.0.9",
1218 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1219 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1220 + "dev": true,
1221 + "license": "MIT"
1222 + },
1223 + "node_modules/@types/prop-types": {
1224 + "version": "15.7.15",
1225 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1226 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1227 + "dev": true,
1228 + "license": "MIT"
1229 + },
1230 + "node_modules/@types/react": {
1231 + "version": "18.3.31",
1232 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1233 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1234 + "dev": true,
1235 + "license": "MIT",
1236 + "dependencies": {
1237 + "@types/prop-types": "*",
1238 + "csstype": "^3.2.2"
1239 + }
1240 + },
1241 + "node_modules/@types/react-dom": {
1242 + "version": "18.3.7",
1243 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1244 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1245 + "dev": true,
1246 + "license": "MIT",
1247 + "peerDependencies": {
1248 + "@types/react": "^18.0.0"
1249 + }
1250 + },
1251 + "node_modules/@vitejs/plugin-react": {
1252 + "version": "4.7.0",
1253 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1254 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1255 + "dev": true,
1256 + "license": "MIT",
1257 + "dependencies": {
1258 + "@babel/core": "^7.28.0",
1259 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1260 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1261 + "@rolldown/pluginutils": "1.0.0-beta.27",
1262 + "@types/babel__core": "^7.20.5",
1263 + "react-refresh": "^0.17.0"
1264 + },
1265 + "engines": {
1266 + "node": "^14.18.0 || >=16.0.0"
1267 + },
1268 + "peerDependencies": {
1269 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1270 + }
1271 + },
1272 + "node_modules/baseline-browser-mapping": {
1273 + "version": "2.11.15",
1274 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
1275 + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
1276 + "dev": true,
1277 + "license": "Apache-2.0",
1278 + "bin": {
1279 + "baseline-browser-mapping": "dist/cli.cjs"
1280 + },
1281 + "engines": {
1282 + "node": ">=6.0.0"
1283 + }
1284 + },
1285 + "node_modules/browserslist": {
1286 + "version": "4.28.8",
1287 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1288 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1289 + "dev": true,
1290 + "funding": [
1291 + {
1292 + "type": "opencollective",
1293 + "url": "https://opencollective.com/browserslist"
1294 + },
1295 + {
1296 + "type": "tidelift",
1297 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1298 + },
1299 + {
1300 + "type": "github",
1301 + "url": "https://github.com/sponsors/ai"
1302 + }
1303 + ],
1304 + "license": "MIT",
1305 + "dependencies": {
1306 + "baseline-browser-mapping": "^2.11.12",
1307 + "caniuse-lite": "^1.0.30001809",
1308 + "electron-to-chromium": "^1.5.402",
1309 + "node-releases": "^2.0.53",
1310 + "update-browserslist-db": "^1.3.0"
1311 + },
1312 + "bin": {
1313 + "browserslist": "cli.js"
1314 + },
1315 + "engines": {
1316 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1317 + }
1318 + },
1319 + "node_modules/caniuse-lite": {
1320 + "version": "1.0.30001809",
1321 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1322 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1323 + "dev": true,
1324 + "funding": [
1325 + {
1326 + "type": "opencollective",
1327 + "url": "https://opencollective.com/browserslist"
1328 + },
1329 + {
1330 + "type": "tidelift",
1331 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1332 + },
1333 + {
1334 + "type": "github",
1335 + "url": "https://github.com/sponsors/ai"
1336 + }
1337 + ],
1338 + "license": "CC-BY-4.0"
1339 + },
1340 + "node_modules/convert-source-map": {
1341 + "version": "2.0.0",
1342 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1343 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1344 + "dev": true,
1345 + "license": "MIT"
1346 + },
1347 + "node_modules/csstype": {
1348 + "version": "3.2.3",
1349 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1350 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1351 + "dev": true,
1352 + "license": "MIT"
1353 + },
1354 + "node_modules/debug": {
1355 + "version": "4.4.3",
1356 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1357 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1358 + "dev": true,
1359 + "license": "MIT",
1360 + "dependencies": {
1361 + "ms": "^2.1.3"
1362 + },
1363 + "engines": {
1364 + "node": ">=6.0"
1365 + },
1366 + "peerDependenciesMeta": {
1367 + "supports-color": {
1368 + "optional": true
1369 + }
1370 + }
1371 + },
1372 + "node_modules/electron-to-chromium": {
1373 + "version": "1.5.408",
1374 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz",
1375 + "integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==",
1376 + "dev": true,
1377 + "license": "ISC"
1378 + },
1379 + "node_modules/esbuild": {
1380 + "version": "0.21.5",
1381 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1382 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1383 + "dev": true,
1384 + "hasInstallScript": true,
1385 + "license": "MIT",
1386 + "bin": {
1387 + "esbuild": "bin/esbuild"
1388 + },
1389 + "engines": {
1390 + "node": ">=12"
1391 + },
1392 + "optionalDependencies": {
1393 + "@esbuild/aix-ppc64": "0.21.5",
1394 + "@esbuild/android-arm": "0.21.5",
1395 + "@esbuild/android-arm64": "0.21.5",
1396 + "@esbuild/android-x64": "0.21.5",
1397 + "@esbuild/darwin-arm64": "0.21.5",
1398 + "@esbuild/darwin-x64": "0.21.5",
1399 + "@esbuild/freebsd-arm64": "0.21.5",
1400 + "@esbuild/freebsd-x64": "0.21.5",
1401 + "@esbuild/linux-arm": "0.21.5",
1402 + "@esbuild/linux-arm64": "0.21.5",
1403 + "@esbuild/linux-ia32": "0.21.5",
1404 + "@esbuild/linux-loong64": "0.21.5",
1405 + "@esbuild/linux-mips64el": "0.21.5",
1406 + "@esbuild/linux-ppc64": "0.21.5",
1407 + "@esbuild/linux-riscv64": "0.21.5",
1408 + "@esbuild/linux-s390x": "0.21.5",
1409 + "@esbuild/linux-x64": "0.21.5",
1410 + "@esbuild/netbsd-x64": "0.21.5",
1411 + "@esbuild/openbsd-x64": "0.21.5",
1412 + "@esbuild/sunos-x64": "0.21.5",
1413 + "@esbuild/win32-arm64": "0.21.5",
1414 + "@esbuild/win32-ia32": "0.21.5",
1415 + "@esbuild/win32-x64": "0.21.5"
1416 + }
1417 + },
1418 + "node_modules/escalade": {
1419 + "version": "3.2.0",
1420 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1421 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1422 + "dev": true,
1423 + "license": "MIT",
1424 + "engines": {
1425 + "node": ">=6"
1426 + }
1427 + },
1428 + "node_modules/fsevents": {
1429 + "version": "2.3.3",
1430 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1431 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1432 + "dev": true,
1433 + "hasInstallScript": true,
1434 + "license": "MIT",
1435 + "optional": true,
1436 + "os": [
1437 + "darwin"
1438 + ],
1439 + "engines": {
1440 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1441 + }
1442 + },
1443 + "node_modules/gensync": {
1444 + "version": "1.0.0-beta.2",
1445 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1446 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1447 + "dev": true,
1448 + "license": "MIT",
1449 + "engines": {
1450 + "node": ">=6.9.0"
1451 + }
1452 + },
1453 + "node_modules/js-tokens": {
1454 + "version": "4.0.0",
1455 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1456 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1457 + "license": "MIT"
1458 + },
1459 + "node_modules/jsesc": {
1460 + "version": "3.1.0",
1461 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1462 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1463 + "dev": true,
1464 + "license": "MIT",
1465 + "bin": {
1466 + "jsesc": "bin/jsesc"
1467 + },
1468 + "engines": {
1469 + "node": ">=6"
1470 + }
1471 + },
1472 + "node_modules/json5": {
1473 + "version": "2.2.3",
1474 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1475 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1476 + "dev": true,
1477 + "license": "MIT",
1478 + "bin": {
1479 + "json5": "lib/cli.js"
1480 + },
1481 + "engines": {
1482 + "node": ">=6"
1483 + }
1484 + },
1485 + "node_modules/loose-envify": {
1486 + "version": "1.4.0",
1487 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1488 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1489 + "license": "MIT",
1490 + "dependencies": {
1491 + "js-tokens": "^3.0.0 || ^4.0.0"
1492 + },
1493 + "bin": {
1494 + "loose-envify": "cli.js"
1495 + }
1496 + },
1497 + "node_modules/lru-cache": {
1498 + "version": "5.1.1",
1499 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1500 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1501 + "dev": true,
1502 + "license": "ISC",
1503 + "dependencies": {
1504 + "yallist": "^3.0.2"
1505 + }
1506 + },
1507 + "node_modules/ms": {
1508 + "version": "2.1.3",
1509 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1510 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1511 + "dev": true,
1512 + "license": "MIT"
1513 + },
1514 + "node_modules/nanoid": {
1515 + "version": "3.3.18",
1516 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1517 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1518 + "dev": true,
1519 + "funding": [
1520 + {
1521 + "type": "github",
1522 + "url": "https://github.com/sponsors/ai"
1523 + }
1524 + ],
1525 + "license": "MIT",
1526 + "bin": {
1527 + "nanoid": "bin/nanoid.cjs"
1528 + },
1529 + "engines": {
1530 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1531 + }
1532 + },
1533 + "node_modules/node-releases": {
1534 + "version": "2.0.53",
1535 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1536 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1537 + "dev": true,
1538 + "license": "MIT",
1539 + "engines": {
1540 + "node": ">=18"
1541 + }
1542 + },
1543 + "node_modules/picocolors": {
1544 + "version": "1.1.1",
1545 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1546 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1547 + "dev": true,
1548 + "license": "ISC"
1549 + },
1550 + "node_modules/postcss": {
1551 + "version": "8.5.26",
1552 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1553 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1554 + "dev": true,
1555 + "funding": [
1556 + {
1557 + "type": "opencollective",
1558 + "url": "https://opencollective.com/postcss/"
1559 + },
1560 + {
1561 + "type": "tidelift",
1562 + "url": "https://tidelift.com/funding/github/npm/postcss"
1563 + },
1564 + {
1565 + "type": "github",
1566 + "url": "https://github.com/sponsors/ai"
1567 + }
1568 + ],
1569 + "license": "MIT",
1570 + "dependencies": {
1571 + "nanoid": "^3.3.17",
1572 + "picocolors": "^1.1.1",
1573 + "source-map-js": "^1.2.1"
1574 + },
1575 + "engines": {
1576 + "node": "^10 || ^12 || >=14"
1577 + }
1578 + },
1579 + "node_modules/react": {
1580 + "version": "18.3.1",
1581 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1582 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1583 + "license": "MIT",
1584 + "dependencies": {
1585 + "loose-envify": "^1.1.0"
1586 + },
1587 + "engines": {
1588 + "node": ">=0.10.0"
1589 + }
1590 + },
1591 + "node_modules/react-dom": {
1592 + "version": "18.3.1",
1593 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1594 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1595 + "license": "MIT",
1596 + "dependencies": {
1597 + "loose-envify": "^1.1.0",
1598 + "scheduler": "^0.23.2"
1599 + },
1600 + "peerDependencies": {
1601 + "react": "^18.3.1"
1602 + }
1603 + },
1604 + "node_modules/react-refresh": {
1605 + "version": "0.17.0",
1606 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1607 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1608 + "dev": true,
1609 + "license": "MIT",
1610 + "engines": {
1611 + "node": ">=0.10.0"
1612 + }
1613 + },
1614 + "node_modules/react-router": {
1615 + "version": "6.30.4",
1616 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1617 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1618 + "license": "MIT",
1619 + "dependencies": {
1620 + "@remix-run/router": "1.23.3"
1621 + },
1622 + "engines": {
1623 + "node": ">=14.0.0"
1624 + },
1625 + "peerDependencies": {
1626 + "react": ">=16.8"
1627 + }
1628 + },
1629 + "node_modules/react-router-dom": {
1630 + "version": "6.30.4",
1631 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1632 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1633 + "license": "MIT",
1634 + "dependencies": {
1635 + "@remix-run/router": "1.23.3",
1636 + "react-router": "6.30.4"
1637 + },
1638 + "engines": {
1639 + "node": ">=14.0.0"
1640 + },
1641 + "peerDependencies": {
1642 + "react": ">=16.8",
1643 + "react-dom": ">=16.8"
1644 + }
1645 + },
1646 + "node_modules/rollup": {
1647 + "version": "4.62.4",
1648 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1649 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1650 + "dev": true,
1651 + "license": "MIT",
1652 + "dependencies": {
1653 + "@types/estree": "1.0.9"
1654 + },
1655 + "bin": {
1656 + "rollup": "dist/bin/rollup"
1657 + },
1658 + "engines": {
1659 + "node": ">=18.0.0",
1660 + "npm": ">=8.0.0"
1661 + },
1662 + "optionalDependencies": {
1663 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1664 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1665 + "@rollup/rollup-android-arm64": "4.62.4",
1666 + "@rollup/rollup-darwin-arm64": "4.62.4",
1667 + "@rollup/rollup-darwin-x64": "4.62.4",
1668 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1669 + "@rollup/rollup-freebsd-x64": "4.62.4",
1670 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1671 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1672 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1673 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1674 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1675 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1676 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1677 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1678 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1679 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1680 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1681 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1682 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1683 + "@rollup/rollup-openbsd-x64": "4.62.4",
1684 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1685 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1686 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1687 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1688 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1689 + "fsevents": "~2.3.2"
1690 + }
1691 + },
1692 + "node_modules/scheduler": {
1693 + "version": "0.23.2",
1694 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1695 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1696 + "license": "MIT",
1697 + "dependencies": {
1698 + "loose-envify": "^1.1.0"
1699 + }
1700 + },
1701 + "node_modules/semver": {
1702 + "version": "6.3.1",
1703 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1704 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1705 + "dev": true,
1706 + "license": "ISC",
1707 + "bin": {
1708 + "semver": "bin/semver.js"
1709 + }
1710 + },
1711 + "node_modules/source-map-js": {
1712 + "version": "1.2.1",
1713 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1714 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1715 + "dev": true,
1716 + "license": "BSD-3-Clause",
1717 + "engines": {
1718 + "node": ">=0.10.0"
1719 + }
1720 + },
1721 + "node_modules/typescript": {
1722 + "version": "5.9.3",
1723 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1724 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1725 + "dev": true,
1726 + "license": "Apache-2.0",
1727 + "bin": {
1728 + "tsc": "bin/tsc",
1729 + "tsserver": "bin/tsserver"
1730 + },
1731 + "engines": {
1732 + "node": ">=14.17"
1733 + }
1734 + },
1735 + "node_modules/update-browserslist-db": {
1736 + "version": "1.3.1",
1737 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
1738 + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
1739 + "dev": true,
1740 + "funding": [
1741 + {
1742 + "type": "opencollective",
1743 + "url": "https://opencollective.com/browserslist"
1744 + },
1745 + {
1746 + "type": "tidelift",
1747 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1748 + },
1749 + {
1750 + "type": "github",
1751 + "url": "https://github.com/sponsors/ai"
1752 + }
1753 + ],
1754 + "license": "MIT",
1755 + "dependencies": {
1756 + "escalade": "^3.2.0",
1757 + "picocolors": "^1.1.1"
1758 + },
1759 + "bin": {
1760 + "update-browserslist-db": "cli.js"
1761 + },
1762 + "peerDependencies": {
1763 + "browserslist": ">= 4.21.0"
1764 + }
1765 + },
1766 + "node_modules/vite": {
1767 + "version": "5.4.21",
1768 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1769 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1770 + "dev": true,
1771 + "license": "MIT",
1772 + "dependencies": {
1773 + "esbuild": "^0.21.3",
1774 + "postcss": "^8.4.43",
1775 + "rollup": "^4.20.0"
1776 + },
1777 + "bin": {
1778 + "vite": "bin/vite.js"
1779 + },
1780 + "engines": {
1781 + "node": "^18.0.0 || >=20.0.0"
1782 + },
1783 + "funding": {
1784 + "url": "https://github.com/vitejs/vite?sponsor=1"
1785 + },
1786 + "optionalDependencies": {
1787 + "fsevents": "~2.3.3"
1788 + },
1789 + "peerDependencies": {
1790 + "@types/node": "^18.0.0 || >=20.0.0",
1791 + "less": "*",
1792 + "lightningcss": "^1.21.0",
1793 + "sass": "*",
1794 + "sass-embedded": "*",
1795 + "stylus": "*",
1796 + "sugarss": "*",
1797 + "terser": "^5.4.0"
1798 + },
1799 + "peerDependenciesMeta": {
1800 + "@types/node": {
1801 + "optional": true
1802 + },
1803 + "less": {
1804 + "optional": true
1805 + },
1806 + "lightningcss": {
1807 + "optional": true
1808 + },
1809 + "sass": {
1810 + "optional": true
1811 + },
1812 + "sass-embedded": {
1813 + "optional": true
1814 + },
1815 + "stylus": {
1816 + "optional": true
1817 + },
1818 + "sugarss": {
1819 + "optional": true
1820 + },
1821 + "terser": {
1822 + "optional": true
1823 + }
1824 + }
1825 + },
1826 + "node_modules/yallist": {
1827 + "version": "3.1.1",
1828 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1829 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1830 + "dev": true,
1831 + "license": "ISC"
1832 + }
1833 + }
1834 +}
added frontend/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "resto-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/react": "^18.3.3",
19 + "@types/react-dom": "^18.3.0",
20 + "@vitejs/plugin-react": "^4.3.1",
21 + "typescript": "^5.5.4",
22 + "vite": "^5.4.0"
23 + }
24 +}
added frontend/src/App.tsx +209 −0
@@ -0,0 +1,209 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: App.tsx
4 +// Desc: Layout global (header + ticker en direct + footer encre) et routage —
5 +// même patron que le App.tsx de Lou·Ka.
6 +// ==============================================================================
7 +import { useEffect, useState } from "react";
8 +import { NavLink, Route, Routes, useLocation } from "react-router-dom";
9 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
10 +import {
11 + IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch,
12 +} from "./components/Icons";
13 +import { LogoIcon } from "./components/Logo";
14 +import Home from "./pages/Home";
15 +import RestoPage from "./pages/Resto";
16 +import SourcesPage from "./pages/Sources";
17 +import StatsPage from "./pages/Stats";
18 +
19 +function Ticker() {
20 + const [items, setItems] = useState<string[]>([]);
21 +
22 + useEffect(() => {
23 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
24 + .then(([stats, facets, src]) => {
25 + registerSourceNames(src.sources);
26 + const parts: string[] = [
27 + `${stats.restaurants} restaurants`,
28 + `${stats.items} plats avec prix`,
29 + `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`,
30 + ];
31 + for (const r of stats.by_region.slice(0, 6))
32 + parts.push(`${r.region} · ${r.n}`);
33 + for (const s of facets.sources.slice(0, 6))
34 + parts.push(`${sourceName(s.source)} · ${s.n}`);
35 + parts.push("Prix réels étiquetés par contexte");
36 + parts.push("Mise à jour automatique");
37 + setItems(parts);
38 + })
39 + .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"]));
40 + }, []);
41 +
42 + if (items.length === 0) return null;
43 + // contenu doublé pour une boucle de défilement continue
44 + return (
45 + <div className="ticker" aria-hidden="true">
46 + <div className="ticker-track">
47 + {[...items, ...items].map((t, i) => (
48 + <span key={i}>{t}</span>
49 + ))}
50 + </div>
51 + </div>
52 + );
53 +}
54 +
55 +const NAV_LINKS = [
56 + { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },
57 + { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },
58 + { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },
59 +];
60 +
61 +function Header() {
62 + const [open, setOpen] = useState(false);
63 + const location = useLocation();
64 +
65 + // fermer le menu à chaque navigation + verrouiller le défilement en dessous
66 + useEffect(() => { setOpen(false); }, [location]);
67 + useEffect(() => {
68 + document.body.style.overflow = open ? "hidden" : "";
69 + return () => { document.body.style.overflow = ""; };
70 + }, [open]);
71 +
72 + return (
73 + <>
74 + <header className="header">
75 + <div className="container header-inner">
76 + <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil">
77 + <LogoIcon size={30} />
78 + Resto-<span className="ka">Ka</span>
79 + <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span>
80 + </NavLink>
81 + <nav className="nav" aria-label="Navigation principale">
82 + {NAV_LINKS.map((l) => (
83 + <NavLink key={l.to} to={l.to} end={l.end}
84 + className={({ isActive }) => (isActive ? "active" : "")}>
85 + {l.label}
86 + </NavLink>
87 + ))}
88 + </nav>
89 + <button
90 + className={`menu-btn ${open ? "open" : ""}`}
91 + aria-expanded={open}
92 + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
93 + onClick={() => setOpen(!open)}
94 + >
95 + <span /><span /><span />
96 + </button>
97 + </div>
98 +
99 + {/* menu déroulant mobile */}
100 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">
101 + {NAV_LINKS.map((l, i) => (
102 + <NavLink
103 + key={l.to}
104 + to={l.to}
105 + end={l.end}
106 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
107 + className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}
108 + onClick={() => setOpen(false)}
109 + >
110 + <span className="mm-ico" aria-hidden="true">{l.icon}</span>
111 + {l.label}
112 + <span className="mm-arrow" aria-hidden="true">→</span>
113 + </NavLink>
114 + ))}
115 + <div className="mm-foot">
116 + Agrégateur indépendant — menus et prix captés aux sources, chaque
117 + fiche renvoie au resto original et affiche le contexte du prix.
118 + </div>
119 + </div>
120 + </header>
121 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
122 + <Ticker />
123 + </>
124 + );
125 +}
126 +
127 +function Footer() {
128 + return (
129 + <footer className="footer">
130 + <div className="container">
131 + <div className="fbrand">
132 + <LogoIcon size={32} dark />
133 + Resto-<span className="ka">Ka</span>
134 + </div>
135 + <div className="fbaseline">
136 + Chaque resto, chaque plat, <span className="hl">chaque prix.</span>
137 + </div>
138 + <div className="frow">
139 + <div>
140 + <b>Agrégateur indépendant</b> des restaurants du Québec — menus
141 + complets et prix réels dans les 17 régions. Les menus proviennent
142 + des plateformes de commande des restos eux-mêmes ; chaque prix est
143 + étiqueté selon son contexte (salle, emporter, livraison) et chaque
144 + fiche renvoie à la source originale.
145 + </div>
146 + <div>
147 + Resto-Ka est une plateforme du{" "}
148 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
149 + <b>Groupe KA</b>
150 + </a>{" "}
151 + — contact :{" "}
152 + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>
153 + </div>
154 + </div>
155 + <div className="fmono">
156 + © {new Date().getFullYear()} Groupe KA — Resto-Ka
157 + </div>
158 + </div>
159 + </footer>
160 + );
161 +}
162 +
163 +/** Barre de navigation inférieure (mobile) — icône active en orange. */
164 +function MobileTabBar() {
165 + const location = useLocation();
166 + const tabs = [
167 + { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" },
168 + { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") },
169 + { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") },
170 + ];
171 + return (
172 + <nav className="tabbar tabbar-3" aria-label="Navigation mobile">
173 + {tabs.map((t) => (
174 + <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>
175 + {t.icon}
176 + {t.label}
177 + </NavLink>
178 + ))}
179 + </nav>
180 + );
181 +}
182 +
183 +export default function App() {
184 + return (
185 + <>
186 + <Header />
187 + <main>
188 + <Routes>
189 + <Route path="/" element={<Home />} />
190 + <Route path="/resto/:uid" element={<RestoPage />} />
191 + <Route path="/stats" element={<StatsPage />} />
192 + <Route path="/sources" element={<SourcesPage />} />
193 + <Route
194 + path="*"
195 + element={
196 + <div className="notice container">
197 + <div className="big"><IcoCompass size={40} /></div>
198 + <h2>Page introuvable</h2>
199 + <p>Le lien demandé n'existe pas.</p>
200 + </div>
201 + }
202 + />
203 + </Routes>
204 + </main>
205 + <Footer />
206 + <MobileTabBar />
207 + </>
208 + );
209 +}
added frontend/src/api.ts +244 −0
@@ -0,0 +1,244 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: api.ts
4 +// Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR
5 +// (cuisines, contextes de prix, régions). Même patron que Lou·Ka.
6 +// ==============================================================================
7 +
8 +export interface MenuOptionChoice { name: string; price_delta: number }
9 +export interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }
10 +
11 +export interface MenuItem {
12 + id?: string;
13 + name: string;
14 + description?: string;
15 + price: number | null;
16 + currency?: string;
17 + tags?: string[];
18 + options?: MenuOption[];
19 + image?: string;
20 +}
21 +
22 +export interface MenuSection { name: string; items: MenuItem[]; image?: string }
23 +
24 +export interface Menu {
25 + price_context: "dine-in" | "takeout" | "delivery";
26 + price_source: string;
27 + currency: string;
28 + captured_at: string;
29 + item_count: number;
30 + sections: MenuSection[];
31 +}
32 +
33 +export interface MenuSummary {
34 + image?: string | null;
35 + price_context: string;
36 + price_source: string;
37 + captured_at: string;
38 + item_count: number;
39 + price_min: number | null;
40 + price_median: number | null;
41 +}
42 +
43 +export interface Restaurant {
44 + uid: string;
45 + source: string;
46 + external_id: string;
47 + name: string;
48 + chain: string | null;
49 + cuisines: string[];
50 + establishment_type: string;
51 + price_range: string;
52 + address: string;
53 + city: string;
54 + region: string;
55 + postal_code: string;
56 + lat: number | null;
57 + lng: number | null;
58 + phone: string;
59 + website: string;
60 + url: string;
61 + hours: Record<string, string>;
62 + services: string[];
63 + dietary_options: string[];
64 + languages: string[];
65 + images: string[];
66 + status: string;
67 + menu_summary?: MenuSummary | null;
68 + menus?: Menu[];
69 + recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];
70 + dup_sources?: string[];
71 + first_seen?: number;
72 + last_seen: number;
73 + updated_at: number;
74 + active: number;
75 +}
76 +
77 +export interface Facets {
78 + regions: { region: string; n: number }[];
79 + all_regions: string[];
80 + cities: string[];
81 + cuisines: { cuisine: string; n: number }[];
82 + diets: { diet: string; n: number }[];
83 + establishment_types: { t: string; n: number }[];
84 + chains: string[];
85 + sources: { source: string; n: number }[];
86 +}
87 +
88 +export interface Source {
89 + id: string;
90 + name: string;
91 + url: string;
92 + platform?: string;
93 + tier?: number;
94 + price_context?: string;
95 + extraction?: string;
96 + cadence?: string;
97 + status: string;
98 + active_restaurants: number;
99 + menu_items: number;
100 + last_sync: number | null;
101 + integrations?: { label: string; status: string }[];
102 +}
103 +
104 +export interface Stats {
105 + restaurants: number;
106 + chains: number;
107 + regions: number;
108 + sources: number;
109 + menus: number;
110 + items: number;
111 + by_region: { region: string; n: number }[];
112 + by_context: { price_context: string; n: number }[];
113 + recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];
114 +}
115 +
116 +// --- libellés français --------------------------------------------------------
117 +
118 +export const CUISINE_LABELS: Record<string, string> = {
119 + "quebecois": "Québécois", "francais": "Français", "italien": "Italien",
120 + "pizza": "Pizza", "burgers": "Burgers", "poulet": "Poulet",
121 + "bbq-grillades": "BBQ & grillades", "fruits-de-mer": "Fruits de mer",
122 + "sushi-japonais": "Sushi & japonais", "chinois": "Chinois", "thai": "Thaï",
123 + "vietnamien": "Vietnamien", "coreen": "Coréen", "indien": "Indien",
124 + "libanais-moyen-orient": "Libanais & Moyen-Orient", "mexicain": "Mexicain",
125 + "grec": "Grec", "mediterraneen": "Méditerranéen",
126 + "dejeuner-brunch": "Déjeuner & brunch", "cafe-dessert": "Café & dessert",
127 + "vegetarien-vegan": "Végé & vegan", "fast-food": "Fast-food", "autre": "Autre",
128 +};
129 +
130 +export const CONTEXT_LABELS: Record<string, string> = {
131 + "dine-in": "Prix en salle",
132 + "takeout": "Prix pour emporter",
133 + "delivery": "Prix livraison (majoré 25-30 %)",
134 +};
135 +
136 +export const CONTEXT_SHORT: Record<string, string> = {
137 + "dine-in": "Salle", "takeout": "Emporter", "delivery": "Livraison",
138 +};
139 +
140 +export const TYPE_LABELS: Record<string, string> = {
141 + "restaurant": "Restaurant", "fast-food": "Fast-food", "cafe": "Café",
142 + "bar": "Bar & pub", "food-truck": "Food truck", "traiteur": "Traiteur",
143 + "boulangerie-patisserie": "Boulangerie-pâtisserie",
144 + "casse-croute": "Casse-croûte", "microbrasserie": "Microbrasserie",
145 + "hotel": "Resto d'hôtel", "ghost-kitchen": "Cuisine fantôme",
146 +};
147 +
148 +export const DIET_LABELS: Record<string, string> = {
149 + "vegan": "Vegan", "vegetarien": "Végétarien", "sans-gluten": "Sans gluten",
150 + "halal": "Halal", "casher": "Casher", "sans-noix": "Sans noix", "epice": "Épicé",
151 +};
152 +
153 +export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;
154 +export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;
155 +export const dietLabel = (d: string) => DIET_LABELS[d] ?? d;
156 +
157 +const SOURCE_NAMES: Record<string, string> = { ueat: "UEAT (commande en ligne)" };
158 +export function registerSourceNames(sources: Source[]) {
159 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
160 +}
161 +export const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;
162 +
163 +/** 16.5 -> « 16,50 $ » */
164 +export const fmtPrice = (p: number | null | undefined): string =>
165 + p == null ? "—" : `${p.toFixed(2).replace(".", ",")} $`;
166 +
167 +export const fmtDate = (iso: string | null | undefined): string => {
168 + if (!iso) return "—";
169 + const d = new Date(iso);
170 + return isNaN(d.getTime()) ? iso : d.toLocaleDateString("fr-CA");
171 +};
172 +
173 +export const fmtTs = (ts: number | null | undefined): string =>
174 + ts ? new Date(ts * 1000).toLocaleString("fr-CA") : "—";
175 +
176 +// --- client -------------------------------------------------------------------
177 +
178 +async function get<T>(path: string): Promise<T> {
179 + const ctl = new AbortController();
180 + const timer = setTimeout(() => ctl.abort(), 20000);
181 + try {
182 + const resp = await fetch(path, { signal: ctl.signal });
183 + if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);
184 + return (await resp.json()) as T;
185 + } finally {
186 + clearTimeout(timer);
187 + }
188 +}
189 +
190 +export interface Dish {
191 + item: string;
192 + description: string;
193 + price: number | null;
194 + currency: string;
195 + image?: string | null;
196 + tags: string[];
197 + section: string;
198 + price_context: string;
199 + price_source: string;
200 + captured_at: string;
201 + brand: string;
202 + restaurant: string;
203 + uid: string;
204 + city: string;
205 + region: string;
206 + locations: number;
207 +}
208 +
209 +export interface DishQuery {
210 + q: string; region?: string; city?: string; cuisine?: string;
211 + price_max?: number; limit?: number; offset?: number;
212 +}
213 +
214 +export function fetchDishes(query: DishQuery) {
215 + const params = new URLSearchParams();
216 + for (const [k, v] of Object.entries(query))
217 + if (v !== undefined && v !== "" && v !== null) params.set(k, String(v));
218 + return get<{ total: number; count: number; dishes: Dish[] }>(
219 + `/api/dishes?${params.toString()}`);
220 +}
221 +
222 +export interface RestaurantQuery {
223 + region?: string; city?: string; cuisine?: string; establishment_type?: string;
224 + diet?: string; service?: string; price_range?: string; chain?: string;
225 + source?: string; q?: string; sort?: string; limit?: number; offset?: number;
226 +}
227 +
228 +export function fetchRestaurants(query: RestaurantQuery = {}) {
229 + const params = new URLSearchParams();
230 + for (const [k, v] of Object.entries(query))
231 + if (v !== undefined && v !== "" && v !== null) params.set(k, String(v));
232 + return get<{ total: number; count: number; restaurants: Restaurant[] }>(
233 + `/api/restaurants?${params.toString()}`);
234 +}
235 +
236 +export const fetchRestaurant = (uid: string) =>
237 + get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);
238 +
239 +export const fetchFacets = (region?: string) =>
240 + get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`);
241 +
242 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
243 +
244 +export const fetchStats = () => get<Stats>("/api/stats");
added frontend/src/components/Icons.tsx +80 −0
@@ -0,0 +1,80 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: components/Icons.tsx
4 +// Desc: Icônes SVG inline (traits 2 u, coins ronds) — même style que Lou·Ka.
5 +// ==============================================================================
6 +interface IcoProps { size?: number; strokeWidth?: number }
7 +
8 +const base = (size: number) => ({
9 + width: size, height: size, viewBox: "0 0 24 24", fill: "none",
10 + stroke: "currentColor", strokeLinecap: "round" as const,
11 + strokeLinejoin: "round" as const,
12 +});
13 +
14 +export const IcoSearch = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
15 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
16 + <circle cx="11" cy="11" r="7" />
17 + <path d="m20 20-3.8-3.8" />
18 + </svg>
19 +);
20 +
21 +export const IcoPlate = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
22 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
23 + <circle cx="12" cy="12" r="9" />
24 + <circle cx="12" cy="12" r="3.5" />
25 + </svg>
26 +);
27 +
28 +export const IcoChart = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
29 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
30 + <path d="M4 20V10M10 20V4M16 20v-7M21 20H3" />
31 + </svg>
32 +);
33 +
34 +export const IcoFolder = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
35 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
36 + <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
37 + </svg>
38 +);
39 +
40 +export const IcoPin = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
41 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
42 + <path d="M12 21s-7-5.4-7-11a7 7 0 0 1 14 0c0 5.6-7 11-7 11Z" />
43 + <circle cx="12" cy="10" r="2.6" />
44 + </svg>
45 +);
46 +
47 +export const IcoTag = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
48 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
49 + <path d="M3 3h8l10 10-8 8L3 11Z" />
50 + <circle cx="8" cy="8" r="1.6" />
51 + </svg>
52 +);
53 +
54 +export const IcoClock = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
55 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
56 + <circle cx="12" cy="12" r="9" />
57 + <path d="M12 7v5l3.2 2" />
58 + </svg>
59 +);
60 +
61 +export const IcoCompass = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
62 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
63 + <circle cx="12" cy="12" r="9" />
64 + <path d="m15.5 8.5-2 5-5 2 2-5Z" />
65 + </svg>
66 +);
67 +
68 +export const IcoLeaf = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
69 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
70 + <path d="M5 19C5 9 12 4 20 4c0 8-5 15-15 15Z" />
71 + <path d="M5 19c3-5 7-8 11-10" />
72 + </svg>
73 +);
74 +
75 +export const IcoBag = ({ size = 18, strokeWidth = 2 }: IcoProps) => (
76 + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true">
77 + <path d="M6 8h12l-1 12H7L6 8Z" />
78 + <path d="M9 8a3 3 0 0 1 6 0" />
79 + </svg>
80 +);
added frontend/src/components/Logo.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: components/Logo.tsx
4 +// Desc: Icône officielle Resto·Ka — l'assiette servie, dans le langage
5 +// « éditorial sharp » du Groupe KA (grille 100 × 100) :
6 +// A. assiette : cercle ENCRE #141814 en contour seul (7,5 u) — la
7 +// bordure encre signature du groupe ;
8 +// B. couverts : fourchette (gauche) et couteau (droite) encre, traits
9 +// francs à bouts ronds ;
10 +// C. cœur d'assiette : disque central LIME électrique #d9f26b — le
11 +// plat servi, l'accent du groupe.
12 +// Version dark (fond encre) : contours papier, cœur lime inchangé.
13 +// Baseline : « Chaque resto, chaque plat, chaque prix. »
14 +// ==============================================================================
15 +
16 +/** Icône seule (fond transparent — dark = contours papier pour fond encre). */
17 +export function LogoIcon({ size = 30, dark = false }: { size?: number; dark?: boolean }) {
18 + const ink = dark ? "#f5f3ee" : "#141814";
19 + return (
20 + <svg
21 + className="logo-ico"
22 + width={size}
23 + height={size}
24 + viewBox="0 0 100 100"
25 + fill="none"
26 + aria-hidden="true"
27 + >
28 + {/* B — fourchette (3 dents) à gauche */}
29 + <path
30 + d="M13 26 v14 M7.5 26 v9 M18.5 26 v9 M13 40 v34"
31 + stroke={ink}
32 + strokeWidth="5"
33 + strokeLinecap="round"
34 + />
35 + {/* B — couteau à droite (lame courbe) */}
36 + <path
37 + d="M87 26 q5 12 0 24 v24"
38 + stroke={ink}
39 + strokeWidth="5"
40 + strokeLinecap="round"
41 + strokeLinejoin="round"
42 + />
43 + {/* C — cœur d'assiette : le plat servi, lime électrique */}
44 + <circle cx="50" cy="50" r="14" fill="#d9f26b" stroke={ink} strokeWidth="4" />
45 + {/* A — assiette : contour encre seul, bordure franche */}
46 + <circle cx="50" cy="50" r="31" stroke={ink} strokeWidth="7" />
47 + </svg>
48 + );
49 +}
50 +
51 +/** Logo horizontal complet : icône + wordmark « Resto-Ka » (Ka en pastille lime). */
52 +export default function Logo({ size = 30 }: { size?: number }) {
53 + return (
54 + <span style={{ display: "inline-flex", alignItems: "center", gap: size * 0.3 }}>
55 + <LogoIcon size={size} />
56 + <span style={{ color: "#141814", fontWeight: 700, letterSpacing: "-0.03em" }}>
57 + Resto-
58 + <span style={{
59 + background: "#d9f26b", color: "#141814", padding: "1px 6px 3px",
60 + borderRadius: 5, marginLeft: 3, display: "inline-block",
61 + transform: "rotate(-2deg)",
62 + }}>
63 + Ka
64 + </span>
65 + </span>
66 + </span>
67 + );
68 +}
added frontend/src/components/Pager.tsx +50 −0
@@ -0,0 +1,50 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: components/Pager.tsx
4 +// Desc: Pagination numérotée en bas des résultats (fenêtre 1 … n-1 n n+1 … N)
5 +// pour ne jamais surcharger une page — style éditorial sharp.
6 +// ==============================================================================
7 +
8 +export default function Pager({ page, pageCount, onPage }: {
9 + page: number; // page courante, base 0
10 + pageCount: number;
11 + onPage: (p: number) => void;
12 +}) {
13 + if (pageCount <= 1) return null;
14 +
15 + const nums: (number | "…")[] = [];
16 + const push = (n: number | "…") => {
17 + if (nums[nums.length - 1] !== n) nums.push(n);
18 + };
19 + for (let i = 0; i < pageCount; i++) {
20 + if (i === 0 || i === pageCount - 1 || Math.abs(i - page) <= 1) push(i);
21 + else push("…");
22 + }
23 +
24 + const go = (p: number) => {
25 + onPage(p);
26 + window.scrollTo({ top: 0 });
27 + };
28 +
29 + return (
30 + <nav className="pager" aria-label="Pagination">
31 + <button className="pg-btn" disabled={page === 0} onClick={() => go(page - 1)}
32 + aria-label="Page précédente">←</button>
33 + {nums.map((n, i) =>
34 + n === "…" ? (
35 + <span className="pg-ellipsis" key={`e${i}`}>…</span>
36 + ) : (
37 + <button
38 + key={n}
39 + className={`pg-btn pg-num ${n === page ? "on" : ""}`}
40 + aria-current={n === page ? "page" : undefined}
41 + onClick={() => go(n)}
42 + >
43 + {n + 1}
44 + </button>
45 + ))}
46 + <button className="pg-btn" disabled={page >= pageCount - 1} onClick={() => go(page + 1)}
47 + aria-label="Page suivante">→</button>
48 + </nav>
49 + );
50 +}
added frontend/src/components/RestoCard.tsx +66 −0
@@ -0,0 +1,66 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: components/RestoCard.tsx
4 +// Desc: Carte de restaurant (grille de résultats) — même patron que la
5 +// ListingCard de Lou·Ka : prix en tête, badge de contexte de prix
6 +// TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.
7 +// ==============================================================================
8 +import { Link } from "react-router-dom";
9 +import {
10 + CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,
11 +} from "../api";
12 +import { IcoPlate } from "./Icons";
13 +
14 +export default function RestoCard({ r }: { r: Restaurant }) {
15 + const ms = r.menu_summary;
16 + const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord
17 + const logo = (r.images ?? [])[0] || null;
18 + return (
19 + <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card">
20 + <div className="card-img">
21 + {img ? (
22 + <img src={img} alt={r.name} loading="lazy" />
23 + ) : logo ? (
24 + <div className="noimg"><img className="card-logo" src={logo} alt={r.name} loading="lazy" /></div>
25 + ) : (
26 + <div className="noimg"><IcoPlate size={38} strokeWidth={1.6} /></div>
27 + )}
28 + {r.price_range && <span className="badge type">{r.price_range}</span>}
29 + {ms && (
30 + <span className={`badge right ctx ctx-${ms.price_context}`}>
31 + {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}
32 + </span>
33 + )}
34 + </div>
35 + <div className="card-body">
36 + <div className="card-price">
37 + {ms?.price_median != null ? (
38 + <>
39 + {fmtPrice(ms.price_median)} <small>plat médian</small>
40 + </>
41 + ) : (
42 + <span className="no-menu">Menu à venir</span>
43 + )}
44 + </div>
45 + <div className="card-title">{r.name}</div>
46 + <div className="card-meta">
47 + <span>{typeLabel(r.establishment_type)}</span>
48 + {r.cuisines.slice(0, 2).map((c) => (
49 + <span key={c}>
50 + <span className="sep" /> {cuisineLabel(c)}
51 + </span>
52 + ))}
53 + </div>
54 + <div className="card-meta">
55 + {r.city && <span>{r.city}</span>}
56 + {r.city && r.region && <span className="sep" />}
57 + {r.region && <span>{r.region}</span>}
58 + </div>
59 + <div className="card-foot">
60 + <span className="source-tag">{sourceName(r.source)}</span>
61 + {ms && <span className="avail">{ms.item_count} plats au menu</span>}
62 + </div>
63 + </div>
64 + </Link>
65 + );
66 +}
added frontend/src/main.tsx +18 −0
@@ -0,0 +1,18 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: main.tsx
4 +// Desc: Point d'entrée React de Resto·Ka (même patron que Lou·Ka).
5 +// ==============================================================================
6 +import React from "react";
7 +import ReactDOM from "react-dom/client";
8 +import { BrowserRouter } from "react-router-dom";
9 +import App from "./App";
10 +import "./styles.css";
11 +
12 +ReactDOM.createRoot(document.getElementById("root")!).render(
13 + <React.StrictMode>
14 + <BrowserRouter>
15 + <App />
16 + </BrowserRouter>
17 + </React.StrictMode>
18 +);
added frontend/src/pages/Home.tsx +370 −0
@@ -0,0 +1,370 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: pages/Home.tsx
4 +// Desc: Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans
5 +// tous les menus), chips cuisines, barre de filtres (région, ville,
6 +// type, diète, prix), grilles de résultats + pagination numérotée.
7 +// Filtres synchronisés à l'URL, feuille mobile, skeletons (patron
8 +// Lou·Ka, design éditorial sharp Groupe KA).
9 +// ==============================================================================
10 +import { useEffect, useMemo, useState } from "react";
11 +import { Link, useSearchParams } from "react-router-dom";
12 +import {
13 + CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,
14 + fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,
15 +} from "../api";
16 +import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from "../components/Icons";
17 +import Pager from "../components/Pager";
18 +import RestoCard from "../components/RestoCard";
19 +
20 +const PAGE_SIZE = 24;
21 +const DISH_PAGE_SIZE = 20;
22 +
23 +const FILTER_KEYS = ["q", "region", "city", "cuisine", "establishment_type",
24 + "diet", "price_range", "sort", "mode"] as const;
25 +type FilterKey = typeof FILTER_KEYS[number];
26 +
27 +function DishRow({ d }: { d: Dish }) {
28 + return (
29 + <Link to={`/resto/${encodeURIComponent(d.uid)}`} className="dish-row">
30 + {d.image ? (
31 + <img className="dish-img" src={d.image} alt={d.item} loading="lazy" />
32 + ) : (
33 + <span className="dish-img dish-noimg"><IcoPlate size={22} strokeWidth={1.8} /></span>
34 + )}
35 + <div className="dish-main">
36 + <div className="dish-line">
37 + <span className="dish-name">{d.item}</span>
38 + <span className="mi-dots" aria-hidden="true" />
39 + <span className="dish-price">{fmtPrice(d.price)}</span>
40 + </div>
41 + {d.description && <p className="dish-desc">{d.description}</p>}
42 + <div className="dish-meta">
43 + <b>{d.brand}</b>
44 + <span className="sep" />
45 + <span>{d.section}</span>
46 + <span className="sep" />
47 + {d.locations > 1
48 + ? <span>{d.locations} emplacements</span>
49 + : <span>{d.city}{d.region ? ` · ${d.region}` : ""}</span>}
50 + <span className={`ctx-pill ctx-${d.price_context}`}>
51 + {CONTEXT_SHORT[d.price_context] ?? d.price_context}
52 + </span>
53 + </div>
54 + </div>
55 + </Link>
56 + );
57 +}
58 +
59 +export default function Home() {
60 + const [params, setParams] = useSearchParams();
61 + const [facets, setFacets] = useState<Facets | null>(null);
62 + const [restos, setRestos] = useState<Restaurant[]>([]);
63 + const [dishes, setDishes] = useState<Dish[]>([]);
64 + const [total, setTotal] = useState(0);
65 + const [loading, setLoading] = useState(true);
66 + const [error, setError] = useState(false);
67 + const [page, setPage] = useState(0);
68 + const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);
69 + const [sheetOpen, setSheetOpen] = useState(false);
70 +
71 + const filters = useMemo(() => {
72 + const f: Partial<Record<FilterKey, string>> = {};
73 + for (const k of FILTER_KEYS) {
74 + const v = params.get(k);
75 + if (v) f[k] = v;
76 + }
77 + return f;
78 + }, [params]);
79 +
80 + const mode = filters.mode === "plats" ? "plats" : "restos";
81 + const pageSize = mode === "plats" ? DISH_PAGE_SIZE : PAGE_SIZE;
82 +
83 + const setFilter = (key: FilterKey, value: string) => {
84 + const next = new URLSearchParams(params);
85 + if (value) next.set(key, value);
86 + else next.delete(key);
87 + setParams(next, { replace: true });
88 + setPage(0);
89 + };
90 +
91 + useEffect(() => {
92 + fetchStats()
93 + .then((s) => setStatChips([
94 + { label: "restaurants", value: s.restaurants },
95 + { label: "plats avec prix", value: s.items },
96 + { label: "régions couvertes", value: s.regions },
97 + { label: "chaînes suivies", value: s.chains },
98 + ]))
99 + .catch(() => {});
100 + }, []);
101 +
102 + useEffect(() => {
103 + fetchFacets(filters.region).then(setFacets).catch(() => {});
104 + }, [filters.region]);
105 +
106 + useEffect(() => {
107 + setLoading(true);
108 + setError(false);
109 + if (mode === "plats") {
110 + const q = (filters.q ?? "").trim();
111 + if (q.length < 2) {
112 + setDishes([]); setTotal(0); setLoading(false);
113 + return;
114 + }
115 + fetchDishes({
116 + q, region: filters.region, city: filters.city, cuisine: filters.cuisine,
117 + limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,
118 + })
119 + .then((r) => { setDishes(r.dishes); setTotal(r.total); })
120 + .catch(() => setError(true))
121 + .finally(() => setLoading(false));
122 + } else {
123 + const { mode: _m, ...rest } = filters;
124 + fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
125 + .then((r) => { setRestos(r.restaurants); setTotal(r.total); })
126 + .catch(() => setError(true))
127 + .finally(() => setLoading(false));
128 + }
129 + }, [filters, page, mode]);
130 +
131 + const activePills: { key: FilterKey; label: string }[] = [];
132 + if (filters.region) activePills.push({ key: "region", label: filters.region });
133 + if (filters.city) activePills.push({ key: "city", label: filters.city });
134 + if (filters.cuisine) activePills.push({ key: "cuisine", label: cuisineLabel(filters.cuisine) });
135 + if (filters.establishment_type) activePills.push({ key: "establishment_type", label: typeLabel(filters.establishment_type) });
136 + if (filters.diet) activePills.push({ key: "diet", label: dietLabel(filters.diet) });
137 + if (filters.price_range) activePills.push({ key: "price_range", label: filters.price_range });
138 + if (filters.q) activePills.push({ key: "q", label: `« ${filters.q} »` });
139 +
140 + const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };
141 +
142 + const filterbar = (
143 + <div className={`filterbar ${sheetOpen ? "open" : ""}`}>
144 + <div className="sheet-head">
145 + Filtres
146 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer">✕</button>
147 + </div>
148 + <div className="f-primary">
149 + <div className="mode-toggle" role="tablist" aria-label="Chercher par">
150 + <button className={mode === "restos" ? "on" : ""} role="tab"
151 + aria-selected={mode === "restos"}
152 + onClick={() => setFilter("mode", "")}>Restos</button>
153 + <button className={mode === "plats" ? "on" : ""} role="tab"
154 + aria-selected={mode === "plats"}
155 + onClick={() => setFilter("mode", "plats")}>Plats</button>
156 + </div>
157 + <label className="f-search">
158 + <IcoSearch size={16} />
159 + <input
160 + type="search"
161 + placeholder={mode === "plats"
162 + ? "Poutine, tartare, pad thaï, sushi…"
163 + : "Resto, chaîne, ville…"}
164 + value={filters.q ?? ""}
165 + onChange={(e) => setFilter("q", e.target.value)}
166 + aria-label="Recherche"
167 + />
168 + {filters.q && (
169 + <button className="f-clear" onClick={() => setFilter("q", "")} aria-label="Effacer">✕</button>
170 + )}
171 + </label>
172 + <label className="f-ctl">
173 + <span>Région</span>
174 + <select value={filters.region ?? ""} onChange={(e) => { setFilter("region", e.target.value); setFilter("city", ""); }}>
175 + <option value="">Toutes</option>
176 + {(facets?.regions ?? []).map((r) => (
177 + <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
178 + ))}
179 + </select>
180 + </label>
181 + <label className="f-ctl">
182 + <span>Ville</span>
183 + <select value={filters.city ?? ""} onChange={(e) => setFilter("city", e.target.value)}>
184 + <option value="">Toutes</option>
185 + {(facets?.cities ?? []).map((c) => (
186 + <option key={c} value={c}>{c}</option>
187 + ))}
188 + </select>
189 + </label>
190 + {mode === "restos" && (
191 + <>
192 + <label className="f-ctl">
193 + <span>Type</span>
194 + <select value={filters.establishment_type ?? ""} onChange={(e) => setFilter("establishment_type", e.target.value)}>
195 + <option value="">Tous</option>
196 + {(facets?.establishment_types ?? []).map((t) => (
197 + <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>
198 + ))}
199 + </select>
200 + </label>
201 + <label className="f-ctl">
202 + <span>Diète</span>
203 + <select value={filters.diet ?? ""} onChange={(e) => setFilter("diet", e.target.value)}>
204 + <option value="">Toutes</option>
205 + {(facets?.diets ?? []).map((d) => (
206 + <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>
207 + ))}
208 + </select>
209 + </label>
210 + <label className="f-ctl">
211 + <span>Prix</span>
212 + <select value={filters.price_range ?? ""} onChange={(e) => setFilter("price_range", e.target.value)}>
213 + <option value="">Tous</option>
214 + {["$", "$$", "$$$", "$$$$"].map((p) => (
215 + <option key={p} value={p}>{p}</option>
216 + ))}
217 + </select>
218 + </label>
219 + <label className="f-ctl">
220 + <span>Tri</span>
221 + <select value={filters.sort ?? "name"} onChange={(e) => setFilter("sort", e.target.value)}>
222 + <option value="name">Nom</option>
223 + <option value="price">Fourchette de prix</option>
224 + <option value="recent">Récents</option>
225 + </select>
226 + </label>
227 + </>
228 + )}
229 + </div>
230 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
231 + Voir les {total} résultats
232 + </button>
233 + </div>
234 + );
235 +
236 + return (
237 + <div className="container">
238 + <section className="hero">
239 + <span className="kicker">Agrégateur — 17 régions du Québec</span>
240 + <h1>
241 + Tous les restos. Tous les menus.<br />
242 + <span className="hl">Tous les prix.</span>
243 + </h1>
244 + <p className="lede">
245 + Resto-Ka rassemble les restaurants du Québec avec leurs menus complets
246 + et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,
247 + le contexte du prix (salle, emporter, livraison) est toujours affiché.
248 + </p>
249 + <div className="stat-row">
250 + <div className="stat-chip"><span className="pulse" />En direct</div>
251 + {statChips.map((s) => (
252 + <div className="stat-chip" key={s.label}>
253 + <b>{s.value.toLocaleString("fr-CA")}</b> {s.label}
254 + </div>
255 + ))}
256 + </div>
257 + <div className="benefits">
258 + <div className="benefit">
259 + <span className="benefit-ico"><IcoTag size={19} /></span>
260 + <span className="benefit-label">Prix réels, jamais sans contexte</span>
261 + </div>
262 + <div className="benefit">
263 + <span className="benefit-ico blue"><IcoPin size={19} /></span>
264 + <span className="benefit-label">17 régions du Québec</span>
265 + </div>
266 + <div className="benefit">
267 + <span className="benefit-ico"><IcoBag size={19} /></span>
268 + <span className="benefit-label">Recherche par plat</span>
269 + </div>
270 + <div className="benefit">
271 + <span className="benefit-ico blue"><IcoLeaf size={19} /></span>
272 + <span className="benefit-label">Filtres diète & cuisine</span>
273 + </div>
274 + </div>
275 + </section>
276 +
277 + {/* chips cuisines (défilement horizontal) */}
278 + <div className="chips" role="tablist" aria-label="Cuisines">
279 + <button className={`chip ${!filters.cuisine ? "on" : ""}`}
280 + onClick={() => setFilter("cuisine", "")}>Toutes les cuisines</button>
281 + {(facets?.cuisines ?? []).slice(0, 14).map((c) => (
282 + <button
283 + key={c.cuisine}
284 + className={`chip ${filters.cuisine === c.cuisine ? "on" : ""}`}
285 + onClick={() => setFilter("cuisine", filters.cuisine === c.cuisine ? "" : c.cuisine)}
286 + >
287 + {cuisineLabel(c.cuisine)} · {c.n}
288 + </button>
289 + ))}
290 + </div>
291 +
292 + {filterbar}
293 + {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}
294 + <button className="fab" onClick={() => setSheetOpen(true)}>
295 + <IcoSearch size={16} /> Filtres
296 + </button>
297 +
298 + {activePills.length > 0 && (
299 + <div className="pills">
300 + {activePills.map((p) => (
301 + <button key={p.key} className="pill" onClick={() => setFilter(p.key, "")}>
302 + {p.label} <span className="pill-x">✕</span>
303 + </button>
304 + ))}
305 + <button className="pill pill-clear" onClick={clearAll}>Tout effacer</button>
306 + </div>
307 + )}
308 +
309 + <div className="results-head">
310 + <h2>{mode === "plats" ? "Plats" : "Restaurants"}</h2>
311 + <span>
312 + {mode === "plats" && (filters.q ?? "").trim().length < 2
313 + ? "tapez un plat (min. 2 lettres)"
314 + : `${total.toLocaleString("fr-CA")} résultat${total > 1 ? "s" : ""}`}
315 + </span>
316 + </div>
317 +
318 + {error && (
319 + <div className="notice">
320 + <h2>Impossible de charger les résultats</h2>
321 + <p>Réessayez dans un instant.</p>
322 + </div>
323 + )}
324 +
325 + {loading ? (
326 + <div className="grid">
327 + {Array.from({ length: 8 }).map((_, i) => (
328 + <div className="skel" key={i}>
329 + <div className="sk-img" />
330 + <div className="sk-line" />
331 + <div className="sk-line short" />
332 + </div>
333 + ))}
334 + </div>
335 + ) : mode === "plats" ? (
336 + (filters.q ?? "").trim().length < 2 ? (
337 + <div className="notice">
338 + <h2>Cherchez n'importe quel plat</h2>
339 + <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus
340 + du Québec sont indexés, avec leurs prix réels.</p>
341 + </div>
342 + ) : dishes.length === 0 ? (
343 + <div className="notice">
344 + <h2>Aucun plat trouvé</h2>
345 + <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>
346 + </div>
347 + ) : (
348 + <>
349 + <div className="dish-list">
350 + {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}
351 + </div>
352 + <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />
353 + </>
354 + )
355 + ) : restos.length === 0 && !error ? (
356 + <div className="notice">
357 + <h2>Aucun resto ne correspond</h2>
358 + <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>
359 + </div>
360 + ) : (
361 + <>
362 + <div className="grid">
363 + {restos.map((r) => <RestoCard key={r.uid} r={r} />)}
364 + </div>
365 + <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />
366 + </>
367 + )}
368 + </div>
369 + );
370 +}
added frontend/src/pages/Resto.tsx +237 −0
@@ -0,0 +1,237 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: pages/Resto.tsx
4 +// Desc: Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)
5 +// + menu complet par sections avec prix, options et badge de CONTEXTE
6 +// de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte
7 +// est inutilisable ; delivery affiché clairement étiqueté).
8 +// ==============================================================================
9 +import { useEffect, useState } from "react";
10 +import { Link, useParams } from "react-router-dom";
11 +import {
12 + CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,
13 + fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,
14 +} from "../api";
15 +import { IcoCompass } from "../components/Icons";
16 +
17 +function MenuBlock({ menu }: { menu: Menu }) {
18 + const [openSections, setOpenSections] = useState<Set<number>>(
19 + new Set(menu.sections.map((_, i) => i).slice(0, 4)));
20 + const toggle = (i: number) => {
21 + const next = new Set(openSections);
22 + if (next.has(i)) next.delete(i);
23 + else next.add(i);
24 + setOpenSections(next);
25 + };
26 + return (
27 + <section className="menu-block">
28 + <div className={`ctx-banner ctx-${menu.price_context}`}>
29 + <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>
30 + <span>
31 + source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}
32 + {" · "}{menu.item_count} plats
33 + </span>
34 + </div>
35 + {menu.sections.map((sec, i) => (
36 + <div className="menu-section" key={`${sec.name}-${i}`}>
37 + <button className="menu-section-head" onClick={() => toggle(i)}
38 + aria-expanded={openSections.has(i)}>
39 + {sec.image && <img className="ms-img" src={sec.image} alt="" loading="lazy" />}
40 + <h3>{sec.name}</h3>
41 + <span className="ms-meta">{sec.items.length} · {openSections.has(i) ? "−" : "+"}</span>
42 + </button>
43 + {openSections.has(i) && (
44 + <ul className="menu-items">
45 + {sec.items.map((it, j) => (
46 + <li className="menu-item" key={`${it.name}-${j}`}>
47 + <div className="mi-row">
48 + <div className="mi-main">
49 + <div className="mi-line">
50 + <span className="mi-name">
51 + {it.name}
52 + {(it.tags ?? []).map((t) => (
53 + <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>
54 + ))}
55 + </span>
56 + <span className="mi-dots" aria-hidden="true" />
57 + <span className="mi-price">{fmtPrice(it.price)}</span>
58 + </div>
59 + {it.description && <p className="mi-desc">{it.description}</p>}
60 + </div>
61 + {it.image && (
62 + <img className="mi-thumb" src={it.image} alt={it.name} loading="lazy" />
63 + )}
64 + </div>
65 + {(it.options ?? []).length > 0 && (
66 + <div className="mi-optgroups">
67 + {(it.options ?? []).map((op, k) => (
68 + <div className="og" key={`${op.group}-${k}`}>
69 + <div className="og-head">
70 + <span className="og-name">{op.group}</span>
71 + <span className={`og-req ${op.required ? "on" : ""}`}>
72 + {op.required ? "Choix requis" : "Optionnel"}
73 + </span>
74 + </div>
75 + <div className="og-choices">
76 + {op.choices.map((c, m) => (
77 + <span className="og-choice" key={`${c.name}-${m}`}>
78 + {c.name}
79 + {c.price_delta !== 0 && (
80 + <b className={c.price_delta > 0 ? "plus" : "moins"}>
81 + {c.price_delta > 0 ? "+" : "−"}
82 + {Math.abs(c.price_delta).toFixed(2).replace(".", ",")} $
83 + </b>
84 + )}
85 + </span>
86 + ))}
87 + </div>
88 + </div>
89 + ))}
90 + </div>
91 + )}
92 + </li>
93 + ))}
94 + </ul>
95 + )}
96 + </div>
97 + ))}
98 + </section>
99 + );
100 +}
101 +
102 +export default function RestoPage() {
103 + const { uid } = useParams<{ uid: string }>();
104 + const [resto, setResto] = useState<Restaurant | null>(null);
105 + const [notFound, setNotFound] = useState(false);
106 + const [ctx, setCtx] = useState<string | null>(null);
107 +
108 + useEffect(() => {
109 + if (!uid) return;
110 + setResto(null);
111 + setNotFound(false);
112 + fetchRestaurant(uid)
113 + .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })
114 + .catch(() => setNotFound(true));
115 + window.scrollTo({ top: 0 });
116 + }, [uid]);
117 +
118 + if (notFound) {
119 + return (
120 + <div className="notice container">
121 + <div className="big"><IcoCompass size={40} /></div>
122 + <h2>Restaurant introuvable</h2>
123 + <p><Link to="/" className="link-btn">Retour à la recherche</Link></p>
124 + </div>
125 + );
126 + }
127 + if (!resto) {
128 + return (
129 + <div className="container detail">
130 + <div className="skel"><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>
131 + </div>
132 + );
133 + }
134 +
135 + const menus = resto.menus ?? [];
136 + const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null;
137 +
138 + return (
139 + <div className="container detail">
140 + <nav className="crumbs" aria-label="Fil d'Ariane">
141 + <Link to="/">Restos</Link> ›
142 + {resto.region && (
143 + <>
144 + <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> ›
145 + </>
146 + )}
147 + <span>{resto.name}</span>
148 + </nav>
149 +
150 + <div className="detail-grid">
151 + <div>
152 + {menus.length > 1 && (
153 + <div className="view-toggle ctx-toggle" role="tablist" aria-label="Contexte de prix">
154 + {menus.map((m) => (
155 + <button key={m.price_context}
156 + className={m.price_context === menu?.price_context ? "on" : ""}
157 + onClick={() => setCtx(m.price_context)}>
158 + {CONTEXT_SHORT[m.price_context] ?? m.price_context}
159 + </button>
160 + ))}
161 + </div>
162 + )}
163 + {menu ? <MenuBlock menu={menu} /> : (
164 + <div className="notice">
165 + <h2>Menu à venir</h2>
166 + <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p>
167 + </div>
168 + )}
169 + </div>
170 +
171 + <aside className="panel">
172 + <div className="panel-head">
173 + {(resto.images ?? [])[0] && (
174 + <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" />
175 + )}
176 + {resto.price_range && <div className="price">{resto.price_range}</div>}
177 + </div>
178 + <h1>{resto.name}</h1>
179 + <div className="loc">
180 + {resto.address}{resto.address && resto.city ? ", " : ""}{resto.city}
181 + {resto.postal_code ? ` (${resto.postal_code})` : ""}
182 + </div>
183 + <div className="kv">
184 + <div className="cell">
185 + <div className="k">Région</div>
186 + <div className="v">{resto.region || "—"}</div>
187 + </div>
188 + <div className="cell">
189 + <div className="k">Type</div>
190 + <div className="v">{typeLabel(resto.establishment_type)}</div>
191 + </div>
192 + {resto.chain && (
193 + <div className="cell">
194 + <div className="k">Chaîne</div>
195 + <div className="v">{resto.chain}</div>
196 + </div>
197 + )}
198 + <div className="cell">
199 + <div className="k">Statut</div>
200 + <div className="v">{resto.status === "open" ? "Ouvert" : "Fermé temporairement"}</div>
201 + </div>
202 + </div>
203 + {resto.cuisines.length > 0 && (
204 + <>
205 + <div className="klabel">Cuisines</div>
206 + <div className="amenity-row">
207 + {resto.cuisines.map((c) => (
208 + <span className="amenity" key={c}>{cuisineLabel(c)}</span>
209 + ))}
210 + </div>
211 + </>
212 + )}
213 + {resto.dietary_options.length > 0 && (
214 + <>
215 + <div className="klabel">Diètes offertes</div>
216 + <div className="amenity-row">
217 + {resto.dietary_options.map((d) => (
218 + <span className="amenity" key={d}>{dietLabel(d)}</span>
219 + ))}
220 + </div>
221 + </>
222 + )}
223 + {(resto.website || resto.url) && (
224 + <a className="cta" href={resto.url || resto.website}
225 + target="_blank" rel="noopener noreferrer">
226 + Commander / voir chez le resto ↗
227 + </a>
228 + )}
229 + <div className="fine">
230 + Source : {sourceName(resto.source)} — les prix appartiennent au
231 + resto et peuvent changer sans préavis.
232 + </div>
233 + </aside>
234 + </div>
235 + </div>
236 + );
237 +}
added frontend/src/pages/Sources.tsx +76 −0
@@ -0,0 +1,76 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: pages/Sources.tsx
4 +// Desc: Registre public des sources connectées (même patron que la page
5 +// Sources de Lou·Ka) : plateforme, contexte de prix produit, volumes,
6 +// dernière synchronisation.
7 +// ==============================================================================
8 +import { useEffect, useState } from "react";
9 +import { CONTEXT_SHORT, Source, fetchSources, fmtTs } from "../api";
10 +
11 +export default function SourcesPage() {
12 + const [sources, setSources] = useState<Source[]>([]);
13 + const [error, setError] = useState(false);
14 +
15 + useEffect(() => {
16 + fetchSources().then((r) => setSources(r.sources)).catch(() => setError(true));
17 + }, []);
18 +
19 + return (
20 + <div className="container sources">
21 + <span className="kicker">Transparence</span>
22 + <h1>Les sources de Resto-Ka</h1>
23 + <p className="sub">
24 + Chaque menu est capté à sa source et étiqueté selon son contexte de
25 + prix. Les plateformes de commande des restos eux-mêmes (prix réels,
26 + non majorés) passent toujours avant les plateformes de livraison.
27 + </p>
28 + {error && <div className="notice"><h2>Impossible de charger les sources</h2></div>}
29 + <div className="src-wrap">
30 + <table className="src-table">
31 + <thead>
32 + <tr>
33 + <th>Source</th>
34 + <th>Contexte de prix</th>
35 + <th>Cadence</th>
36 + <th>Restos actifs</th>
37 + <th>Plats</th>
38 + <th>Dernière synchro</th>
39 + <th>Statut</th>
40 + </tr>
41 + </thead>
42 + <tbody>
43 + {sources.map((s) => (
44 + <tr key={s.id}>
45 + <td>
46 + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
47 + {s.integrations && (
48 + <div className="src-integrations">
49 + {s.integrations.filter((i) => i.status !== "inactif").length} intégrations :{" "}
50 + {s.integrations.filter((i) => i.status !== "inactif")
51 + .map((i) => i.label).join(" · ")}
52 + </div>
53 + )}
54 + </td>
55 + <td>
56 + <span className={`pill ctx-pill ctx-${s.price_context}`}>
57 + {CONTEXT_SHORT[s.price_context ?? ""] ?? s.price_context}
58 + </span>
59 + </td>
60 + <td>{s.cadence ?? "—"}</td>
61 + <td><span className="count-pill">{s.active_restaurants}</span></td>
62 + <td><span className="count-pill">{s.menu_items ?? 0}</span></td>
63 + <td>{fmtTs(s.last_sync)}</td>
64 + <td>
65 + <span className={`pill ${s.status === "actif" ? "ok" : "todo"}`}>
66 + {s.status}
67 + </span>
68 + </td>
69 + </tr>
70 + ))}
71 + </tbody>
72 + </table>
73 + </div>
74 + </div>
75 + );
76 +}
added frontend/src/pages/Stats.tsx +129 −0
@@ -0,0 +1,129 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: pages/Stats.tsx
4 +// Desc: Statistiques de couverture — tuiles + barres horizontales par région
5 +// et par contexte de prix, journal des synchronisations (patron des
6 +// tuiles/hbars de la page Stats de Lou·Ka).
7 +// ==============================================================================
8 +import { useEffect, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import { CONTEXT_LABELS, Stats, fetchStats, fmtTs, sourceName } from "../api";
11 +
12 +function HBars({ rows }: { rows: { label: string; n: number; to?: string }[] }) {
13 + const max = Math.max(1, ...rows.map((r) => r.n));
14 + return (
15 + <div className="hbars">
16 + {rows.map((r) => (
17 + <div className="hbar-row" key={r.label}>
18 + <span className="hbar-label">
19 + {r.to ? <Link to={r.to}>{r.label}</Link> : r.label}
20 + </span>
21 + <span className="hbar-track">
22 + <span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} />
23 + </span>
24 + <span className="hbar-value">{r.n.toLocaleString("fr-CA")}</span>
25 + </div>
26 + ))}
27 + </div>
28 + );
29 +}
30 +
31 +export default function StatsPage() {
32 + const [stats, setStats] = useState<Stats | null>(null);
33 + const [error, setError] = useState(false);
34 +
35 + useEffect(() => {
36 + fetchStats().then(setStats).catch(() => setError(true));
37 + }, []);
38 +
39 + if (error) return <div className="notice container"><h2>Stats indisponibles</h2></div>;
40 + if (!stats) return <div className="notice container"><p>Chargement…</p></div>;
41 +
42 + return (
43 + <div className="container stats-page">
44 + <span className="kicker">Couverture</span>
45 + <h1 className="stats-title">La table du Québec, en chiffres</h1>
46 + <p className="sub">
47 + Volumes agrégés en direct — restaurants actifs, plats avec prix,
48 + régions couvertes et fraîcheur des synchronisations.
49 + </p>
50 +
51 + <div className="tiles">
52 + <div className="tile hero-tile">
53 + <div className="tile-v">{stats.restaurants.toLocaleString("fr-CA")}</div>
54 + <div className="tile-k">Restaurants actifs</div>
55 + </div>
56 + <div className="tile">
57 + <div className="tile-v">{stats.items.toLocaleString("fr-CA")}</div>
58 + <div className="tile-k">Plats avec prix</div>
59 + </div>
60 + <div className="tile">
61 + <div className="tile-v">{stats.menus.toLocaleString("fr-CA")}</div>
62 + <div className="tile-k">Menus captés</div>
63 + </div>
64 + <div className="tile">
65 + <div className="tile-v">{stats.regions}</div>
66 + <div className="tile-k">Régions couvertes / 17</div>
67 + </div>
68 + <div className="tile">
69 + <div className="tile-v">{stats.chains.toLocaleString("fr-CA")}</div>
70 + <div className="tile-k">Chaînes suivies</div>
71 + </div>
72 + </div>
73 +
74 + <div className="viz-grid">
75 + <div className="viz-card">
76 + <h2>Restos par région</h2>
77 + <div className="viz-sub">Fiches actives, doublons masqués</div>
78 + <HBars rows={stats.by_region.map((r) => ({
79 + label: r.region, n: r.n,
80 + to: `/?region=${encodeURIComponent(r.region)}`,
81 + }))} />
82 + </div>
83 + <div className="viz-card">
84 + <h2>Menus par contexte de prix</h2>
85 + <div className="viz-sub">Un prix n'est jamais présenté sans son contexte</div>
86 + <HBars rows={stats.by_context.map((c) => ({
87 + label: CONTEXT_LABELS[c.price_context] ?? c.price_context, n: c.n,
88 + }))} />
89 + </div>
90 + </div>
91 +
92 + <div className="viz-card">
93 + <h2>Dernières synchronisations</h2>
94 + <div className="viz-sub">Journal du pipeline d'ingestion</div>
95 + <div className="src-wrap" style={{ boxShadow: "none", border: "none" }}>
96 + <table className="src-table">
97 + <thead>
98 + <tr>
99 + <th>Source</th><th>Quand</th><th>Trouvés</th><th>Ajoutés</th>
100 + <th>Mis à jour</th><th>Retirés</th><th>État</th>
101 + </tr>
102 + </thead>
103 + <tbody>
104 + {stats.recent_syncs.map((s, i) => (
105 + <tr key={i}>
106 + <td>{sourceName(s.source)}</td>
107 + <td>{fmtTs(s.ts)}</td>
108 + <td>{s.found}</td>
109 + <td>{s.added}</td>
110 + <td>{s.updated}</td>
111 + <td>{s.removed}</td>
112 + <td>
113 + <span className={`pill ${s.ok ? "ok" : "todo"}`}>
114 + {s.ok ? (s.message === "ok" ? "ok" : "alerte") : "échec"}
115 + </span>
116 + </td>
117 + </tr>
118 + ))}
119 + </tbody>
120 + </table>
121 + </div>
122 + </div>
123 + <div className="stats-foot">
124 + Les prix appartiennent aux restaurants et à leurs plateformes ;
125 + Resto-Ka les agrège pour la découverte et renvoie toujours à la source.
126 + </div>
127 + </div>
128 + );
129 +}
added frontend/src/styles.css +812 −0
@@ -0,0 +1,812 @@
1 +/* -----------------------------------------------------------------------------
2 + Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 + File: styles.css
4 + Desc: Identité visuelle Resto·Ka — système de design « éditorial sharp »
5 + du GROUPE KA (identité partagée avec groupe-ka.com, Lou·Ka, Vrai-Prix,
6 + ValoPlex) :
7 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
8 + · Palette : papier #f5f3ee · encre #141814 · vert profond #1c5c41 ·
9 + lime électrique #d9f26b · ambre #e8a33d
10 + · Signature : bordures encre 1,5 px + ombres décalées dures (néo-brutalisme
11 + raffiné), grain de film pleine page, surlignés lime inclinés
12 + · Baseline : « Chaque resto, chaque plat, chaque prix. »
13 + · 100 % adaptatif mobile (safe-areas iOS)
14 +----------------------------------------------------------------------------- */
15 +:root {
16 + /* palette officielle Groupe KA (éditorial sharp) */
17 + --paper: #f5f3ee;
18 + --surface: #ffffff;
19 + --surface-2: #faf9f5;
20 + --ink: #141814;
21 + --ink-2: #4d5551;
22 + --ink-3: #8b928c;
23 + --line: rgba(20, 24, 20, 0.14);
24 + --line-strong: rgba(20, 24, 20, 0.85);
25 + --green: #1c5c41;
26 + --green-deep: #123f2e;
27 + --lime: #d9f26b;
28 + --lime-soft: #f0f9d2;
29 + --amber: #e8a33d;
30 + --amber-soft: #fdf3e2;
31 + --danger: #b3423a;
32 + --danger-soft: #fbe9e7;
33 +
34 + /* géométrie sharp : petits rayons, bordures franches */
35 + --r-card: 10px;
36 + --r-ctl: 6px;
37 + --r-pill: 999px;
38 +
39 + /* ombres décalées dures — la signature du groupe */
40 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
41 + --shadow-off: 6px 6px 0 var(--ink);
42 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
43 + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
44 +
45 + --font-display: "Space Grotesk", system-ui, sans-serif;
46 + --font-body: "Inter", system-ui, sans-serif;
47 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
48 +}
49 +
50 +* { box-sizing: border-box; }
51 +html { scroll-behavior: smooth; }
52 +body {
53 + margin: 0;
54 + background: var(--paper);
55 + color: var(--ink);
56 + font-family: var(--font-body);
57 + font-size: 15px;
58 + line-height: 1.55;
59 + -webkit-font-smoothing: antialiased;
60 + padding-bottom: env(safe-area-inset-bottom);
61 +}
62 +/* grain de film pleine page (signature Groupe KA) */
63 +body::before {
64 + content: "";
65 + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
66 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
67 +}
68 +#root { position: relative; z-index: 1; }
69 +
70 +h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.03em; margin: 0; }
71 +a { color: inherit; text-decoration: none; }
72 +button { font-family: inherit; }
73 +img { display: block; }
74 +::selection { background: var(--lime); color: var(--ink); }
75 +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
76 +
77 +/* surligné lime incliné — la marque des titres du groupe */
78 +h1 .hl, h2 .hl, .fbaseline .hl {
79 + display: inline-block; background: var(--lime); color: var(--ink);
80 + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
81 +}
82 +.kicker {
83 + display: inline-flex; align-items: center; gap: 10px;
84 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
85 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
86 +}
87 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
88 +
89 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
90 +@media (max-width: 640px) { .container { padding: 0 16px; } }
91 +
92 +/* ================= Header ================= */
93 +.header {
94 + position: sticky; top: 0; z-index: 50;
95 + background: rgba(245, 243, 238, 0.88);
96 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
97 + border-bottom: 1.5px solid var(--ink);
98 +}
99 +.header-inner { display: flex; align-items: center; gap: 20px; height: 66px; }
100 +.brand {
101 + font-family: var(--font-display); font-weight: 700; font-size: 24px;
102 + letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;
103 + color: var(--ink);
104 +}
105 +.brand .logo-ico { margin-right: 11px; flex: none; }
106 +/* « Ka » en pastille lime inclinée — même geste que la carte membre Groupe KA */
107 +.brand .ka {
108 + background: var(--lime); color: var(--ink);
109 + padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px;
110 + display: inline-block; transform: rotate(-2deg);
111 +}
112 +.brand-tag {
113 + font-family: var(--font-mono); font-size: 10px; font-weight: 500;
114 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
115 + margin-left: 14px;
116 +}
117 +@media (max-width: 900px) { .brand-tag { display: none; } }
118 +.nav { margin-left: auto; display: flex; gap: 6px; }
119 +.nav a {
120 + padding: 8px 16px; border: 1.5px solid transparent; border-radius: var(--r-ctl);
121 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
122 + color: var(--ink); transition: all 0.13s ease; min-height: 38px;
123 + display: inline-flex; align-items: center;
124 +}
125 +.nav a:hover { background: var(--lime); border-color: var(--ink); }
126 +.nav a.active { background: var(--ink); color: var(--lime); border-color: var(--ink); }
127 +
128 +/* ================= Ticker ================= */
129 +.ticker {
130 + background: var(--ink); color: rgba(245, 243, 238, 0.85); overflow: hidden;
131 + font-family: var(--font-mono); font-size: 11px; font-weight: 500;
132 + letter-spacing: 0.08em; text-transform: uppercase; padding: 7px 0;
133 + white-space: nowrap; border-bottom: 1.5px solid var(--ink);
134 +}
135 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }
136 +.ticker span { padding: 0 26px; position: relative; }
137 +.ticker span::after { content: "◆"; position: absolute; right: -6px; color: var(--lime); opacity: 0.9; font-size: 8px; top: 3px; }
138 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
139 +@media (prefers-reduced-motion: reduce) {
140 + .ticker-track { animation: none; }
141 + * { transition-duration: 0.01ms !important; }
142 +}
143 +
144 +/* ================= Hero ================= */
145 +.hero { padding: 58px 0 22px; }
146 +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
147 +.hero h1 {
148 + font-size: clamp(36px, 5.4vw, 58px); font-weight: 700; line-height: 1.04;
149 + letter-spacing: -0.04em; max-width: 920px; margin-top: 16px; color: var(--ink);
150 +}
151 +.hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; }
152 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
153 +.stat-chip {
154 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
155 + padding: 7px 14px; font-size: 12.5px; font-weight: 500;
156 + color: var(--ink-2); display: flex; gap: 8px; align-items: center;
157 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
158 +}
159 +.stat-chip b { color: var(--ink); font-weight: 700; font-family: var(--font-display); }
160 +.stat-chip .pulse {
161 + width: 8px; height: 8px; border-radius: 50%; background: var(--green);
162 + box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite;
163 +}
164 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(28, 92, 65, 0.18); } }
165 +
166 +/* ================= Rangée d'avantages ================= */
167 +.benefits {
168 + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
169 + gap: 14px; margin-top: 30px;
170 +}
171 +.benefit { display: flex; align-items: center; gap: 12px; }
172 +.benefit-ico {
173 + width: 42px; height: 42px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
174 + flex: none; display: flex; align-items: center; justify-content: center;
175 + background: var(--lime); color: var(--ink); box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);
176 +}
177 +.benefit-ico.blue { background: var(--surface); color: var(--green); }
178 +.benefit-label {
179 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
180 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink);
181 +}
182 +@media (max-width: 640px) { .benefits { grid-template-columns: 1fr 1fr; gap: 12px; } }
183 +
184 +/* ================= Filter bar ================= */
185 +.filterbar {
186 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
187 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 20px 0 6px;
188 + display: flex; flex-direction: column; gap: 0; min-width: 0;
189 +}
190 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
191 +.f-search {
192 + flex: 1 1 220px; min-width: 0; max-width: 440px; align-self: center;
193 + display: flex; align-items: center; gap: 8px;
194 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface-2);
195 + padding: 0 13px; min-height: 46px; color: var(--ink-2);
196 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);
197 + transition: box-shadow 0.15s ease, background 0.15s ease;
198 +}
199 +.f-search:focus-within {
200 + background: var(--white, #fff); outline: 2px solid var(--green); outline-offset: 1px;
201 + box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25);
202 +}
203 +.f-search input {
204 + border: none; background: none; outline: none; flex: 1; min-width: 0;
205 + font-size: 14px; color: var(--ink); font-family: inherit;
206 +}
207 +.f-clear {
208 + border: 1px solid var(--line); background: var(--paper); color: var(--ink-2);
209 + border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer;
210 + flex: none; display: grid; place-items: center;
211 +}
212 +.f-ctl {
213 + flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;
214 + gap: 2px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface);
215 + padding: 7px 14px 6px; min-height: 52px; cursor: pointer;
216 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);
217 + transition: box-shadow 0.15s ease;
218 +}
219 +.f-ctl:focus-within { outline: 2px solid var(--green); outline-offset: 1px; box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25); }
220 +.f-ctl > span {
221 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
222 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
223 +}
224 +.f-ctl select {
225 + border: none; background: transparent; outline: none; font-family: var(--font-display);
226 + font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;
227 + appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;
228 + text-overflow: ellipsis;
229 + 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");
230 + background-repeat: no-repeat; background-position: right center;
231 +}
232 +
233 +/* pastilles de filtres actifs */
234 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; }
235 +.pill {
236 + display: inline-flex; align-items: center; gap: 7px;
237 + border: 1.5px solid var(--ink); background: var(--lime); color: var(--ink);
238 + border-radius: var(--r-ctl); padding: 5px 12px; font-size: 12.5px; font-weight: 700;
239 + font-family: var(--font-display); cursor: pointer;
240 + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2); transition: all 0.12s ease;
241 +}
242 +.pill:hover { background: var(--ink); color: var(--lime); }
243 +.pill-x { font-size: 10px; opacity: 0.65; }
244 +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
245 +.pill-clear:hover { background: var(--danger); color: #fff; }
246 +.link-btn {
247 + background: none; border: none; padding: 0; color: var(--green); font: inherit;
248 + text-decoration: underline; cursor: pointer;
249 +}
250 +.btn {
251 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
252 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 10px 20px;
253 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
254 + cursor: pointer; min-height: 44px;
255 + transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.1s ease;
256 +}
257 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
258 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: var(--shadow-off-mid); }
259 +.btn-primary:hover { background: var(--green-deep); }
260 +.btn-ghost { background: transparent; color: var(--ink); }
261 +.btn-ghost:hover { background: var(--lime); }
262 +.btn:disabled { opacity: 0.4; cursor: default; }
263 +
264 +/* ================= Chips ================= */
265 +.chips { display: flex; gap: 8px; margin: 18px 0 4px; overflow-x: auto; padding: 4px 4px 8px 0; scrollbar-width: none; }
266 +.chips::-webkit-scrollbar { display: none; }
267 +.chip {
268 + border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);
269 + border-radius: var(--r-ctl); padding: 8px 16px; font-size: 13px; font-weight: 700;
270 + cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px;
271 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); transition: all 0.13s ease;
272 +}
273 +.chip:hover { background: var(--lime-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.15); }
274 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.3); }
275 +
276 +/* ================= Results ================= */
277 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }
278 +.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }
279 +.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; }
280 +.grid {
281 + display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
282 + gap: 22px; padding-bottom: 30px;
283 +}
284 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }
285 +
286 +.card {
287 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
288 + overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);
289 + transition: transform 0.18s ease, box-shadow 0.18s ease;
290 +}
291 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
292 +.card:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
293 +.card-img { position: relative; aspect-ratio: 16/9; background: var(--surface-2); overflow: hidden; border-bottom: 1.5px solid var(--ink); }
294 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
295 +.card:hover .card-img img { transform: scale(1.04); }
296 +.card-img .noimg {
297 + display: flex; align-items: center; justify-content: center; height: 100%;
298 + color: var(--ink-3);
299 + background:
300 + radial-gradient(circle at 82% 20%, var(--lime-soft) 0, transparent 46%),
301 + radial-gradient(circle at 12% 85%, var(--amber-soft) 0, transparent 42%),
302 + var(--surface-2);
303 +}
304 +.card-logo { max-width: 46%; max-height: 68%; object-fit: contain; }
305 +.card:hover .card-logo { transform: none; }
306 +.badge {
307 + position: absolute; top: 10px; left: 10px; border: 1.5px solid var(--ink);
308 + border-radius: var(--r-ctl); padding: 3px 10px;
309 + font-family: var(--font-mono); font-size: 10px; font-weight: 700; letter-spacing: 0.05em;
310 + background: rgba(255, 255, 255, 0.95); color: var(--ink);
311 + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.25);
312 +}
313 +.badge.type { background: var(--ink); color: var(--lime); }
314 +.badge.right { left: auto; right: 10px; }
315 +/* badge de contexte de prix — TOUJOURS visible (un prix sans contexte est inutilisable) */
316 +.badge.ctx-takeout, .ctx-pill.ctx-takeout { background: var(--lime); color: var(--ink); }
317 +.badge.ctx-dine-in, .ctx-pill.ctx-dine-in { background: var(--green); color: #fff; }
318 +.badge.ctx-delivery, .ctx-pill.ctx-delivery { background: var(--amber); color: var(--ink); }
319 +.card-body { padding: 15px 17px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
320 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.03em; color: var(--ink); }
321 +.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; }
322 +.card-price .no-menu { font-size: 14px; font-weight: 600; color: var(--ink-3); }
323 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); }
324 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
325 +.card-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; }
326 +.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; }
327 +.source-tag {
328 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
329 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--green);
330 + background: var(--lime-soft); border: 1px solid rgba(28, 92, 65, 0.35);
331 + border-radius: var(--r-ctl); padding: 3px 9px;
332 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
333 +}
334 +.avail { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); font-weight: 500; text-align: right; }
335 +
336 +/* ================= Bascule Restos / Plats ================= */
337 +.mode-toggle {
338 + display: inline-flex; align-self: center; border: 1.5px solid var(--ink);
339 + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);
340 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); flex: none;
341 +}
342 +.mode-toggle button {
343 + border: 0; background: transparent; padding: 12px 20px; cursor: pointer;
344 + font-family: var(--font-display); font-size: 14px; font-weight: 700; color: var(--ink-2);
345 + min-height: 46px;
346 +}
347 +.mode-toggle button + button { border-left: 1.5px solid var(--ink); }
348 +.mode-toggle button.on { background: var(--ink); color: var(--lime); }
349 +
350 +/* ================= Résultats par plat ================= */
351 +.dish-list {
352 + display: flex; flex-direction: column; gap: 12px; padding-bottom: 10px;
353 +}
354 +.dish-row {
355 + display: flex; gap: 14px; align-items: flex-start;
356 + background: var(--surface); border: 1.5px solid var(--ink);
357 + border-radius: var(--r-card); padding: 14px 16px;
358 + box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.08);
359 + transition: transform 0.15s ease, box-shadow 0.15s ease;
360 +}
361 +.dish-row:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
362 +.dish-img {
363 + width: 62px; height: 62px; flex: none; object-fit: cover;
364 + border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2);
365 +}
366 +.dish-noimg { display: flex; align-items: center; justify-content: center; color: var(--ink-3); }
367 +.dish-main { flex: 1; min-width: 0; }
368 +.dish-line { display: flex; align-items: baseline; gap: 10px; }
369 +.dish-name { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); }
370 +.dish-price { font-family: var(--font-display); font-weight: 700; font-size: 16.5px; color: var(--ink); white-space: nowrap; }
371 +.dish-desc {
372 + margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 620px;
373 + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
374 +}
375 +.dish-meta {
376 + display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
377 + margin-top: 7px; font-size: 12px; color: var(--ink-2);
378 +}
379 +.dish-meta b { color: var(--ink); }
380 +.dish-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; }
381 +.dish-meta .ctx-pill {
382 + border: 1px solid var(--ink); border-radius: 4px; padding: 2px 8px;
383 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
384 + text-transform: uppercase; letter-spacing: 0.05em;
385 +}
386 +
387 +/* ================= Pager (numéroté) ================= */
388 +.pager {
389 + display: flex; align-items: center; justify-content: center; gap: 6px;
390 + flex-wrap: wrap; padding: 18px 0 60px;
391 +}
392 +.pg-btn {
393 + min-width: 40px; min-height: 40px; padding: 0 12px;
394 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
395 + background: var(--surface); color: var(--ink); cursor: pointer;
396 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px;
397 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);
398 + transition: all 0.12s ease;
399 +}
400 +.pg-btn:hover:not(:disabled) { background: var(--lime-soft); }
401 +.pg-btn.on { background: var(--ink); color: var(--lime); }
402 +.pg-btn:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
403 +.pg-btn:active:not(:disabled) { transform: translate(2px, 2px); box-shadow: none; }
404 +.pg-ellipsis { color: var(--ink-3); font-family: var(--font-mono); padding: 0 4px; }
405 +
406 +/* ================= Skeletons ================= */
407 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
408 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line-strong); overflow: hidden; background: var(--surface); }
409 +.skel .sk-img, .skel .sk-line {
410 + background: linear-gradient(90deg, #eceae4 25%, #f6f4ef 50%, #eceae4 75%);
411 + background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
412 +}
413 +.skel .sk-img { aspect-ratio: 16/9; }
414 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
415 +.skel .sk-line.short { width: 45%; }
416 +
417 +/* ================= Empty / error ================= */
418 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
419 +.notice .big { font-size: 44px; margin-bottom: 10px; color: var(--green); }
420 +.notice h2 { color: var(--ink); }
421 +
422 +/* ================= Fiche resto ================= */
423 +.detail { padding: 30px 0 90px; }
424 +.crumbs {
425 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;
426 + letter-spacing: 0.08em; text-transform: uppercase;
427 + color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
428 +}
429 +.crumbs a { border-bottom: 1.5px solid transparent; }
430 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
431 +.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
432 +@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }
433 +.detail-grid > * { min-width: 0; }
434 +
435 +.panel {
436 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
437 + box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px;
438 +}
439 +@media (max-width: 900px) { .panel { position: static; order: -1; } }
440 +.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
441 +.panel-logo { max-width: 110px; max-height: 54px; object-fit: contain; border-radius: var(--r-ctl); }
442 +.panel .price {
443 + font-family: var(--font-display); font-size: 30px; font-weight: 700;
444 + letter-spacing: -0.03em; color: var(--ink);
445 + background: var(--lime); border-radius: 8px; padding: 0 10px 2px;
446 + display: inline-block; transform: rotate(-1deg);
447 +}
448 +.panel h1 { font-size: 23px; margin: 10px 0 2px; }
449 +.panel .loc { color: var(--ink-2); font-size: 14px; }
450 +.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
451 +@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }
452 +.kv .cell { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 10px 14px; }
453 +.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; }
454 +.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); color: var(--ink); }
455 +.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; }
456 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }
457 +.amenity {
458 + background: var(--lime-soft); color: var(--green); font-size: 12px; font-weight: 600;
459 + border: 1px solid rgba(28, 92, 65, 0.35); border-radius: var(--r-ctl); padding: 4px 11px;
460 +}
461 +.cta {
462 + display: block; text-align: center; background: var(--ink); color: var(--lime);
463 + font-weight: 700; font-family: var(--font-display); border: 1.5px solid var(--ink);
464 + border-radius: var(--r-ctl); padding: 14px; min-height: 48px;
465 + box-shadow: var(--shadow-off-mid); transition: all 0.14s ease;
466 +}
467 +.cta:hover { background: var(--green-deep); }
468 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
469 +.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; }
470 +
471 +/* --- menu (sections, items, options) --------------------------------------- */
472 +.view-toggle {
473 + display: inline-flex; border: 1.5px solid var(--ink);
474 + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);
475 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
476 +}
477 +.view-toggle button {
478 + border: 0; background: transparent; padding: 8px 16px; cursor: pointer;
479 + font-family: var(--font-display); font-size: 12.5px; font-weight: 700; color: var(--ink-2);
480 +}
481 +.view-toggle button + button { border-left: 1.5px solid var(--ink); }
482 +.view-toggle button.on { background: var(--ink); color: var(--lime); }
483 +.ctx-toggle { margin-bottom: 14px; }
484 +
485 +.menu-block { display: flex; flex-direction: column; gap: 16px; }
486 +.ctx-banner {
487 + border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 12px 18px;
488 + display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;
489 + font-size: 12.5px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);
490 +}
491 +.ctx-banner b { font-family: var(--font-display); font-size: 14px; }
492 +.ctx-banner span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); }
493 +.ctx-banner.ctx-takeout { background: var(--lime-soft); }
494 +.ctx-banner.ctx-takeout b { color: var(--green); }
495 +.ctx-banner.ctx-dine-in { background: var(--surface); }
496 +.ctx-banner.ctx-dine-in b { color: var(--green-deep); }
497 +.ctx-banner.ctx-delivery { background: var(--amber-soft); }
498 +.ctx-banner.ctx-delivery b { color: #9a6414; }
499 +
500 +.menu-section {
501 + background: var(--surface); border: 1.5px solid var(--ink);
502 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden;
503 +}
504 +.menu-section-head {
505 + width: 100%; display: flex; align-items: center; gap: 12px;
506 + border: none; background: none; cursor: pointer;
507 + padding: 14px 18px; text-align: left;
508 +}
509 +.menu-section-head h3 { font-size: 17px; color: var(--ink); letter-spacing: -0.02em; flex: 1; }
510 +.menu-section-head:hover { background: var(--lime-soft); }
511 +.ms-img {
512 + width: 36px; height: 36px; object-fit: cover; border-radius: var(--r-ctl);
513 + border: 1.5px solid var(--ink); flex: none;
514 +}
515 +.ms-meta { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 600; white-space: nowrap; }
516 +.menu-items { list-style: none; margin: 0; padding: 0 18px 14px; }
517 +.menu-item { padding: 12px 0; border-top: 1px solid var(--line); }
518 +.mi-row { display: flex; gap: 14px; align-items: flex-start; }
519 +.mi-main { flex: 1; min-width: 0; }
520 +.mi-line { display: flex; align-items: baseline; gap: 10px; }
521 +.mi-name { font-weight: 600; font-size: 14.5px; color: var(--ink); }
522 +.mi-dots { flex: 1; border-bottom: 2px dotted var(--line-strong); opacity: 0.35; transform: translateY(-3px); min-width: 20px; }
523 +.mi-price { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); white-space: nowrap; }
524 +.mi-desc { margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 560px; }
525 +.mi-thumb {
526 + width: 68px; height: 68px; flex: none; object-fit: cover;
527 + border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2);
528 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
529 +}
530 +.mi-tag {
531 + font-style: normal; font-family: var(--font-mono); font-size: 9px; font-weight: 700;
532 + text-transform: uppercase; letter-spacing: 0.06em; border-radius: 4px;
533 + padding: 2px 7px; margin-left: 7px;
534 + background: var(--lime-soft); color: var(--green); border: 1px solid rgba(28, 92, 65, 0.3);
535 + vertical-align: 2px;
536 +}
537 +.mi-tag-epice { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.5); }
538 +.mi-tag-rupture { background: var(--surface-2); color: var(--ink-3); border-color: var(--line); }
539 +
540 +/* groupes d'options : blocs structurés, choix en pastilles avec ± prix */
541 +.mi-optgroups {
542 + margin-top: 10px; display: flex; flex-direction: column; gap: 8px;
543 + background: var(--surface-2); border: 1px solid var(--line);
544 + border-radius: var(--r-ctl); padding: 10px 12px;
545 +}
546 +.og-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
547 +.og-name {
548 + font-family: var(--font-mono); font-weight: 700; color: var(--ink);
549 + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
550 +}
551 +.og-req {
552 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
553 + text-transform: uppercase; letter-spacing: 0.05em; border-radius: 4px; padding: 2px 7px;
554 + background: var(--surface); color: var(--ink-3); border: 1px solid var(--line);
555 +}
556 +.og-req.on { background: var(--lime); color: var(--ink); border-color: var(--ink); }
557 +.og-choices { display: flex; flex-wrap: wrap; gap: 6px; }
558 +.og-choice {
559 + display: inline-flex; align-items: center; gap: 6px;
560 + background: var(--surface); border: 1px solid var(--line-strong);
561 + border-radius: var(--r-ctl); padding: 4px 10px;
562 + font-size: 12px; color: var(--ink-2);
563 +}
564 +.og-choice b { font-family: var(--font-mono); font-weight: 700; font-size: 11px; }
565 +.og-choice b.plus { color: #9a6414; }
566 +.og-choice b.moins { color: var(--green); }
567 +
568 +/* ================= Sources page ================= */
569 +.sources { padding: 44px 0 90px; }
570 +.sources h1 { font-size: clamp(28px, 4vw, 42px); margin: 12px 0 6px; color: var(--ink); }
571 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }
572 +.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); }
573 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }
574 +.src-table th {
575 + text-align: left; font-family: var(--font-mono); font-size: 10px;
576 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-2);
577 + padding: 13px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2);
578 +}
579 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
580 +.src-table tr:last-child td { border-bottom: none; }
581 +.src-table tr:hover td { background: var(--lime-soft); }
582 +.src-table a { color: var(--green); font-weight: 600; border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); }
583 +.src-integrations { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; max-width: 380px; }
584 +.src-table .pill, .pill.ok, .pill.todo {
585 + display: inline-block; border-radius: 4px; padding: 3px 9px;
586 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
587 + text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--ink);
588 + box-shadow: none; cursor: default; transform: none;
589 +}
590 +.pill.ok { background: var(--lime); color: var(--ink); }
591 +.pill.todo { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.6); }
592 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; color: var(--ink); }
593 +
594 +/* ================= Page Statistiques ================= */
595 +.stats-page { padding: 44px 0 90px; }
596 +.stats-title { font-size: clamp(30px, 4.6vw, 48px); margin: 12px 0 6px; color: var(--ink); }
597 +.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }
598 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
599 +.tile {
600 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
601 + padding: 18px 20px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);
602 +}
603 +.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); }
604 +.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; }
605 +.hero-tile { background: var(--ink); color: var(--paper); border-color: var(--ink); box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.25); }
606 +.hero-tile .tile-v { color: var(--lime); }
607 +.hero-tile .tile-k { color: rgba(245, 243, 238, 0.6); }
608 +
609 +.viz-card {
610 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
611 + box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;
612 +}
613 +.viz-card h2 { font-size: 20px; letter-spacing: -0.02em; color: var(--ink); }
614 +.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; }
615 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
616 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
617 +
618 +.hbars { display: flex; flex-direction: column; gap: 7px; }
619 +.hbar-row {
620 + display: grid; grid-template-columns: minmax(96px, 190px) 1fr auto;
621 + gap: 12px; align-items: center; min-height: 26px; cursor: default;
622 +}
623 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
624 +.hbar-label a { border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); }
625 +.hbar-label a:hover { color: var(--green); }
626 +.hbar-track { background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 4px; height: 18px; overflow: hidden; }
627 +.hbar-fill {
628 + display: block; height: 100%; background: var(--green); border-radius: 0;
629 + min-width: 2px; transition: background 0.12s ease;
630 +}
631 +.hbar-row:hover .hbar-fill { background: var(--lime); }
632 +.hbar-value { font-family: var(--font-mono); font-size: 11px; font-weight: 700; white-space: nowrap; color: var(--ink); }
633 +@media (max-width: 640px) {
634 + .hbar-row { grid-template-columns: minmax(80px, 120px) 1fr auto; gap: 8px; }
635 + .viz-card { padding: 20px 16px 14px; }
636 +}
637 +.stats-foot { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.02em; margin-top: 6px; }
638 +
639 +/* ================= Mobile : feuille de filtres + FAB ================= */
640 +.sheet-head { display: none; }
641 +.sheet-apply { display: none; }
642 +.sheet-backdrop { display: none; }
643 +.fab { display: none; }
644 +
645 +@media (max-width: 640px) {
646 + .header-inner { height: 58px; }
647 + .brand { font-size: 20px; }
648 + .nav a { padding: 8px 13px; font-size: 13.5px; }
649 + .ticker { font-size: 10px; padding: 6px 0; }
650 + .hero h1 { font-size: clamp(30px, 8.6vw, 42px); }
651 + .hero p.lede { font-size: 15px; }
652 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
653 + .stat-row::-webkit-scrollbar { display: none; }
654 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
655 +
656 + /* la barre de filtres devient une feuille coulissante (bottom sheet) */
657 + .filterbar { display: none; }
658 + .filterbar.open .f-primary { flex-direction: column; }
659 + .filterbar.open .f-search { align-self: stretch; max-width: none; min-height: 46px; }
660 + .filterbar.open .f-search input { font-size: 16px; } /* anti-zoom iOS */
661 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
662 + .filterbar.open {
663 + display: flex; flex-direction: column; gap: 12px;
664 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
665 + margin: 0; border-radius: 16px 16px 0 0; border: none; border-top: 2px solid var(--ink);
666 + max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
667 + padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
668 + box-shadow: 0 -16px 48px rgba(20, 24, 20, 0.28);
669 + animation: sheet-up 0.22s ease;
670 + }
671 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
672 + .filterbar.open .sheet-head {
673 + display: flex; justify-content: space-between; align-items: center;
674 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
675 + letter-spacing: -0.01em; color: var(--ink);
676 + position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;
677 + border-bottom: 1px solid var(--line); margin-bottom: 2px; z-index: 1;
678 + }
679 + .sheet-close {
680 + border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl);
681 + width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; color: var(--ink);
682 + }
683 + .filterbar.open .sheet-apply { display: flex; width: 100%; }
684 + .sheet-backdrop {
685 + display: block; position: fixed; inset: 0; z-index: 90;
686 + background: rgba(20, 24, 20, 0.45); backdrop-filter: blur(2px);
687 + }
688 + .fab {
689 + display: flex; align-items: center; gap: 6px;
690 + position: fixed; left: 50%; transform: translateX(-50%);
691 + bottom: calc(74px + env(safe-area-inset-bottom)); z-index: 80;
692 + background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink);
693 + border-radius: var(--r-ctl); padding: 13px 24px; font-family: var(--font-display);
694 + font-weight: 700; font-size: 15px; cursor: pointer;
695 + box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.3);
696 + }
697 + .fab:active { transform: translateX(-50%) translate(2px, 2px); box-shadow: none; }
698 +
699 + .detail { padding-top: 20px; }
700 + .panel { padding: 20px; }
701 + .panel .price { font-size: 26px; }
702 + .results-head { margin-top: 20px; }
703 + .chips { margin-right: -16px; padding-right: 16px; }
704 + .notice { padding: 48px 16px; }
705 + .menu-items { padding: 0 14px 12px; }
706 + .menu-section-head { padding: 13px 14px; }
707 + .mi-thumb { width: 56px; height: 56px; }
708 +}
709 +
710 +/* ================= Barre de navigation inférieure (mobile) ================= */
711 +.tabbar { display: none; }
712 +@media (max-width: 760px) {
713 + .tabbar {
714 + display: grid;
715 + position: fixed; left: 0; right: 0; bottom: 0; z-index: 85;
716 + background: rgba(245, 243, 238, 0.94);
717 + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
718 + border-top: 1.5px solid var(--ink);
719 + padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
720 + }
721 + .tabbar-3 { grid-template-columns: repeat(3, 1fr); }
722 + .tabbar a {
723 + display: flex; flex-direction: column; align-items: center; gap: 3px;
724 + padding: 5px 2px; min-height: 48px; justify-content: center;
725 + font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--ink-3);
726 + border-radius: var(--r-ctl); transition: color 0.12s ease;
727 + }
728 + .tabbar a.active { color: var(--green); }
729 + .tabbar a svg { display: block; }
730 + body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
731 + .footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); }
732 +}
733 +
734 +/* Anti-zoom iOS : les champs doivent faire >= 16px */
735 +@media (max-width: 900px) {
736 + .f-search input, .f-ctl select { font-size: 16px; }
737 +}
738 +
739 +/* ================= Footer ================= */
740 +.footer {
741 + background: var(--ink); color: rgba(245, 243, 238, 0.72); margin-top: 20px;
742 + padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px;
743 + border-top: 2px solid var(--ink);
744 +}
745 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 30px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 6px; display: flex; align-items: center; }
746 +.footer .fbrand .logo-ico { margin-right: 13px; }
747 +.footer .fbrand .ka {
748 + background: var(--lime); color: var(--ink);
749 + padding: 1px 7px 3px; border-radius: 5px; margin-left: 4px;
750 + display: inline-block; transform: rotate(-2deg);
751 +}
752 +.footer .fbaseline { color: rgba(245, 243, 238, 0.65); font-size: 14px; margin-bottom: 16px; }
753 +.footer b { color: var(--paper); }
754 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
755 +.footer .fmono { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.04em; color: rgba(245, 243, 238, 0.42); margin-top: 18px; }
756 +
757 +/* --- Menu déroulant mobile ------------------------------------------------- */
758 +.menu-btn {
759 + display: none; position: relative; z-index: 60;
760 + width: 44px; height: 44px; margin-left: auto;
761 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
762 + background: var(--surface); cursor: pointer; padding: 0;
763 + flex-direction: column; align-items: center; justify-content: center; gap: 5px;
764 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);
765 + transition: box-shadow 0.15s ease, transform 0.15s ease;
766 +}
767 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: none; }
768 +.menu-btn span {
769 + display: block; width: 18px; height: 2px; background: var(--ink);
770 + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease;
771 +}
772 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
773 +.menu-btn.open span:nth-child(2) { opacity: 0; }
774 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
775 +
776 +.mobile-menu {
777 + display: none; position: absolute; left: 0; right: 0; top: 100%;
778 + background: var(--paper); border-bottom: 2px solid var(--ink);
779 + box-shadow: 0 18px 30px rgba(20, 24, 20, 0.14);
780 + padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
781 + max-height: 0; overflow: hidden; opacity: 0;
782 + transition: max-height 0.3s ease, opacity 0.22s ease;
783 +}
784 +.mobile-menu.open { max-height: 480px; opacity: 1; }
785 +.mm-link {
786 + display: flex; align-items: center; gap: 12px;
787 + padding: 15px 10px; min-height: 52px;
788 + font-family: var(--font-display); font-weight: 700; font-size: 19px;
789 + letter-spacing: -0.02em; border-bottom: 1px solid var(--line);
790 + color: var(--ink);
791 + opacity: 0; transform: translateY(-8px);
792 + transition: opacity 0.25s ease, transform 0.25s ease;
793 +}
794 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
795 +.mm-link.active { color: var(--green); }
796 +.mm-link.active .mm-arrow { opacity: 1; color: var(--green); }
797 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
798 +.mm-arrow { margin-left: auto; opacity: 0.25; color: var(--green); transition: opacity 0.15s ease; }
799 +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
800 +.mm-foot {
801 + padding: 12px 10px 4px;
802 + font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.02em;
803 +}
804 +.mm-backdrop {
805 + position: fixed; inset: 0; z-index: 40;
806 + background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px);
807 +}
808 +@media (max-width: 760px) {
809 + .menu-btn { display: flex; }
810 + .nav { display: none; }
811 + .mobile-menu { display: block; }
812 +}
added frontend/tsconfig.json +21 −0
@@ -0,0 +1,21 @@
1 +{
2 + "_comment": "Author: Simon-Pierre Boucher <contact@spboucher.ai> — File: tsconfig.json — Desc: Resto·Ka frontend, mêmes options que Lou·Ka.",
3 + "compilerOptions": {
4 + "target": "ES2020",
5 + "useDefineForClassFields": true,
6 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 + "module": "ESNext",
8 + "skipLibCheck": true,
9 + "moduleResolution": "bundler",
10 + "allowImportingTsExtensions": true,
11 + "resolveJsonModule": true,
12 + "isolatedModules": true,
13 + "noEmit": true,
14 + "jsx": "react-jsx",
15 + "strict": true,
16 + "noUnusedLocals": false,
17 + "noUnusedParameters": false,
18 + "noFallthroughCasesInSwitch": true
19 + },
20 + "include": ["src"]
21 +}
added frontend/vite.config.ts +16 −0
@@ -0,0 +1,16 @@
1 +// ==============================================================================
2 +// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +// File: vite.config.ts
4 +// Desc: Configuration Vite (proxy API en dev, build vers dist/) — même patron
5 +// que Lou·Ka.
6 +// ==============================================================================
7 +import { defineConfig } from "vite";
8 +import react from "@vitejs/plugin-react";
9 +
10 +export default defineConfig({
11 + plugins: [react()],
12 + server: {
13 + proxy: { "/api": "http://localhost:8115" },
14 + },
15 + build: { outDir: "dist" },
16 +});
added requirements.txt +9 −0
@@ -0,0 +1,9 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: requirements.txt
4 +# Desc: Resto·Ka — dépendances backend (mêmes versions plancher que Lou·Ka).
5 +# ==============================================================================
6 +fastapi>=0.110
7 +uvicorn>=0.29
8 +requests>=2.31
9 +beautifulsoup4>=4.12
added restoka/__init__.py +6 −0
@@ -0,0 +1,6 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/__init__.py
4 +# Desc: Resto·Ka — agrégateur des restaurants du Québec (menus & prix).
5 +# Paquet principal ; architecture calquée sur Lou·Ka (famille ·Ka).
6 +# ==============================================================================
added restoka/connectors/__init__.py +29 −0
@@ -0,0 +1,29 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/__init__.py
4 +# Desc: Registre AUTO-DÉCOUVRANT des connecteurs : tout module de ce paquet
5 +# contenant une sous-classe de BaseConnector avec un source_id non
6 +# vide est enregistré automatiquement (même patron que Lou·Ka).
7 +# ==============================================================================
8 +from __future__ import annotations
9 +
10 +import importlib
11 +import pkgutil
12 +import sys
13 +
14 +from .base import BaseConnector
15 +
16 +CONNECTORS: dict[str, type[BaseConnector]] = {}
17 +
18 +for _mod in pkgutil.iter_modules(__path__):
19 + if _mod.name in ("base", "__init__"):
20 + continue
21 + try:
22 + module = importlib.import_module(f"{__name__}.{_mod.name}")
23 + except Exception as exc: # un connecteur cassé ne bloque pas les autres
24 + print(f"[resto-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr)
25 + continue
26 + for obj in vars(module).values():
27 + if (isinstance(obj, type) and issubclass(obj, BaseConnector)
28 + and obj is not BaseConnector and getattr(obj, "source_id", "")):
29 + CONNECTORS[obj.source_id] = obj
added restoka/connectors/base.py +157 −0
@@ -0,0 +1,157 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/base.py
4 +# Desc: Classe de base des connecteurs + backends de fetch (requests direct,
5 +# Firecrawl ou Scrapfly pour les sites JavaScript / anti-bot).
6 +# Calquée sur louka/connectors/base.py — mêmes conventions.
7 +# ==============================================================================
8 +from __future__ import annotations
9 +
10 +import json
11 +import os
12 +import time
13 +
14 +import requests
15 +
16 +from ..schema import Restaurant
17 +
18 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
19 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
20 + "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)")
21 +
22 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
23 +SCRAPFLY_API = "https://api.scrapfly.io/scrape"
24 +
25 +
26 +class BaseConnector:
27 + """Un connecteur = un adaptateur propre à une source (ou une plateforme).
28 +
29 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
30 + la liste complète des restaurants (avec menu) actuellement publiés par la
31 + source. Le pipeline (ingest.py) s'occupe du diff avec la base de données.
32 + """
33 +
34 + source_id: str = ""
35 + request_delay: float = 0.6 # politesse entre requêtes
36 + timeout: int = 30
37 + use_detail_cache: bool = True # cache BD des payloads détail
38 +
39 + def __init__(self) -> None:
40 + self.session = requests.Session()
41 + self.session.headers["User-Agent"] = USER_AGENT
42 + self._last_request = 0.0
43 + self._detail_con = None
44 +
45 + # -- backends -------------------------------------------------------------
46 + def _throttle(self) -> None:
47 + wait = self.request_delay - (time.time() - self._last_request)
48 + if wait > 0:
49 + time.sleep(wait)
50 +
51 + def get(self, url: str, **kw) -> requests.Response:
52 + """GET direct avec throttling poli."""
53 + self._throttle()
54 + resp = self.session.get(url, timeout=self.timeout, **kw)
55 + self._last_request = time.time()
56 + resp.raise_for_status()
57 + return resp
58 +
59 + def post(self, url: str, **kw) -> requests.Response:
60 + """POST direct avec throttling poli (APIs internes des plateformes)."""
61 + self._throttle()
62 + resp = self.session.post(url, timeout=self.timeout, **kw)
63 + self._last_request = time.time()
64 + resp.raise_for_status()
65 + return resp
66 +
67 + def get_rendered(self, url: str) -> str:
68 + """HTML rendu (JavaScript exécuté) via Firecrawl.
69 +
70 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). À utiliser
71 + pour les sites de restos SPA relativement ouverts.
72 + """
73 + key = os.environ.get("FIRECRAWL_API_KEY")
74 + if not key:
75 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
76 + resp = requests.post(
77 + FIRECRAWL_API,
78 + json={"url": url, "formats": ["html"]},
79 + headers={"Authorization": f"Bearer {key}"},
80 + timeout=90,
81 + )
82 + resp.raise_for_status()
83 + data = resp.json()
84 + return (data.get("data") or {}).get("html", "")
85 +
86 + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
87 + rendering_wait: int = 0, country: str = "ca",
88 + wait_for_selector: str | None = None,
89 + js_scenario: list | str | None = None,
90 + proxy_pool: str | None = None, headers: dict | None = None,
91 + method: str = "GET", body: str | None = None) -> dict:
92 + """Appel Scrapfly complet — retourne le dict `result` (content, status…).
93 +
94 + Requis pour les plateformes de livraison (anti-bot agressif) ; voir
95 + CLAUDE.md §10 et §15 avant d'industrialiser sur ces sources.
96 + """
97 + import base64
98 + key = os.environ.get("SCRAPFLY_KEY")
99 + if not key:
100 + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
101 + params: dict = {"key": key, "url": url, "country": country}
102 + if asp:
103 + params["asp"] = "true"
104 + if render_js:
105 + params["render_js"] = "true"
106 + if rendering_wait:
107 + params["rendering_wait"] = rendering_wait
108 + if wait_for_selector:
109 + params["wait_for_selector"] = wait_for_selector
110 + if proxy_pool:
111 + params["proxy_pool"] = proxy_pool
112 + if js_scenario is not None:
113 + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)
114 + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()
115 + if headers:
116 + for k, v in headers.items():
117 + params[f"headers[{k}]"] = v
118 + self._throttle()
119 + if method.upper() == "POST":
120 + resp = requests.post(SCRAPFLY_API, params=params,
121 + data=(body or ""), timeout=180)
122 + else:
123 + resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
124 + self._last_request = time.time()
125 + try:
126 + return resp.json().get("result") or {}
127 + except ValueError:
128 + return {}
129 +
130 + def get_scrapfly(self, url: str, **kw) -> str:
131 + """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS)."""
132 + return self.scrapfly(url, **kw).get("content") or ""
133 +
134 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
135 + """Payload « détail » avec cache : `fetch_fn` n'est appelé que si le
136 + resto est nouveau ou si sa clé (hash du contenu liste) a changé.
137 +
138 + Évite de recapturer un menu complet (dizaines de requêtes) quand rien
139 + n'a bougé. `fetch_fn` doit retourner un dict JSON-sérialisable.
140 + """
141 + if not self.use_detail_cache:
142 + return fetch_fn() or {}
143 + from .. import db
144 + if self._detail_con is None:
145 + self._detail_con = db.connect()
146 + cached = db.get_cached_detail(self._detail_con, self.source_id,
147 + str(external_id), key)
148 + if cached is not None:
149 + return cached
150 + payload = fetch_fn() or {}
151 + db.put_cached_detail(self._detail_con, self.source_id,
152 + str(external_id), key, payload)
153 + return payload
154 +
155 + # -- contrat --------------------------------------------------------------
156 + def fetch(self) -> list[Restaurant]:
157 + raise NotImplementedError
added restoka/connectors/ueat.py +343 −0
@@ -0,0 +1,343 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/ueat.py
4 +# Desc: Connecteur TEMPLATISÉ pour UEAT (foodtech de Québec — plateforme de
5 +# commande en ligne marque blanche, très répandue au QC). Couvre tous
6 +# les restos listés dans data/sources.json -> source « ueat » ->
7 +# « integrations » (un GUID d'intégration par marque/resto).
8 +#
9 +# Mode d'extraction : API GraphQL interne (https://api.ueat.io/graphql),
10 +# la même que consomme le widget de commande officiel — données
11 +# structurées, aucun rendu navigateur requis. Auth anonyme :
12 +# en-têtes `x-ueatapikey: <GUID d'intégration>` + `x-ueatculture: fr`.
13 +#
14 +# Contexte de prix produit : TAKEOUT (prix réels de la commande en
15 +# ligne du resto lui-même, non majorés — source de première partie,
16 +# voir CLAUDE.md §9 Palier 1 et §15).
17 +#
18 +# Couverture par intégration :
19 +# - restaurant { name url logo } -> identité de la marque
20 +# - franchises(modeType: TAKEOUT) -> UNE FICHE PAR ADRESSE
21 +# (succursales reliées par `chain`, jamais fusionnées — §12.1)
22 +# - menu { categories } + items(categoryId) -> sections & plats
23 +# (sous-catégories via menu(parentCategoryId), récursif)
24 +# - variations d'item -> options « Format »
25 +# - item.question (1er niveau) -> groupes d'options
26 +# (garnitures, choix de base…) — mis en cache par marque, refait
27 +# seulement quand le menu change (detail_cache).
28 +#
29 +# Limite documentée : le menu est capté au niveau de la MARQUE (menu
30 +# par défaut du siège). Les écarts locaux de prix entre succursales
31 +# d'une même bannière ne sont pas captés (menus standardisés §9 P6).
32 +# ==============================================================================
33 +from __future__ import annotations
34 +
35 +import datetime
36 +import hashlib
37 +import json
38 +import sys
39 +from pathlib import Path
40 +
41 +from ..normalize import infer_item_tags
42 +from ..schema import Restaurant
43 +from .base import BaseConnector
44 +
45 +API = "https://api.ueat.io/graphql"
46 +ORDER_URL = "https://order.ueat.io/integration/{guid}/fr"
47 +SOURCES_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "sources.json"
48 +
49 +# Nombre maximal de niveaux de sous-catégories suivis (garde-fou récursion).
50 +MAX_DEPTH = 3
51 +# Nombre maximal d'items dont on va chercher les groupes d'options (par marque,
52 +# par sync) — garde-fou volume ; le cache évite de refaire les items connus.
53 +MAX_QUESTION_FETCH = 400
54 +
55 +_Q_FRANCHISES = """
56 +query getFranchises($orderType: OrderTypeEnum!) {
57 + restaurant { name url logo }
58 + franchises(modeType: $orderType) {
59 + companyProfileId code name currency countryCode
60 + address { civicNumber streetName postalCode city province }
61 + }
62 +}
63 +"""
64 +
65 +_Q_CATEGORIES = """
66 +query menuCategories {
67 + menu { categories { id name description numberOfMenuItems hasSubCategory
68 + image(size: 1024) } }
69 +}
70 +"""
71 +
72 +_Q_SUBMENU = """
73 +query menu($parentCategoryId: Int!) {
74 + menu(parentCategoryId: $parentCategoryId) {
75 + categories { id name description numberOfMenuItems hasSubCategory
76 + image(size: 1024) }
77 + items { id name description subtitle image regularPrice unitPrice
78 + hasQuestions isOutOfStock isTemporaryDisabled
79 + labels { name }
80 + variations { id name description unitPrice regularPrice
81 + variations { id name unitPrice regularPrice } } }
82 + }
83 +}
84 +"""
85 +
86 +_Q_ITEMS = """
87 +query items($categoryId: Int!) {
88 + items(categoryId: $categoryId) {
89 + id name description subtitle image regularPrice unitPrice hasQuestions
90 + isOutOfStock isTemporaryDisabled
91 + labels { name }
92 + variations { id name description unitPrice regularPrice
93 + variations { id name unitPrice regularPrice } }
94 + }
95 +}
96 +"""
97 +
98 +_Q_QUESTION = """
99 +query questions($menuItemId: Int!) {
100 + item(id: $menuItemId) {
101 + id name
102 + question {
103 + questionText selectionType minimum maximum
104 + choices { name unitPrice isNotAdded }
105 + }
106 + }
107 +}
108 +"""
109 +
110 +
111 +def _price(val) -> float | None:
112 + if isinstance(val, (int, float)) and val > 0:
113 + return round(float(val), 2)
114 + return None
115 +
116 +
117 +class UeatConnector(BaseConnector):
118 + source_id = "ueat"
119 + request_delay = 0.35
120 +
121 + # -- transport --------------------------------------------------------------
122 + def _gql(self, api_key: str, query: str, variables: dict | None = None,
123 + operation: str | None = None) -> dict:
124 + """Une requête GraphQL UEAT ; lève sur toute erreur GraphQL."""
125 + body: dict = {"query": query}
126 + if variables:
127 + body["variables"] = variables
128 + if operation:
129 + body["operationName"] = operation
130 + resp = self.post(API, json=body, headers={
131 + "x-ueatapikey": api_key, "x-ueatculture": "fr"})
132 + data = resp.json()
133 + if data.get("errors"):
134 + raise RuntimeError(data["errors"][0].get("message", "erreur GraphQL"))
135 + return data.get("data") or {}
136 +
137 + # -- menu ---------------------------------------------------------------------
138 + def _item_options(self, api_key: str, item: dict,
139 + base: float) -> list[dict]:
140 + """Groupes d'options d'un item : variations inline + 1er niveau de
141 + « questions » (les niveaux suivants dépendent des réponses — non captés).
142 +
143 + `base` = prix affiché de l'item (celui du menu) : les deltas des
144 + variations sont calculés par rapport à LUI, jamais par rapport à un
145 + unitPrice nul (sinon on affiche des « +10,60 $ » qui sont en fait le
146 + prix absolu du format de base)."""
147 + options: list[dict] = []
148 + variations = item.get("variations") or []
149 + if variations:
150 + choices = []
151 + for v in variations:
152 + vp = _price(v.get("unitPrice"))
153 + choices.append({"name": (v.get("name") or "").strip(),
154 + "price_delta": (round(vp - base, 2)
155 + if vp is not None else 0.0)})
156 + if choices:
157 + options.append({"group": "Format", "required": True,
158 + "choices": choices})
159 + if item.get("hasQuestions"):
160 + try:
161 + data = self._gql(api_key, _Q_QUESTION,
162 + {"menuItemId": item["id"]}, "questions")
163 + q = (data.get("item") or {}).get("question")
164 + except Exception:
165 + q = None # une question inaccessible ne bloque pas l'item
166 + if q and q.get("choices"):
167 + choices = [
168 + {"name": (c.get("name") or "").strip(),
169 + "price_delta": _price(c.get("unitPrice")) or 0.0}
170 + for c in q["choices"] if not c.get("isNotAdded")
171 + ]
172 + # si la « question » ne fait que répéter les variations
173 + # (choix de format), ne pas dupliquer le groupe Format
174 + var_names = {(v.get("name") or "").strip().lower()
175 + for v in variations}
176 + q_names = {c["name"].lower() for c in choices}
177 + if choices and (not var_names or q_names != var_names):
178 + options.append({
179 + "group": (q.get("questionText") or "Options").strip(),
180 + "required": (q.get("minimum") or 0) >= 1,
181 + "choices": choices,
182 + })
183 + return options
184 +
185 + def _to_menu_item(self, api_key: str, item: dict,
186 + budget: list[int]) -> dict | None:
187 + name = (item.get("name") or "").strip()
188 + if not name:
189 + return None
190 + price = _price(item.get("unitPrice"))
191 + variations = item.get("variations") or []
192 + if price is None and variations:
193 + vprices = [_price(v.get("unitPrice")) for v in variations]
194 + vprices = [p for p in vprices if p is not None]
195 + price = min(vprices) if vprices else None
196 + labels = [l.get("name") or "" for l in item.get("labels") or []]
197 + out = {
198 + "id": str(item.get("id") or ""),
199 + "name": name,
200 + "description": (item.get("description") or "").strip(),
201 + "price": price,
202 + "currency": "CAD",
203 + "tags": infer_item_tags(name, item.get("description") or "", labels),
204 + }
205 + if item.get("image"):
206 + out["image"] = item["image"]
207 + if item.get("isOutOfStock"):
208 + out["tags"].append("rupture")
209 + can_fetch_q = budget[0] < MAX_QUESTION_FETCH
210 + if item.get("hasQuestions") and can_fetch_q:
211 + budget[0] += 1
212 + elif item.get("hasQuestions"):
213 + item = dict(item, hasQuestions=False) # budget épuisé : variations seulement
214 + options = self._item_options(api_key, item, price or 0.0)
215 + if options:
216 + out["options"] = options
217 + return out
218 +
219 + def _walk_category(self, api_key: str, cat: dict, budget: list[int],
220 + depth: int = 0) -> list[dict]:
221 + """Retourne les sections (aplaties « Parent — Enfant ») d'une catégorie."""
222 + cat_name = (cat.get("name") or "").strip()
223 + sections: list[dict] = []
224 + if cat.get("hasSubCategory") and depth < MAX_DEPTH:
225 + data = self._gql(api_key, _Q_SUBMENU,
226 + {"parentCategoryId": cat["id"]}, "menu")
227 + sub = data.get("menu") or {}
228 + items = [self._to_menu_item(api_key, it, budget)
229 + for it in sub.get("items") or []]
230 + items = [it for it in items if it]
231 + if items:
232 + sec = {"name": cat_name, "items": items}
233 + if cat.get("image"):
234 + sec["image"] = cat["image"]
235 + sections.append(sec)
236 + for child in sub.get("categories") or []:
237 + child_secs = self._walk_category(api_key, child, budget, depth + 1)
238 + for cs in child_secs:
239 + cs["name"] = f"{cat_name} — {cs['name']}"
240 + sections.extend(child_secs)
241 + else:
242 + data = self._gql(api_key, _Q_ITEMS, {"categoryId": cat["id"]}, "items")
243 + items = [self._to_menu_item(api_key, it, budget)
244 + for it in data.get("items") or []]
245 + items = [it for it in items if it]
246 + if items:
247 + sec = {"name": cat_name, "items": items}
248 + if cat.get("image"):
249 + sec["image"] = cat["image"]
250 + sections.append(sec)
251 + return sections
252 +
253 + def _fetch_menu_sections(self, api_key: str) -> list[dict]:
254 + data = self._gql(api_key, _Q_CATEGORIES, operation="menuCategories")
255 + categories = ((data.get("menu") or {}).get("categories")) or []
256 + budget = [0] # compteur partagé de requêtes « questions »
257 + sections: list[dict] = []
258 + for cat in categories:
259 + sections.extend(self._walk_category(api_key, cat, budget))
260 + return sections
261 +
262 + # -- fetch ----------------------------------------------------------------------
263 + def _integrations(self) -> list[dict]:
264 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
265 + for src in registry.get("sources", []):
266 + if src.get("id") == self.source_id:
267 + return src.get("integrations") or []
268 + return []
269 +
270 + def fetch(self) -> list[Restaurant]:
271 + out: dict[str, Restaurant] = {}
272 + for integ in self._integrations():
273 + guid = integ.get("key") or ""
274 + if not guid or integ.get("status") == "inactif":
275 + continue
276 + try:
277 + self._fetch_integration(guid, out)
278 + except Exception as exc: # une marque ne bloque pas les autres
279 + print(f"[resto-ka] ueat: intégration {integ.get('label') or guid} "
280 + f"ignorée : {exc}", file=sys.stderr)
281 + return list(out.values())
282 +
283 + def _fetch_integration(self, guid: str, out: dict[str, Restaurant]) -> None:
284 + data = self._gql(guid, _Q_FRANCHISES, {"orderType": "TAKEOUT"},
285 + "getFranchises")
286 + brand = data.get("restaurant") or {}
287 + brand_name = (brand.get("name") or "").strip()
288 + franchises = [f for f in data.get("franchises") or []
289 + if ((f.get("address") or {}).get("province") or "")
290 + .strip().lower() in ("québec", "quebec", "qc")]
291 + if not brand_name or not franchises:
292 + return
293 +
294 + # menu de marque, avec cache : recapturé seulement si l'empreinte des
295 + # catégories (ids + nb d'items) a changé — sinon rejoué depuis le cache
296 + cats = self._gql(guid, _Q_CATEGORIES, operation="menuCategories")
297 + cat_list = ((cats.get("menu") or {}).get("categories")) or []
298 + # « v2 » = version du format de payload (images) : bust le cache v1
299 + cache_key = "v2-" + hashlib.sha256(json.dumps(
300 + [(c.get("id"), c.get("numberOfMenuItems")) for c in cat_list],
301 + sort_keys=True).encode()).hexdigest()
302 + payload = self.detail(guid, cache_key,
303 + lambda: {"sections": self._fetch_menu_sections(guid)})
304 + sections = payload.get("sections") or []
305 + captured_at = datetime.datetime.now(datetime.timezone.utc) \
306 + .strftime("%Y-%m-%dT%H:%M:%SZ")
307 +
308 + is_chain = len(franchises) > 1
309 + for fr in franchises:
310 + addr = fr.get("address") or {}
311 + civic = (addr.get("civicNumber") or "").strip()
312 + street = (addr.get("streetName") or "").strip()
313 + profile_id = fr.get("companyProfileId")
314 + if profile_id is None:
315 + continue
316 + menu = {
317 + "currency": fr.get("currency") or "CAD",
318 + "price_context": "takeout", # commande en ligne du resto :
319 + "price_source": "ueat", # prix réels, non majorés (§9 P1)
320 + "captured_at": captured_at,
321 + "sections": sections,
322 + } if sections else None
323 + resto = Restaurant(
324 + source=self.source_id,
325 + external_id=f"{guid}:{profile_id}",
326 + name=(fr.get("name") or brand_name).strip()
327 + if is_chain else brand_name,
328 + url=ORDER_URL.format(guid=guid),
329 + chain=brand_name if is_chain else None,
330 + address=f"{civic} {street}".strip(),
331 + city=(addr.get("city") or "").strip(),
332 + postal_code=(addr.get("postalCode") or "").strip(),
333 + website=(brand.get("url") or "").strip(),
334 + services=["takeout"],
335 + languages=["fr", "en"],
336 + images=[u for u in ([brand.get("logo") or ""]
337 + + [s.get("image") or "" for s in sections][:4]) if u],
338 + menu=menu,
339 + )
340 + # préfixer le nom de la succursale par la marque si absent
341 + if is_chain and brand_name.lower() not in resto.name.lower():
342 + resto.name = f"{brand_name} — {resto.name}"
343 + out.setdefault(resto.uid, resto)
added restoka/db.py +414 −0
@@ -0,0 +1,414 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/db.py
4 +# Desc: Persistance SQLite — upsert des restaurants avec détection de
5 +# changements, menus conservés PAR CONTEXTE de prix (dine-in/takeout/
6 +# delivery, jamais écrasés entre eux), historique des prix par item,
7 +# cycle de vie avec délai de grâce, détection de dérive (connecteur
8 +# cassé), caches détail et géocodage. Calqué sur louka/db.py.
9 +# ==============================================================================
10 +from __future__ import annotations
11 +
12 +import json
13 +import sqlite3
14 +import statistics
15 +import time
16 +from pathlib import Path
17 +
18 +from .schema import Restaurant
19 +
20 +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "restoka.db"
21 +
22 +# Nombre d'exécutions consécutives où un resto doit être absent de la source
23 +# avant d'être marqué temporarily_closed (délai de grâce contre les ratés).
24 +MISS_GRACE = 2
25 +
26 +# Dérive source : si une source retourne <= DRIFT_RATIO × sa médiane historique
27 +# (médiane >= DRIFT_MIN_BASE restos), on alerte et on suspend les retraits.
28 +DRIFT_RATIO = 0.25
29 +DRIFT_MIN_BASE = 4
30 +DRIFT_HISTORY = 5
31 +
32 +# Dérive menu : si le nombre d'items d'un resto chute sous MENU_DROP_RATIO ×
33 +# le compte précédent (précédent >= MENU_DROP_MIN items), on conserve l'ancien
34 +# menu et on consigne l'alerte (connecteur probablement cassé — CLAUDE.md §18).
35 +MENU_DROP_RATIO = 0.25
36 +MENU_DROP_MIN = 8
37 +
38 +_SCHEMA = """
39 +CREATE TABLE IF NOT EXISTS restaurants (
40 + uid TEXT PRIMARY KEY, -- source:external_id
41 + source TEXT NOT NULL,
42 + external_id TEXT NOT NULL,
43 + name TEXT,
44 + chain TEXT,
45 + cuisines TEXT, -- JSON (taxonomie §6.1)
46 + establishment_type TEXT,
47 + price_range TEXT,
48 + address TEXT,
49 + city TEXT,
50 + region TEXT, -- une des 17 régions
51 + postal_code TEXT,
52 + lat REAL,
53 + lng REAL,
54 + phone TEXT,
55 + website TEXT,
56 + url TEXT,
57 + hours TEXT, -- JSON
58 + services TEXT, -- JSON
59 + dietary_options TEXT, -- JSON
60 + languages TEXT, -- JSON
61 + images TEXT, -- JSON (logo + photos, URLs absolues)
62 + status TEXT DEFAULT 'open',
63 + geocode_failed INTEGER DEFAULT 0,
64 + content_hash TEXT,
65 + first_seen REAL,
66 + last_seen REAL,
67 + updated_at REAL,
68 + miss_count INTEGER DEFAULT 0,
69 + active INTEGER DEFAULT 1,
70 + dup_of TEXT, -- uid canonique si doublon inter-sources
71 + dup_sources TEXT -- JSON : autres sources où le resto est publié
72 +);
73 +CREATE INDEX IF NOT EXISTS idx_restaurants_source ON restaurants(source);
74 +CREATE INDEX IF NOT EXISTS idx_restaurants_city ON restaurants(city);
75 +CREATE INDEX IF NOT EXISTS idx_restaurants_region ON restaurants(region);
76 +CREATE INDEX IF NOT EXISTS idx_restaurants_active ON restaurants(active);
77 +CREATE INDEX IF NOT EXISTS idx_restaurants_geo ON restaurants(lat, lng);
78 +
79 +-- Un menu PAR CONTEXTE de prix : un menu dine-in n'est JAMAIS écrasé par un
80 +-- menu delivery (CLAUDE.md §12.2) — on conserve les deux, l'API sert le
81 +-- meilleur contexte disponible (dine-in > takeout > delivery).
82 +CREATE TABLE IF NOT EXISTS menus (
83 + uid TEXT NOT NULL, -- restaurants.uid
84 + price_context TEXT NOT NULL, -- dine-in | takeout | delivery
85 + price_source TEXT NOT NULL, -- source du prix (traçabilité)
86 + currency TEXT DEFAULT 'CAD',
87 + captured_at TEXT, -- ISO-8601 (fraîcheur)
88 + item_count INTEGER,
89 + sections TEXT, -- JSON : sections -> items -> options
90 + PRIMARY KEY (uid, price_context)
91 +);
92 +
93 +-- Historique des prix par item (détection des hausses, crédibilité §12.2).
94 +CREATE TABLE IF NOT EXISTS item_price_log (
95 + uid TEXT NOT NULL,
96 + price_context TEXT NOT NULL,
97 + item_key TEXT NOT NULL, -- "section :: item" normalisé
98 + ts REAL NOT NULL,
99 + price REAL
100 +);
101 +CREATE INDEX IF NOT EXISTS idx_item_price_log ON item_price_log(uid, item_key);
102 +
103 +CREATE TABLE IF NOT EXISTS sync_log (
104 + id INTEGER PRIMARY KEY AUTOINCREMENT,
105 + source TEXT,
106 + ts REAL,
107 + found INTEGER,
108 + added INTEGER,
109 + updated INTEGER,
110 + removed INTEGER,
111 + ok INTEGER,
112 + message TEXT,
113 + stats TEXT -- JSON : items totaux, taux sans prix, alertes…
114 +);
115 +
116 +CREATE TABLE IF NOT EXISTS detail_cache (
117 + source TEXT NOT NULL,
118 + external_id TEXT NOT NULL,
119 + key TEXT,
120 + payload TEXT,
121 + fetched_at REAL,
122 + PRIMARY KEY (source, external_id)
123 +);
124 +
125 +CREATE TABLE IF NOT EXISTS geocode_cache (
126 + address TEXT PRIMARY KEY,
127 + lat REAL,
128 + lng REAL,
129 + provider TEXT,
130 + failed INTEGER DEFAULT 0,
131 + ts REAL
132 +);
133 +"""
134 +
135 +
136 +# Colonnes ajoutées après la v1 — migration automatique des bases existantes
137 +# (même mécanisme que louka/db.py).
138 +_MIGRATIONS = {
139 + "restaurants": {
140 + "images": "TEXT",
141 + },
142 +}
143 +
144 +
145 +def connect(path: Path | None = None) -> sqlite3.Connection:
146 + db_path = path or DB_PATH
147 + db_path.parent.mkdir(parents=True, exist_ok=True)
148 + con = sqlite3.connect(db_path)
149 + con.row_factory = sqlite3.Row
150 + con.executescript(_SCHEMA)
151 + for table, cols in _MIGRATIONS.items():
152 + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")}
153 + for col, decl in cols.items():
154 + if col not in existing:
155 + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}")
156 + con.commit()
157 + return con
158 +
159 +
160 +# ---------------------------------------------------------------------------
161 +# Menus & historique des prix
162 +# ---------------------------------------------------------------------------
163 +
164 +def _item_key(section: str, item: str) -> str:
165 + return f"{(section or '').strip().lower()} :: {(item or '').strip().lower()}"
166 +
167 +
168 +def _menu_items(menu: dict) -> dict[str, float | None]:
169 + """{item_key: prix} pour la comparaison et l'historique."""
170 + out: dict[str, float | None] = {}
171 + for sec in menu.get("sections") or []:
172 + for it in sec.get("items") or []:
173 + out[_item_key(sec.get("name"), it.get("name"))] = it.get("price")
174 + return out
175 +
176 +
177 +def upsert_menu(con: sqlite3.Connection, uid: str, menu: dict,
178 + now: float) -> dict:
179 + """Insère/actualise le menu d'un resto POUR SON CONTEXTE de prix.
180 +
181 + - historise chaque changement de prix d'item (item_price_log) ;
182 + - garde-fou : chute anormale du nombre d'items -> on conserve l'ancien
183 + menu et on retourne une alerte (connecteur probablement cassé).
184 + """
185 + ctx = menu["price_context"]
186 + new_items = _menu_items(menu)
187 + row = con.execute(
188 + "SELECT sections, item_count FROM menus WHERE uid=? AND price_context=?",
189 + (uid, ctx)).fetchone()
190 +
191 + alert = None
192 + if row is not None:
193 + old_count = row["item_count"] or 0
194 + if (old_count >= MENU_DROP_MIN
195 + and len(new_items) <= MENU_DROP_RATIO * old_count):
196 + return {"alert": (f"{uid}[{ctx}]: {len(new_items)} item(s) contre "
197 + f"{old_count} auparavant — ancien menu conservé")}
198 + # sections est stocké comme liste JSON : reconstruire le dict attendu
199 + try:
200 + old_sections = json.loads(row["sections"] or "[]")
201 + except ValueError:
202 + old_sections = []
203 + old_items = _menu_items({"sections": old_sections})
204 + for key, price in new_items.items():
205 + if key not in old_items or old_items[key] != price:
206 + con.execute(
207 + "INSERT INTO item_price_log (uid, price_context, item_key,"
208 + " ts, price) VALUES (?,?,?,?,?)",
209 + (uid, ctx, key, now, price))
210 + else:
211 + for key, price in new_items.items():
212 + if price is not None:
213 + con.execute(
214 + "INSERT INTO item_price_log (uid, price_context, item_key,"
215 + " ts, price) VALUES (?,?,?,?,?)",
216 + (uid, ctx, key, now, price))
217 +
218 + con.execute(
219 + "INSERT INTO menus (uid, price_context, price_source, currency,"
220 + " captured_at, item_count, sections) VALUES (?,?,?,?,?,?,?)"
221 + " ON CONFLICT(uid, price_context) DO UPDATE SET"
222 + " price_source=excluded.price_source, currency=excluded.currency,"
223 + " captured_at=excluded.captured_at, item_count=excluded.item_count,"
224 + " sections=excluded.sections",
225 + (uid, ctx, menu["price_source"], menu.get("currency", "CAD"),
226 + menu.get("captured_at"), len(new_items),
227 + json.dumps(menu.get("sections") or [], ensure_ascii=False)))
228 + return {"alert": alert}
229 +
230 +
231 +# ---------------------------------------------------------------------------
232 +# Synchronisation d'une source
233 +# ---------------------------------------------------------------------------
234 +
235 +def _drift_alert(con: sqlite3.Connection, source: str, found: int) -> str | None:
236 + hist = con.execute(
237 + "SELECT found FROM sync_log WHERE source=? AND ok=1"
238 + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()
239 + if len(hist) < 3:
240 + return None
241 + med_found = statistics.median(r["found"] for r in hist)
242 + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:
243 + return (f"dérive: {found} resto(s) trouvé(s) contre une médiane de "
244 + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")
245 + return None
246 +
247 +
248 +def sync_source(con: sqlite3.Connection, source: str,
249 + restaurants: list[Restaurant]) -> dict:
250 + """Synchronise les restaurants d'une source.
251 +
252 + - nouveau resto -> insertion (+ menu, + historique de prix initial)
253 + - resto modifié -> mise à jour (comparaison de content_hash)
254 + - resto disparu -> miss_count += 1, puis status=temporarily_closed
255 + après MISS_GRACE exécutions (délai de grâce, jamais de suppression)
256 + - dérive détectée -> alerte consignée, retraits suspendus
257 + """
258 + now = time.time()
259 + added = updated = 0
260 + seen_uids = set()
261 + menu_alerts: list[str] = []
262 +
263 + n = len(restaurants)
264 + total_items = 0
265 + items_sans_prix = 0
266 + for r in restaurants:
267 + if r.menu:
268 + for sec in r.menu.get("sections") or []:
269 + for it in sec.get("items") or []:
270 + total_items += 1
271 + if it.get("price") is None:
272 + items_sans_prix += 1
273 +
274 + alert = _drift_alert(con, source, n)
275 +
276 + for resto in restaurants:
277 + seen_uids.add(resto.uid)
278 + h = resto.content_hash()
279 + row = con.execute("SELECT content_hash FROM restaurants WHERE uid=?",
280 + (resto.uid,)).fetchone()
281 + params = dict(
282 + uid=resto.uid, source=resto.source, external_id=resto.external_id,
283 + name=resto.name, chain=resto.chain,
284 + cuisines=json.dumps(resto.cuisines, ensure_ascii=False),
285 + establishment_type=resto.establishment_type,
286 + price_range=resto.price_range, address=resto.address,
287 + city=resto.city, region=resto.region,
288 + postal_code=resto.postal_code, lat=resto.lat, lng=resto.lng,
289 + phone=resto.phone, website=resto.website, url=resto.url,
290 + hours=json.dumps(resto.hours, ensure_ascii=False),
291 + services=json.dumps(resto.services, ensure_ascii=False),
292 + dietary_options=json.dumps(resto.dietary_options, ensure_ascii=False),
293 + languages=json.dumps(resto.languages, ensure_ascii=False),
294 + images=json.dumps(resto.images, ensure_ascii=False),
295 + status=resto.status, content_hash=h, now=now,
296 + )
297 + if row is None:
298 + con.execute(
299 + """INSERT INTO restaurants (uid, source, external_id, name,
300 + chain, cuisines, establishment_type, price_range, address,
301 + city, region, postal_code, lat, lng, phone, website, url,
302 + hours, services, dietary_options, languages, images,
303 + status, content_hash, first_seen, last_seen, updated_at,
304 + miss_count, active)
305 + VALUES (:uid,:source,:external_id,:name,:chain,:cuisines,
306 + :establishment_type,:price_range,:address,:city,:region,
307 + :postal_code,:lat,:lng,:phone,:website,:url,:hours,
308 + :services,:dietary_options,:languages,:images,:status,
309 + :content_hash,:now,:now,:now,0,1)""", params)
310 + added += 1
311 + elif row["content_hash"] != h:
312 + # COALESCE : ne jamais écraser des coordonnées géocodées par null
313 + con.execute(
314 + """UPDATE restaurants SET name=:name, chain=:chain,
315 + cuisines=:cuisines, establishment_type=:establishment_type,
316 + price_range=:price_range, address=:address, city=:city,
317 + region=:region, postal_code=:postal_code,
318 + lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
319 + phone=:phone, website=:website, url=:url, hours=:hours,
320 + services=:services, dietary_options=:dietary_options,
321 + languages=:languages, images=:images, status=:status,
322 + content_hash=:content_hash, last_seen=:now, updated_at=:now,
323 + miss_count=0, active=1 WHERE uid=:uid""", params)
324 + updated += 1
325 + else:
326 + con.execute(
327 + "UPDATE restaurants SET last_seen=?, miss_count=0, active=1,"
328 + " status='open' WHERE uid=?", (now, resto.uid))
329 +
330 + if resto.menu:
331 + res = upsert_menu(con, resto.uid, resto.menu, now)
332 + if res.get("alert"):
333 + menu_alerts.append(res["alert"])
334 +
335 + # Restos de cette source qui n'apparaissent plus : délai de grâce, puis
336 + # status=temporarily_closed (politique de grâce §13 — jamais de suppression).
337 + removed = missed = 0
338 + if not alert:
339 + for r in con.execute(
340 + "SELECT uid, miss_count FROM restaurants WHERE source=? AND active=1",
341 + (source,)).fetchall():
342 + if r["uid"] in seen_uids:
343 + continue
344 + missed += 1
345 + if r["miss_count"] + 1 >= MISS_GRACE:
346 + con.execute(
347 + "UPDATE restaurants SET active=0, status='temporarily_closed',"
348 + " miss_count=?, updated_at=? WHERE uid=?",
349 + (r["miss_count"] + 1, now, r["uid"]))
350 + removed += 1
351 + else:
352 + con.execute(
353 + "UPDATE restaurants SET miss_count=miss_count+1 WHERE uid=?",
354 + (r["uid"],))
355 +
356 + stats = {
357 + "menu_items": total_items,
358 + "null_price_rate": (round(items_sans_prix / total_items, 3)
359 + if total_items else 0.0),
360 + "missed": missed,
361 + }
362 + if alert:
363 + stats["alert"] = alert
364 + if menu_alerts:
365 + stats["menu_alerts"] = menu_alerts
366 + con.execute(
367 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"
368 + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",
369 + (source, now, n, added, updated, removed, alert or "ok",
370 + json.dumps(stats, ensure_ascii=False)))
371 + con.commit()
372 + out = {"source": source, "found": n, "added": added, "updated": updated,
373 + "removed": removed, "menu_items": total_items}
374 + if alert:
375 + out["alert"] = alert
376 + if menu_alerts:
377 + out["menu_alerts"] = len(menu_alerts)
378 + return out
379 +
380 +
381 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
382 + con.execute(
383 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
384 + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
385 + con.commit()
386 +
387 +
388 +# ---------------------------------------------------------------------------
389 +# Cache des pages détail (« détail si nouveau/modifié »)
390 +# ---------------------------------------------------------------------------
391 +
392 +def get_cached_detail(con: sqlite3.Connection, source: str,
393 + external_id: str, key: str) -> dict | None:
394 + row = con.execute(
395 + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",
396 + (source, external_id)).fetchone()
397 + if row and row["key"] == key and row["payload"]:
398 + try:
399 + return json.loads(row["payload"])
400 + except ValueError:
401 + return None
402 + return None
403 +
404 +
405 +def put_cached_detail(con: sqlite3.Connection, source: str,
406 + external_id: str, key: str, payload: dict) -> None:
407 + con.execute(
408 + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"
409 + " VALUES (?,?,?,?,?)"
410 + " ON CONFLICT(source, external_id) DO UPDATE SET"
411 + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",
412 + (source, external_id, key, json.dumps(payload, ensure_ascii=False),
413 + time.time()))
414 + con.commit()
added restoka/dedup.py +131 −0
@@ -0,0 +1,131 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/dedup.py
4 +# Desc: Déduplication inter-sources des RESTAURANTS (CLAUDE.md §12.1) :
5 +# un même resto publié sur plusieurs plateformes est fusionné vers une
6 +# fiche canonique (dup_of) ; les succursales d'une chaîne ne sont
7 +# JAMAIS fusionnées entre elles (une adresse = une fiche). Les menus
8 +# restent conservés PAR CONTEXTE de prix (db.menus) — la préférence
9 +# dine-in > takeout > delivery est appliquée au service (web.py).
10 +# Inspiré de louka/dedup.py et de l'identité forte d'Auto·Ka.
11 +# ==============================================================================
12 +from __future__ import annotations
13 +
14 +import json
15 +import re
16 +import time
17 +
18 +from . import db
19 +from .regions import strip_accents
20 +
21 +# Ordre de préférence des sources pour le choix de la fiche canonique :
22 +# sources de première partie (prix réels) avant les plateformes de livraison.
23 +SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow",
24 + "site-resto", "ubereats", "doordash", "skip"]
25 +
26 +
27 +def _name_key(name: str) -> str:
28 + """Nom normalisé : accents, ponctuation, mots vides d'enseigne."""
29 + s = strip_accents((name or "").lower())
30 + s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b",
31 + " ", s)
32 + s = re.sub(r"[^a-z0-9]+", " ", s)
33 + return re.sub(r"\s+", " ", s).strip()
34 +
35 +
36 +def _phone_key(phone: str) -> str:
37 + return re.sub(r"\D", "", phone or "")[-10:]
38 +
39 +
40 +def fingerprints(row: dict) -> list[str]:
41 + """Empreintes d'identité d'un resto. Deux fiches partageant une empreinte
42 + sont candidates à la fusion (même établissement, même adresse)."""
43 + name = _name_key(row.get("name") or "")
44 + if not name:
45 + return []
46 + out = []
47 + # nom + géolocalisation arrondie (~110 m) — la plus fiable
48 + lat, lng = row.get("lat"), row.get("lng")
49 + if lat is not None and lng is not None:
50 + out.append(f"{name}|{round(lat, 3)},{round(lng, 3)}")
51 + # nom + code postal (repli quand pas encore géocodé)
52 + pc = (row.get("postal_code") or "").replace(" ", "").upper()
53 + if pc:
54 + out.append(f"{name}|{pc}")
55 + # nom + téléphone
56 + ph = _phone_key(row.get("phone") or "")
57 + if ph:
58 + out.append(f"{name}|{ph}")
59 + return out
60 +
61 +
62 +def _priority(source: str) -> int:
63 + try:
64 + return SOURCE_PRIORITY.index(source)
65 + except ValueError:
66 + return len(SOURCE_PRIORITY)
67 +
68 +
69 +def run() -> dict:
70 + """Déduplique les restos actifs. Retourne des statistiques."""
71 + con = db.connect()
72 + rows = [dict(r) for r in con.execute(
73 + "SELECT uid, source, name, phone, postal_code, lat, lng"
74 + " FROM restaurants WHERE active=1")]
75 + groups: dict[str, set[str]] = {}
76 + by_uid = {r["uid"]: r for r in rows}
77 + for r in rows:
78 + for fp in fingerprints(r):
79 + groups.setdefault(fp, set()).add(r["uid"])
80 +
81 + # union des groupes qui partagent une fiche (empreintes multiples)
82 + parent: dict[str, str] = {}
83 +
84 + def find(u: str) -> str:
85 + while parent.get(u, u) != u:
86 + parent[u] = parent.get(parent[u], parent[u])
87 + u = parent[u]
88 + return u
89 +
90 + def union(a: str, b: str) -> None:
91 + ra, rb = find(a), find(b)
92 + if ra != rb:
93 + parent[rb] = ra
94 +
95 + for uids in groups.values():
96 + uids = sorted(uids)
97 + for other in uids[1:]:
98 + union(uids[0], other)
99 +
100 + clusters: dict[str, list[str]] = {}
101 + for r in rows:
102 + clusters.setdefault(find(r["uid"]), []).append(r["uid"])
103 +
104 + now = time.time()
105 + merged = 0
106 + for members in clusters.values():
107 + if len(members) < 2:
108 + continue
109 + # une fiche par SOURCE au maximum peut être fusionnée : deux fiches de
110 + # la même source = deux succursales (jamais fusionnées, §12.1)
111 + by_source: dict[str, str] = {}
112 + for uid in sorted(members, key=lambda u: _priority(by_uid[u]["source"])):
113 + src = by_uid[uid]["source"]
114 + if src in by_source:
115 + continue
116 + by_source[src] = uid
117 + canon, *dups = list(by_source.values())
118 + if not dups:
119 + continue
120 + dup_sources = sorted({by_uid[u]["source"] for u in dups})
121 + con.execute("UPDATE restaurants SET dup_sources=?, updated_at=? WHERE uid=?",
122 + (json.dumps(dup_sources, ensure_ascii=False), now, canon))
123 + for uid in dups:
124 + con.execute("UPDATE restaurants SET dup_of=?, updated_at=? WHERE uid=?",
125 + (canon, now, uid))
126 + merged += 1
127 + print(f"[resto-ka] dedup: {canon} <- {dups}") # journaliser (§12.1)
128 + con.commit()
129 + con.close()
130 + return {"clusters": sum(1 for m in clusters.values() if len(m) > 1),
131 + "merged": merged}
added restoka/geocode.py +173 −0
@@ -0,0 +1,173 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/geocode.py
4 +# Desc: Géocodage des adresses de restaurants (lat/lng OBLIGATOIRES, §5) via
5 +# Nominatim (OpenStreetMap) avec repli Adresses Québec (MERN/ArcGIS).
6 +# Cache persistant (geocode_cache), politesse 1 req/s, validation
7 +# provinciale des coordonnées. Adapté de louka/geocode.py.
8 +# ==============================================================================
9 +from __future__ import annotations
10 +
11 +import re
12 +import time
13 +
14 +import requests
15 +
16 +from . import db
17 +from .regions import attach_region, strip_accents
18 +
19 +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
20 +# Repli officiel du gouvernement du Québec (couvre les adresses récentes)
21 +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
22 + "Adresse_Geocodage/GeocodeServer/findAddressCandidates")
23 +AQ_MIN_SCORE = 75
24 +# ASCII pur : le serveur ArcGIS retourne 500 si le User-Agent contient des accents
25 +USER_AGENT = "RestoKaBot/1.0 (agregateur restaurants Quebec; +contact@spboucher.ai)"
26 +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s
27 +RETRY_FAILED_AFTER = 30 * 86400
28 +
29 +# garde-fou provincial (même boîte que schema.finalize())
30 +_BBOX_QC = (44.50, 63.00, -80.00, -56.00)
31 +
32 +
33 +def norm_key(address: str, city: str) -> str:
34 + """Clé de cache : adresse+ville normalisées (casse, accents, espaces)."""
35 + s = strip_accents(f"{address} {city}".lower())
36 + s = re.sub(r"\b(?:app?t?|suite|local|bureau|#)\.?\s*[\w-]+\b", " ", s)
37 + s = re.sub(r"[^a-z0-9]+", " ", s)
38 + return re.sub(r"\s+", " ", s).strip()
39 +
40 +
41 +class Geocoder:
42 + def __init__(self, con) -> None:
43 + self.con = con
44 + self.session = requests.Session()
45 + self.session.headers["User-Agent"] = USER_AGENT
46 + self._last = 0.0
47 +
48 + def _throttle(self) -> None:
49 + wait = REQUEST_DELAY - (time.time() - self._last)
50 + if wait > 0:
51 + time.sleep(wait)
52 +
53 + @staticmethod
54 + def _clean(address: str) -> str:
55 + """Nettoyages qui aident Nominatim sur les adresses de commerces :
56 + suffixes de local/suite, mentions d'unité après virgule."""
57 + s = (address or "").strip()
58 + s = re.sub(r",?\s*(?:local|suite|bureau|unite|app\.?|apt\.?|#|s?suite)\s*"
59 + r"[\w-]+\s*$", "", s, flags=re.I)
60 + s = re.sub(r",\s*[A-Z]?\d+[A-Z]?\s*$", "", s) # « …, F10 »
61 + return s.strip().strip(",")
62 +
63 + def _query(self, address: str, city: str) -> str:
64 + q = self._clean(address)
65 + if city and strip_accents(city.lower()) not in strip_accents(q.lower()):
66 + q += f", {city}"
67 + if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
68 + q += ", Québec"
69 + return q + ", Canada"
70 +
71 + def _nominatim(self, q: str) -> tuple[float, float] | None:
72 + self._throttle()
73 + try:
74 + resp = self.session.get(NOMINATIM_URL, params={
75 + "q": q, "format": "jsonv2", "limit": 1, "countrycodes": "ca",
76 + }, timeout=20)
77 + self._last = time.time()
78 + resp.raise_for_status()
79 + hits = resp.json()
80 + except Exception:
81 + self._last = time.time()
82 + return None
83 + if not hits:
84 + return None
85 + try:
86 + return float(hits[0]["lat"]), float(hits[0]["lon"])
87 + except (KeyError, ValueError):
88 + return None
89 +
90 + def _adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:
91 + self._throttle()
92 + try:
93 + resp = self.session.get(AQ_URL, params={
94 + "SingleLine": f"{self._clean(address)}, {city}", "f": "json",
95 + "outSR": '{"wkid":4326}', "maxLocations": 1,
96 + }, timeout=20)
97 + self._last = time.time()
98 + resp.raise_for_status()
99 + cands = resp.json().get("candidates") or []
100 + except Exception:
101 + self._last = time.time()
102 + return None
103 + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:
104 + return None
105 + loc = cands[0].get("location") or {}
106 + try:
107 + return float(loc["y"]), float(loc["x"])
108 + except (KeyError, ValueError):
109 + return None
110 +
111 + def geocode(self, address: str, city: str) -> tuple[float, float] | None:
112 + """Géocode avec cache. None si échec (JAMAIS de coordonnées bidon)."""
113 + if not address:
114 + return None
115 + key = norm_key(address, city)
116 + row = self.con.execute(
117 + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",
118 + (key,)).fetchone()
119 + if row is not None:
120 + if not row["failed"]:
121 + return row["lat"], row["lng"]
122 + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
123 + return None
124 + q = self._query(address, city)
125 + hit = self._nominatim(q)
126 + provider = "nominatim"
127 + if hit is None or not (_BBOX_QC[0] <= hit[0] <= _BBOX_QC[1]
128 + and _BBOX_QC[2] <= hit[1] <= _BBOX_QC[3]):
129 + hit = self._adresses_quebec(address, city)
130 + provider = "adresses-quebec"
131 + if hit is not None and (_BBOX_QC[0] <= hit[0] <= _BBOX_QC[1]
132 + and _BBOX_QC[2] <= hit[1] <= _BBOX_QC[3]):
133 + self.con.execute(
134 + "INSERT OR REPLACE INTO geocode_cache (address, lat, lng,"
135 + " provider, failed, ts) VALUES (?,?,?,?,0,?)",
136 + (key, hit[0], hit[1], provider, time.time()))
137 + self.con.commit()
138 + return hit
139 + self.con.execute(
140 + "INSERT OR REPLACE INTO geocode_cache (address, lat, lng, provider,"
141 + " failed, ts) VALUES (?,NULL,NULL,NULL,1,?)", (key, time.time()))
142 + self.con.commit()
143 + return None
144 +
145 +
146 +def run_batch(limit: int = 200) -> dict:
147 + """Géocode les restos actifs sans coordonnées (max `limit` requêtes),
148 + puis rattache la région manquante à partir des coordonnées obtenues."""
149 + con = db.connect()
150 + geo = Geocoder(con)
151 + done = failed = 0
152 + rows = con.execute(
153 + "SELECT uid, address, city, postal_code, region FROM restaurants"
154 + " WHERE active=1 AND (lat IS NULL OR lng IS NULL) AND geocode_failed=0"
155 + " LIMIT ?", (limit,)).fetchall()
156 + for r in rows:
157 + hit = geo.geocode(r["address"], r["city"])
158 + if hit is None:
159 + con.execute("UPDATE restaurants SET geocode_failed=1 WHERE uid=?",
160 + (r["uid"],))
161 + failed += 1
162 + continue
163 + region = r["region"] or attach_region(r["city"], r["postal_code"],
164 + hit[0], hit[1])
165 + con.execute(
166 + "UPDATE restaurants SET lat=?, lng=?, region=?, geocode_failed=0"
167 + " WHERE uid=?", (hit[0], hit[1], region, r["uid"]))
168 + done += 1
169 + con.commit()
170 + con.close()
171 + stats = {"geocoded": done, "failed": failed, "pending": max(0, len(rows) - done - failed)}
172 + print(f"[resto-ka] geocode: {stats}")
173 + return stats
added restoka/ingest.py +87 −0
@@ -0,0 +1,87 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/ingest.py
4 +# Desc: Pipeline d'ingestion — exécute les connecteurs et synchronise la base
5 +# (ajouts / mises à jour / retraits avec délai de grâce). Une fiche
6 +# malformée (menu sans price_context…) est rejetée, jamais stockée.
7 +# Calqué sur louka/ingest.py.
8 +# ==============================================================================
9 +from __future__ import annotations
10 +
11 +import sys
12 +import time
13 +import traceback
14 +
15 +from . import db
16 +from .connectors import CONNECTORS
17 +
18 +
19 +def run(sources: list[str] | None = None) -> list[dict]:
20 + """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
21 + con = db.connect()
22 + results = []
23 + targets = sources or list(CONNECTORS.keys())
24 + for sid in targets:
25 + cls = CONNECTORS.get(sid)
26 + if cls is None:
27 + print(f"[resto-ka] connecteur inconnu : {sid}", file=sys.stderr)
28 + continue
29 + t0 = time.time()
30 + print(f"[resto-ka] sync {sid} ...")
31 + try:
32 + restos = cls().fetch()
33 + finalized, dropped = [], 0
34 + for r in restos:
35 + try:
36 + finalized.append(r.finalize())
37 + except Exception as exc: # une fiche malformée ne bloque pas la source
38 + dropped += 1
39 + print(f"[resto-ka] fiche rejetée : {exc}", file=sys.stderr)
40 + stats = db.sync_source(con, sid, finalized)
41 + stats["seconds"] = round(time.time() - t0, 1)
42 + if dropped:
43 + stats["dropped"] = dropped
44 + if stats.get("alert"):
45 + print(f"[resto-ka] ⚠ ALERTE {sid} : {stats['alert']}")
46 + print(f"[resto-ka] {stats}")
47 + results.append(stats)
48 + except Exception as exc: # robustesse : une source ne bloque pas les autres
49 + db.log_failure(con, sid, f"{exc}")
50 + traceback.print_exc()
51 + results.append({"source": sid, "error": str(exc)})
52 + try:
53 + con.execute("PRAGMA optimize")
54 + except Exception:
55 + pass
56 + con.close()
57 + return results
58 +
59 +
60 +def enrich() -> None:
61 + """Étapes d'enrichissement post-sync (géocodage, région, déduplication)."""
62 + try: # géocoder les nouvelles adresses (lat/lng obligatoires §5)
63 + from . import geocode
64 + geocode.run_batch()
65 + except Exception as exc:
66 + print(f"[resto-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr)
67 + try: # déduplication inter-sources (APRÈS géocodage : coords requises)
68 + from . import dedup
69 + stats = dedup.run()
70 + print(f"[resto-ka] dedup: {stats}")
71 + except Exception as exc:
72 + print(f"[resto-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr)
73 +
74 +
75 +def watch(interval_seconds: int = 7 * 86400 // 7) -> None:
76 + """Boucle de rafraîchissement périodique (défaut : quotidien — les grandes
77 + plateformes se rafraîchissent au moins chaque semaine, CLAUDE.md §14)."""
78 + while True:
79 + run()
80 + enrich()
81 + print(f"[resto-ka] prochaine synchronisation dans {interval_seconds}s")
82 + time.sleep(interval_seconds)
83 +
84 +
85 +if __name__ == "__main__":
86 + run(sys.argv[1:] or None)
87 + enrich()
added restoka/normalize.py +203 −0
@@ -0,0 +1,203 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/normalize.py
4 +# Desc: Couche de normalisation commune : nettoyage des noms/adresses,
5 +# parsing des prix, classification des cuisines et du type
6 +# d'établissement, diètes, fourchette de prix ($ à $$$$).
7 +# Calquée sur louka/normalize.py (famille ·Ka).
8 +# ==============================================================================
9 +from __future__ import annotations
10 +
11 +import html
12 +import re
13 +import statistics
14 +
15 +from .regions import strip_accents
16 +
17 +__all__ = [
18 + "strip_accents", "clean_name", "clean_address", "parse_price",
19 + "classify_cuisines", "classify_establishment", "infer_dietary",
20 + "price_range_from_menu", "CUISINES", "PRICE_CONTEXTS",
21 +]
22 +
23 +# Taxonomie des cuisines (CLAUDE.md §6.1) — multi-valué.
24 +CUISINES = [
25 + "quebecois", "francais", "italien", "pizza", "burgers", "poulet",
26 + "bbq-grillades", "fruits-de-mer", "sushi-japonais", "chinois", "thai",
27 + "vietnamien", "coreen", "indien", "libanais-moyen-orient", "mexicain",
28 + "grec", "mediterraneen", "dejeuner-brunch", "cafe-dessert",
29 + "vegetarien-vegan", "fast-food", "autre",
30 +]
31 +
32 +# Contextes de prix admissibles (CLAUDE.md §6.2) — un prix sans contexte est
33 +# inutilisable ; `delivery` est majoré de 25-30 % vs la salle.
34 +PRICE_CONTEXTS = ("dine-in", "takeout", "delivery")
35 +
36 +ESTABLISHMENT_TYPES = (
37 + "restaurant", "fast-food", "cafe", "bar", "food-truck", "traiteur",
38 + "boulangerie-patisserie", "casse-croute", "microbrasserie", "hotel",
39 + "ghost-kitchen",
40 +)
41 +
42 +
43 +def clean_name(name: str) -> str:
44 + """Nettoie un nom de resto/section/plat : HTML résiduel, espaces, casse SPAM."""
45 + s = html.unescape(name or "")
46 + s = re.sub(r"<[^>]+>", " ", s)
47 + s = re.sub(r"\s+", " ", s).strip()
48 + # « CRÉEZ VOTRE BOL » -> « Créez Votre Bol » seulement si TOUT est en capitales
49 + if len(s) > 3 and s == s.upper() and any(c.isalpha() for c in s):
50 + s = s.title()
51 + return s
52 +
53 +
54 +def clean_address(address: str) -> str:
55 + s = html.unescape(address or "")
56 + s = re.sub(r"\s+", " ", s).strip().strip(",")
57 + # « 8245 Local A15 » -> garder le civique
58 + s = re.sub(r"^(\d+)\s+(?:local|suite|bureau|unite|#)\s*[\w-]+\s", r"\1 ", s,
59 + flags=re.I)
60 + return s
61 +
62 +
63 +_PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?")
64 +
65 +
66 +def parse_price(text: str | float | None) -> float | None:
67 + """Extrait un prix en dollars d'un texte (« 16,50 $ », « à partir de 12$ »)."""
68 + if text is None:
69 + return None
70 + if isinstance(text, (int, float)):
71 + return round(float(text), 2) if float(text) > 0 else None
72 + m = _PRICE_RE.search(text.replace("\xa0", " "))
73 + if not m:
74 + return None
75 + raw = m.group(1).replace(" ", "").replace(" ", "").replace(",", ".")
76 + try:
77 + val = round(float(raw), 2)
78 + except ValueError:
79 + return None
80 + return val if val > 0 else None
81 +
82 +
83 +# --- classification des cuisines ---------------------------------------------
84 +# mots-clés (sans accents, minuscules) -> cuisine. L'ordre n'importe pas :
85 +# multi-valué, toutes les cuisines détectées sont retournées.
86 +_CUISINE_KEYWORDS: dict[str, tuple[str, ...]] = {
87 + "pizza": ("pizza", "pizzeria", "pizzas"),
88 + "sushi-japonais": ("sushi", "maki", "sashimi", "izakaya", "ramen",
89 + "japonais", "poke", "bento", "tempura"),
90 + "burgers": ("burger", "burgers", "smash"),
91 + "poulet": ("poulet", "chicken", "rotisserie", "shish taouk"),
92 + "italien": ("italien", "trattoria", "osteria", "pasta", "pates fraiches",
93 + "risotto", "gnocchi"),
94 + "francais": ("bistro francais", "brasserie francaise", "cuisine francaise"),
95 + "quebecois": ("poutine", "casse-croute", "tourtiere", "cabane a sucre",
96 + "smoked meat", "quebecois"),
97 + "chinois": ("chinois", "dim sum", "szechuan", "cantonais", "wok",
98 + "dumpling", "boba", "bubble tea"),
99 + "thai": ("thai", "pad thai", "thailandais"),
100 + "vietnamien": ("pho", "vietnamien", "banh mi", "tonkinoise"),
101 + "coreen": ("coreen", "kimchi", "bibimbap", "bbq coreen"),
102 + "indien": ("indien", "curry", "tandoori", "biryani", "naan"),
103 + "libanais-moyen-orient": ("libanais", "shawarma", "falafel", "kebab",
104 + "moyen-orient", "hummus", "syrien"),
105 + "mexicain": ("taco", "tacos", "mexicain", "burrito", "quesadilla",
106 + "taqueria"),
107 + "grec": ("grec", "gyro", "souvlaki"),
108 + "mediterraneen": ("mediterraneen", "meze"),
109 + "fruits-de-mer": ("fruits de mer", "poissonnerie", "homard", "crabe",
110 + "huitres", "fish and chips"),
111 + "bbq-grillades": ("bbq", "grillades", "steakhouse", "cotes levees",
112 + "smokehouse", "grill"),
113 + "dejeuner-brunch": ("dejeuner", "brunch", "matin", "creperie", "oeufs",
114 + "pancake", "waffle", "gaufres"),
115 + "cafe-dessert": ("cafe", "espresso", "patisserie", "dessert", "creme glacee",
116 + "chocolat", "gelato", "beigne", "the ", "salon de the"),
117 + "vegetarien-vegan": ("vegan", "vegetalien", "vegetarien", "vege"),
118 + "fast-food": ("fast food", "restauration rapide", "frites"),
119 +}
120 +
121 +
122 +def classify_cuisines(name: str, menu_text: str = "") -> list[str]:
123 + """Classe un resto dans la taxonomie cuisine à partir de son nom et du texte
124 + de son menu. Retourne au moins ["autre"]."""
125 + hay = strip_accents(f"{name} {menu_text}".lower())
126 + found = [c for c, kws in _CUISINE_KEYWORDS.items()
127 + if any(k in hay for k in kws)]
128 + # le nom du resto prime : ne garder que les 4 meilleures correspondances
129 + return found[:4] if found else ["autre"]
130 +
131 +
132 +def classify_establishment(name: str, cuisines: list[str]) -> str:
133 + hay = strip_accents((name or "").lower())
134 + if any(k in hay for k in ("cafe", "salon de the", "boba", "bubble tea",
135 + "espresso", "torrefacteur")):
136 + return "cafe"
137 + if any(k in hay for k in ("boulangerie", "patisserie")):
138 + return "boulangerie-patisserie"
139 + if any(k in hay for k in ("casse-croute", "cantine", "roulotte")):
140 + return "casse-croute"
141 + if any(k in hay for k in ("food truck", "camion")):
142 + return "food-truck"
143 + if any(k in hay for k in ("pub ", "taverne", "bar ", "brasserie artisanale")):
144 + return "bar"
145 + if any(k in hay for k in ("microbrasserie", "brouepub")):
146 + return "microbrasserie"
147 + if any(k in hay for k in ("traiteur",)):
148 + return "traiteur"
149 + if "fast-food" in cuisines:
150 + return "fast-food"
151 + return "restaurant"
152 +
153 +
154 +_DIET_KEYWORDS = {
155 + "vegan": ("vegan", "vegetalien", "vegetalienne"),
156 + "vegetarien": ("vegetarien", "vegetarienne", "vege", "veggie"),
157 + "sans-gluten": ("sans gluten", "gluten free", "sans-gluten"),
158 + "halal": ("halal",),
159 + "casher": ("casher", "kascher", "kosher"),
160 + "sans-noix": ("sans noix", "sans arachide"),
161 + "epice": ("epice", "piquant", "spicy"),
162 +}
163 +
164 +
165 +def infer_dietary(menu: dict | None) -> list[str]:
166 + """Diètes offertes, déduites des tags et descriptions du menu."""
167 + if not menu:
168 + return []
169 + parts: list[str] = []
170 + for sec in menu.get("sections") or []:
171 + for it in sec.get("items") or []:
172 + parts.append(it.get("name") or "")
173 + parts.append(it.get("description") or "")
174 + parts.extend(it.get("tags") or [])
175 + hay = strip_accents(" ".join(parts).lower())
176 + return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)]
177 +
178 +
179 +def infer_item_tags(name: str, description: str = "",
180 + labels: list[str] | None = None) -> list[str]:
181 + """Tags diète d'un item de menu (vegan, sans-gluten, épicé…)."""
182 + hay = strip_accents(" ".join([name or "", description or ""]
183 + + list(labels or [])).lower())
184 + return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)]
185 +
186 +
187 +def price_range_from_menu(menu: dict | None) -> str:
188 + """Fourchette $ à $$$$ estimée sur le prix médian des items non nuls."""
189 + if not menu:
190 + return ""
191 + prices = [it.get("price") for sec in menu.get("sections") or []
192 + for it in sec.get("items") or []
193 + if isinstance(it.get("price"), (int, float)) and it["price"] > 3]
194 + if not prices:
195 + return ""
196 + med = statistics.median(prices)
197 + if med < 15:
198 + return "$"
199 + if med < 30:
200 + return "$$"
201 + if med < 60:
202 + return "$$$"
203 + return "$$$$"
added restoka/regions.py +241 −0
@@ -0,0 +1,241 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/regions.py
4 +# Desc: Rattachement obligatoire de chaque restaurant à l'une des 17 régions
5 +# administratives du Québec : table ville → région, repli par préfixe
6 +# de code postal, puis par coordonnées (boîtes englobantes).
7 +# ==============================================================================
8 +from __future__ import annotations
9 +
10 +import re
11 +import unicodedata
12 +
13 +REGIONS = [
14 + "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale",
15 + "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue",
16 + "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",
17 + "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides",
18 + "Montérégie", "Centre-du-Québec",
19 +]
20 +
21 +
22 +def strip_accents(text: str) -> str:
23 + return "".join(c for c in unicodedata.normalize("NFD", text)
24 + if unicodedata.category(c) != "Mn")
25 +
26 +
27 +def _key(city: str) -> str:
28 + s = strip_accents((city or "").strip().lower())
29 + s = re.sub(r"^(ville de|city of)\s+", "", s)
30 + s = re.sub(r"\s*\(.*\)$", "", s) # « Fiset Sorel-Tracy (arr.) »
31 + s = re.sub(r"[^a-z0-9]+", "-", s)
32 + return s.strip("-")
33 +
34 +
35 +# Ville (clé normalisée) → région. Municipalités principales + arrondissements
36 +# fréquents dans les adresses des plateformes. Étendre au besoin (une ville
37 +# inconnue n'est jamais inventée : repli code postal, puis coordonnées).
38 +VILLE_REGION: dict[str, str] = {
39 + # --- Montréal (île) -------------------------------------------------------
40 + **{v: "Montréal" for v in (
41 + "montreal", "montreal-est", "montreal-ouest", "montreal-nord",
42 + "anjou", "lasalle", "lachine", "verdun", "outremont", "westmount",
43 + "saint-leonard", "saint-laurent", "ahuntsic", "ahuntsic-cartierville",
44 + "rosemont", "villeray", "hochelaga-maisonneuve", "mercier",
45 + "riviere-des-prairies", "pointe-aux-trembles", "pierrefonds",
46 + "roxboro", "dollard-des-ormeaux", "pointe-claire", "kirkland",
47 + "beaconsfield", "baie-d-urfe", "sainte-anne-de-bellevue", "senneville",
48 + "dorval", "mont-royal", "ville-mont-royal", "cote-saint-luc",
49 + "hampstead", "montreal-royal", "le-plateau-mont-royal", "griffintown",
50 + "ville-marie", "cote-des-neiges", "notre-dame-de-grace",
51 + "ile-bizard", "sainte-genevieve",
52 + )},
53 + # --- Laval ----------------------------------------------------------------
54 + **{v: "Laval" for v in (
55 + "laval", "chomedey", "sainte-rose", "vimont", "duvernay", "fabreville",
56 + "sainte-dorothee", "pont-viau", "laval-des-rapides", "auteuil",
57 + )},
58 + # --- Capitale-Nationale ---------------------------------------------------
59 + **{v: "Capitale-Nationale" for v in (
60 + "quebec", "l-ancienne-lorette", "ancienne-lorette",
61 + "saint-augustin-de-desmaures", "sainte-foy", "beauport", "charlesbourg",
62 + "loretteville", "val-belair", "cap-rouge", "sillery", "limoilou",
63 + "wendake", "boischatel", "l-ange-gardien", "chateau-richer",
64 + "sainte-anne-de-beaupre", "beaupre", "saint-ferreol-les-neiges",
65 + "baie-saint-paul", "la-malbaie", "clermont", "saint-simeon",
66 + "petite-riviere-saint-francois", "donnacona", "pont-rouge",
67 + "saint-raymond", "portneuf", "cap-sante", "neuville",
68 + "lac-beauport", "stoneham", "stoneham-et-tewkesbury", "deschambault",
69 + "deschambault-grondines",
70 + "sainte-catherine-de-la-jacques-cartier", "shannon", "sainte-brigitte-de-laval",
71 + "ile-d-orleans", "saint-pierre-de-l-ile-d-orleans",
72 + )},
73 + # --- Chaudière-Appalaches -------------------------------------------------
74 + **{v: "Chaudière-Appalaches" for v in (
75 + "levis", "saint-romuald", "charny", "saint-nicolas",
76 + "saint-jean-chrysostome", "pintendre", "saint-redempteur",
77 + "breakeyville", "saint-etienne-de-lauzon", "saint-henri",
78 + "montmagny", "l-islet", "saint-jean-port-joli", "sainte-marie",
79 + "saint-georges", "saint-joseph-de-beauce", "beauceville",
80 + "thetford-mines", "lac-etchemin", "saint-anselme", "scott",
81 + "saint-come-liniere", "st-georges-de-beauce", "saint-georges-de-beauce",
82 + "vallee-jonction", "saint-lambert-de-lauzon", "laurier-station",
83 + "saint-apollinaire", "saint-agapit",
84 + )},
85 + # --- Montérégie -----------------------------------------------------------
86 + **{v: "Montérégie" for v in (
87 + "longueuil", "brossard", "saint-lambert", "greenfield-park",
88 + "saint-hubert", "boucherville", "saint-bruno-de-montarville",
89 + "sainte-julie", "varennes", "vercheres", "contrecoeur", "beloeil",
90 + "mont-saint-hilaire", "otterburn-park", "mcmasterville",
91 + "saint-basile-le-grand", "chambly", "carignan", "richelieu", "marieville",
92 + "saint-jean-sur-richelieu", "iberville", "saint-luc",
93 + "granby", "waterloo", "cowansville", "bromont", "farnham",
94 + "salaberry-de-valleyfield", "vaudreuil-dorion", "l-ile-perrot",
95 + "pincourt", "saint-lazare", "hudson", "rigaud", "les-coteaux",
96 + "coteau-du-lac", "beauharnois", "chateauguay", "mercier-monteregie",
97 + "sainte-catherine", "saint-constant", "delson", "candiac",
98 + "la-prairie", "saint-philippe", "saint-remi", "napierville",
99 + "sorel-tracy", "fiset-sorel-tracy", "sainte-anne-de-sorel",
100 + "saint-hyacinthe", "sainte-helene-de-bagot", "acton-vale", "lac-brome",
101 + "sutton-monteregie",
102 + "huntingdon", "ormstown", "kahnawake", "saint-amable",
103 + )},
104 + # --- Estrie ----------------------------------------------------------------
105 + **{v: "Estrie" for v in (
106 + "sherbrooke", "magog", "orford", "north-hatley", "ayer-s-cliff",
107 + "coaticook", "stanstead", "rock-forest", "fleurimont", "lennoxville",
108 + "windsor", "val-des-sources", "asbestos", "danville", "richmond",
109 + "east-angus", "cookshire-eaton", "lac-megantic", "sutton",
110 + "knowlton", "eastman", "valcourt", "waterville", "compton",
111 + )},
112 + # --- Mauricie ---------------------------------------------------------------
113 + **{v: "Mauricie" for v in (
114 + "trois-rivieres", "cap-de-la-madeleine", "shawinigan", "grand-mere",
115 + "la-tuque", "louiseville", "yamachiche", "saint-tite",
116 + "sainte-anne-de-la-perade", "batiscan", "nicolet-mauricie", "maskinonge",
117 + "saint-boniface", "saint-etienne-des-gres", "notre-dame-du-mont-carmel",
118 + )},
119 + # --- Centre-du-Québec -------------------------------------------------------
120 + **{v: "Centre-du-Québec" for v in (
121 + "drummondville", "victoriaville", "becancour", "nicolet",
122 + "plessisville", "princeville", "warwick", "kingsey-falls",
123 + "saint-cyrille-de-wendover", "gentilly", "daveluyville",
124 + )},
125 + # --- Lanaudière -------------------------------------------------------------
126 + **{v: "Lanaudière" for v in (
127 + "terrebonne", "mascouche", "repentigny", "l-assomption", "charlemagne",
128 + "lachenaie", "la-plaine", "joliette", "notre-dame-des-prairies",
129 + "saint-charles-borromee", "berthierville", "lavaltrie", "lanoraie",
130 + "rawdon", "saint-donat", "saint-michel-des-saints", "saint-lin-laurentides",
131 + "saint-roch-de-l-achigan", "l-epiphanie", "saint-sulpice",
132 + )},
133 + # --- Laurentides ------------------------------------------------------------
134 + **{v: "Laurentides" for v in (
135 + "saint-jerome", "saint-antoine", "bellefeuille", "lafontaine",
136 + "blainville", "boisbriand", "sainte-therese", "rosemere", "lorraine",
137 + "bois-des-filion", "deux-montagnes", "saint-eustache", "mirabel",
138 + "oka", "pointe-calumet", "sainte-marthe-sur-le-lac", "saint-joseph-du-lac",
139 + "prevost", "saint-sauveur", "saint-sauveur-des-monts", "piedmont",
140 + "sainte-adele", "morin-heights",
141 + "sainte-agathe-des-monts", "val-david", "val-morin", "saint-donat-laurentides",
142 + "mont-tremblant", "labelle", "mont-laurier", "riviere-rouge",
143 + "sainte-anne-des-plaines", "lachute", "brownsburg-chatham", "grenville",
144 + )},
145 + # --- Outaouais --------------------------------------------------------------
146 + **{v: "Outaouais" for v in (
147 + "gatineau", "hull", "aylmer", "buckingham", "masson-angers",
148 + "chelsea", "cantley", "wakefield", "la-peche", "val-des-monts",
149 + "papineauville", "montebello", "thurso", "maniwaki", "fort-coulonge",
150 + "shawville", "campbell-s-bay",
151 + )},
152 + # --- Abitibi-Témiscamingue ---------------------------------------------------
153 + **{v: "Abitibi-Témiscamingue" for v in (
154 + "rouyn-noranda", "val-d-or", "amos", "la-sarre", "malartic",
155 + "riviere-heva",
156 + "senneterre", "ville-marie-temiscamingue", "temiscaming", "macamic",
157 + )},
158 + # --- Saguenay–Lac-Saint-Jean -------------------------------------------------
159 + **{v: "Saguenay–Lac-Saint-Jean" for v in (
160 + "saguenay", "chicoutimi", "jonquiere", "la-baie", "laterriere",
161 + "alma", "roberval", "saint-felicien", "dolbeau-mistassini",
162 + "normandin", "saint-prime", "metabetchouan", "hebertville",
163 + "saint-ambroise", "saint-honore", "l-anse-saint-jean", "petit-saguenay",
164 + )},
165 + # --- Bas-Saint-Laurent -------------------------------------------------------
166 + **{v: "Bas-Saint-Laurent" for v in (
167 + "rimouski", "riviere-du-loup", "matane", "mont-joli", "amqui",
168 + "causapscal", "trois-pistoles", "temiscouata-sur-le-lac", "cabano",
169 + "degelis", "pohenegamook", "la-pocatiere", "saint-pascal",
170 + "kamouraska", "le-bic", "sainte-luce", "sainte-flavie", "saint-fabien",
171 + )},
172 + # --- Gaspésie–Îles-de-la-Madeleine --------------------------------------------
173 + **{v: "Gaspésie–Îles-de-la-Madeleine" for v in (
174 + "gaspe", "perce", "chandler", "grande-riviere", "paspebiac",
175 + "bonaventure", "new-richmond", "carleton-sur-mer", "maria",
176 + "matapedia", "sainte-anne-des-monts", "cap-chat", "murdochville",
177 + "les-iles-de-la-madeleine", "cap-aux-meules", "havre-aubert",
178 + )},
179 + # --- Côte-Nord ----------------------------------------------------------------
180 + **{v: "Côte-Nord" for v in (
181 + "baie-comeau", "sept-iles", "port-cartier", "forestville",
182 + "les-escoumins", "tadoussac", "bergeronnes", "havre-saint-pierre",
183 + "fermont", "blanc-sablon", "natashquan", "chute-aux-outardes",
184 + )},
185 + # --- Nord-du-Québec -------------------------------------------------------------
186 + **{v: "Nord-du-Québec" for v in (
187 + "chibougamau", "chapais", "lebel-sur-quevillon", "matagami",
188 + "radisson", "kuujjuaq", "mistissini", "waskaganish",
189 + )},
190 +}
191 +
192 +# Repli nº 1 : préfixe de code postal (3 caractères) → région. Couverture
193 +# partielle mais fiable (les préfixes listés sont sans ambiguïté).
194 +_POSTAL_REGION = {
195 + "H7": "Laval", # Laval au complet
196 + "G1": "Capitale-Nationale", "G2": "Capitale-Nationale",
197 + "G3": "Capitale-Nationale",
198 + "G6": "Chaudière-Appalaches", "G5V": "Chaudière-Appalaches",
199 + "G5X": "Chaudière-Appalaches",
200 + "G7": "Saguenay–Lac-Saint-Jean", "G8": "Saguenay–Lac-Saint-Jean",
201 + "G5L": "Bas-Saint-Laurent", "G5R": "Bas-Saint-Laurent",
202 + "G4X": "Gaspésie–Îles-de-la-Madeleine", "G4W": "Bas-Saint-Laurent",
203 + "G4R": "Côte-Nord", "G4Z": "Côte-Nord", "G5C": "Côte-Nord",
204 + "G9A": "Mauricie", "G8T": "Mauricie", "G9N": "Mauricie", "G9H": "Centre-du-Québec",
205 + "J1": "Estrie",
206 + "J2": "Montérégie", "J3": "Montérégie", "J4": "Montérégie", "J5R": "Montérégie",
207 + "J6": "Montérégie",
208 + "J7": "Laurentides", "J8": "Outaouais", "J9": "Abitibi-Témiscamingue",
209 + "J9B": "Outaouais",
210 + "J5W": "Lanaudière", "J6E": "Lanaudière",
211 +}
212 +
213 +# Repli nº 2 : boîtes englobantes grossières (lat_min, lat_max, lng_min, lng_max)
214 +# — utilisées seulement quand ni la ville ni le code postal ne tranchent.
215 +_BBOX_REGION = [
216 + ("Montréal", 45.40, 45.71, -73.98, -73.47),
217 + ("Laval", 45.51, 45.70, -73.90, -73.60),
218 + ("Capitale-Nationale", 46.70, 47.60, -72.40, -70.30),
219 + ("Estrie", 45.00, 45.90, -72.60, -70.80),
220 + ("Outaouais", 45.35, 46.60, -77.50, -74.90),
221 + ("Saguenay–Lac-Saint-Jean", 48.00, 49.10, -72.90, -70.50),
222 +]
223 +
224 +
225 +def attach_region(city: str, postal_code: str = "",
226 + lat: float | None = None, lng: float | None = None) -> str:
227 + """Région administrative d'un restaurant. Retourne "" si indéterminable
228 + (jamais de région inventée)."""
229 + region = VILLE_REGION.get(_key(city))
230 + if region:
231 + return region
232 + pc = (postal_code or "").replace(" ", "").upper()
233 + if pc:
234 + for prefix in (pc[:3], pc[:2]):
235 + if prefix in _POSTAL_REGION:
236 + return _POSTAL_REGION[prefix]
237 + if lat is not None and lng is not None:
238 + for name, la0, la1, lo0, lo1 in _BBOX_REGION:
239 + if la0 <= lat <= la1 and lo0 <= lng <= lo1:
240 + return name
241 + return ""
added restoka/schema.py +154 −0
@@ -0,0 +1,154 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/schema.py
4 +# Desc: Modèle de données standardisé Restaurant + Menu imbriqué
5 +# (catégories → plats → prix → options) et normalisation centrale.
6 +# Calqué sur louka/schema.py — le schéma est simplement plus profond.
7 +# ==============================================================================
8 +"""Schéma standard d'un restaurant (CLAUDE.md §5) et validation du menu.
9 +
10 +Chaque connecteur, peu importe la source, produit des objets `Restaurant`
11 +conformes à ce schéma. `finalize()` applique la couche commune : nettoyage,
12 +région (obligatoire), cuisines, type d'établissement, fourchette de prix,
13 +diètes, validation stricte du menu (`price_context`/`price_source` requis).
14 +"""
15 +from __future__ import annotations
16 +
17 +import hashlib
18 +import json
19 +from dataclasses import dataclass, field, asdict
20 +
21 +from .normalize import (
22 + PRICE_CONTEXTS,
23 + classify_cuisines,
24 + classify_establishment,
25 + clean_address,
26 + clean_name,
27 + infer_dietary,
28 + price_range_from_menu,
29 +)
30 +from .regions import attach_region
31 +
32 +__all__ = ["Restaurant", "MenuValidationError"]
33 +
34 +
35 +class MenuValidationError(ValueError):
36 + """Menu rejeté (contexte/source de prix manquant, structure invalide)."""
37 +
38 +
39 +@dataclass
40 +class Restaurant:
41 + """Restaurant standardisé Resto·Ka (une fiche par adresse/succursale)."""
42 +
43 + source: str # id de la source (data/sources.json)
44 + external_id: str # identifiant chez la source
45 + name: str # nom normalisé
46 + url: str = "" # page du resto chez la source
47 + chain: str | None = None # marque/chaîne si multi-succursales
48 + cuisines: list[str] = field(default_factory=list)
49 + establishment_type: str = "" # restaurant|fast-food|cafe|bar|...
50 + price_range: str = "" # $ à $$$$ (estimé du menu)
51 + address: str = ""
52 + city: str = ""
53 + region: str = "" # une des 17 régions (regions.py)
54 + postal_code: str = ""
55 + lat: float | None = None
56 + lng: float | None = None
57 + phone: str = ""
58 + website: str = ""
59 + hours: dict = field(default_factory=dict) # horaires par jour
60 + services: list[str] = field(default_factory=list) # dine-in|takeout|delivery
61 + dietary_options: list[str] = field(default_factory=list)
62 + languages: list[str] = field(default_factory=list)
63 + images: list[str] = field(default_factory=list) # logo + photos (URLs absolues)
64 + menu: dict | None = None # voir CLAUDE.md §5.2
65 + status: str = "open" # open|temporarily_closed|closed
66 +
67 + @property
68 + def uid(self) -> str:
69 + return f"{self.source}:{self.external_id}"
70 +
71 + def content_hash(self) -> str:
72 + """Hash du contenu pour la détection de changements. `captured_at`
73 + est exclu : un menu identique recapturé ne doit pas compter comme
74 + une mise à jour."""
75 + payload = asdict(self)
76 + if payload.get("menu"):
77 + payload["menu"] = dict(payload["menu"], captured_at="")
78 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
79 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
80 +
81 + # -- validation du menu ----------------------------------------------------
82 + def _validate_menu(self) -> None:
83 + m = self.menu
84 + if m is None:
85 + return
86 + if m.get("price_context") not in PRICE_CONTEXTS:
87 + raise MenuValidationError(
88 + f"{self.uid}: price_context invalide ou manquant "
89 + f"({m.get('price_context')!r}) — un prix sans contexte est "
90 + "inutilisable (livraison ≠ salle)")
91 + if not m.get("price_source"):
92 + raise MenuValidationError(f"{self.uid}: price_source manquant")
93 + if not m.get("captured_at"):
94 + raise MenuValidationError(f"{self.uid}: captured_at manquant")
95 + m.setdefault("currency", "CAD")
96 + for sec in m.get("sections") or []:
97 + if not sec.get("name"):
98 + raise MenuValidationError(f"{self.uid}: section sans nom")
99 + for it in sec.get("items") or []:
100 + if not it.get("name"):
101 + raise MenuValidationError(
102 + f"{self.uid}: item sans nom dans « {sec['name']} »")
103 + price = it.get("price")
104 + if price is not None and (not isinstance(price, (int, float))
105 + or price < 0 or price > 10000):
106 + raise MenuValidationError(
107 + f"{self.uid}: prix implausible {price!r} pour "
108 + f"« {it['name']} »")
109 + it.setdefault("currency", m["currency"])
110 +
111 + def _menu_text(self) -> str:
112 + if not self.menu:
113 + return ""
114 + parts = []
115 + for sec in self.menu.get("sections") or []:
116 + parts.append(sec.get("name") or "")
117 + parts.extend(it.get("name") or "" for it in sec.get("items") or [])
118 + return " ".join(parts)
119 +
120 + def finalize(self) -> "Restaurant":
121 + """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
122 +
123 + Idempotent ; ne remplace jamais une valeur explicite du connecteur.
124 + Lève MenuValidationError si le menu viole le schéma (le pipeline
125 + rejette alors la fiche plutôt que de stocker un prix sans contexte).
126 + """
127 + self.name = clean_name(self.name)
128 + self.address = clean_address(self.address)
129 + self.city = self.city.strip()
130 + self.postal_code = (self.postal_code or "").strip().upper()
131 +
132 + self._validate_menu()
133 +
134 + # coordonnées fournies par la source : rejeter tout point hors de la
135 + # province (lat/lng inversés, 0/0) — le géocodeur prendra le relais
136 + if self.lat is not None and self.lng is not None:
137 + if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):
138 + self.lat = self.lng = None
139 +
140 + if not self.region:
141 + self.region = attach_region(self.city, self.postal_code,
142 + self.lat, self.lng)
143 + if not self.cuisines:
144 + self.cuisines = classify_cuisines(self.name, self._menu_text())
145 + if not self.establishment_type:
146 + self.establishment_type = classify_establishment(self.name,
147 + self.cuisines)
148 + if not self.price_range:
149 + self.price_range = price_range_from_menu(self.menu)
150 + if not self.dietary_options:
151 + self.dietary_options = infer_dietary(self.menu)
152 + if not self.languages:
153 + self.languages = ["fr"]
154 + return self
added restoka/web.py +409 −0
@@ -0,0 +1,409 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/web.py
4 +# Desc: API FastAPI (JSON) + service du frontend React (frontend/dist).
5 +# Recherche des restaurants (région, ville, cuisine, diète, prix, texte),
6 +# fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >
7 +# delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.
8 +# ==============================================================================
9 +from __future__ import annotations
10 +
11 +import json
12 +import statistics
13 +import threading
14 +from pathlib import Path
15 +
16 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
17 +from fastapi.middleware.cors import CORSMiddleware
18 +from fastapi.middleware.gzip import GZipMiddleware
19 +from fastapi.responses import FileResponse
20 +from fastapi.staticfiles import StaticFiles
21 +
22 +from . import db, ingest
23 +from .normalize import PRICE_CONTEXTS
24 +from .regions import REGIONS
25 +
26 +ROOT = Path(__file__).resolve().parent.parent
27 +SOURCES_PATH = ROOT / "data" / "sources.json"
28 +FRONTEND_DIST = ROOT / "frontend" / "dist"
29 +
30 +# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)
31 +_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}
32 +
33 +app = FastAPI(title="Resto-Ka API", version="1.0",
34 + description="Agrégateur des restaurants du Québec — menus & prix")
35 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
36 + allow_methods=["*"], allow_headers=["*"])
37 +app.add_middleware(GZipMiddleware, minimum_size=1000)
38 +
39 +_sync_lock = threading.Lock()
40 +
41 +
42 +def _row_to_dict(row) -> dict:
43 + d = dict(row)
44 + for col in ("cuisines", "services", "dietary_options", "languages", "images"):
45 + d[col] = json.loads(d.get(col) or "[]")
46 + d["hours"] = json.loads(d.get("hours") or "{}")
47 + if d.get("dup_sources"):
48 + try:
49 + d["dup_sources"] = json.loads(d["dup_sources"])
50 + except (ValueError, TypeError):
51 + d["dup_sources"] = []
52 + return d
53 +
54 +
55 +def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:
56 + """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes."""
57 + if not uids:
58 + return {}
59 + qmarks = ",".join("?" * len(uids))
60 + best: dict[str, dict] = {}
61 + for r in con.execute(
62 + f"SELECT uid, price_context, price_source, captured_at, item_count,"
63 + f" sections FROM menus WHERE uid IN ({qmarks})", uids):
64 + cur = best.get(r["uid"])
65 + if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \
66 + _CTX_ORDER.get(r["price_context"], 9):
67 + continue
68 + sections = json.loads(r["sections"] or "[]")
69 + prices = [it.get("price")
70 + for sec in sections
71 + for it in sec.get("items") or []
72 + if isinstance(it.get("price"), (int, float)) and it["price"] > 0]
73 + image = next((it["image"] for sec in sections
74 + for it in sec.get("items") or [] if it.get("image")),
75 + next((sec["image"] for sec in sections
76 + if sec.get("image")), None))
77 + best[r["uid"]] = {
78 + "image": image,
79 + "price_context": r["price_context"],
80 + "price_source": r["price_source"],
81 + "captured_at": r["captured_at"],
82 + "item_count": r["item_count"],
83 + "price_min": min(prices) if prices else None,
84 + "price_median": round(statistics.median(prices), 2) if prices else None,
85 + }
86 + return best
87 +
88 +
89 +@app.get("/healthz")
90 +def healthz():
91 + return {"status": "ok"}
92 +
93 +
94 +@app.get("/api/restaurants")
95 +def list_restaurants(
96 + region: str | None = None,
97 + city: str | None = None,
98 + cuisine: str | None = None,
99 + establishment_type: str | None = None,
100 + diet: str | None = None, # vegan, sans-gluten…
101 + service: str | None = None, # takeout, delivery, salle
102 + price_range: str | None = None, # $ à $$$$
103 + chain: str | None = None,
104 + source: str | None = None,
105 + q: str | None = None,
106 + active: int = 1,
107 + sort: str = "name", # name | price | recent
108 + limit: int = Query(60, le=500),
109 + offset: int = 0,
110 +):
111 + con = db.connect()
112 + sql = "SELECT * FROM restaurants WHERE dup_of IS NULL" # doublons masqués
113 + args: list = []
114 + if active in (0, 1):
115 + sql += " AND active=?"; args.append(active)
116 + if region:
117 + sql += " AND region=?"; args.append(region)
118 + if city:
119 + sql += " AND city=?"; args.append(city)
120 + if cuisine:
121 + sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%')
122 + if establishment_type:
123 + sql += " AND establishment_type=?"; args.append(establishment_type)
124 + if diet:
125 + sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%')
126 + if service:
127 + sql += " AND services LIKE ?"; args.append(f'%"{service}"%')
128 + if price_range:
129 + sql += " AND price_range=?"; args.append(price_range)
130 + if chain:
131 + sql += " AND chain=?"; args.append(chain)
132 + if source:
133 + sql += " AND source=?"; args.append(source)
134 + if q:
135 + sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"
136 + args += [f"%{q}%"] * 4
137 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
138 + if sort == "recent":
139 + sql += " ORDER BY updated_at DESC"
140 + elif sort == "price":
141 + sql += " ORDER BY price_range='' , price_range ASC, name ASC"
142 + else:
143 + sql += " ORDER BY name COLLATE NOCASE ASC"
144 + sql += " LIMIT ? OFFSET ?"
145 + args += [limit, offset]
146 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
147 + menus = _menu_summaries(con, [r["uid"] for r in rows])
148 + for r in rows:
149 + r["menu_summary"] = menus.get(r["uid"])
150 + con.close()
151 + return {"total": total, "count": len(rows), "restaurants": rows}
152 +
153 +
154 +@app.get("/api/dishes")
155 +def search_dishes(
156 + q: str = Query(..., min_length=2),
157 + region: str | None = None,
158 + city: str | None = None,
159 + cuisine: str | None = None,
160 + price_max: float | None = None,
161 + limit: int = Query(30, le=100),
162 + offset: int = 0,
163 +):
164 + """Recherche PAR PLAT dans tous les menus (nom + description).
165 +
166 + Les succursales d'une même chaîne partagent le même menu : les résultats
167 + sont regroupés par (marque, plat, prix, contexte) avec le nombre
168 + d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.
169 + """
170 + con = db.connect()
171 + needle = q.strip().lower()
172 + sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at,"
173 + " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines"
174 + " FROM menus m JOIN restaurants r ON r.uid = m.uid"
175 + " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?")
176 + args: list = [f"%{needle}%"]
177 + if region:
178 + sql += " AND r.region=?"; args.append(region)
179 + if city:
180 + sql += " AND r.city=?"; args.append(city)
181 + if cuisine:
182 + sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%')
183 +
184 + groups: dict[tuple, dict] = {}
185 + for row in con.execute(sql, args):
186 + try:
187 + sections = json.loads(row["sections"] or "[]")
188 + except ValueError:
189 + continue
190 + brand = row["chain"] or row["rname"]
191 + for sec in sections:
192 + for it in sec.get("items") or []:
193 + hay = f"{it.get('name', '')} {it.get('description', '')}".lower()
194 + if needle not in hay:
195 + continue
196 + price = it.get("price")
197 + if price_max is not None and (price is None or price > price_max):
198 + continue
199 + key = (brand, it.get("name"), price, row["price_context"])
200 + g = groups.get(key)
201 + if g is None:
202 + groups[key] = {
203 + "item": it.get("name"),
204 + "description": it.get("description") or "",
205 + "price": price,
206 + "currency": it.get("currency", "CAD"),
207 + "image": it.get("image"),
208 + "tags": it.get("tags") or [],
209 + "section": sec.get("name"),
210 + "price_context": row["price_context"],
211 + "price_source": row["price_source"],
212 + "captured_at": row["captured_at"],
213 + "brand": brand,
214 + "restaurant": row["rname"],
215 + "uid": row["uid"],
216 + "city": row["city"],
217 + "region": row["region"],
218 + "locations": 1,
219 + }
220 + else:
221 + g["locations"] += 1
222 + con.close()
223 + results = sorted(groups.values(),
224 + key=lambda g: (g["price"] is None, g["price"] or 0))
225 + total = len(results)
226 + return {"total": total, "count": min(limit, max(0, total - offset)),
227 + "dishes": results[offset:offset + limit]}
228 +
229 +
230 +@app.get("/api/restaurants/{uid:path}/prices")
231 +def price_history(uid: str):
232 + """Historique des prix par item (série temporelle, détection des hausses)."""
233 + con = db.connect()
234 + rows = [dict(r) for r in con.execute(
235 + "SELECT price_context, item_key, ts, price FROM item_price_log"
236 + " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))]
237 + con.close()
238 + return {"uid": uid, "prices": rows}
239 +
240 +
241 +@app.get("/api/restaurants/{uid:path}")
242 +def get_restaurant(uid: str):
243 + con = db.connect()
244 + row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone()
245 + if row is None:
246 + con.close()
247 + raise HTTPException(404, "Restaurant introuvable")
248 + d = _row_to_dict(row)
249 + # tous les contextes de prix disponibles, meilleur en premier — un menu
250 + # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)
251 + menus = []
252 + for m in con.execute(
253 + "SELECT price_context, price_source, currency, captured_at,"
254 + " item_count, sections FROM menus WHERE uid=?", (uid,)):
255 + menus.append({
256 + "price_context": m["price_context"],
257 + "price_source": m["price_source"],
258 + "currency": m["currency"],
259 + "captured_at": m["captured_at"],
260 + "item_count": m["item_count"],
261 + "sections": json.loads(m["sections"] or "[]"),
262 + })
263 + menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9))
264 + d["menus"] = menus
265 + # items dont le prix a changé récemment (crédibilité, §12.2)
266 + d["recent_price_changes"] = [dict(r) for r in con.execute(
267 + "SELECT price_context, item_key, ts, price FROM item_price_log"
268 + " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)"
269 + " GROUP BY item_key HAVING COUNT(*) > 1"
270 + " ORDER BY ts DESC LIMIT 20", (uid, uid))]
271 + con.close()
272 + return d
273 +
274 +
275 +@app.get("/api/facets")
276 +def facets(region: str | None = None):
277 + """Valeurs distinctes pour construire les filtres du frontend."""
278 + con = db.connect()
279 + city_sql = ("SELECT DISTINCT city FROM restaurants"
280 + " WHERE active=1 AND dup_of IS NULL AND city<>''")
281 + city_args: list = []
282 + if region:
283 + city_sql += " AND region=?"
284 + city_args.append(region)
285 + cuisines: dict[str, int] = {}
286 + diets: dict[str, int] = {}
287 + for r in con.execute("SELECT cuisines, dietary_options FROM restaurants"
288 + " WHERE active=1 AND dup_of IS NULL"):
289 + for c in json.loads(r["cuisines"] or "[]"):
290 + cuisines[c] = cuisines.get(c, 0) + 1
291 + for dd in json.loads(r["dietary_options"] or "[]"):
292 + diets[dd] = diets.get(dd, 0) + 1
293 + out = {
294 + "regions": [dict(r) for r in con.execute(
295 + "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"
296 + " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")],
297 + "all_regions": REGIONS,
298 + "cities": [r["city"] for r in con.execute(
299 + city_sql + " ORDER BY city", city_args)],
300 + "cuisines": [{"cuisine": c, "n": n} for c, n in
301 + sorted(cuisines.items(), key=lambda kv: -kv[1])],
302 + "diets": [{"diet": d, "n": n} for d, n in
303 + sorted(diets.items(), key=lambda kv: -kv[1])],
304 + "establishment_types": [dict(r) for r in con.execute(
305 + "SELECT establishment_type t, COUNT(*) n FROM restaurants"
306 + " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''"
307 + " GROUP BY establishment_type ORDER BY n DESC")],
308 + "chains": [r["chain"] for r in con.execute(
309 + "SELECT DISTINCT chain FROM restaurants WHERE active=1"
310 + " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")],
311 + "sources": [dict(r) for r in con.execute(
312 + "SELECT source, COUNT(*) n FROM restaurants WHERE active=1"
313 + " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],
314 + }
315 + con.close()
316 + return out
317 +
318 +
319 +@app.get("/api/sources")
320 +def sources():
321 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
322 + con = db.connect()
323 + counts = {r["source"]: r["n"] for r in con.execute(
324 + "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")}
325 + items = {r["source"]: r["n"] for r in con.execute(
326 + "SELECT r.source, SUM(m.item_count) n FROM menus m"
327 + " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")}
328 + last = {r["source"]: r["ts"] for r in con.execute(
329 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
330 + con.close()
331 + for s in registry:
332 + s["active_restaurants"] = counts.get(s["id"], 0)
333 + s["menu_items"] = items.get(s["id"], 0)
334 + s["last_sync"] = last.get(s["id"])
335 + # ne pas exposer les clés d'intégration dans l'API publique
336 + if "integrations" in s:
337 + s["integrations"] = [{"label": i.get("label"),
338 + "status": i.get("status", "actif")}
339 + for i in s["integrations"]]
340 + return {"sources": registry}
341 +
342 +
343 +@app.get("/api/stats")
344 +def stats():
345 + con = db.connect()
346 + head = dict(con.execute(
347 + """SELECT COUNT(*) restaurants,
348 + COUNT(DISTINCT chain) chains,
349 + COUNT(DISTINCT region) regions,
350 + COUNT(DISTINCT source) sources
351 + FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())
352 + m = dict(con.execute(
353 + """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items
354 + FROM menus m JOIN restaurants r ON r.uid=m.uid
355 + WHERE r.active=1 AND r.dup_of IS NULL""").fetchone())
356 + by_region = [dict(r) for r in con.execute(
357 + "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"
358 + " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")]
359 + by_context = [dict(r) for r in con.execute(
360 + "SELECT m.price_context, COUNT(*) n FROM menus m"
361 + " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1"
362 + " GROUP BY m.price_context")]
363 + log = [dict(r) for r in con.execute(
364 + "SELECT source, ts, found, added, updated, removed, ok, message"
365 + " FROM sync_log ORDER BY ts DESC LIMIT 20")]
366 + con.close()
367 + return {**head, **m, "by_region": by_region, "by_context": by_context,
368 + "recent_syncs": log}
369 +
370 +
371 +@app.post("/api/sync")
372 +def trigger_sync(background: BackgroundTasks, source: str | None = None):
373 + """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
374 + def _job():
375 + with _sync_lock:
376 + ingest.run([source] if source else None)
377 + ingest.enrich()
378 + background.add_task(_job)
379 + return {"status": "démarré", "source": source or "toutes"}
380 +
381 +
382 +# --- Frontend React (build Vite) --------------------------------------------
383 +if FRONTEND_DIST.exists():
384 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),
385 + name="assets")
386 +
387 + @app.middleware("http")
388 + async def _cache_headers(request, call_next):
389 + """Bundles hachés immuables ; index.html toujours revalidé."""
390 + resp = await call_next(request)
391 + path = request.url.path
392 + if path.startswith("/assets/"):
393 + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
394 + elif "text/html" in (resp.headers.get("content-type") or ""):
395 + resp.headers["Cache-Control"] = "no-cache"
396 + return resp
397 +
398 + _CLIENT_PREFIXES = ("resto/", "sources", "stats")
399 +
400 + @app.get("/{full_path:path}")
401 + def spa(full_path: str):
402 + target = FRONTEND_DIST / full_path
403 + if full_path and target.is_file():
404 + return FileResponse(target)
405 + known = (full_path == "" or full_path in _CLIENT_PREFIXES
406 + or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))
407 + return FileResponse(FRONTEND_DIST / "index.html",
408 + status_code=200 if known else 404,
409 + headers={"Cache-Control": "no-cache"})
added run.py +59 −0
@@ -0,0 +1,59 @@
1 +#!/usr/bin/env python3
2 +# ==============================================================================
3 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 +# File: run.py
5 +# Desc: Resto·Ka — point d'entrée : `sync`, `watch`, `serve`, `geocode`,
6 +# `dedup`. Même patron que le run.py de Lou·Ka.
7 +# ==============================================================================
8 +"""Utilisation :
9 + python run.py sync [source ...] # synchronise les restos + menus
10 + python run.py watch [heures] # synchronise en boucle (défaut 24 h)
11 + python run.py serve [port] # démarre l'API + le frontend (défaut 8115)
12 + python run.py geocode [n] # géocode les restos sans coordonnées
13 + python run.py dedup # déduplication inter-sources
14 +"""
15 +from __future__ import annotations
16 +
17 +import os
18 +import sys
19 +from pathlib import Path
20 +
21 +# Charger .env (SCRAPFLY_KEY, FIRECRAWL_API_KEY…) sans dépendance externe
22 +_env = Path(__file__).parent / ".env"
23 +if _env.exists():
24 + for line in _env.read_text().splitlines():
25 + line = line.strip()
26 + if line and not line.startswith("#") and "=" in line:
27 + k, _, v = line.partition("=")
28 + os.environ.setdefault(k.strip(), v.strip())
29 +
30 +
31 +def main() -> None:
32 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
33 + args = sys.argv[2:]
34 +
35 + if cmd == "sync":
36 + from restoka import ingest
37 + ingest.run(args or None)
38 + ingest.enrich()
39 + elif cmd == "watch":
40 + from restoka import ingest
41 + hours = float(args[0]) if args else 24.0
42 + ingest.watch(int(hours * 3600))
43 + elif cmd == "serve":
44 + import uvicorn
45 + port = int(args[0]) if args else 8115
46 + uvicorn.run("restoka.web:app", host="0.0.0.0", port=port)
47 + elif cmd == "geocode":
48 + from restoka import geocode
49 + geocode.run_batch(int(args[0]) if args else 200)
50 + elif cmd == "dedup":
51 + from restoka import dedup
52 + print(dedup.run())
53 + else:
54 + print(__doc__)
55 + sys.exit(1)
56 +
57 +
58 +if __name__ == "__main__":
59 + main()
added tests/conftest.py +25 −0
@@ -0,0 +1,25 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/conftest.py
4 +# Desc: Fixtures pytest communes — base SQLite éphémère par test.
5 +# ==============================================================================
6 +from __future__ import annotations
7 +
8 +import sys
9 +from pathlib import Path
10 +
11 +import pytest
12 +
13 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14 +
15 +from restoka import db # noqa: E402
16 +
17 +
18 +@pytest.fixture()
19 +def con(tmp_path, monkeypatch):
20 + """Connexion SQLite isolée (jamais la base réelle data/restoka.db)."""
21 + path = tmp_path / "test.db"
22 + monkeypatch.setattr(db, "DB_PATH", path)
23 + connection = db.connect(path)
24 + yield connection
25 + connection.close()
added tests/fixtures/ueat/poulet_rouge.json +76 −0
@@ -0,0 +1,76 @@
1 +{
2 + "_comment": "Author: Simon-Pierre Boucher <contact@spboucher.ai> — Fixture réelle (extrait) captée le 2026-08-17 sur api.ueat.io/graphql (intégration Poulet Rouge, tronquée à 2 succursales et 2 catégories pour le test de régression du parsing).",
3 + "getFranchises": {
4 + "restaurant": { "name": "Poulet Rouge", "url": "https://poulet-rouge.ca/", "logo": null },
5 + "franchises": [
6 + {
7 + "companyProfileId": 22944, "code": "Beauport", "name": "Poulet Rouge - Beauport",
8 + "currency": "CAD", "countryCode": "CA",
9 + "address": { "civicNumber": "898", "streetName": "Boulevard Raymond", "postalCode": "G1C 5P4", "city": "Québec", "province": "Québec" }
10 + },
11 + {
12 + "companyProfileId": 21131, "code": "BankSt", "name": "Poulet Rouge - Bank St",
13 + "currency": "CAD", "countryCode": "CA",
14 + "address": { "civicNumber": "900", "streetName": "Bank Street", "postalCode": "K1S 3W6", "city": "Ottawa", "province": "Ontario" }
15 + },
16 + {
17 + "companyProfileId": 10636, "code": "boulBourque", "name": "Poulet Rouge - Boul. Bourque",
18 + "currency": "CAD", "countryCode": "CA",
19 + "address": { "civicNumber": "4796", "streetName": "Boulevard Bourque", "postalCode": "J1N 2A7", "city": "Sherbrooke", "province": "Québec" }
20 + }
21 + ]
22 + },
23 + "menuCategories": {
24 + "menu": {
25 + "categories": [
26 + { "id": 65413, "name": "Rouge Bol", "description": "", "numberOfMenuItems": 3, "hasSubCategory": false },
27 + { "id": 65417, "name": "À-Côtés & Desserts", "description": "", "numberOfMenuItems": 5, "hasSubCategory": false }
28 + ]
29 + }
30 + },
31 + "items": {
32 + "65413": [
33 + {
34 + "id": 1428587, "name": "Créez Votre Rouge Bol (1 Saveur)",
35 + "description": "Votre choix de saveur de poulet grillé servi sur une base de votre choix (36g Protéine)",
36 + "subtitle": "", "regularPrice": 14.29, "unitPrice": 14.29,
37 + "hasQuestions": true, "isOutOfStock": false, "isTemporaryDisabled": false,
38 + "labels": [], "variations": []
39 + },
40 + {
41 + "id": 1428596, "name": "Rouge Bol (Végé)",
42 + "description": "Base de votre choix et garnissez le bol végé à votre goût",
43 + "subtitle": "", "regularPrice": 11.29, "unitPrice": 11.29,
44 + "hasQuestions": false, "isOutOfStock": false, "isTemporaryDisabled": false,
45 + "labels": [{ "name": "Végétarien" }], "variations": []
46 + }
47 + ],
48 + "65417": [
49 + {
50 + "id": 1428646, "name": "Patates Frites", "description": "", "subtitle": "",
51 + "regularPrice": 4.99, "unitPrice": 4.99,
52 + "hasQuestions": false, "isOutOfStock": false, "isTemporaryDisabled": false,
53 + "labels": [],
54 + "variations": [
55 + { "id": 1, "name": "Régulière", "description": "", "unitPrice": 4.99, "regularPrice": 4.99, "variations": [] },
56 + { "id": 2, "name": "Grande", "description": "", "unitPrice": 6.99, "regularPrice": 6.99, "variations": [] }
57 + ]
58 + }
59 + ]
60 + },
61 + "questions": {
62 + "1428587": {
63 + "item": {
64 + "id": 1428587, "name": "Créez Votre Rouge Bol (1 Saveur)",
65 + "question": {
66 + "questionText": "Choisissez votre base", "selectionType": "Single",
67 + "minimum": 1, "maximum": 1,
68 + "choices": [
69 + { "name": "Riz basmati", "unitPrice": 0, "isNotAdded": false },
70 + { "name": "Salade César", "unitPrice": 1.5, "isNotAdded": false }
71 + ]
72 + }
73 + }
74 + }
75 + }
76 +}
added tests/test_normalize.py +107 −0
@@ -0,0 +1,107 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_normalize.py
4 +# Desc: Normalisation : parsing des prix (l'actif le plus critique),
5 +# cuisines, type d'établissement, fourchette de prix, régions.
6 +# ==============================================================================
7 +from restoka.normalize import (
8 + classify_cuisines,
9 + classify_establishment,
10 + clean_name,
11 + parse_price,
12 + price_range_from_menu,
13 +)
14 +from restoka.regions import attach_region
15 +
16 +
17 +class TestParsePrice:
18 + def test_nombre(self):
19 + assert parse_price(14.29) == 14.29
20 +
21 + def test_texte_virgule(self):
22 + assert parse_price("16,50 $") == 16.50
23 +
24 + def test_texte_point(self):
25 + assert parse_price("à partir de 12.75$") == 12.75
26 +
27 + def test_zero_et_negatif(self):
28 + assert parse_price(0) is None
29 + assert parse_price(-3) is None
30 +
31 + def test_vide(self):
32 + assert parse_price(None) is None
33 + assert parse_price("gratuit") is None
34 +
35 +
36 +class TestCuisines:
37 + def test_pizza(self):
38 + assert "pizza" in classify_cuisines("Pizza Mia")
39 +
40 + def test_sushi(self):
41 + assert "sushi-japonais" in classify_cuisines("Ryu Sushi")
42 +
43 + def test_menu_texte(self):
44 + cuisines = classify_cuisines("Chez Georges", "Poutine Tourtière Smoked meat")
45 + assert "quebecois" in cuisines
46 +
47 + def test_inconnu(self):
48 + assert classify_cuisines("Xyzzy") == ["autre"]
49 +
50 +
51 +class TestEstablishment:
52 + def test_cafe(self):
53 + assert classify_establishment("Café Station W", []) == "cafe"
54 +
55 + def test_boba(self):
56 + assert classify_establishment("Panda Boba-T", []) == "cafe"
57 +
58 + def test_defaut(self):
59 + assert classify_establishment("Chez Georges", ["italien"]) == "restaurant"
60 +
61 +
62 +class TestPriceRange:
63 + def _menu(self, prices):
64 + return {"sections": [{"name": "Plats",
65 + "items": [{"name": f"p{i}", "price": p}
66 + for i, p in enumerate(prices)]}]}
67 +
68 + def test_dollar(self):
69 + assert price_range_from_menu(self._menu([8, 10, 12])) == "$"
70 +
71 + def test_deux_dollars(self):
72 + assert price_range_from_menu(self._menu([16, 22, 28])) == "$$"
73 +
74 + def test_quatre_dollars(self):
75 + assert price_range_from_menu(self._menu([70, 95, 120])) == "$$$$"
76 +
77 + def test_vide(self):
78 + assert price_range_from_menu(None) == ""
79 +
80 +
81 +class TestRegions:
82 + def test_villes(self):
83 + assert attach_region("Montréal") == "Montréal"
84 + assert attach_region("Québec") == "Capitale-Nationale"
85 + assert attach_region("Lévis") == "Chaudière-Appalaches"
86 + assert attach_region("Sherbrooke") == "Estrie"
87 + assert attach_region("Saguenay") == "Saguenay–Lac-Saint-Jean"
88 + assert attach_region("Gaspé") == "Gaspésie–Îles-de-la-Madeleine"
89 + assert attach_region("Laval") == "Laval"
90 + assert attach_region("Brossard") == "Montérégie"
91 +
92 + def test_accents_et_casse(self):
93 + assert attach_region("MONTREAL") == "Montréal"
94 + assert attach_region("Trois-Rivieres") == "Mauricie"
95 +
96 + def test_repli_postal(self):
97 + assert attach_region("Village-inconnu", "G1R 4P5") == "Capitale-Nationale"
98 + assert attach_region("Village-inconnu", "H7E 0H3") == "Laval"
99 + assert attach_region("Village-inconnu", "J1N 2A7") == "Estrie"
100 +
101 + def test_jamais_inventee(self):
102 + assert attach_region("Ottawa") == ""
103 +
104 +
105 +def test_clean_name():
106 + assert clean_name(" CRÈME BRÛLÉE ") == "Crème Brûlée"
107 + assert clean_name("Pizza <b>Mia</b>") == "Pizza Mia"
added tests/test_schema_db.py +145 −0
@@ -0,0 +1,145 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_schema_db.py
4 +# Desc: Validation du schéma (menu sans price_context REJETÉ), cycle de vie
5 +# en base (upsert, délai de grâce, historique des prix, garde-fou de
6 +# chute d'items) et déduplication inter-sources.
7 +# ==============================================================================
8 +import json
9 +
10 +import pytest
11 +
12 +from restoka import db, dedup
13 +from restoka.schema import MenuValidationError, Restaurant
14 +
15 +
16 +def _resto(source="ueat", eid="g:1", name="Chez Test", menu=True, price=12.0,
17 + **kw) -> Restaurant:
18 + m = None
19 + if menu:
20 + m = {
21 + "currency": "CAD",
22 + "price_context": "takeout",
23 + "price_source": source,
24 + "captured_at": "2026-08-17T12:00:00Z",
25 + "sections": [{"name": "Plats", "items": [
26 + {"name": "Poutine", "price": price},
27 + {"name": "Burger", "price": 14.5},
28 + ]}],
29 + }
30 + defaults = dict(source=source, external_id=eid, name=name,
31 + address="123 Rue Principale", city="Québec",
32 + postal_code="G1R 4P5", menu=m)
33 + defaults.update(kw)
34 + return Restaurant(**defaults)
35 +
36 +
37 +class TestValidation:
38 + def test_menu_sans_contexte_rejete(self):
39 + r = _resto()
40 + r.menu.pop("price_context")
41 + with pytest.raises(MenuValidationError):
42 + r.finalize()
43 +
44 + def test_menu_sans_source_rejete(self):
45 + r = _resto()
46 + r.menu.pop("price_source")
47 + with pytest.raises(MenuValidationError):
48 + r.finalize()
49 +
50 + def test_contexte_invalide_rejete(self):
51 + r = _resto()
52 + r.menu["price_context"] = "menu"
53 + with pytest.raises(MenuValidationError):
54 + r.finalize()
55 +
56 + def test_prix_implausible_rejete(self):
57 + with pytest.raises(MenuValidationError):
58 + _resto(price=99999).finalize()
59 +
60 + def test_finalize_enrichit(self):
61 + r = _resto().finalize()
62 + assert r.region == "Capitale-Nationale"
63 + assert r.cuisines # jamais vide
64 + assert r.price_range == "$"
65 + assert r.uid == "ueat:g:1"
66 +
67 + def test_coordonnees_hors_province_rejetees(self):
68 + r = _resto(lat=48.85, lng=2.35).finalize() # Paris -> aberrant
69 + assert r.lat is None and r.lng is None
70 +
71 +
72 +class TestSync:
73 + def test_ajout_puis_stable(self, con):
74 + stats = db.sync_source(con, "ueat", [_resto().finalize()])
75 + assert stats["added"] == 1
76 + stats = db.sync_source(con, "ueat", [_resto().finalize()])
77 + assert stats["added"] == 0 and stats["updated"] == 0
78 +
79 + def test_changement_de_prix_historise(self, con):
80 + db.sync_source(con, "ueat", [_resto(price=12.0).finalize()])
81 + db.sync_source(con, "ueat", [_resto(price=13.5).finalize()])
82 + hist = con.execute(
83 + "SELECT price FROM item_price_log WHERE item_key LIKE '%poutine%'"
84 + " ORDER BY ts").fetchall()
85 + assert [h["price"] for h in hist] == [12.0, 13.5]
86 +
87 + def test_delai_de_grace_puis_fermeture(self, con):
88 + db.sync_source(con, "ueat", [_resto().finalize()])
89 + db.sync_source(con, "ueat", []) # raté 1 : encore actif
90 + row = con.execute("SELECT active FROM restaurants").fetchone()
91 + assert row["active"] == 1
92 + db.sync_source(con, "ueat", []) # raté 2 : fermé temporairement
93 + row = con.execute("SELECT active, status FROM restaurants").fetchone()
94 + assert row["active"] == 0 and row["status"] == "temporarily_closed"
95 +
96 + def test_chute_items_conserve_ancien_menu(self, con):
97 + gros = _resto()
98 + gros.menu["sections"] = [{"name": "Plats", "items": [
99 + {"name": f"Plat {i}", "price": 10.0 + i} for i in range(12)]}]
100 + db.sync_source(con, "ueat", [gros.finalize()])
101 + petit = _resto(price=9.0)
102 + petit.menu["sections"] = [{"name": "Plats",
103 + "items": [{"name": "Seul", "price": 9.0}]}]
104 + stats = db.sync_source(con, "ueat", [petit.finalize()])
105 + assert stats.get("menu_alerts") == 1
106 + row = con.execute("SELECT item_count FROM menus").fetchone()
107 + assert row["item_count"] == 12 # l'ancien menu est conservé
108 +
109 + def test_contextes_jamais_ecrases(self, con):
110 + salle = _resto()
111 + salle.menu["price_context"] = "dine-in"
112 + db.sync_source(con, "site-resto" if False else "ueat", [salle.finalize()])
113 + livraison = _resto()
114 + livraison.menu["price_context"] = "delivery"
115 + livraison.menu["sections"][0]["items"][0]["price"] = 16.0 # majoré
116 + db.sync_source(con, "ueat", [livraison.finalize()])
117 + rows = con.execute(
118 + "SELECT price_context FROM menus ORDER BY price_context").fetchall()
119 + assert [r["price_context"] for r in rows] == ["delivery", "dine-in"]
120 +
121 +
122 +class TestDedup:
123 + def test_meme_resto_deux_sources(self, con):
124 + a = _resto(source="ueat", eid="1", lat=46.81, lng=-71.21).finalize()
125 + b = _resto(source="ubereats", eid="x9", lat=46.8101, lng=-71.2101,
126 + menu=False).finalize()
127 + db.sync_source(con, "ueat", [a])
128 + db.sync_source(con, "ubereats", [b])
129 + stats = dedup.run()
130 + assert stats["merged"] == 1
131 + canon = con.execute(
132 + "SELECT uid, dup_sources FROM restaurants WHERE dup_of IS NULL"
133 + " AND dup_sources IS NOT NULL").fetchone()
134 + # la source de première partie (ueat) gagne la fiche canonique
135 + assert canon["uid"] == "ueat:1"
136 + assert json.loads(canon["dup_sources"]) == ["ubereats"]
137 +
138 + def test_succursales_jamais_fusionnees(self, con):
139 + s1 = _resto(eid="1", name="Poulet Rouge — Beauport",
140 + address="898 Boulevard Raymond", postal_code="G1C 5P4").finalize()
141 + s2 = _resto(eid="2", name="Poulet Rouge — Sainte-Foy",
142 + address="2600 Boulevard Laurier", postal_code="G1V 4T3").finalize()
143 + db.sync_source(con, "ueat", [s1, s2])
144 + stats = dedup.run()
145 + assert stats["merged"] == 0
added tests/test_ueat.py +102 −0
@@ -0,0 +1,102 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_ueat.py
4 +# Desc: Régression du connecteur UEAT — rejeu HORS LIGNE d'une fixture réelle
5 +# (extrait Poulet Rouge capté sur api.ueat.io). Vérifie : une fiche par
6 +# succursale QC, filtrage hors-province, prix exacts, options
7 +# (variations + questions), contexte takeout, tags diète.
8 +# ==============================================================================
9 +import json
10 +from pathlib import Path
11 +
12 +import pytest
13 +
14 +from restoka.connectors.ueat import UeatConnector
15 +
16 +FIXTURE = json.loads(
17 + (Path(__file__).parent / "fixtures" / "ueat" / "poulet_rouge.json")
18 + .read_text(encoding="utf-8"))
19 +
20 +
21 +class OfflineUeat(UeatConnector):
22 + """Connecteur rejoué depuis la fixture (aucun appel réseau)."""
23 + use_detail_cache = False
24 +
25 + def _integrations(self):
26 + return [{"key": "fixture-guid", "label": "Poulet Rouge (fixture)"}]
27 +
28 + def _gql(self, api_key, query, variables=None, operation=None):
29 + if operation == "getFranchises":
30 + return FIXTURE["getFranchises"]
31 + if operation == "menuCategories" or "menuCategories" in query:
32 + return FIXTURE["menuCategories"]
33 + if operation == "items":
34 + return {"items": FIXTURE["items"][str(variables["categoryId"])]}
35 + if operation == "questions":
36 + return FIXTURE["questions"][str(variables["menuItemId"])]
37 + raise AssertionError(f"requête inattendue : {operation}")
38 +
39 +
40 +@pytest.fixture(scope="module")
41 +def restos():
42 + return [r.finalize() for r in OfflineUeat().fetch()]
43 +
44 +
45 +def test_une_fiche_par_succursale_quebec(restos):
46 + # 3 succursales dans la fixture, mais Ottawa (Ontario) est hors périmètre
47 + assert len(restos) == 2
48 + assert {r.city for r in restos} == {"Québec", "Sherbrooke"}
49 + assert all(r.chain == "Poulet Rouge" for r in restos)
50 + assert len({r.uid for r in restos}) == 2
51 +
52 +
53 +def test_regions_rattachees(restos):
54 + regions = {r.city: r.region for r in restos}
55 + assert regions["Québec"] == "Capitale-Nationale"
56 + assert regions["Sherbrooke"] == "Estrie"
57 +
58 +
59 +def test_prix_exacts(restos):
60 + """Les prix sont l'actif le plus critique : ils doivent être EXACTS."""
61 + menu = restos[0].menu
62 + assert menu["price_context"] == "takeout"
63 + assert menu["price_source"] == "ueat"
64 + prix = {it["name"]: it["price"]
65 + for sec in menu["sections"] for it in sec["items"]}
66 + assert prix["Créez Votre Rouge Bol (1 Saveur)"] == 14.29
67 + assert prix["Rouge Bol (Végé)"] == 11.29
68 + assert prix["Patates Frites"] == 4.99
69 +
70 +
71 +def test_sections(restos):
72 + noms = [s["name"] for s in restos[0].menu["sections"]]
73 + assert noms == ["Rouge Bol", "À-Côtés & Desserts"]
74 +
75 +
76 +def test_options_variations(restos):
77 + frites = next(it for sec in restos[0].menu["sections"]
78 + for it in sec["items"] if it["name"] == "Patates Frites")
79 + fmt = next(op for op in frites["options"] if op["group"] == "Format")
80 + assert fmt["required"] is True
81 + deltas = {c["name"]: c["price_delta"] for c in fmt["choices"]}
82 + assert deltas["Régulière"] == 0.0
83 + assert deltas["Grande"] == 2.0 # 6,99 − 4,99
84 +
85 +
86 +def test_options_questions(restos):
87 + bol = next(it for sec in restos[0].menu["sections"]
88 + for it in sec["items"]
89 + if it["name"] == "Créez Votre Rouge Bol (1 Saveur)")
90 + base = next(op for op in bol["options"]
91 + if op["group"] == "Choisissez votre base")
92 + assert base["required"] is True
93 + deltas = {c["name"]: c["price_delta"] for c in base["choices"]}
94 + assert deltas["Riz basmati"] == 0.0
95 + assert deltas["Salade César"] == 1.5
96 +
97 +
98 +def test_tags_diete(restos):
99 + vege = next(it for sec in restos[0].menu["sections"]
100 + for it in sec["items"] if it["name"] == "Rouge Bol (Végé)")
101 + assert "vegetarien" in vege["tags"]
102 + assert "vegetarien" in restos[0].dietary_options
103