Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
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 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}7677export 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}8788export 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}103104export interface Stats {105 restaurants: number;106 with_menu: number;107 chains: number;108 regions: number;109 sources: number;110 menus: number;111 items: number;112 by_region: { region: string; n: number }[];113 by_context: { price_context: string; n: number }[];114 recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];115}116117// --- libellés français --------------------------------------------------------118119export const CUISINE_LABELS: Record<string, string> = {120 "quebecois": "Québécois", "francais": "Français", "italien": "Italien",121 "pizza": "Pizza", "burgers": "Burgers", "poulet": "Poulet",122 "bbq-grillades": "BBQ & grillades", "fruits-de-mer": "Fruits de mer",123 "sushi-japonais": "Sushi & japonais", "chinois": "Chinois", "thai": "Thaï",124 "vietnamien": "Vietnamien", "coreen": "Coréen", "indien": "Indien",125 "libanais-moyen-orient": "Libanais & Moyen-Orient", "mexicain": "Mexicain",126 "grec": "Grec", "mediterraneen": "Méditerranéen",127 "dejeuner-brunch": "Déjeuner & brunch", "cafe-dessert": "Café & dessert",128 "vegetarien-vegan": "Végé & vegan", "fast-food": "Fast-food", "autre": "Autre",129};130131export const CONTEXT_LABELS: Record<string, string> = {132 "dine-in": "Prix en salle",133 "takeout": "Prix pour emporter",134 "delivery": "Prix livraison (majoré 25-30 %)",135};136137export const CONTEXT_SHORT: Record<string, string> = {138 "dine-in": "Salle", "takeout": "Emporter", "delivery": "Livraison",139};140141export const TYPE_LABELS: Record<string, string> = {142 "restaurant": "Restaurant", "fast-food": "Fast-food", "cafe": "Café",143 "bar": "Bar & pub", "food-truck": "Food truck", "traiteur": "Traiteur",144 "boulangerie-patisserie": "Boulangerie-pâtisserie",145 "casse-croute": "Casse-croûte", "microbrasserie": "Microbrasserie",146 "hotel": "Resto d'hôtel", "ghost-kitchen": "Cuisine fantôme",147};148149export const DIET_LABELS: Record<string, string> = {150 "vegan": "Vegan", "vegetarien": "Végétarien", "sans-gluten": "Sans gluten",151 "halal": "Halal", "casher": "Casher", "sans-noix": "Sans noix", "epice": "Épicé",152};153154export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;155export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;156export const dietLabel = (d: string) => DIET_LABELS[d] ?? d;157158const SOURCE_NAMES: Record<string, string> = {159 ueat: "UEAT (commande en ligne)",160 osm: "OpenStreetMap",161};162export function registerSourceNames(sources: Source[]) {163 for (const s of sources) SOURCE_NAMES[s.id] = s.name;164}165export const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;166167/** 16.5 -> « 16,50 $ » */168export const fmtPrice = (p: number | null | undefined): string =>169 p == null ? "—" : `${p.toFixed(2).replace(".", ",")} $`;170171export const fmtDate = (iso: string | null | undefined): string => {172 if (!iso) return "—";173 const d = new Date(iso);174 return isNaN(d.getTime()) ? iso : d.toLocaleDateString("fr-CA");175};176177export const fmtTs = (ts: number | null | undefined): string =>178 ts ? new Date(ts * 1000).toLocaleString("fr-CA") : "—";179180// --- client -------------------------------------------------------------------181182async function get<T>(path: string): Promise<T> {183 const ctl = new AbortController();184 const timer = setTimeout(() => ctl.abort(), 20000);185 try {186 const resp = await fetch(path, { signal: ctl.signal });187 if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);188 return (await resp.json()) as T;189 } finally {190 clearTimeout(timer);191 }192}193194export interface Dish {195 item: string;196 description: string;197 price: number | null;198 currency: string;199 image?: string | null;200 tags: string[];201 section: string;202 price_context: string;203 price_source: string;204 captured_at: string;205 brand: string;206 restaurant: string;207 uid: string;208 city: string;209 region: string;210 locations: number;211}212213export interface DishQuery {214 q: string; region?: string; city?: string; cuisine?: string;215 price_max?: number; limit?: number; offset?: number;216}217218export function fetchDishes(query: DishQuery) {219 const params = new URLSearchParams();220 for (const [k, v] of Object.entries(query))221 if (v !== undefined && v !== "" && v !== null) params.set(k, String(v));222 return get<{ total: number; count: number; dishes: Dish[] }>(223 `/api/dishes?${params.toString()}`);224}225226export interface RestaurantQuery {227 region?: string; city?: string; cuisine?: string; establishment_type?: string;228 diet?: string; service?: string; price_range?: string; chain?: string;229 source?: string; q?: string; has_menu?: string; sort?: string;230 limit?: number; offset?: number;231}232233export function fetchRestaurants(query: RestaurantQuery = {}) {234 const params = new URLSearchParams();235 for (const [k, v] of Object.entries(query))236 if (v !== undefined && v !== "" && v !== null) params.set(k, String(v));237 return get<{ total: number; count: number; restaurants: Restaurant[] }>(238 `/api/restaurants?${params.toString()}`);239}240241export const fetchRestaurant = (uid: string) =>242 get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);243244// --- récemment consultés (localStorage, par appareil) ---------------------------245246const RECENT_KEY = "restoka:recent";247const RECENT_MAX = 12;248249export function getRecentUids(): string[] {250 try {251 const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");252 return Array.isArray(raw) ? raw.filter((u) => typeof u === "string") : [];253 } catch {254 return [];255 }256}257258export function pushRecentUid(uid: string) {259 try {260 const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);261 localStorage.setItem(RECENT_KEY, JSON.stringify(next));262 } catch { /* stockage indisponible (navigation privée) */ }263}264265/** Fiches des restos consultés, remises dans l'ordre de consultation. */266export async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {267 if (uids.length === 0) return [];268 const params = new URLSearchParams({ uids: uids.join(","), limit: String(uids.length) });269 const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);270 const by = new Map(r.restaurants.map((x) => [x.uid, x]));271 return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);272}273274export const fetchFacets = (region?: string) =>275 get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`);276277export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");278279export const fetchStats = () => get<Stats>("/api/stats");280281// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------282283export interface Me {284 uid: number;285 ka_id: string;286 email: string;287 name: string;288 picture: string;289 bio?: string;290 city?: string;291 public_url?: string;292 profile_source: string;293}294295export const fetchAuthConfig = () => get<{ ka: boolean }>("/api/auth/config");296297export async function fetchMe(): Promise<Me | null> {298 try {299 return await get<Me>("/api/me");300 } catch {301 return null;302 }303}304305export async function logout(): Promise<void> {306 await fetch("/api/auth/logout", { method: "POST" });307}308309// --- Favoris « Mon univers Ka » (magasin central : hub Groupe KA) ------------310311/** Item de favori tel que poussé au hub Groupe KA (groupe-ka.com). */312export interface FavItem {313 item_id: string;314 title: string;315 subtitle?: string;316 price_label?: string;317 image_url?: string;318 url?: string;319}320321/** Favoris du membre (lus au hub) + fiches locales correspondantes. */322export const fetchFavorites = () =>323 get<{ ids: string[]; items: FavItem[]; restaurants: Restaurant[] }>("/api/favorites");324325/** Pousse un ♥ (on=true : ajout ; on=false : retrait) au hub, via l'API locale. */326export async function toggleFavorite(on: boolean, item: FavItem): Promise<void> {327 const resp = await fetch("/api/favorites/toggle", {328 method: "POST",329 credentials: "same-origin",330 headers: { "Content-Type": "application/json" },331 body: JSON.stringify({ on, item }),332 });333 if (!resp.ok) throw new Error(`API ${resp.status}`);334}335