SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

8.8 KB · 287 lines typescript
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)}`);243244export const fetchFacets = (region?: string) =>245  get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`);246247export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");248249export const fetchStats = () => get<Stats>("/api/stats");250251// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------252253export interface Me {254  uid: number;255  ka_id: string;256  email: string;257  name: string;258  picture: string;259  bio?: string;260  city?: string;261  public_url?: string;262  profile_source: string;263}264265export const fetchAuthConfig = () => get<{ ka: boolean }>("/api/auth/config");266267export async function fetchMe(): Promise<Me | null> {268  try {269    return await get<Me>("/api/me");270  } catch {271    return null;272  }273}274275export async function logout(): Promise<void> {276  await fetch("/api/auth/logout", { method: "POST" });277}278279export const fetchFavoris = () =>280  get<{ count: number; restaurants: Restaurant[]; uids: string[] }>("/api/favoris");281282export async function toggleFavori(uid: string): Promise<boolean> {283  const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: "POST" });284  if (!resp.ok) throw new Error(`API ${resp.status}`);285  return (await resp.json()).favori as boolean;286}287
Files · c9f9e0970a11c1ae39fed1d26753cd00e6448cef
Menu

Theme