From 3a11d90164267afedc5394d231e6420dda9cef0a Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:20:47 +0200 Subject: [PATCH] feat(restaurant): menu browse views (Home + RestaurantPage + ItemPage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end menu browse for one restaurant. composables/useMenu(slugOrId) — fetches via REST. Resolves slug or id via heuristic, calls getMenu(), exposes {restaurant, tree, items, isLoading, error, refresh} as reactive refs. Cancels in-flight requests on param change /scope dispose. components: RestaurantHeader.vue — banner, logo, name, description, open badge, currency badge, location. CategoryNav.vue — sticky horizontal pill nav over root menu nodes; scrolls to anchors. MenuTree.vue — recursive renderer (self-references by name). Renders a node's items first, then its children — items can attach to any node per the menu-tree refactor. MenuItemCard.vue — image, name, price (msat-native via currencyHint), sold-out / low-stock / featured badges, dietary + allergen chips, '+' button that opens ItemPage or quick-adds when no modifier groups. ModifierSelector.vue — radio (selection='one') / checkbox (selection='many') with min/max enforcement. v-model-style emits (update:selected, update:valid). Seeds from is_default modifiers when no existing selection is passed. views: HomePage.vue — slug input + auto-redirect when VITE_RESTAURANT_DEFAULT_SLUG is set. RestaurantPage.vue — composite: header + CategoryNav + MenuTree. Loading / error states via shadcn Alert. ItemPage.vue — full item detail: image, dietary + allergen chips, ModifierSelector, note textarea, sticky bottom bar with qty stepper + 'Add to cart' CTA (disabled for v1; cart wires in commit 5). Routes registered on the module: /, /r/:slug, /r/:slug/item/:itemId. Design: shadcn-vue components throughout (Alert, Badge, Button, Card, Checkbox, Input, Label, RadioGroup, Textarea), Tailwind 4 utility classes, theme-aware semantic colors (text-foreground, bg-background, bg-card, text-muted-foreground, bg-primary, etc.). No raw hex or theme-blind classes. Verified: vue-tsc -b clean against the whole webapp. --- .../restaurant/components/CategoryNav.vue | 59 +++++ .../restaurant/components/MenuItemCard.vue | 149 +++++++++++ .../restaurant/components/MenuTree.vue | 82 ++++++ .../components/ModifierSelector.vue | 221 ++++++++++++++++ .../components/RestaurantHeader.vue | 81 ++++++ src/modules/restaurant/composables/useMenu.ts | 97 +++++++ src/modules/restaurant/index.ts | 12 + src/modules/restaurant/views/HomePage.vue | 95 +++++-- src/modules/restaurant/views/ItemPage.vue | 236 ++++++++++++++++++ .../restaurant/views/RestaurantPage.vue | 85 +++++++ 10 files changed, 1096 insertions(+), 21 deletions(-) create mode 100644 src/modules/restaurant/components/CategoryNav.vue create mode 100644 src/modules/restaurant/components/MenuItemCard.vue create mode 100644 src/modules/restaurant/components/MenuTree.vue create mode 100644 src/modules/restaurant/components/ModifierSelector.vue create mode 100644 src/modules/restaurant/components/RestaurantHeader.vue create mode 100644 src/modules/restaurant/composables/useMenu.ts create mode 100644 src/modules/restaurant/views/ItemPage.vue create mode 100644 src/modules/restaurant/views/RestaurantPage.vue diff --git a/src/modules/restaurant/components/CategoryNav.vue b/src/modules/restaurant/components/CategoryNav.vue new file mode 100644 index 0000000..21b1dff --- /dev/null +++ b/src/modules/restaurant/components/CategoryNav.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/modules/restaurant/components/MenuItemCard.vue b/src/modules/restaurant/components/MenuItemCard.vue new file mode 100644 index 0000000..4f00537 --- /dev/null +++ b/src/modules/restaurant/components/MenuItemCard.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/modules/restaurant/components/MenuTree.vue b/src/modules/restaurant/components/MenuTree.vue new file mode 100644 index 0000000..d53885a --- /dev/null +++ b/src/modules/restaurant/components/MenuTree.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/modules/restaurant/components/ModifierSelector.vue b/src/modules/restaurant/components/ModifierSelector.vue new file mode 100644 index 0000000..b556b3d --- /dev/null +++ b/src/modules/restaurant/components/ModifierSelector.vue @@ -0,0 +1,221 @@ + + + diff --git a/src/modules/restaurant/components/RestaurantHeader.vue b/src/modules/restaurant/components/RestaurantHeader.vue new file mode 100644 index 0000000..8a4af4f --- /dev/null +++ b/src/modules/restaurant/components/RestaurantHeader.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/modules/restaurant/composables/useMenu.ts b/src/modules/restaurant/composables/useMenu.ts new file mode 100644 index 0000000..b35e178 --- /dev/null +++ b/src/modules/restaurant/composables/useMenu.ts @@ -0,0 +1,97 @@ +/** + * useMenu — fetches a restaurant's menu via REST. + * + * v1: REST-only via RestaurantAPI.getMenu(). The Nostr live-overlay + * merge lands in commit 8 (subscribes to kind-30402 listings for the + * restaurant's pubkey and patches `items` reactively). + * + * Usage: + * const { restaurant, tree, items, isLoading, error, refresh } + * = useMenu(slugOrId) + * + * Pass a slug or an id — the composable picks the right endpoint + * based on the format. Slugs are kebab-case strings; ids are + * urlsafe-short-hash from the extension (alphanumeric, ~22 chars). + */ + +import { ref, computed, onScopeDispose, watch, type Ref } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { RestaurantAPI } from '../services/RestaurantAPI' +import type { + EnrichedMenuItem, + MenuNode, + Restaurant, +} from '../types/restaurant' + +// Heuristic: ids from urlsafe_short_hash are 22-char base64url. Slugs +// allow dashes and are typically shorter. Anything containing a dash +// or shorter than 20 chars is treated as a slug. +function looksLikeId(value: string): boolean { + return !value.includes('-') && value.length >= 20 && /^[A-Za-z0-9_-]+$/.test(value) +} + +export interface UseMenuReturn { + restaurant: Ref + tree: Ref + items: Ref + isLoading: Ref + error: Ref + refresh: () => Promise +} + +export function useMenu(slugOrId: Ref | string): UseMenuReturn { + const api = injectService(SERVICE_TOKENS.RESTAURANT_API) + + const restaurant = ref(null) + const tree = ref([]) + const items = ref([]) + const isLoading = ref(false) + const error = ref(null) + + let abortController: AbortController | null = null + + const target = computed(() => + typeof slugOrId === 'string' ? slugOrId : slugOrId.value + ) + + async function load(value: string): Promise { + if (!value) return + abortController?.abort() + abortController = new AbortController() + const my = abortController + + isLoading.value = true + error.value = null + try { + const r = looksLikeId(value) + ? await api.getRestaurantById(value) + : await api.getRestaurantBySlug(value) + if (my.signal.aborted) return + + const menu = await api.getMenu(r.id) + if (my.signal.aborted) return + + restaurant.value = menu.restaurant + tree.value = menu.tree + items.value = menu.items + } catch (err) { + if (my.signal.aborted) return + error.value = err instanceof Error ? err : new Error(String(err)) + } finally { + if (!my.signal.aborted) isLoading.value = false + } + } + + async function refresh(): Promise { + if (target.value) await load(target.value) + } + + // React to slug/id changes (Vue Router param updates). + watch(target, (value) => load(value), { immediate: true }) + + onScopeDispose(() => { + abortController?.abort() + }) + + return { restaurant, tree, items, isLoading, error, refresh } +} diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index 864e32c..dbec7da 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -76,6 +76,18 @@ export const restaurantModule: ModulePlugin = { component: () => import('./views/HomePage.vue'), meta: { requiresAuth: false, title: 'Restaurant' }, }, + { + path: '/r/:slug', + name: 'restaurant-menu', + component: () => import('./views/RestaurantPage.vue'), + meta: { requiresAuth: false, title: 'Menu' }, + }, + { + path: '/r/:slug/item/:itemId', + name: 'restaurant-item', + component: () => import('./views/ItemPage.vue'), + meta: { requiresAuth: false, title: 'Item' }, + }, ] as RouteRecordRaw[], } diff --git a/src/modules/restaurant/views/HomePage.vue b/src/modules/restaurant/views/HomePage.vue index d31faf9..5f71516 100644 --- a/src/modules/restaurant/views/HomePage.vue +++ b/src/modules/restaurant/views/HomePage.vue @@ -1,32 +1,85 @@ diff --git a/src/modules/restaurant/views/ItemPage.vue b/src/modules/restaurant/views/ItemPage.vue new file mode 100644 index 0000000..9d6e271 --- /dev/null +++ b/src/modules/restaurant/views/ItemPage.vue @@ -0,0 +1,236 @@ + + +