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'),
|
component: () => import('./views/HomePage.vue'),
|
||||||
meta: { requiresAuth: false, title: 'Restaurant' },
|
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[],
|
] as RouteRecordRaw[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,85 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// v1 skeleton placeholder. Real implementation lands in commit 4:
|
/**
|
||||||
// - Redirect to `/r/${defaultSlug}` when one is configured.
|
* Landing / discovery.
|
||||||
// - Otherwise show a slug input + "recent venues" list pulled
|
* - If `VITE_RESTAURANT_DEFAULT_SLUG` is configured, redirect to
|
||||||
// from STORAGE_SERVICE['restaurant.recentRestaurants.v1'].
|
* /r/<slug> on mount.
|
||||||
|
* - Otherwise show a slug input + a "recent venues" list pulled
|
||||||
import { computed } from 'vue'
|
* 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'
|
import appConfig from '@/app.config'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const defaultSlug = computed<string>(
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="mx-auto max-w-md p-6 text-center text-foreground">
|
<main class="mx-auto max-w-md px-4 py-8 text-foreground sm:py-12">
|
||||||
<h1 class="text-2xl font-semibold">Restaurant — Order</h1>
|
<Card>
|
||||||
<p class="mt-4 text-sm text-muted-foreground">
|
<CardHeader class="text-center">
|
||||||
v1 skeleton. Real browse / cart / checkout land in commits 4–8.
|
<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>
|
||||||
<p v-if="defaultSlug" class="mt-6 text-sm">
|
</CardHeader>
|
||||||
Default venue:
|
<CardContent>
|
||||||
<code class="font-mono text-primary">{{ defaultSlug }}</code>
|
<form
|
||||||
</p>
|
class="space-y-3"
|
||||||
<p v-else class="mt-6 text-xs text-muted-foreground">
|
@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
|
Set <code class="font-mono">VITE_RESTAURANT_DEFAULT_SLUG</code> to
|
||||||
auto-redirect.
|
auto-redirect on load.
|
||||||
</p>
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</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