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.
This commit is contained in:
parent
1cdf87b04b
commit
3a11d90164
10 changed files with 1096 additions and 21 deletions
59
src/modules/restaurant/components/CategoryNav.vue
Normal file
59
src/modules/restaurant/components/CategoryNav.vue
Normal 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>
|
||||
149
src/modules/restaurant/components/MenuItemCard.vue
Normal file
149
src/modules/restaurant/components/MenuItemCard.vue
Normal 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>
|
||||
82
src/modules/restaurant/components/MenuTree.vue
Normal file
82
src/modules/restaurant/components/MenuTree.vue
Normal 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>
|
||||
221
src/modules/restaurant/components/ModifierSelector.vue
Normal file
221
src/modules/restaurant/components/ModifierSelector.vue
Normal 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>
|
||||
81
src/modules/restaurant/components/RestaurantHeader.vue
Normal file
81
src/modules/restaurant/components/RestaurantHeader.vue
Normal 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>
|
||||
97
src/modules/restaurant/composables/useMenu.ts
Normal file
97
src/modules/restaurant/composables/useMenu.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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[],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 4–8.
|
||||
<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>
|
||||
|
|
|
|||
236
src/modules/restaurant/views/ItemPage.vue
Normal file
236
src/modules/restaurant/views/ItemPage.vue
Normal 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>
|
||||
85
src/modules/restaurant/views/RestaurantPage.vue
Normal file
85
src/modules/restaurant/views/RestaurantPage.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue