diff --git a/src/modules/restaurant/components/CartLineItem.vue b/src/modules/restaurant/components/CartLineItem.vue new file mode 100644 index 0000000..0149867 --- /dev/null +++ b/src/modules/restaurant/components/CartLineItem.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index dbec7da..1195dc0 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -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[], } diff --git a/src/modules/restaurant/stores/cart.ts b/src/modules/restaurant/stores/cart.ts new file mode 100644 index 0000000..7beb1e4 --- /dev/null +++ b/src/modules/restaurant/stores/cart.ts @@ -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 + 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( + SERVICE_TOKENS.STORAGE_SERVICE + ) + + // Hydrate from persistence (if available). + const initial = storage?.getUserData(STORAGE_KEY, blank) ?? blank + + const lines = ref>( + structuredClone(initial.lines || {}) + ) + const activeRestaurantId = ref( + initial.activeRestaurantId ?? null + ) + + // -------------------------- getters -------------------------------- + + const restaurantsInCart = computed(() => + Object.keys(lines.value).filter((rid) => lines.value[rid].length > 0) + ) + + const itemCount = computed(() => { + 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>(() => { + const out: Record = {} + 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(() => + 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 { + 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 | null = null + function persist(): void { + if (!storage) return + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + storage.setUserData(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, + } +}) diff --git a/src/modules/restaurant/views/CartPage.vue b/src/modules/restaurant/views/CartPage.vue new file mode 100644 index 0000000..7771c85 --- /dev/null +++ b/src/modules/restaurant/views/CartPage.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/modules/restaurant/views/ItemPage.vue b/src/modules/restaurant/views/ItemPage.vue index 9d6e271..945a4ca 100644 --- a/src/modules/restaurant/views/ItemPage.vue +++ b/src/modules/restaurant/views/ItemPage.vue @@ -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() { diff --git a/src/modules/restaurant/views/RestaurantPage.vue b/src/modules/restaurant/views/RestaurantPage.vue index b60a8ed..abbdb50 100644 --- a/src/modules/restaurant/views/RestaurantPage.vue +++ b/src/modules/restaurant/views/RestaurantPage.vue @@ -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, + }) } diff --git a/src/restaurant-app/App.vue b/src/restaurant-app/App.vue index f1e75b8..908ba59 100644 --- a/src/restaurant-app/App.vue +++ b/src/restaurant-app/App.vue @@ -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(() => null) +const cartItemCount = computed(() => + cart.itemCount > 0 ? cart.itemCount : null +) const tabs = computed(() => [ { name: 'Browse', icon: Utensils, path: '/' },