Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1// ==============================================================================2// Author: Simon-Pierre Boucher <contact@spboucher.ai>3// File: api.ts4// Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR5// (cuisines, contextes de prix, régions). Même patron que Lou·Ka.6// ==============================================================================78export interface MenuOptionChoice { name: string; price_delta: number }9export interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }1011export 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}2122export interface MenuSection { name: string; items: MenuItem[]; image?: string }2324export 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}3233export 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}4243export interface Restaurant {44 /** personnalisation KA ID (restoka/kaid.py) : « Recommandé pour vous » */45 ka_reco?: { score: number; reasons: string[] } | null;46 uid: string;47 source: string;48 external_id: string;49 name: string;50 chain: string | null;51 cuisines: string[];52 establishment_type: string;53 price_range: string;54 address: string;55 city: string;56 region: string;57 postal_code: string;58 lat: number | null;59 lng: number | null;60 phone: string;61 website: string;62 url: string;63 hours: Record<string, string>;64 services: string[];65 dietary_options: string[];66 languages: string[];67 images: string[];68 status: string;69 menu_summary?: MenuSummary | null;70 menus?: Menu[];71 recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];72 dup_sources?: string[];73 first_seen?: number;74 last_seen: number;75 updated_at: number;76 active: number;77}7879export interface Facets {80 regions: { region: string; n: number }[];81 all_regions: string[];82 cities: string[];83 cuisines: { cuisine: string; n: number }[];84 diets: { diet: string; n: number }[];85 establishment_types: { t: string; n: number }[];86 chains: string[];87 sources: { source: string; n: number }[];88}8990export interface Source {91 id: string;92 name: string;93 url: string;94 platform?: string;95 tier?: number;96 price_context?: string;97 extraction?: string;98 cadence?: string;99 status: string;100 active_restaurants: number;101 menu_items: number;102 last_sync: number | null;103 integrations?: { label: string; status: string }[];104}105106export interface Stats {107 restaurants: number;108 with_menu: number;109 chains: number;110 regions: number;111 sources: number;112 menus: number;113 items: number;114 by_region: { region: string; n: number }[];115 by_context: { price_context: string; n: number }[];116 recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];117}118119// --- libellés français --------------------------------------------------------120121export const CUISINE_LABELS: Record<string, string> = {122 "quebecois": "Québécois", "francais": "Français", "italien": "Italien",123 "pizza": "Pizza", "burgers": "Burgers", "poulet": "Poulet",124 "bbq-grillades": "BBQ & grillades", "fruits-de-mer": "Fruits de mer",125 "sushi-japonais": "Sushi & japonais", "chinois": "Chinois", "thai": "Thaï",126 "vietnamien": "Vietnamien", "coreen": "Coréen", "indien": "Indien",127 "libanais-moyen-orient": "Libanais & Moyen-Orient", "mexicain": "Mexicain",128 "grec": "Grec", "mediterraneen": "Méditerranéen",129 "dejeuner-brunch": "Déjeuner & brunch", "cafe-dessert": "Café & dessert",130 "vegetarien-vegan": "Végé & vegan", "fast-food": "Fast-food", "autre": "Autre",131};132133export const CONTEXT_LABELS: Record<string, string> = {134 "dine-in": "Prix en salle",135 "takeout": "Prix pour emporter",136 "delivery": "Prix livraison (majoré 25-30 %)",137};138139export const CONTEXT_SHORT: Record<string, string> = {140 "dine-in": "Salle", "takeout": "Emporter", "delivery": "Livraison",141};142143export const TYPE_LABELS: Record<string, string> = {144 "restaurant": "Restaurant", "fast-food": "Fast-food", "cafe": "Café",145 "bar": "Bar & pub", "food-truck": "Food truck", "traiteur": "Traiteur",146 "boulangerie-patisserie": "Boulangerie-pâtisserie",147 "casse-croute": "Casse-croûte", "microbrasserie": "Microbrasserie",148 "hotel": "Resto d'hôtel", "ghost-kitchen": "Cuisine fantôme",149};150151export const DIET_LABELS: Record<string, string> = {152 "vegan": "Vegan", "vegetarien": "Végétarien", "sans-gluten": "Sans gluten",153 "halal": "Halal", "casher": "Casher", "sans-noix": "Sans noix", "epice": "Épicé",154};155156export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;157export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;158export const dietLabel = (d: string) => DIET_LABELS[d] ?? d;159160const SOURCE_NAMES: Record<string, string> = {161 ueat: "UEAT (commande en ligne)",162 osm: "OpenStreetMap",163};164export function registerSourceNames(sources: Source[]) {165 for (const s of sources) SOURCE_NAMES[s.id] = s.name;166}167export const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;168169/** 16.5 -> « 16,50 $ » */170export const fmtPrice = (p: number | null | undefined): string =>171 p == null ? "—" : `${p.toFixed(2).replace(".", ",")} $`;172173export const fmtDate = (iso: string | null | undefined): string => {174 if (!iso) return "—";175 const d = new Date(iso);176 return isNaN(d.getTime()) ? iso : d.toLocaleDateString("fr-CA");177};178179export const fmtTs = (ts: number | null | undefined): string =>180 ts ? new Date(ts * 1000).toLocaleString("fr-CA") : "—";181182// --- client -------------------------------------------------------------------183184async function get<T>(path: string): Promise<T> {185 const ctl = new AbortController();186 const timer = setTimeout(() => ctl.abort(), 20000);187 try {188 const resp = await fetch(path, { signal: ctl.signal });189 if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);190 return (await resp.json()) as T;191 } finally {192 clearTimeout(timer);193 }194}195196export interface Dish {197 item: string;198 description: string;199 price: number | null;200 currency: string;201 image?: string | null;202 tags: string[];203 section: string;204 price_context: string;205 price_source: string;206 captured_at: string;207 brand: string;208 restaurant: string;209 uid: string;210 city: string;211 region: string;212 locations: number;213}214215export interface DishQuery {216 q: string; region?: string; city?: string; cuisine?: string;217 price_max?: number; limit?: number; offset?: number;218}219220export function fetchDishes(query: DishQuery) {221 const params = new URLSearchParams();222 for (const [k, v] of Object.entries(query))223 if (v !== undefined && v !== "" && v !== null) params.set(k, String(v));224 return get<{ total: number; count: number; dishes: Dish[] }>(225 `/api/dishes?${params.toString()}`);226}227228export interface RestaurantQuery {229 region?: string; city?: string; cuisine?: string; establishment_type?: string;230 diet?: string; service?: string; price_range?: string; chain?: string;231 source?: string; q?: string; has_menu?: string; sort?: string;232 limit?: number; offset?: number;233}234235export function fetchRestaurants(query: RestaurantQuery = {}) {236 const params = new URLSearchParams();237 for (const [k, v] of Object.entries(query))238 if (v !== undefined && v !== "" && v !== null) params.set(k, String(v));239 return get<{ total: number; count: number; restaurants: Restaurant[] }>(240 `/api/restaurants?${params.toString()}`);241}242243export const fetchRestaurant = (uid: string) =>244 get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);245246// --- récemment consultés (localStorage, par appareil) ---------------------------247248const RECENT_KEY = "restoka:recent";249const RECENT_MAX = 12;250251export function getRecentUids(): string[] {252 try {253 const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");254 return Array.isArray(raw) ? raw.filter((u) => typeof u === "string") : [];255 } catch {256 return [];257 }258}259260export function pushRecentUid(uid: string) {261 try {262 const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);263 localStorage.setItem(RECENT_KEY, JSON.stringify(next));264 } catch { /* stockage indisponible (navigation privée) */ }265}266267/** Fiches des restos consultés, remises dans l'ordre de consultation. */268export async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {269 if (uids.length === 0) return [];270 const params = new URLSearchParams({ uids: uids.join(","), limit: String(uids.length) });271 const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);272 const by = new Map(r.restaurants.map((x) => [x.uid, x]));273 return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);274}275276export const fetchFacets = (region?: string) =>277 get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`);278279export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");280281export const fetchStats = () => get<Stats>("/api/stats");282283// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------284285export interface Me {286 uid: number;287 ka_id: string;288 email: string;289 name: string;290 picture: string;291 bio?: string;292 city?: string;293 public_url?: string;294 profile_source: string;295}296297export const fetchAuthConfig = () => get<{ ka: boolean }>("/api/auth/config");298299export async function fetchMe(): Promise<Me | null> {300 try {301 return await get<Me>("/api/me");302 } catch {303 return null;304 }305}306307export async function logout(): Promise<void> {308 await fetch("/api/auth/logout", { method: "POST" });309}310311// --- Favoris « Mon univers Ka » (magasin central : hub Groupe KA) ------------312313/** Item de favori tel que poussé au hub Groupe KA (groupe-ka.com). */314export interface FavItem {315 item_id: string;316 title: string;317 subtitle?: string;318 price_label?: string;319 image_url?: string;320 url?: string;321}322323/** Favoris du membre (lus au hub) + fiches locales correspondantes. */324export const fetchFavorites = () =>325 get<{ ids: string[]; items: FavItem[]; restaurants: Restaurant[] }>("/api/favorites");326327/** Pousse un ♥ (on=true : ajout ; on=false : retrait) au hub, via l'API locale. */328export async function toggleFavorite(on: boolean, item: FavItem): Promise<void> {329 const resp = await fetch("/api/favorites/toggle", {330 method: "POST",331 credentials: "same-origin",332 headers: { "Content-Type": "application/json" },333 body: JSON.stringify({ on, item }),334 });335 if (!resp.ok) throw new Error(`API ${resp.status}`);336}337