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.
This commit is contained in:
Padreug 2026-05-11 17:24:20 +02:00
commit 27d98ce851
7 changed files with 493 additions and 13 deletions

View 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>

View file

@ -88,6 +88,12 @@ export const restaurantModule: ModulePlugin = {
component: () => import('./views/ItemPage.vue'),
meta: { requiresAuth: false, title: 'Item' },
},
{
path: '/cart',
name: 'restaurant-cart',
component: () => import('./views/CartPage.vue'),
meta: { requiresAuth: false, title: 'Cart' },
},
] as RouteRecordRaw[],
}

View 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,
}
})

View 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>

View file

@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import ModifierSelector from '../components/ModifierSelector.vue'
import { useMenu } from '../composables/useMenu'
import { useCartStore } from '../stores/cart'
import type {
EnrichedMenuItem,
SelectedModifier,
@ -22,6 +23,7 @@ import type {
const route = useRoute()
const router = useRouter()
const cart = useCartStore()
const slug = computed(() => String(route.params.slug || ''))
const itemId = computed(() => String(route.params.itemId || ''))
@ -74,16 +76,23 @@ 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.'
)
if (!item.value || !restaurant.value) return
cart.addLine({
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() {

View file

@ -20,9 +20,11 @@ import RestaurantHeader from '../components/RestaurantHeader.vue'
import CategoryNav from '../components/CategoryNav.vue'
import MenuTree from '../components/MenuTree.vue'
import { useMenu } from '../composables/useMenu'
import { useCartStore } from '../stores/cart'
const route = useRoute()
const router = useRouter()
const cart = useCartStore()
const slug = computed(() => String(route.params.slug || ''))
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
@ -33,11 +35,28 @@ 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.
/**
* Quick-add bypasses ItemPage for items that have no modifier groups.
* Items with modifier groups always route through ItemPage so the
* customer can pick required options before the line is added.
*/
function quickAdd(itemId: string) {
openItem(itemId)
const it = items.value.find((i) => i.id === itemId)
if (!it || !restaurant.value) return
if (it.modifier_groups.length > 0) {
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>

View file

@ -4,13 +4,14 @@ import { useRoute } from 'vue-router'
import { Utensils, ShoppingCart, ReceiptText } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useCartStore } from '@/modules/restaurant/stores/cart'
const route = useRoute()
const cart = useCartStore()
// The cart store hooks in during commit 5; v1 skeleton renders the
// badge as null. Bottom-nav lives in AppShell and works fine with
// zero badge values.
const cartItemCount = computed<number | null>(() => null)
const cartItemCount = computed<number | null>(() =>
cart.itemCount > 0 ? cart.itemCount : null
)
const tabs = computed<BottomTab[]>(() => [
{ name: 'Browse', icon: Utensils, path: '/' },