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%
10.7 KB · 337 lines typescript
Raw Blame History
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