feat(restaurant): customer-facing restaurant bundle (v1) #54
7 changed files with 493 additions and 13 deletions
feat(restaurant): cart store + cart page + add-to-cart wiring
stores/cart.ts — Pinia store keyed by restaurant_id (multi- restaurant ready for the festival aggregator, aiolabs/restaurant#8, without schema changes). Persists to STORAGE_SERVICE under 'restaurant.cart.v1' (debounced 200ms); hydrates on creation. Money is integer msat-ish (the cart stores unit_msat as the per-unit value pulled from MenuItem.price; the buildCreateOrder helper in commit 6 owns the canonical msat conversion at order-place time). State: { lines: Record<restaurant_id, CartLine[]>, activeRestaurantId: string | null } Lines that match item + modifier set + note merge into the same line with quantity++ rather than duplicating. Getters: restaurantsInCart, itemCount, restaurantTotalsMsat, grandTotalMsat, linesFor(rid). Actions: addLine, setQty, incrementQty, decrementQty, removeLine, clearRestaurant, clear, setActiveRestaurant. components/CartLineItem.vue — single line with modifier summary, qty stepper, note display, remove button. views/CartPage.vue — lines grouped by restaurant. Multi-restaurant display already works (each restaurant is its own card). Empty state, subtotal, clear-cart, Checkout CTA (lands in commit 6). Wiring: - ItemPage 'Add to cart' now actually adds, then routes to /cart. - RestaurantPage's quick-add (the '+' on cards with NO modifier groups) adds directly without opening ItemPage; cards WITH modifier groups still open the detail page so the customer can satisfy required choices. - App.vue bottom-nav 'Cart' badge reflects cart.itemCount. - New route /cart registered on the module. Verified: vue-tsc -b clean.
commit
27d98ce851
97
src/modules/restaurant/components/CartLineItem.vue
Normal file
97
src/modules/restaurant/components/CartLineItem.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Single line in the cart: name, modifier summary, qty stepper,
|
||||||
|
* remove button.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Plus, Minus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { CartLine } from '../stores/cart'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
line: CartLine
|
||||||
|
currencyHint?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'increment'): void
|
||||||
|
(e: 'decrement'): void
|
||||||
|
(e: 'remove'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modifierSummary = computed(() =>
|
||||||
|
props.line.selected_modifiers.map((m) => m.name).join(' · ')
|
||||||
|
)
|
||||||
|
|
||||||
|
const lineTotal = computed(() => {
|
||||||
|
// unit_msat is base + modifier delta snapshot; the cart store
|
||||||
|
// currently stores it as a sat-major number (price * 1000-less
|
||||||
|
// because the extension's `price` is already in the declared
|
||||||
|
// currency, not msat — see useCheckout's buildCreateOrder for the
|
||||||
|
// canonical conversion at order-place time).
|
||||||
|
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
|
||||||
|
const total = props.line.unit_msat * props.line.quantity
|
||||||
|
return `${fmt.format(total)} ${props.currencyHint || 'sat'}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="flex items-stretch gap-3 rounded-xl border border-border bg-card p-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h4 class="line-clamp-1 font-semibold text-foreground">
|
||||||
|
{{ line.name }}
|
||||||
|
</h4>
|
||||||
|
<span class="shrink-0 font-mono text-sm text-primary">
|
||||||
|
{{ lineTotal }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="modifierSummary"
|
||||||
|
class="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ modifierSummary }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="line.note"
|
||||||
|
class="mt-1 line-clamp-2 text-xs italic text-muted-foreground"
|
||||||
|
>
|
||||||
|
Note: {{ line.note }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<div class="inline-flex items-center rounded-full border border-border bg-background">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 rounded-full"
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
@click="emit('decrement')"
|
||||||
|
>
|
||||||
|
<Minus class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span class="w-7 text-center font-mono text-sm" aria-live="polite">
|
||||||
|
{{ line.quantity }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 rounded-full"
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
@click="emit('increment')"
|
||||||
|
>
|
||||||
|
<Plus class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
aria-label="Remove line"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
@ -88,6 +88,12 @@ export const restaurantModule: ModulePlugin = {
|
||||||
component: () => import('./views/ItemPage.vue'),
|
component: () => import('./views/ItemPage.vue'),
|
||||||
meta: { requiresAuth: false, title: 'Item' },
|
meta: { requiresAuth: false, title: 'Item' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/cart',
|
||||||
|
name: 'restaurant-cart',
|
||||||
|
component: () => import('./views/CartPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Cart' },
|
||||||
|
},
|
||||||
] as RouteRecordRaw[],
|
] as RouteRecordRaw[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
230
src/modules/restaurant/stores/cart.ts
Normal file
230
src/modules/restaurant/stores/cart.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
/**
|
||||||
|
* Restaurant cart store (Pinia).
|
||||||
|
*
|
||||||
|
* Multi-restaurant ready by design — `lines` is keyed by
|
||||||
|
* `restaurant_id`. v1 only exercises the single-restaurant path
|
||||||
|
* (URL-driven /r/:slug), but the festival aggregator
|
||||||
|
* (aiolabs/restaurant#8) lands on top of this same store with
|
||||||
|
* **zero schema changes** — it just adds lines from more restaurants.
|
||||||
|
*
|
||||||
|
* Persistence: state is mirrored to STORAGE_SERVICE under
|
||||||
|
* `restaurant.cart.v1` (debounced). Re-hydrated on store creation.
|
||||||
|
* Money is integer **msat** end-to-end (matches the extension).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
import type { SelectedModifier } from '../types/restaurant'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'restaurant.cart.v1'
|
||||||
|
|
||||||
|
export interface CartLine {
|
||||||
|
/** UUID for dedup of identical items + modifier sets. */
|
||||||
|
line_id: string
|
||||||
|
restaurant_id: string
|
||||||
|
restaurant_slug: string
|
||||||
|
menu_item_id: string
|
||||||
|
/** Snapshot at add-time so the cart still renders if the menu
|
||||||
|
* item is later edited / deleted. */
|
||||||
|
name: string
|
||||||
|
/** Base price + selected modifier deltas, in msat. */
|
||||||
|
unit_msat: number
|
||||||
|
quantity: number
|
||||||
|
selected_modifiers: SelectedModifier[]
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartState {
|
||||||
|
lines: Record<string, CartLine[]>
|
||||||
|
activeRestaurantId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const blank: CartState = { lines: {}, activeRestaurantId: null }
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
// crypto.randomUUID is available in modern browsers + Node 19+.
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `line-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two lines are merge-equivalent iff same item + same modifier set
|
||||||
|
* + same note. Modifier id sort gives an order-independent compare. */
|
||||||
|
function modifierKey(mods: SelectedModifier[]): string {
|
||||||
|
return mods
|
||||||
|
.map((m) => m.modifier_id ?? m.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCartStore = defineStore('restaurant-cart', () => {
|
||||||
|
const storage = tryInjectService<StorageService>(
|
||||||
|
SERVICE_TOKENS.STORAGE_SERVICE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hydrate from persistence (if available).
|
||||||
|
const initial = storage?.getUserData<CartState>(STORAGE_KEY, blank) ?? blank
|
||||||
|
|
||||||
|
const lines = ref<Record<string, CartLine[]>>(
|
||||||
|
structuredClone(initial.lines || {})
|
||||||
|
)
|
||||||
|
const activeRestaurantId = ref<string | null>(
|
||||||
|
initial.activeRestaurantId ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// -------------------------- getters --------------------------------
|
||||||
|
|
||||||
|
const restaurantsInCart = computed<string[]>(() =>
|
||||||
|
Object.keys(lines.value).filter((rid) => lines.value[rid].length > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemCount = computed<number>(() => {
|
||||||
|
let n = 0
|
||||||
|
for (const rid of Object.keys(lines.value)) {
|
||||||
|
for (const l of lines.value[rid]) n += l.quantity
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
|
||||||
|
const restaurantTotalsMsat = computed<Record<string, number>>(() => {
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const rid of Object.keys(lines.value)) {
|
||||||
|
out[rid] = lines.value[rid].reduce(
|
||||||
|
(s, l) => s + l.unit_msat * l.quantity,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const grandTotalMsat = computed<number>(() =>
|
||||||
|
Object.values(restaurantTotalsMsat.value).reduce((s, v) => s + v, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
function linesFor(restaurantId: string): CartLine[] {
|
||||||
|
return lines.value[restaurantId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- actions --------------------------------
|
||||||
|
|
||||||
|
function setActiveRestaurant(restaurantId: string | null): void {
|
||||||
|
activeRestaurantId.value = restaurantId
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(line: Omit<CartLine, 'line_id'>): CartLine {
|
||||||
|
const bucket = (lines.value[line.restaurant_id] ||= [])
|
||||||
|
const myKey = modifierKey(line.selected_modifiers)
|
||||||
|
const existing = bucket.find(
|
||||||
|
(l) =>
|
||||||
|
l.menu_item_id === line.menu_item_id &&
|
||||||
|
modifierKey(l.selected_modifiers) === myKey &&
|
||||||
|
(l.note ?? '') === (line.note ?? '')
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += line.quantity
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const newLine: CartLine = { line_id: uuid(), ...line }
|
||||||
|
bucket.push(newLine)
|
||||||
|
if (!activeRestaurantId.value) {
|
||||||
|
activeRestaurantId.value = line.restaurant_id
|
||||||
|
}
|
||||||
|
return newLine
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQty(restaurantId: string, lineId: string, qty: number): void {
|
||||||
|
const bucket = lines.value[restaurantId]
|
||||||
|
if (!bucket) return
|
||||||
|
const line = bucket.find((l) => l.line_id === lineId)
|
||||||
|
if (!line) return
|
||||||
|
if (qty <= 0) {
|
||||||
|
removeLine(restaurantId, lineId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line.quantity = qty
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementQty(restaurantId: string, lineId: string): void {
|
||||||
|
const bucket = lines.value[restaurantId]
|
||||||
|
if (!bucket) return
|
||||||
|
const line = bucket.find((l) => l.line_id === lineId)
|
||||||
|
if (line) line.quantity++
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementQty(restaurantId: string, lineId: string): void {
|
||||||
|
const bucket = lines.value[restaurantId]
|
||||||
|
if (!bucket) return
|
||||||
|
const line = bucket.find((l) => l.line_id === lineId)
|
||||||
|
if (!line) return
|
||||||
|
if (line.quantity <= 1) {
|
||||||
|
removeLine(restaurantId, lineId)
|
||||||
|
} else {
|
||||||
|
line.quantity--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(restaurantId: string, lineId: string): void {
|
||||||
|
const bucket = lines.value[restaurantId]
|
||||||
|
if (!bucket) return
|
||||||
|
lines.value[restaurantId] = bucket.filter((l) => l.line_id !== lineId)
|
||||||
|
if (lines.value[restaurantId].length === 0) {
|
||||||
|
delete lines.value[restaurantId]
|
||||||
|
if (activeRestaurantId.value === restaurantId) {
|
||||||
|
activeRestaurantId.value = restaurantsInCart.value[0] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRestaurant(restaurantId: string): void {
|
||||||
|
delete lines.value[restaurantId]
|
||||||
|
if (activeRestaurantId.value === restaurantId) {
|
||||||
|
activeRestaurantId.value = restaurantsInCart.value[0] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
lines.value = {}
|
||||||
|
activeRestaurantId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- persistence ----------------------------
|
||||||
|
|
||||||
|
let writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function persist(): void {
|
||||||
|
if (!storage) return
|
||||||
|
if (writeTimer) clearTimeout(writeTimer)
|
||||||
|
writeTimer = setTimeout(() => {
|
||||||
|
storage.setUserData<CartState>(STORAGE_KEY, {
|
||||||
|
lines: lines.value,
|
||||||
|
activeRestaurantId: activeRestaurantId.value,
|
||||||
|
})
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
watch([lines, activeRestaurantId], persist, { deep: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
lines,
|
||||||
|
activeRestaurantId,
|
||||||
|
// getters
|
||||||
|
restaurantsInCart,
|
||||||
|
itemCount,
|
||||||
|
restaurantTotalsMsat,
|
||||||
|
grandTotalMsat,
|
||||||
|
linesFor,
|
||||||
|
// actions
|
||||||
|
setActiveRestaurant,
|
||||||
|
addLine,
|
||||||
|
setQty,
|
||||||
|
incrementQty,
|
||||||
|
decrementQty,
|
||||||
|
removeLine,
|
||||||
|
clearRestaurant,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
118
src/modules/restaurant/views/CartPage.vue
Normal file
118
src/modules/restaurant/views/CartPage.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Cart review. v1 shows lines grouped by restaurant (multi-
|
||||||
|
* restaurant ready — single bucket today). "Checkout" jumps to
|
||||||
|
* /checkout where useCheckout runs the place-order + bolt11 pay
|
||||||
|
* sequence (commit 6).
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, ShoppingCart } from 'lucide-vue-next'
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import CartLineItem from '../components/CartLineItem.vue'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
|
const buckets = computed(() =>
|
||||||
|
cart.restaurantsInCart.map((rid) => ({
|
||||||
|
restaurantId: rid,
|
||||||
|
// restaurant_slug comes from the line snapshot so we don't need
|
||||||
|
// a separate fetch for the cart page header.
|
||||||
|
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||||
|
lines: cart.linesFor(rid),
|
||||||
|
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function fmtSat(value: number): string {
|
||||||
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||||
|
<Button variant="ghost" size="sm" class="mb-3" @click="router.back()">
|
||||||
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<header class="mb-4 flex items-baseline justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Cart</h1>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{{ cart.itemCount }} item{{ cart.itemCount === 1 ? '' : 's' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Alert v-if="!buckets.length" class="border-border">
|
||||||
|
<ShoppingCart class="h-4 w-4" />
|
||||||
|
<AlertTitle>Your cart is empty</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Browse a menu and tap “+” to add items.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Card v-for="b in buckets" :key="b.restaurantId">
|
||||||
|
<CardHeader class="flex flex-row items-baseline justify-between space-y-0">
|
||||||
|
<CardTitle class="text-base">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-left text-foreground hover:text-primary"
|
||||||
|
@click="router.push(`/r/${b.restaurantSlug}`)"
|
||||||
|
>
|
||||||
|
{{ b.restaurantSlug }}
|
||||||
|
</button>
|
||||||
|
</CardTitle>
|
||||||
|
<span class="font-mono text-sm text-primary">
|
||||||
|
{{ fmtSat(b.totalMsat) }}
|
||||||
|
</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<CartLineItem
|
||||||
|
v-for="line in b.lines"
|
||||||
|
:key="line.line_id"
|
||||||
|
:line="line"
|
||||||
|
@increment="cart.incrementQty(b.restaurantId, line.line_id)"
|
||||||
|
@decrement="cart.decrementQty(b.restaurantId, line.line_id)"
|
||||||
|
@remove="cart.removeLine(b.restaurantId, line.line_id)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator class="my-6" />
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">Subtotal</span>
|
||||||
|
<span class="font-mono text-lg font-semibold text-foreground">
|
||||||
|
{{ fmtSat(cart.grandTotalMsat) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:disabled="!buckets.length"
|
||||||
|
@click="router.push('/checkout')"
|
||||||
|
>
|
||||||
|
Checkout
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full text-destructive hover:text-destructive"
|
||||||
|
@click="cart.clear()"
|
||||||
|
>
|
||||||
|
Clear cart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import ModifierSelector from '../components/ModifierSelector.vue'
|
import ModifierSelector from '../components/ModifierSelector.vue'
|
||||||
import { useMenu } from '../composables/useMenu'
|
import { useMenu } from '../composables/useMenu'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
import type {
|
import type {
|
||||||
EnrichedMenuItem,
|
EnrichedMenuItem,
|
||||||
SelectedModifier,
|
SelectedModifier,
|
||||||
|
|
@ -22,6 +23,7 @@ import type {
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
const slug = computed(() => String(route.params.slug || ''))
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
const itemId = computed(() => String(route.params.itemId || ''))
|
const itemId = computed(() => String(route.params.itemId || ''))
|
||||||
|
|
@ -74,16 +76,23 @@ function decrement() {
|
||||||
if (quantity.value > 1) quantity.value--
|
if (quantity.value > 1) quantity.value--
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cart store is added in commit 5 — placeholder for now.
|
|
||||||
const canAdd = computed(
|
const canAdd = computed(
|
||||||
() => !!item.value && !isSoldOut.value && modifiersValid.value
|
() => !!item.value && !isSoldOut.value && modifiersValid.value
|
||||||
)
|
)
|
||||||
|
|
||||||
function addToCart() {
|
function addToCart() {
|
||||||
// Wired in commit 5.
|
if (!item.value || !restaurant.value) return
|
||||||
console.warn(
|
cart.addLine({
|
||||||
'[restaurant] Cart store not yet wired — commit 5 adds this.'
|
restaurant_id: restaurant.value.id,
|
||||||
)
|
restaurant_slug: restaurant.value.slug,
|
||||||
|
menu_item_id: item.value.id,
|
||||||
|
name: item.value.name,
|
||||||
|
unit_msat: unitPrice.value,
|
||||||
|
quantity: quantity.value,
|
||||||
|
selected_modifiers: selectedModifiers.value,
|
||||||
|
note: note.value || null,
|
||||||
|
})
|
||||||
|
router.push('/cart')
|
||||||
}
|
}
|
||||||
|
|
||||||
function back() {
|
function back() {
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ import RestaurantHeader from '../components/RestaurantHeader.vue'
|
||||||
import CategoryNav from '../components/CategoryNav.vue'
|
import CategoryNav from '../components/CategoryNav.vue'
|
||||||
import MenuTree from '../components/MenuTree.vue'
|
import MenuTree from '../components/MenuTree.vue'
|
||||||
import { useMenu } from '../composables/useMenu'
|
import { useMenu } from '../composables/useMenu'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
const slug = computed(() => String(route.params.slug || ''))
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
|
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
|
||||||
|
|
@ -33,11 +35,28 @@ function openItem(itemId: string) {
|
||||||
router.push(`/r/${slug.value}/item/${itemId}`)
|
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
|
* Quick-add bypasses ItemPage for items that have no modifier groups.
|
||||||
// behaves the same as opening the item detail.
|
* Items with modifier groups always route through ItemPage so the
|
||||||
|
* customer can pick required options before the line is added.
|
||||||
|
*/
|
||||||
function quickAdd(itemId: string) {
|
function quickAdd(itemId: string) {
|
||||||
|
const it = items.value.find((i) => i.id === itemId)
|
||||||
|
if (!it || !restaurant.value) return
|
||||||
|
if (it.modifier_groups.length > 0) {
|
||||||
openItem(itemId)
|
openItem(itemId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cart.addLine({
|
||||||
|
restaurant_id: restaurant.value.id,
|
||||||
|
restaurant_slug: restaurant.value.slug,
|
||||||
|
menu_item_id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
unit_msat: it.price,
|
||||||
|
quantity: 1,
|
||||||
|
selected_modifiers: [],
|
||||||
|
note: null,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import { useRoute } from 'vue-router'
|
||||||
import { Utensils, ShoppingCart, ReceiptText } from 'lucide-vue-next'
|
import { Utensils, ShoppingCart, ReceiptText } from 'lucide-vue-next'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
|
import { useCartStore } from '@/modules/restaurant/stores/cart'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
// The cart store hooks in during commit 5; v1 skeleton renders the
|
const cartItemCount = computed<number | null>(() =>
|
||||||
// badge as null. Bottom-nav lives in AppShell and works fine with
|
cart.itemCount > 0 ? cart.itemCount : null
|
||||||
// zero badge values.
|
)
|
||||||
const cartItemCount = computed<number | null>(() => null)
|
|
||||||
|
|
||||||
const tabs = computed<BottomTab[]>(() => [
|
const tabs = computed<BottomTab[]>(() => [
|
||||||
{ name: 'Browse', icon: Utensils, path: '/' },
|
{ name: 'Browse', icon: Utensils, path: '/' },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue