feat(restaurant): customer-facing restaurant bundle (v1) #54

Merged
padreug merged 9 commits from feat/restaurant-bundle into main 2026-05-11 17:49:19 +00:00
10 changed files with 1096 additions and 21 deletions
Showing only changes of commit 3a11d90164 - Show all commits

feat(restaurant): menu browse views (Home + RestaurantPage + ItemPage)

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.
Padreug 2026-05-11 17:20:47 +02:00

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
/**
* Horizontally-scrolling sticky pill nav over the top-level menu
* tree nodes. Clicking scrolls the page to the anchor with the
* matching id. v1 doesn't try to do intersection-observer scrollspy
* that polish lands in a follow-up.
*/
import type { MenuNode } from '../types/restaurant'
defineProps<{
roots: MenuNode[]
activeId?: string | null
}>()
const emit = defineEmits<{
(e: 'select', nodeId: string): void
}>()
function scrollTo(node: MenuNode) {
emit('select', node.id)
const target = document.getElementById(`node-${node.id}`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
</script>
<template>
<nav
v-if="roots.length"
class="sticky top-0 z-10 -mx-4 border-b border-border bg-background/95 px-4 backdrop-blur sm:-mx-6 sm:px-6"
>
<div class="flex gap-2 overflow-x-auto py-3 scrollbar-none">
<button
v-for="root in roots"
:key="root.id"
type="button"
:class="[
'inline-flex shrink-0 items-center rounded-full border px-3 py-1 text-sm font-medium transition-colors',
activeId === root.id
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card text-foreground hover:bg-accent hover:text-accent-foreground',
]"
@click="scrollTo(root)"
>
{{ root.name }}
</button>
</div>
</nav>
</template>
<style scoped>
.scrollbar-none::-webkit-scrollbar {
display: none;
}
.scrollbar-none {
scrollbar-width: none;
}
</style>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Leaf, AlertTriangle, Sparkles, Plus } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { EnrichedMenuItem, MenuItem } from '../types/restaurant'
const props = defineProps<{
item: MenuItem | EnrichedMenuItem
currencyHint?: string
}>()
const emit = defineEmits<{
(e: 'open', id: string): void
(e: 'quick-add', id: string): void
}>()
// Sold-out + low-stock display flows from the existing
// `stock` / `low_stock_threshold` columns. Future inventory work
// (aiolabs/restaurant#3) may add `expires_soon`, batch info, etc.,
// which will ride on `item.extra`.
const isSoldOut = computed(() => {
if (!props.item.is_available) return true
if (props.item.stock !== null && props.item.stock !== undefined) {
return props.item.stock <= 0
}
return false
})
const isLowStock = computed(() => {
if (isSoldOut.value) return false
if (
props.item.stock !== null &&
props.item.stock !== undefined &&
props.item.low_stock_threshold !== null &&
props.item.low_stock_threshold !== undefined
) {
return props.item.stock <= props.item.low_stock_threshold
}
return false
})
const hasModifiers = computed(() => {
return (
'modifier_groups' in props.item &&
Array.isArray(props.item.modifier_groups) &&
props.item.modifier_groups.length > 0
)
})
const formattedPrice = computed(() => {
const fmt = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
})
const cur = props.currencyHint || props.item.currency || 'sat'
return `${fmt.format(props.item.price)} ${cur}`
})
</script>
<template>
<button
type="button"
class="flex w-full items-stretch gap-3 rounded-xl border border-border bg-card p-3 text-left transition-colors hover:bg-accent hover:text-accent-foreground sm:gap-4 sm:p-4"
:class="{ 'opacity-60': isSoldOut }"
@click="emit('open', item.id)"
>
<div
v-if="item.images?.length"
class="h-20 w-20 flex-shrink-0 overflow-hidden rounded-lg bg-muted sm:h-24 sm:w-24"
>
<img
:src="item.images[0]"
:alt="item.name"
class="h-full w-full object-cover"
loading="lazy"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<h3 class="line-clamp-1 font-semibold text-foreground">
{{ item.name }}
</h3>
<span class="shrink-0 font-mono text-sm font-semibold text-primary">
{{ formattedPrice }}
</span>
</div>
<p
v-if="item.description"
class="mt-1 line-clamp-2 text-xs text-muted-foreground"
>
{{ item.description }}
</p>
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<Badge
v-if="isSoldOut"
variant="secondary"
class="text-[10px]"
>
Sold out
</Badge>
<Badge
v-else-if="isLowStock"
variant="outline"
class="border-amber-500/40 text-[10px] text-amber-700 dark:text-amber-300"
>
Low stock
</Badge>
<Badge
v-if="item.is_featured"
variant="default"
class="text-[10px]"
>
<Sparkles class="mr-1 h-3 w-3" />
Featured
</Badge>
<span
v-if="item.dietary?.length"
class="inline-flex items-center gap-1 text-[10px] text-muted-foreground"
>
<Leaf class="h-3 w-3" />
{{ item.dietary.join(', ') }}
</span>
<span
v-if="item.allergens?.length"
class="inline-flex items-center gap-1 text-[10px] text-amber-700 dark:text-amber-300"
>
<AlertTriangle class="h-3 w-3" />
{{ item.allergens.join(', ') }}
</span>
</div>
</div>
<div class="flex flex-shrink-0 items-end">
<Button
size="icon"
variant="default"
:disabled="isSoldOut"
class="h-8 w-8 rounded-full"
:aria-label="hasModifiers ? `Open ${item.name}` : `Add ${item.name} to cart`"
@click.stop="hasModifiers ? emit('open', item.id) : emit('quick-add', item.id)"
>
<Plus class="h-4 w-4" />
</Button>
</div>
</button>
</template>

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* Recursive menu tree renderer. Walks the hydrated MenuNode tree
* from `GET /restaurants/{id}/menu`, renders a section heading for
* each non-root node (or root, when at the top) and lists its
* items via MenuItemCard. Children render via this same component
* by name Vue 3 self-reference.
*
* Items can attach to ANY node (per
* docs/menu-tree.md in the extension repo) we render the node's
* own items above its children.
*/
import { computed } from 'vue'
import MenuItemCard from './MenuItemCard.vue'
import type { MenuNode } from '../types/restaurant'
const props = defineProps<{
nodes: MenuNode[]
currencyHint?: string
/** Heading level for THIS layer (2 = top-level <h2>, increases
* with depth). Capped at 6 by the renderer. */
level?: number
}>()
const emit = defineEmits<{
(e: 'open-item', id: string): void
(e: 'quick-add', id: string): void
}>()
const heading = computed(() => `h${Math.min(props.level ?? 2, 6)}`)
</script>
<template>
<div class="space-y-8">
<section
v-for="node in nodes"
:key="node.id"
:id="`node-${node.id}`"
class="scroll-mt-20"
>
<component
:is="heading"
:class="
(level ?? 2) === 2
? 'mb-3 text-2xl font-bold text-foreground'
: (level ?? 2) === 3
? 'mb-2 text-lg font-semibold text-foreground'
: 'mb-2 text-base font-semibold text-muted-foreground'
"
>
{{ node.name }}
</component>
<p
v-if="node.description"
class="mb-3 text-sm text-muted-foreground"
>
{{ node.description }}
</p>
<div v-if="node.items.length" class="space-y-2">
<MenuItemCard
v-for="item in node.items"
:key="item.id"
:item="item"
:currency-hint="currencyHint"
@open="(id) => emit('open-item', id)"
@quick-add="(id) => emit('quick-add', id)"
/>
</div>
<MenuTree
v-if="node.children.length"
:nodes="node.children"
:currency-hint="currencyHint"
:level="(level ?? 2) + 1"
class="mt-6"
@open-item="(id) => emit('open-item', id)"
@quick-add="(id) => emit('quick-add', id)"
/>
</section>
</div>
</template>

View file

@ -0,0 +1,221 @@
<script setup lang="ts">
/**
* ModifierSelector renders an EnrichedMenuItem's modifier groups
* with the right input type per group:
* - selection='one' radio
* - selection='many' checkbox
*
* Enforces min/max via :disabled on options. Emits a v-model-ish
* `update:selected` event with the chosen `SelectedModifier[]` and
* an `update:valid` event for the parent's "Add to cart" CTA gate.
*/
import { computed, ref, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import type {
EnrichedMenuItem,
Modifier,
SelectedModifier,
} from '../types/restaurant'
const props = defineProps<{
item: EnrichedMenuItem
currencyHint?: string
/** Existing selection (for edit-line flow). Optional. */
initial?: SelectedModifier[]
}>()
const emit = defineEmits<{
(e: 'update:selected', value: SelectedModifier[]): void
(e: 'update:valid', value: boolean): void
}>()
interface GroupState {
groupId: string
selectedIds: Set<string>
}
function priceLabel(value: number) {
if (value === 0) return ''
const sign = value > 0 ? '+' : ''
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
return ` (${sign}${fmt.format(value)} ${props.currencyHint || props.item.currency || 'sat'})`
}
// Seed selected ids per group:
// - prefer caller's `initial` (edit flow),
// - else `is_default` modifiers (recommended baseline),
// - else empty.
function seed(): Record<string, GroupState> {
const out: Record<string, GroupState> = {}
for (const grp of props.item.modifier_groups) {
const fromInitial = (props.initial || []).filter(
(m) => m.group_id === grp.id
)
const initialIds = fromInitial.map((m) => m.modifier_id).filter(
(v): v is string => !!v
)
const defaultIds = grp.modifiers
.filter((m) => m.is_default)
.map((m) => m.id)
out[grp.id] = {
groupId: grp.id,
selectedIds: new Set(
initialIds.length ? initialIds : defaultIds
),
}
}
return out
}
const state = ref<Record<string, GroupState>>(seed())
// Re-seed if the item changes (rare but supports prop swaps in dialog).
watch(
() => props.item.id,
() => {
state.value = seed()
}
)
function toggleOne(groupId: string, modifierId: string) {
const g = state.value[groupId]
if (!g) return
g.selectedIds = new Set([modifierId])
}
function toggleMany(groupId: string, modifierId: string, checked: boolean) {
const g = state.value[groupId]
if (!g) return
const next = new Set(g.selectedIds)
if (checked) next.add(modifierId)
else next.delete(modifierId)
g.selectedIds = next
}
function isAtMax(groupId: string): boolean {
const grp = props.item.modifier_groups.find((g) => g.id === groupId)
if (!grp || !grp.max_selections) return false
const g = state.value[groupId]
return !!g && g.selectedIds.size >= grp.max_selections
}
const selectedFlat = computed<SelectedModifier[]>(() => {
const out: SelectedModifier[] = []
for (const grp of props.item.modifier_groups) {
const g = state.value[grp.id]
if (!g) continue
for (const id of g.selectedIds) {
const mod = grp.modifiers.find((m) => m.id === id) as
| Modifier
| undefined
if (mod) {
out.push({
group_id: grp.id,
group_name: grp.name,
modifier_id: mod.id,
name: mod.name,
price_delta: mod.price_delta,
})
}
}
}
return out
})
const isValid = computed(() => {
for (const grp of props.item.modifier_groups) {
const count = state.value[grp.id]?.selectedIds.size ?? 0
if (count < grp.min_selections) return false
if (grp.max_selections && count > grp.max_selections) return false
}
return true
})
watch(selectedFlat, (v) => emit('update:selected', v), { immediate: true })
watch(isValid, (v) => emit('update:valid', v), { immediate: true })
</script>
<template>
<div class="space-y-6">
<section
v-for="grp in item.modifier_groups"
:key="grp.id"
class="space-y-2"
>
<header class="flex items-baseline justify-between">
<h3 class="text-sm font-semibold text-foreground">
{{ grp.name }}
</h3>
<span class="text-xs text-muted-foreground">
<span v-if="grp.kind === 'required'">Required</span>
<span v-else>Optional</span>
<span v-if="grp.selection === 'one'"> · pick one</span>
<span v-else-if="grp.max_selections">
· up to {{ grp.max_selections }}
</span>
<span v-else> · pick any</span>
</span>
</header>
<RadioGroup
v-if="grp.selection === 'one'"
:model-value="
[...(state[grp.id]?.selectedIds ?? new Set<string>())][0] ?? ''
"
@update:model-value="(v) => v && toggleOne(grp.id, String(v))"
class="space-y-1.5"
>
<Label
v-for="mod in grp.modifiers"
:key="mod.id"
:for="`mod-${mod.id}`"
class="flex cursor-pointer items-center justify-between rounded-lg border border-border bg-card px-3 py-2 transition-colors hover:bg-accent"
>
<span class="flex items-center gap-2">
<RadioGroupItem :id="`mod-${mod.id}`" :value="mod.id" />
<span class="text-sm text-foreground">{{ mod.name }}</span>
</span>
<span
v-if="mod.price_delta"
class="font-mono text-xs text-muted-foreground"
>
{{ priceLabel(mod.price_delta) }}
</span>
</Label>
</RadioGroup>
<div v-else class="space-y-1.5">
<Label
v-for="mod in grp.modifiers"
:key="mod.id"
:for="`mod-${mod.id}`"
class="flex cursor-pointer items-center justify-between rounded-lg border border-border bg-card px-3 py-2 transition-colors hover:bg-accent"
>
<span class="flex items-center gap-2">
<Checkbox
:id="`mod-${mod.id}`"
:model-value="state[grp.id]?.selectedIds.has(mod.id) ?? false"
:disabled="
isAtMax(grp.id) &&
!state[grp.id]?.selectedIds.has(mod.id)
"
@update:model-value="
(checked) =>
toggleMany(grp.id, mod.id, Boolean(checked))
"
/>
<span class="text-sm text-foreground">{{ mod.name }}</span>
</span>
<span
v-if="mod.price_delta"
class="font-mono text-xs text-muted-foreground"
>
{{ priceLabel(mod.price_delta) }}
</span>
</Label>
</div>
</section>
</div>
</template>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Store, MapPin } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import type { Restaurant } from '../types/restaurant'
const props = defineProps<{
restaurant: Restaurant
}>()
const openBadge = computed(() => {
if (!props.restaurant.is_open) {
return { label: 'Closed', variant: 'secondary' as const }
}
return { label: 'Open now', variant: 'default' as const }
})
</script>
<template>
<Card class="overflow-hidden border-l-4 border-l-primary/60 bg-gradient-to-r from-primary/5 via-background to-accent/5 shadow-md">
<div class="relative">
<img
v-if="restaurant.banner_url"
:src="restaurant.banner_url"
:alt="restaurant.name"
class="h-32 w-full object-cover sm:h-40"
loading="lazy"
/>
</div>
<div class="p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<div
v-if="restaurant.logo_url"
class="h-14 w-14 overflow-hidden rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/20 to-accent/20 ring-2 ring-primary/10 sm:h-16 sm:w-16"
>
<img
:src="restaurant.logo_url"
:alt="restaurant.name"
class="h-full w-full object-cover"
loading="lazy"
/>
</div>
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/20 to-accent/20 ring-2 ring-primary/10 sm:h-16 sm:w-16"
>
<Store class="h-7 w-7 text-primary" />
</div>
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-xl font-bold text-foreground sm:text-2xl">
{{ restaurant.name }}
</h1>
<p
v-if="restaurant.description"
class="mt-1 line-clamp-2 text-sm text-muted-foreground"
>
{{ restaurant.description }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Badge :variant="openBadge.variant">{{ openBadge.label }}</Badge>
<Badge variant="outline" class="font-mono">
{{ restaurant.currency || 'sat' }}
</Badge>
<span
v-if="restaurant.location"
class="inline-flex items-center gap-1 text-xs text-muted-foreground"
>
<MapPin class="h-3 w-3" />
{{ restaurant.location }}
</span>
</div>
</div>
</div>
</div>
</Card>
</template>

View file

@ -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<Restaurant | null>
tree: Ref<MenuNode[]>
items: Ref<EnrichedMenuItem[]>
isLoading: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
}
export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
const restaurant = ref<Restaurant | null>(null)
const tree = ref<MenuNode[]>([])
const items = ref<EnrichedMenuItem[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
let abortController: AbortController | null = null
const target = computed(() =>
typeof slugOrId === 'string' ? slugOrId : slugOrId.value
)
async function load(value: string): Promise<void> {
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<void> {
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 }
}

View file

@ -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[],
}

View file

@ -1,32 +1,85 @@
<script setup lang="ts">
// v1 skeleton placeholder. Real implementation lands in commit 4:
// - Redirect to `/r/${defaultSlug}` when one is configured.
// - Otherwise show a slug input + "recent venues" list pulled
// from STORAGE_SERVICE['restaurant.recentRestaurants.v1'].
import { computed } from 'vue'
/**
* Landing / discovery.
* - If `VITE_RESTAURANT_DEFAULT_SLUG` is configured, redirect to
* /r/<slug> on mount.
* - Otherwise show a slug input + a "recent venues" list pulled
* from STORAGE_SERVICE['restaurant.recentRestaurants.v1'].
* (Recent venues are populated by RestaurantPage in commit 4
* and used here from commit 7 onward.)
*
* Festival/aggregator discovery (aiolabs/restaurant#8) lands here in
* a follow-up.
*/
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Utensils } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import appConfig from '@/app.config'
const router = useRouter()
const defaultSlug = computed<string>(
() =>
(appConfig.modules.restaurant?.config as { defaultSlug?: string })
?.defaultSlug || ''
(
appConfig.modules.restaurant as
| { config?: { defaultSlug?: string } }
| undefined
)?.config?.defaultSlug || ''
)
const slug = ref('')
function go() {
const trimmed = slug.value.trim()
if (!trimmed) return
router.push(`/r/${encodeURIComponent(trimmed)}`)
}
onMounted(() => {
if (defaultSlug.value) {
router.replace(`/r/${defaultSlug.value}`)
}
})
</script>
<template>
<main class="mx-auto max-w-md p-6 text-center text-foreground">
<h1 class="text-2xl font-semibold">Restaurant Order</h1>
<p class="mt-4 text-sm text-muted-foreground">
v1 skeleton. Real browse / cart / checkout land in commits 48.
<main class="mx-auto max-w-md px-4 py-8 text-foreground sm:py-12">
<Card>
<CardHeader class="text-center">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<Utensils class="h-7 w-7" />
</div>
<CardTitle class="mt-4 text-2xl">Restaurant Order</CardTitle>
<p class="mt-1 text-sm text-muted-foreground">
Open a venue by its slug.
</p>
<p v-if="defaultSlug" class="mt-6 text-sm">
Default venue:
<code class="font-mono text-primary">{{ defaultSlug }}</code>
</p>
<p v-else class="mt-6 text-xs text-muted-foreground">
</CardHeader>
<CardContent>
<form
class="space-y-3"
@submit.prevent="go"
>
<Input
v-model="slug"
placeholder="e.g. big-jays-bustaurant"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="font-mono"
/>
<Button type="submit" class="w-full" :disabled="!slug.trim()">
Open
</Button>
</form>
<p class="mt-4 text-center text-xs text-muted-foreground">
Set <code class="font-mono">VITE_RESTAURANT_DEFAULT_SLUG</code> to
auto-redirect.
auto-redirect on load.
</p>
</CardContent>
</Card>
</main>
</template>

View file

@ -0,0 +1,236 @@
<script setup lang="ts">
/**
* Item detail + modifier selection + "Add to cart" CTA.
*
* v1 (this commit) lays out the detail surface. The cart store wires
* up in commit 5 until then the "Add to cart" button is disabled
* with a tooltip explaining it.
*/
import { computed, ref, toRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Loader2, Plus, Minus, AlertCircle } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import ModifierSelector from '../components/ModifierSelector.vue'
import { useMenu } from '../composables/useMenu'
import type {
EnrichedMenuItem,
SelectedModifier,
} from '../types/restaurant'
const route = useRoute()
const router = useRouter()
const slug = computed(() => String(route.params.slug || ''))
const itemId = computed(() => String(route.params.itemId || ''))
const { restaurant, items, isLoading, error } = useMenu(
toRef(() => slug.value)
)
const item = computed<EnrichedMenuItem | null>(() => {
return items.value.find((i) => i.id === itemId.value) || null
})
const quantity = ref(1)
const note = ref('')
const selectedModifiers = ref<SelectedModifier[]>([])
const modifiersValid = ref(true)
const isSoldOut = computed(() => {
if (!item.value) return false
if (!item.value.is_available) return true
if (item.value.stock !== null && item.value.stock !== undefined) {
return item.value.stock <= 0
}
return false
})
const unitPrice = computed(() => {
if (!item.value) return 0
const delta = selectedModifiers.value.reduce(
(s, m) => s + (m.price_delta || 0),
0
)
return item.value.price + delta
})
const linePrice = computed(() => unitPrice.value * quantity.value)
const formattedLine = computed(() => {
if (!item.value) return ''
const cur = restaurant.value?.currency || item.value.currency || 'sat'
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
return `${fmt.format(linePrice.value)} ${cur}`
})
function increment() {
if (item.value?.stock && quantity.value >= item.value.stock) return
quantity.value++
}
function decrement() {
if (quantity.value > 1) quantity.value--
}
// Cart store is added in commit 5 placeholder for now.
const canAdd = computed(
() => !!item.value && !isSoldOut.value && modifiersValid.value
)
function addToCart() {
// Wired in commit 5.
console.warn(
'[restaurant] Cart store not yet wired — commit 5 adds this.'
)
}
function back() {
if (window.history.length > 1) router.back()
else router.push(`/r/${slug.value}`)
}
</script>
<template>
<main class="container mx-auto max-w-2xl px-4 pb-32 pt-3 sm:py-6">
<Button variant="ghost" size="sm" class="mb-3" @click="back">
<ArrowLeft class="mr-2 h-4 w-4" />
Back
</Button>
<div v-if="isLoading && !item" class="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
Loading
</div>
<Alert v-else-if="error" variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertTitle>Couldn't load menu</AlertTitle>
<AlertDescription>{{ error.message }}</AlertDescription>
</Alert>
<Alert v-else-if="!item" class="border-amber-500/40">
<AlertCircle class="h-4 w-4" />
<AlertTitle>Item not found</AlertTitle>
<AlertDescription>
This item may have been removed.
<Button
variant="link"
class="ml-1 h-auto p-0"
@click="router.push(`/r/${slug}`)"
>
Back to menu.
</Button>
</AlertDescription>
</Alert>
<template v-else>
<figure
v-if="item.images?.length"
class="mb-4 overflow-hidden rounded-xl bg-muted"
>
<img
:src="item.images[0]"
:alt="item.name"
class="aspect-[3/2] w-full object-cover"
loading="lazy"
/>
</figure>
<header class="mb-4">
<h1 class="text-2xl font-bold text-foreground">{{ item.name }}</h1>
<p
v-if="item.description"
class="mt-1 text-sm text-muted-foreground"
>
{{ item.description }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-1.5">
<Badge v-if="isSoldOut" variant="secondary">Sold out</Badge>
<Badge
v-for="diet in item.dietary"
:key="diet"
variant="outline"
class="text-[10px]"
>
{{ diet }}
</Badge>
<Badge
v-for="al in item.allergens"
:key="al"
variant="outline"
class="border-amber-500/40 text-[10px] text-amber-700 dark:text-amber-300"
>
{{ al }}
</Badge>
</div>
</header>
<ModifierSelector
v-if="item.modifier_groups.length"
:item="item"
:currency-hint="restaurant?.currency"
class="mb-6"
@update:selected="(v) => (selectedModifiers = v)"
@update:valid="(v) => (modifiersValid = v)"
/>
<section class="mb-6">
<label class="mb-1 block text-sm font-semibold text-foreground">
Note for the kitchen (optional)
</label>
<Textarea
v-model="note"
placeholder="Allergies, prep preferences, etc."
rows="2"
/>
</section>
<!-- Sticky bottom bar with qty stepper + add-to-cart -->
<div
class="fixed inset-x-0 bottom-16 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur sm:bottom-0"
>
<div class="container mx-auto flex max-w-2xl items-center gap-3">
<div class="inline-flex items-center rounded-full border border-border bg-card">
<Button
variant="ghost"
size="icon"
class="h-9 w-9 rounded-full"
:disabled="quantity <= 1"
aria-label="Decrease quantity"
@click="decrement"
>
<Minus class="h-4 w-4" />
</Button>
<span class="w-8 text-center font-mono text-sm" aria-live="polite">
{{ quantity }}
</span>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 rounded-full"
:disabled="
!!item.stock && quantity >= item.stock
"
aria-label="Increase quantity"
@click="increment"
>
<Plus class="h-4 w-4" />
</Button>
</div>
<Button
class="flex-1"
:disabled="!canAdd"
@click="addToCart"
>
Add to cart
<span class="ml-2 font-mono text-xs opacity-90">
· {{ formattedLine }}
</span>
</Button>
</div>
</div>
</template>
</main>
</template>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
/**
* The browse view for one restaurant. Loads the menu via REST,
* renders the header + sticky CategoryNav + recursive MenuTree.
*
* In commit 8 this view also opens a Nostr live-overlay subscription
* on mount (kind 30402 listings for `restaurant.nostr_pubkey`) and
* tears it down on route leave. Today it's REST-only.
*
* Tapping an item card opens ItemPage at /r/:slug/item/:id, which
* handles modifier selection and "Add to cart" (cart store lands in
* commit 5).
*/
import { computed, toRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Loader2, AlertCircle } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import RestaurantHeader from '../components/RestaurantHeader.vue'
import CategoryNav from '../components/CategoryNav.vue'
import MenuTree from '../components/MenuTree.vue'
import { useMenu } from '../composables/useMenu'
const route = useRoute()
const router = useRouter()
const slug = computed(() => String(route.params.slug || ''))
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
toRef(() => slug.value)
)
function openItem(itemId: string) {
router.push(`/r/${slug.value}/item/${itemId}`)
}
// "Quick-add" is forwarded by MenuItemCard when an item has no
// modifier groups. Cart store lands in commit 5 until then it
// behaves the same as opening the item detail.
function quickAdd(itemId: string) {
openItem(itemId)
}
</script>
<template>
<main class="container mx-auto px-4 pb-24 pt-3 sm:py-6">
<div v-if="isLoading && !restaurant" class="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
Loading menu
</div>
<Alert v-else-if="error" variant="destructive" class="mt-4">
<AlertCircle class="h-4 w-4" />
<AlertTitle>Couldn't load restaurant</AlertTitle>
<AlertDescription class="mt-2">
<p>{{ error.message }}</p>
<Button
variant="outline"
size="sm"
class="mt-3"
@click="refresh"
>
Try again
</Button>
</AlertDescription>
</Alert>
<template v-else-if="restaurant">
<RestaurantHeader :restaurant="restaurant" class="mb-4 sm:mb-6" />
<CategoryNav :roots="tree" class="mb-6" />
<div v-if="!tree.length && !items.length" class="py-16 text-center text-muted-foreground">
This restaurant hasn't published any menu items yet.
</div>
<MenuTree
v-else
:nodes="tree"
:currency-hint="restaurant.currency"
@open-item="openItem"
@quick-add="quickAdd"
/>
</template>
</main>
</template>