// ============================================================================== // Author: Simon-Pierre Boucher // File: api.ts // Desc: Types + client API robuste (timeout, erreurs typées) + libellés FR // (cuisines, contextes de prix, régions). Même patron que Lou·Ka. // ============================================================================== export interface MenuOptionChoice { name: string; price_delta: number } export interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] } export interface MenuItem { id?: string; name: string; description?: string; price: number | null; currency?: string; tags?: string[]; options?: MenuOption[]; image?: string; } export interface MenuSection { name: string; items: MenuItem[]; image?: string } export interface Menu { price_context: "dine-in" | "takeout" | "delivery"; price_source: string; currency: string; captured_at: string; item_count: number; sections: MenuSection[]; } export interface MenuSummary { image?: string | null; price_context: string; price_source: string; captured_at: string; item_count: number; price_min: number | null; price_median: number | null; } export interface Restaurant { /** personnalisation KA ID (restoka/kaid.py) : « Recommandé pour vous » */ ka_reco?: { score: number; reasons: string[] } | null; uid: string; source: string; external_id: string; name: string; chain: string | null; cuisines: string[]; establishment_type: string; price_range: string; address: string; city: string; region: string; postal_code: string; lat: number | null; lng: number | null; phone: string; website: string; url: string; hours: Record; services: string[]; dietary_options: string[]; languages: string[]; images: string[]; status: string; menu_summary?: MenuSummary | null; menus?: Menu[]; recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[]; dup_sources?: string[]; first_seen?: number; last_seen: number; updated_at: number; active: number; } export interface Facets { regions: { region: string; n: number }[]; all_regions: string[]; cities: string[]; cuisines: { cuisine: string; n: number }[]; diets: { diet: string; n: number }[]; establishment_types: { t: string; n: number }[]; chains: string[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; url: string; platform?: string; tier?: number; price_context?: string; extraction?: string; cadence?: string; status: string; active_restaurants: number; menu_items: number; last_sync: number | null; integrations?: { label: string; status: string }[]; } export interface Stats { restaurants: number; with_menu: number; chains: number; regions: number; sources: number; menus: number; items: number; by_region: { region: string; n: number }[]; by_context: { price_context: string; n: number }[]; recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[]; } // --- libellés français -------------------------------------------------------- export const CUISINE_LABELS: Record = { "quebecois": "Québécois", "francais": "Français", "italien": "Italien", "pizza": "Pizza", "burgers": "Burgers", "poulet": "Poulet", "bbq-grillades": "BBQ & grillades", "fruits-de-mer": "Fruits de mer", "sushi-japonais": "Sushi & japonais", "chinois": "Chinois", "thai": "Thaï", "vietnamien": "Vietnamien", "coreen": "Coréen", "indien": "Indien", "libanais-moyen-orient": "Libanais & Moyen-Orient", "mexicain": "Mexicain", "grec": "Grec", "mediterraneen": "Méditerranéen", "dejeuner-brunch": "Déjeuner & brunch", "cafe-dessert": "Café & dessert", "vegetarien-vegan": "Végé & vegan", "fast-food": "Fast-food", "autre": "Autre", }; export const CONTEXT_LABELS: Record = { "dine-in": "Prix en salle", "takeout": "Prix pour emporter", "delivery": "Prix livraison (majoré 25-30 %)", }; export const CONTEXT_SHORT: Record = { "dine-in": "Salle", "takeout": "Emporter", "delivery": "Livraison", }; export const TYPE_LABELS: Record = { "restaurant": "Restaurant", "fast-food": "Fast-food", "cafe": "Café", "bar": "Bar & pub", "food-truck": "Food truck", "traiteur": "Traiteur", "boulangerie-patisserie": "Boulangerie-pâtisserie", "casse-croute": "Casse-croûte", "microbrasserie": "Microbrasserie", "hotel": "Resto d'hôtel", "ghost-kitchen": "Cuisine fantôme", }; export const DIET_LABELS: Record = { "vegan": "Vegan", "vegetarien": "Végétarien", "sans-gluten": "Sans gluten", "halal": "Halal", "casher": "Casher", "sans-noix": "Sans noix", "epice": "Épicé", }; export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c; export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t; export const dietLabel = (d: string) => DIET_LABELS[d] ?? d; const SOURCE_NAMES: Record = { ueat: "UEAT (commande en ligne)", osm: "OpenStreetMap", }; export function registerSourceNames(sources: Source[]) { for (const s of sources) SOURCE_NAMES[s.id] = s.name; } export const sourceName = (id: string) => SOURCE_NAMES[id] ?? id; /** 16.5 -> « 16,50 $ » */ export const fmtPrice = (p: number | null | undefined): string => p == null ? "—" : `${p.toFixed(2).replace(".", ",")} $`; export const fmtDate = (iso: string | null | undefined): string => { if (!iso) return "—"; const d = new Date(iso); return isNaN(d.getTime()) ? iso : d.toLocaleDateString("fr-CA"); }; export const fmtTs = (ts: number | null | undefined): string => ts ? new Date(ts * 1000).toLocaleString("fr-CA") : "—"; // --- client ------------------------------------------------------------------- async function get(path: string): Promise { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 20000); try { const resp = await fetch(path, { signal: ctl.signal }); if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`); return (await resp.json()) as T; } finally { clearTimeout(timer); } } export interface Dish { item: string; description: string; price: number | null; currency: string; image?: string | null; tags: string[]; section: string; price_context: string; price_source: string; captured_at: string; brand: string; restaurant: string; uid: string; city: string; region: string; locations: number; } export interface DishQuery { q: string; region?: string; city?: string; cuisine?: string; price_max?: number; limit?: number; offset?: number; } export function fetchDishes(query: DishQuery) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== "" && v !== null) params.set(k, String(v)); return get<{ total: number; count: number; dishes: Dish[] }>( `/api/dishes?${params.toString()}`); } export interface RestaurantQuery { region?: string; city?: string; cuisine?: string; establishment_type?: string; diet?: string; service?: string; price_range?: string; chain?: string; source?: string; q?: string; has_menu?: string; sort?: string; limit?: number; offset?: number; } export function fetchRestaurants(query: RestaurantQuery = {}) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== "" && v !== null) params.set(k, String(v)); return get<{ total: number; count: number; restaurants: Restaurant[] }>( `/api/restaurants?${params.toString()}`); } export const fetchRestaurant = (uid: string) => get(`/api/restaurants/${encodeURIComponent(uid)}`); // --- récemment consultés (localStorage, par appareil) --------------------------- const RECENT_KEY = "restoka:recent"; const RECENT_MAX = 12; export function getRecentUids(): string[] { try { const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); return Array.isArray(raw) ? raw.filter((u) => typeof u === "string") : []; } catch { return []; } } export function pushRecentUid(uid: string) { try { const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX); localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* stockage indisponible (navigation privée) */ } } /** Fiches des restos consultés, remises dans l'ordre de consultation. */ export async function fetchRecentRestaurants(uids: string[]): Promise { if (uids.length === 0) return []; const params = new URLSearchParams({ uids: uids.join(","), limit: String(uids.length) }); const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`); const by = new Map(r.restaurants.map((x) => [x.uid, x])); return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null); } export const fetchFacets = (region?: string) => get(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); // --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------ export interface Me { uid: number; ka_id: string; email: string; name: string; picture: string; bio?: string; city?: string; public_url?: string; profile_source: string; } export const fetchAuthConfig = () => get<{ ka: boolean }>("/api/auth/config"); export async function fetchMe(): Promise { try { return await get("/api/me"); } catch { return null; } } export async function logout(): Promise { await fetch("/api/auth/logout", { method: "POST" }); } // --- Favoris « Mon univers Ka » (magasin central : hub Groupe KA) ------------ /** Item de favori tel que poussé au hub Groupe KA (groupe-ka.com). */ export interface FavItem { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; } /** Favoris du membre (lus au hub) + fiches locales correspondantes. */ export const fetchFavorites = () => get<{ ids: string[]; items: FavItem[]; restaurants: Restaurant[] }>("/api/favorites"); /** Pousse un ♥ (on=true : ajout ; on=false : retrait) au hub, via l'API locale. */ export async function toggleFavorite(on: boolean, item: FavItem): Promise { const resp = await fetch("/api/favorites/toggle", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on, item }), }); if (!resp.ok) throw new Error(`API ${resp.status}`); }