feat(restaurant): customer-facing restaurant bundle (v1) #54
31 changed files with 4621 additions and 2 deletions
|
|
@ -30,8 +30,11 @@
|
||||||
"dev:forum": "vite --host --config vite.forum.config.ts",
|
"dev:forum": "vite --host --config vite.forum.config.ts",
|
||||||
"build:forum": "vue-tsc -b && vite build --config vite.forum.config.ts",
|
"build:forum": "vue-tsc -b && vite build --config vite.forum.config.ts",
|
||||||
"preview:forum": "vite preview --host --config vite.forum.config.ts",
|
"preview:forum": "vite preview --host --config vite.forum.config.ts",
|
||||||
"dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks -c blue,magenta,cyan,yellow,green,blue,red,gray \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\"",
|
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
||||||
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks",
|
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
|
||||||
|
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
|
||||||
|
"dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
|
||||||
|
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
|
||||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||||
"electron:package": "electron-builder",
|
"electron:package": "electron-builder",
|
||||||
|
|
|
||||||
20
restaurant.html
Normal file
20
restaurant.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||||
|
<title>Restaurant — Order</title>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Restaurant">
|
||||||
|
<meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/restaurant-app/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -173,6 +173,10 @@ export const SERVICE_TOKENS = {
|
||||||
|
|
||||||
// Expenses services
|
// Expenses services
|
||||||
EXPENSES_API: Symbol('expensesAPI'),
|
EXPENSES_API: Symbol('expensesAPI'),
|
||||||
|
|
||||||
|
// Restaurant services
|
||||||
|
RESTAURANT_API: Symbol('restaurantAPI'),
|
||||||
|
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Type-safe injection helpers
|
// Type-safe injection helpers
|
||||||
|
|
|
||||||
94
src/modules/restaurant/components/CartLineItem.vue
Normal file
94
src/modules/restaurant/components/CartLineItem.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
// Displayed in the menu item's currency (e.g. GTQ). Authoritative
|
||||||
|
// sat conversion happens server-side at /orders/quote — the
|
||||||
|
// checkout page surfaces that as a "≈ X sat" badge.
|
||||||
|
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
|
||||||
|
const total = props.line.unit_price * props.line.quantity
|
||||||
|
return `${fmt.format(total)} ${props.line.currency}`
|
||||||
|
})
|
||||||
|
</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>
|
||||||
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>
|
||||||
104
src/modules/restaurant/components/OrderInvoiceCard.vue
Normal file
104
src/modules/restaurant/components/OrderInvoiceCard.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Renders a bolt11 invoice as a copyable QR + amount + expiry.
|
||||||
|
* Used on OrderStatusPage when the order is still pending.
|
||||||
|
*/
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { Copy, Check } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import type { OrderInvoice } from '../types/restaurant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
invoice: OrderInvoice
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const qrDataUrl = ref<string>('')
|
||||||
|
const copied = ref(false)
|
||||||
|
const expiresInSec = ref<number>(0)
|
||||||
|
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
try {
|
||||||
|
qrDataUrl.value = await QRCode.toDataURL(props.invoice.bolt11, {
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
margin: 1,
|
||||||
|
scale: 6,
|
||||||
|
// Theme-aware colors are tricky for QR (must stay
|
||||||
|
// high-contrast for scanners). Stick with pure white bg /
|
||||||
|
// black fg regardless of theme — QRs are read by camera not
|
||||||
|
// by humans.
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to render bolt11 QR:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.invoice.bolt11, render, { immediate: true })
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
expiresInSec.value = Math.max(0, props.invoice.expires_at - now)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateCountdown()
|
||||||
|
countdownTimer = setInterval(updateCountdown, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
void navigator.clipboard.writeText(props.invoice.bolt11)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => (copied.value = false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCountdown(seconds: number) {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardContent class="space-y-3 p-4 sm:p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Lightning invoice</p>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">
|
||||||
|
<span v-if="expiresInSec > 0">expires in {{ fmtCountdown(expiresInSec) }}</span>
|
||||||
|
<span v-else class="text-destructive">expired</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img
|
||||||
|
v-if="qrDataUrl"
|
||||||
|
:src="qrDataUrl"
|
||||||
|
alt="bolt11 invoice QR code"
|
||||||
|
class="block h-56 w-56 sm:h-64 sm:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-xs text-muted-foreground">Amount</span>
|
||||||
|
<span class="font-mono text-sm font-semibold text-primary">
|
||||||
|
{{ Math.ceil(invoice.amount_msat / 1000) }} sat
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full font-mono text-xs"
|
||||||
|
@click="copy"
|
||||||
|
>
|
||||||
|
<Check v-if="copied" class="mr-2 h-3.5 w-3.5" />
|
||||||
|
<Copy v-else class="mr-2 h-3.5 w-3.5" />
|
||||||
|
{{ copied ? 'Copied' : 'Copy invoice' }}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
309
src/modules/restaurant/composables/useCheckout.ts
Normal file
309
src/modules/restaurant/composables/useCheckout.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
/**
|
||||||
|
* useCheckout — drives the customer's place-and-pay flow against
|
||||||
|
* the restaurant extension.
|
||||||
|
*
|
||||||
|
* v1 ships REST-only:
|
||||||
|
* 1. quote (msat required)
|
||||||
|
* 2. balance pre-check (sum across all restaurants in the cart)
|
||||||
|
* 3. POST /orders → { order, invoice }
|
||||||
|
* 4. (optional, customer choice) POST /api/v1/payments to settle
|
||||||
|
* the bolt11 from the customer's LNbits wallet. They can also
|
||||||
|
* skip this step and scan the QR with any other wallet — the
|
||||||
|
* extension's invoice listener marks the order paid either way.
|
||||||
|
*
|
||||||
|
* Split into two distinct actions so the UI can render the QR codes
|
||||||
|
* between place and pay, giving the customer the option to scan
|
||||||
|
* with an external wallet rather than auto-paying from LNbits:
|
||||||
|
*
|
||||||
|
* placeOrders() — runs steps 1-3, populates `state.value.placedOrders`
|
||||||
|
* payOrder(idx) — runs step 4 for one placed-order index
|
||||||
|
*
|
||||||
|
* The festival aggregator (aiolabs/restaurant#8) exercises this same
|
||||||
|
* path with N > 1 restaurants in the cart. v1 happens to ship a UI
|
||||||
|
* where N == 1, but the orchestration is multi-restaurant ready.
|
||||||
|
*
|
||||||
|
* NIP-17 transport (aiolabs/restaurant#9) plugs in via the
|
||||||
|
* `buildCreateOrder` helper — single point both REST and Nostr
|
||||||
|
* transports construct CreateOrder. Loyalty (#5) injects its
|
||||||
|
* pass-through fields the same way.
|
||||||
|
*/
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import appConfig from '@/app.config'
|
||||||
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
import { useCartStore, type CartLine } from '../stores/cart'
|
||||||
|
import type {
|
||||||
|
CreateOrder,
|
||||||
|
CreateOrderItem,
|
||||||
|
Order,
|
||||||
|
OrderInvoice,
|
||||||
|
PlaceOrderResponse,
|
||||||
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
export interface PlacedOrder {
|
||||||
|
restaurantId: string
|
||||||
|
restaurantSlug: string
|
||||||
|
order: Order
|
||||||
|
invoice: OrderInvoice | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutState {
|
||||||
|
step:
|
||||||
|
| 'idle'
|
||||||
|
| 'quoting'
|
||||||
|
| 'placing'
|
||||||
|
| 'placed'
|
||||||
|
| 'paying'
|
||||||
|
| 'paid'
|
||||||
|
| 'error'
|
||||||
|
progress: { current: number; total: number }
|
||||||
|
currentRestaurantSlug: string | null
|
||||||
|
placedOrders: PlacedOrder[]
|
||||||
|
/** Set of `placedOrders[i].order.id` that have been auto-paid
|
||||||
|
* via LNbits in this session. External-wallet payments don't
|
||||||
|
* populate this — they're detected via the per-order poller in
|
||||||
|
* CheckoutPage. */
|
||||||
|
paidOrderIds: Set<string>
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCheckoutReturn {
|
||||||
|
state: Ref<CheckoutState>
|
||||||
|
/** Run quote → balance precheck → POST /orders for every cart
|
||||||
|
* bucket. Returns the placed orders (also persisted in state). */
|
||||||
|
placeOrders: () => Promise<PlacedOrder[]>
|
||||||
|
/** Pay one already-placed order's bolt11 from the customer's
|
||||||
|
* LNbits wallet. Idempotent — calling twice on the same order
|
||||||
|
* is a no-op after the first success. */
|
||||||
|
payOrder: (placedIndex: number) => Promise<void>
|
||||||
|
/** Pay every unpaid placed-order in sequence. Best-effort: if
|
||||||
|
* one fails, earlier successes stay paid. */
|
||||||
|
payAll: () => Promise<void>
|
||||||
|
/** Reset to idle and drop placed/paid state. */
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single point CreateOrder is built — keeps loyalty (#5), NIP-17
|
||||||
|
* transport (#9), and tip overrides one-place changes rather than
|
||||||
|
* touching the whole flow.
|
||||||
|
*/
|
||||||
|
function buildCreateOrder(
|
||||||
|
restaurantId: string,
|
||||||
|
customerPubkey: string | undefined,
|
||||||
|
lines: CartLine[]
|
||||||
|
): CreateOrder {
|
||||||
|
const items: CreateOrderItem[] = lines.map((l) => ({
|
||||||
|
menu_item_id: l.menu_item_id,
|
||||||
|
quantity: l.quantity,
|
||||||
|
selected_modifiers: l.selected_modifiers,
|
||||||
|
note: l.note ?? undefined,
|
||||||
|
}))
|
||||||
|
// Loyalty (#5) future-extension point: when implemented, inject
|
||||||
|
// { loyalty_credits_msat, loyalty_pubkey } into extra.fields here.
|
||||||
|
return {
|
||||||
|
restaurant_id: restaurantId,
|
||||||
|
customer_pubkey: customerPubkey || null,
|
||||||
|
items,
|
||||||
|
channel: 'rest',
|
||||||
|
payment_method: 'lightning',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankState(): CheckoutState {
|
||||||
|
return {
|
||||||
|
step: 'idle',
|
||||||
|
progress: { current: 0, total: 0 },
|
||||||
|
currentRestaurantSlug: null,
|
||||||
|
placedOrders: [],
|
||||||
|
paidOrderIds: new Set(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCheckout(): UseCheckoutReturn {
|
||||||
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
|
const cart = useCartStore()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// We talk to LNbits's payments endpoint directly rather than
|
||||||
|
// pulling in the whole `wallet` module — the restaurant-app
|
||||||
|
// bundle is a customer surface, not a wallet UI.
|
||||||
|
const apiBaseUrl =
|
||||||
|
(
|
||||||
|
appConfig.modules.restaurant as
|
||||||
|
| { config?: { apiBaseUrl?: string } }
|
||||||
|
| undefined
|
||||||
|
)?.config?.apiBaseUrl || ''
|
||||||
|
|
||||||
|
const state = ref<CheckoutState>(blankState())
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
state.value = blankState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// place //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
async function placeOrders(): Promise<PlacedOrder[]> {
|
||||||
|
const buckets = cart.restaurantsInCart.map((rid) => ({
|
||||||
|
restaurantId: rid,
|
||||||
|
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||||
|
lines: cart.linesFor(rid),
|
||||||
|
}))
|
||||||
|
if (!buckets.length) {
|
||||||
|
throw new Error('Cart is empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = {
|
||||||
|
...blankState(),
|
||||||
|
step: 'quoting',
|
||||||
|
progress: { current: 0, total: buckets.length },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Quote per restaurant.
|
||||||
|
const quotes: Array<{
|
||||||
|
restaurantId: string
|
||||||
|
restaurantSlug: string
|
||||||
|
lines: CartLine[]
|
||||||
|
msat: number
|
||||||
|
}> = []
|
||||||
|
for (let i = 0; i < buckets.length; i++) {
|
||||||
|
const b = buckets[i]
|
||||||
|
state.value.currentRestaurantSlug = b.restaurantSlug
|
||||||
|
state.value.progress = { current: i, total: buckets.length }
|
||||||
|
const quote = await api.quoteOrder(
|
||||||
|
b.lines.map((l) => ({
|
||||||
|
menu_item_id: l.menu_item_id,
|
||||||
|
quantity: l.quantity,
|
||||||
|
selected_modifiers: l.selected_modifiers,
|
||||||
|
note: l.note ?? undefined,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
quotes.push({ ...b, msat: quote.required_msat })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Balance pre-check.
|
||||||
|
const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0)
|
||||||
|
const wallet0 = user.value?.wallets?.[0]
|
||||||
|
if (wallet0 && typeof wallet0.balance_msat === 'number') {
|
||||||
|
if (wallet0.balance_msat < totalMsatRequired) {
|
||||||
|
const needSat = Math.ceil(totalMsatRequired / 1000)
|
||||||
|
const haveSat = Math.floor(wallet0.balance_msat / 1000)
|
||||||
|
// Not fatal — the customer may still want to scan the QR
|
||||||
|
// and pay from an external wallet. Surface a warning but
|
||||||
|
// continue.
|
||||||
|
console.warn(
|
||||||
|
`[restaurant] LNbits wallet balance is below the cart total ` +
|
||||||
|
`(have ${haveSat} sat, need ${needSat} sat). Auto-pay will ` +
|
||||||
|
`fail; scan the QR with an external wallet to settle.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Place orders.
|
||||||
|
state.value.step = 'placing'
|
||||||
|
const placed: PlacedOrder[] = []
|
||||||
|
for (let i = 0; i < quotes.length; i++) {
|
||||||
|
const q = quotes[i]
|
||||||
|
state.value.currentRestaurantSlug = q.restaurantSlug
|
||||||
|
state.value.progress = { current: i, total: quotes.length }
|
||||||
|
const payload = buildCreateOrder(
|
||||||
|
q.restaurantId,
|
||||||
|
user.value?.pubkey,
|
||||||
|
q.lines
|
||||||
|
)
|
||||||
|
const result: PlaceOrderResponse = await api.placeOrder(payload)
|
||||||
|
placed.push({
|
||||||
|
restaurantId: q.restaurantId,
|
||||||
|
restaurantSlug: q.restaurantSlug,
|
||||||
|
order: result.order,
|
||||||
|
invoice: result.invoice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.step = 'placed'
|
||||||
|
state.value.placedOrders = placed
|
||||||
|
state.value.currentRestaurantSlug = null
|
||||||
|
return placed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// pay //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
async function payBolt11Raw(
|
||||||
|
bolt11: string,
|
||||||
|
adminkey: string
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api/v1/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': adminkey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ out: true, bolt11 }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = response.statusText
|
||||||
|
try {
|
||||||
|
const body = await response.json()
|
||||||
|
if (body?.detail) detail = body.detail
|
||||||
|
} catch {
|
||||||
|
/* body wasn't JSON */
|
||||||
|
}
|
||||||
|
throw new Error(`Payment failed: ${response.status} ${detail}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payOrder(placedIndex: number): Promise<void> {
|
||||||
|
const placed = state.value.placedOrders[placedIndex]
|
||||||
|
if (!placed) throw new Error(`No placed order at index ${placedIndex}`)
|
||||||
|
if (!placed.invoice) return // cash orders skip payment
|
||||||
|
if (state.value.paidOrderIds.has(placed.order.id)) return // already paid
|
||||||
|
|
||||||
|
const adminkey = user.value?.wallets?.[0]?.adminkey
|
||||||
|
if (!adminkey) {
|
||||||
|
throw new Error('No wallet available — please log in first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.step = 'paying'
|
||||||
|
state.value.currentRestaurantSlug = placed.restaurantSlug
|
||||||
|
try {
|
||||||
|
await payBolt11Raw(placed.invoice.bolt11, adminkey)
|
||||||
|
// Set semantics keeps `paidOrderIds` from re-renders; rebuild
|
||||||
|
// it on update so Vue picks up the change.
|
||||||
|
state.value.paidOrderIds = new Set([
|
||||||
|
...state.value.paidOrderIds,
|
||||||
|
placed.order.id,
|
||||||
|
])
|
||||||
|
// Bump to 'paid' only when every placed order is paid.
|
||||||
|
if (
|
||||||
|
state.value.placedOrders.every((p) =>
|
||||||
|
state.value.paidOrderIds.has(p.order.id)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
state.value.step = 'paid'
|
||||||
|
} else {
|
||||||
|
state.value.step = 'placed'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
state.value.step = 'error'
|
||||||
|
state.value.error = err instanceof Error ? err.message : String(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payAll(): Promise<void> {
|
||||||
|
for (let i = 0; i < state.value.placedOrders.length; i++) {
|
||||||
|
const p = state.value.placedOrders[i]
|
||||||
|
if (!p.invoice) continue
|
||||||
|
if (state.value.paidOrderIds.has(p.order.id)) continue
|
||||||
|
await payOrder(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, placeOrders, payOrder, payAll, reset }
|
||||||
|
}
|
||||||
124
src/modules/restaurant/composables/useMenu.ts
Normal file
124
src/modules/restaurant/composables/useMenu.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
|
||||||
|
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 with the Nostr live overlay merged in. */
|
||||||
|
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 sync = tryInjectService<RestaurantNostrSync>(
|
||||||
|
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
|
||||||
|
)
|
||||||
|
|
||||||
|
const restaurant = ref<Restaurant | null>(null)
|
||||||
|
const tree = ref<MenuNode[]>([])
|
||||||
|
const baseItems = 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
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `items` exposes the base REST snapshot patched with the live
|
||||||
|
* overlay from RestaurantNostrSync. Operator edits on the
|
||||||
|
* extension side surface here within ~1s of arriving at the
|
||||||
|
* relay, without a refetch.
|
||||||
|
*/
|
||||||
|
const items = computed<EnrichedMenuItem[]>(() => {
|
||||||
|
const overlay = sync?.overlay
|
||||||
|
const deleted = sync?.deleted
|
||||||
|
if (!overlay && !deleted) return baseItems.value
|
||||||
|
return baseItems.value
|
||||||
|
.filter((it) => !(deleted && deleted.has(it.id)))
|
||||||
|
.map((it) => {
|
||||||
|
const patch = overlay?.get(it.id)
|
||||||
|
return patch ? ({ ...it, ...patch } as EnrichedMenuItem) : it
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
baseItems.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 }
|
||||||
|
}
|
||||||
154
src/modules/restaurant/composables/useOrder.ts
Normal file
154
src/modules/restaurant/composables/useOrder.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* useOrder(orderId) — polls the restaurant extension for status
|
||||||
|
* updates on a single order.
|
||||||
|
*
|
||||||
|
* Polls every `orderPollMs` (from app.config.modules.restaurant)
|
||||||
|
* while status is in a non-terminal state. Resets to an immediate
|
||||||
|
* fetch on `VisibilityService.onVisible` so a backgrounded tab
|
||||||
|
* catches up the moment it comes back. Cleans the interval on
|
||||||
|
* scope dispose.
|
||||||
|
*
|
||||||
|
* Status is treated as an open string (see KNOWN_ORDER_STATUSES)
|
||||||
|
* — production / kitchen workflow (aiolabs/restaurant#4) may
|
||||||
|
* introduce new states.
|
||||||
|
*/
|
||||||
|
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||||
|
import {
|
||||||
|
injectService,
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import appConfig from '@/app.config'
|
||||||
|
import type { VisibilityService } from '@/core/services/VisibilityService'
|
||||||
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
import type {
|
||||||
|
Order,
|
||||||
|
OrderItemRow,
|
||||||
|
OrderStatus,
|
||||||
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES: OrderStatus[] = [
|
||||||
|
'completed',
|
||||||
|
'canceled',
|
||||||
|
'refunded',
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface UseOrderReturn {
|
||||||
|
order: Ref<Order | null>
|
||||||
|
items: Ref<OrderItemRow[]>
|
||||||
|
isLoading: Ref<boolean>
|
||||||
|
error: Ref<Error | null>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrder(orderId: Ref<string> | string): UseOrderReturn {
|
||||||
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
|
const visibility = tryInjectService<VisibilityService>(
|
||||||
|
SERVICE_TOKENS.VISIBILITY_SERVICE
|
||||||
|
)
|
||||||
|
|
||||||
|
const pollMs =
|
||||||
|
(
|
||||||
|
appConfig.modules.restaurant as
|
||||||
|
| { config?: { orderPollMs?: number } }
|
||||||
|
| undefined
|
||||||
|
)?.config?.orderPollMs ?? 5000
|
||||||
|
|
||||||
|
const order = ref<Order | null>(null)
|
||||||
|
const items = ref<OrderItemRow[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let unregisterVisibility: (() => void) | null = null
|
||||||
|
|
||||||
|
function targetId(): string {
|
||||||
|
return typeof orderId === 'string' ? orderId : orderId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnce(): Promise<void> {
|
||||||
|
const id = targetId()
|
||||||
|
if (!id) return
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.getOrder(id)
|
||||||
|
order.value = data.order
|
||||||
|
items.value = data.items
|
||||||
|
error.value = null
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err : new Error(String(err))
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(status: OrderStatus | undefined): boolean {
|
||||||
|
return !!status && TERMINAL_STATUSES.includes(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(): void {
|
||||||
|
if (timer) return
|
||||||
|
timer = setInterval(async () => {
|
||||||
|
if (isTerminal(order.value?.status)) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await fetchOnce()
|
||||||
|
}, pollMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch immediately when the tab becomes visible again — useful
|
||||||
|
// for mobile where polling pauses during background. The
|
||||||
|
// VisibilityService takes (name, onResume, onPause) and returns
|
||||||
|
// an unregister fn.
|
||||||
|
if (visibility) {
|
||||||
|
unregisterVisibility = visibility.registerService(
|
||||||
|
`useOrder-${typeof orderId === 'string' ? orderId : 'ref'}`,
|
||||||
|
async () => {
|
||||||
|
await fetchOnce()
|
||||||
|
if (
|
||||||
|
!isTerminal((order.value as Order | null)?.status)
|
||||||
|
) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
// pause polling while hidden — saves battery on mobile
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => targetId(),
|
||||||
|
async (id) => {
|
||||||
|
stopPolling()
|
||||||
|
order.value = null
|
||||||
|
items.value = []
|
||||||
|
if (!id) return
|
||||||
|
await fetchOnce()
|
||||||
|
if (!isTerminal((order.value as Order | null)?.status)) startPolling()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
stopPolling()
|
||||||
|
unregisterVisibility?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchOnce,
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/modules/restaurant/index.ts
Normal file
139
src/modules/restaurant/index.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import type { App } from 'vue'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import type { ModulePlugin } from '@/core/types'
|
||||||
|
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
|
||||||
|
import { RestaurantAPI } from './services/RestaurantAPI'
|
||||||
|
import { RestaurantNostrSync } from './services/RestaurantNostrSync'
|
||||||
|
|
||||||
|
// v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug).
|
||||||
|
//
|
||||||
|
// Feature-roadmap context (do NOT build in v1; see issues on
|
||||||
|
// aiolabs/restaurant):
|
||||||
|
// #1 PDF menu, #2 tier modes, #3 inventory, #4 kitchen workflow,
|
||||||
|
// #5 loyalty, #6 cost-of-goods, #7 deployment/monetization,
|
||||||
|
// #8 festival/aggregator (NIP-51), #9 NIP-17 order transport.
|
||||||
|
//
|
||||||
|
// Future-compatibility scaffolding baked in even at v1:
|
||||||
|
// • Cart store keys by restaurant_id (multi-restaurant ready
|
||||||
|
// for #8 without a refactor).
|
||||||
|
// • OrderStatus is an open string type (#4 may add states).
|
||||||
|
// • MenuItem.extra carries forward-compatible metadata for
|
||||||
|
// inventory (#3), cost-of-goods (#6), loyalty (#5),
|
||||||
|
// mode-gated badges (#2).
|
||||||
|
// • Module config has a `features: Record<string, boolean>`
|
||||||
|
// slot reserved for tier gating (#2).
|
||||||
|
// • useCheckout builds CreateOrder through a single
|
||||||
|
// buildCreateOrder() helper so loyalty (#5) can inject
|
||||||
|
// loyalty fields without rewriting the flow.
|
||||||
|
|
||||||
|
export interface RestaurantModuleConfig {
|
||||||
|
apiBaseUrl: string
|
||||||
|
defaultSlug: string
|
||||||
|
orderPollMs: number
|
||||||
|
currencyDisplay: 'sats' | 'msat'
|
||||||
|
features: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaurant Module Plugin (v1 skeleton).
|
||||||
|
*
|
||||||
|
* The real surface — types/RestaurantAPI/views/cart/checkout/Nostr —
|
||||||
|
* lands across commits 3–8. This file is the lifecycle anchor and
|
||||||
|
* the route table.
|
||||||
|
*/
|
||||||
|
export const restaurantModule: ModulePlugin = {
|
||||||
|
name: 'restaurant',
|
||||||
|
version: '0.1.0',
|
||||||
|
dependencies: ['base'],
|
||||||
|
|
||||||
|
async install(_app: App, options?: { config?: RestaurantModuleConfig }) {
|
||||||
|
console.log('🍴 Installing restaurant module…')
|
||||||
|
|
||||||
|
if (!options?.config) {
|
||||||
|
throw new Error('Restaurant module requires configuration')
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST client. Initialized lazily — onInitialize() is a no-op
|
||||||
|
// (no async dependencies); failures here would only fire if
|
||||||
|
// the appConfig is malformed and we want to know about that.
|
||||||
|
const restaurantAPI = new RestaurantAPI()
|
||||||
|
container.provide(SERVICE_TOKENS.RESTAURANT_API, restaurantAPI)
|
||||||
|
await restaurantAPI
|
||||||
|
.initialize({ waitForDependencies: true, maxRetries: 1 })
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('🍴 RestaurantAPI init deferred:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nostr live-overlay sync. Requires RelayHub from baseModule;
|
||||||
|
// BaseService.waitForDependencies handles the timing if base
|
||||||
|
// initialization hasn't quite landed by the time we get here.
|
||||||
|
const restaurantNostrSync = new RestaurantNostrSync()
|
||||||
|
container.provide(
|
||||||
|
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC,
|
||||||
|
restaurantNostrSync
|
||||||
|
)
|
||||||
|
await restaurantNostrSync
|
||||||
|
.initialize({ waitForDependencies: true, maxRetries: 3 })
|
||||||
|
.catch((error) => {
|
||||||
|
// No-overlay mode is fine: REST still works, the menu just
|
||||||
|
// doesn't reflect operator edits without a page refresh.
|
||||||
|
console.warn('🍴 RestaurantNostrSync init deferred:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Restaurant module installed')
|
||||||
|
},
|
||||||
|
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'restaurant-home',
|
||||||
|
component: () => import('./views/HomePage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Restaurant' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/r/:slug',
|
||||||
|
name: 'restaurant-menu',
|
||||||
|
component: () => import('./views/RestaurantPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Menu' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/r/:slug/item/:itemId',
|
||||||
|
name: 'restaurant-item',
|
||||||
|
component: () => import('./views/ItemPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Item' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/cart',
|
||||||
|
name: 'restaurant-cart',
|
||||||
|
component: () => import('./views/CartPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Cart' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/checkout',
|
||||||
|
name: 'restaurant-checkout',
|
||||||
|
component: () => import('./views/CheckoutPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Checkout' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/orders/:id',
|
||||||
|
name: 'restaurant-order',
|
||||||
|
component: () => import('./views/OrderStatusPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Order' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/orders',
|
||||||
|
name: 'restaurant-orders',
|
||||||
|
component: () => import('./views/OrdersListPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Orders' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'restaurant-settings',
|
||||||
|
component: () => import('./views/SettingsPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Settings' },
|
||||||
|
},
|
||||||
|
] as RouteRecordRaw[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default restaurantModule
|
||||||
170
src/modules/restaurant/services/RestaurantAPI.ts
Normal file
170
src/modules/restaurant/services/RestaurantAPI.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* Typed REST client for the LNbits "restaurant" extension.
|
||||||
|
*
|
||||||
|
* Mirrors the surface in ~/dev/shared/extensions/restaurant/views_api.py.
|
||||||
|
* Public read endpoints (`/restaurants/by-slug/{slug}`,
|
||||||
|
* `/restaurants/{id}/menu`, `/menu_items/{id}`) and customer order
|
||||||
|
* endpoints (`/orders/quote`, `/orders`, `/orders/{id}`) need no API
|
||||||
|
* key; `customer_pubkey` rides in the request body as optional
|
||||||
|
* metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
|
import appConfig from '@/app.config'
|
||||||
|
import type {
|
||||||
|
CreateOrder,
|
||||||
|
CreateOrderItem,
|
||||||
|
MenuResponse,
|
||||||
|
MenuItem,
|
||||||
|
Order,
|
||||||
|
OrderInvoice,
|
||||||
|
OrderQuote,
|
||||||
|
OrderWithItems,
|
||||||
|
PlaceOrderResponse,
|
||||||
|
Restaurant,
|
||||||
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
export class RestaurantAPI extends BaseService {
|
||||||
|
protected readonly metadata = {
|
||||||
|
name: 'RestaurantAPI',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: [] as string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseUrl: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
const config = (
|
||||||
|
appConfig.modules.restaurant as
|
||||||
|
| { config?: { apiBaseUrl?: string } }
|
||||||
|
| undefined
|
||||||
|
)?.config
|
||||||
|
if (!config?.apiBaseUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'RestaurantAPI: Missing apiBaseUrl in restaurant module config'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.baseUrl = config.apiBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
this.debug('RestaurantAPI initialized with base URL:', this.baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// request helper //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}/restaurant/api/v1${endpoint}`
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (options.headers) {
|
||||||
|
Object.assign(headers, options.headers as Record<string, string>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...options, headers })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = response.statusText
|
||||||
|
try {
|
||||||
|
const body = await response.json()
|
||||||
|
if (body?.detail) detail = body.detail
|
||||||
|
} catch {
|
||||||
|
// body wasn't JSON, fall through
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`RestaurantAPI ${options.method || 'GET'} ${endpoint} ` +
|
||||||
|
`failed: ${response.status} ${detail}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// Restaurants //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
/** Resolve a URL slug → Restaurant payload. Used by /r/:slug. */
|
||||||
|
async getRestaurantBySlug(slug: string): Promise<Restaurant> {
|
||||||
|
return this.request<Restaurant>(
|
||||||
|
`/restaurants/by-slug/${encodeURIComponent(slug)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRestaurantById(id: string): Promise<Restaurant> {
|
||||||
|
return this.request<Restaurant>(
|
||||||
|
`/restaurants/${encodeURIComponent(id)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full hydrated menu — returns `{restaurant, tree, items}` where
|
||||||
|
* `tree` is the rooted MenuNode tree with children + items attached
|
||||||
|
* and `items` is the flat enriched list (modifier groups + options
|
||||||
|
* + availability windows pre-joined).
|
||||||
|
*/
|
||||||
|
async getMenu(restaurantId: string): Promise<MenuResponse> {
|
||||||
|
return this.request<MenuResponse>(
|
||||||
|
`/restaurants/${encodeURIComponent(restaurantId)}/menu`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMenuItem(itemId: string): Promise<MenuItem> {
|
||||||
|
return this.request<MenuItem>(
|
||||||
|
`/menu_items/${encodeURIComponent(itemId)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// Orders //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-flight balance check. Returns the msat the customer needs to
|
||||||
|
* cover this cart at one restaurant. Called per-restaurant by the
|
||||||
|
* webapp before opening any invoice — so a customer with
|
||||||
|
* insufficient funds gets one clean error rather than partially
|
||||||
|
* paid carts.
|
||||||
|
*/
|
||||||
|
async quoteOrder(items: CreateOrderItem[]): Promise<OrderQuote> {
|
||||||
|
return this.request<OrderQuote>('/orders/quote', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place an order against one restaurant. Returns
|
||||||
|
* { order, invoice }
|
||||||
|
* where `invoice` is null for cash orders and the bolt11 payload
|
||||||
|
* otherwise. Pay the bolt11 with WalletService.sendPayment to
|
||||||
|
* settle.
|
||||||
|
*/
|
||||||
|
async placeOrder(payload: CreateOrder): Promise<PlaceOrderResponse> {
|
||||||
|
return this.request<PlaceOrderResponse>('/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrder(orderId: string): Promise<OrderWithItems> {
|
||||||
|
return this.request<OrderWithItems>(
|
||||||
|
`/orders/${encodeURIComponent(orderId)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export Order type for consumers reaching this surface for status
|
||||||
|
// strings — keeps the import chain shallow in views/composables.
|
||||||
|
export type { Order, OrderInvoice }
|
||||||
211
src/modules/restaurant/services/RestaurantNostrSync.ts
Normal file
211
src/modules/restaurant/services/RestaurantNostrSync.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
/**
|
||||||
|
* RestaurantNostrSync — live overlay for menu item state via NIP-99.
|
||||||
|
*
|
||||||
|
* Subscribes to the restaurant's `nostr_pubkey` for:
|
||||||
|
* - kind 30402 (NIP-99 classified listings) tagged
|
||||||
|
* `["l", "restaurant:<restaurant.id>"]`
|
||||||
|
* - kind 5 (NIP-09 deletion requests)
|
||||||
|
*
|
||||||
|
* Each incoming 30402 is parsed into a partial MenuItem patch keyed
|
||||||
|
* by the `d` tag (= menu item id) and pushed to a reactive overlay
|
||||||
|
* map. `useMenu` merges this overlay into its `items` computed so
|
||||||
|
* price changes, sold-out flips, and availability updates render
|
||||||
|
* within ~1s of the operator's edit on the extension side.
|
||||||
|
*
|
||||||
|
* Subscription lifecycle is owned by the *consumer* (RestaurantPage
|
||||||
|
* opens on mount, closes on route leave). Visibility integration is
|
||||||
|
* handled implicitly by the RelayHub — backgrounded tabs lose the
|
||||||
|
* underlying WebSocket; we re-subscribe on the next mount.
|
||||||
|
*
|
||||||
|
* This service holds NO state about which restaurants are subscribed
|
||||||
|
* — it expects callers to track their own sub ids if they need to
|
||||||
|
* tear them down individually.
|
||||||
|
*/
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import type { Filter } from 'nostr-tools'
|
||||||
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
|
import type { MenuItem } from '../types/restaurant'
|
||||||
|
|
||||||
|
interface NostrEvent {
|
||||||
|
id: string
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
sig?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_KIND_LISTING = 30402
|
||||||
|
const SUB_KIND_DELETION = 5
|
||||||
|
|
||||||
|
export type MenuItemPatch = Partial<
|
||||||
|
Pick<
|
||||||
|
MenuItem,
|
||||||
|
'name' | 'description' | 'price' | 'is_available' | 'stock' | 'nostr_event_id' | 'nostr_event_created_at'
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
|
export class RestaurantNostrSync extends BaseService {
|
||||||
|
protected readonly metadata = {
|
||||||
|
name: 'RestaurantNostrSync',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: ['RelayHub'] as string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive overlay: itemId → partial patch. `useMenu` watches
|
||||||
|
* this and merges into its `items` array.
|
||||||
|
*/
|
||||||
|
readonly overlay = reactive(new Map<string, MenuItemPatch>())
|
||||||
|
|
||||||
|
/** Deleted item ids — useMenu filters these out. */
|
||||||
|
readonly deleted = reactive(new Set<string>())
|
||||||
|
|
||||||
|
// BaseService auto-populates `this.relayHub` from
|
||||||
|
// SERVICE_TOKENS.RELAY_HUB because our `metadata.dependencies`
|
||||||
|
// includes 'RelayHub' — no manual inject needed in onInitialize.
|
||||||
|
|
||||||
|
private unsubscribers = new Map<string, () => void>()
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
this.debug('RestaurantNostrSync ready')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed accessor for the BaseService-injected relay hub. */
|
||||||
|
private get hub(): RelayHub | null {
|
||||||
|
return (this.relayHub as RelayHub | null) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the per-restaurant subscription. Returns an unsubscribe
|
||||||
|
* fn for convenience; calling subscribe() again with the same
|
||||||
|
* restaurantId is a no-op (idempotent).
|
||||||
|
*/
|
||||||
|
subscribe(restaurantPubkey: string, restaurantId: string): () => void {
|
||||||
|
const hub = this.hub
|
||||||
|
if (!hub) {
|
||||||
|
this.debug('subscribe: relay hub not ready')
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
if (this.unsubscribers.has(restaurantId)) {
|
||||||
|
return () => this.unsubscribe(restaurantId)
|
||||||
|
}
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [SUB_KIND_LISTING, SUB_KIND_DELETION],
|
||||||
|
authors: [restaurantPubkey],
|
||||||
|
'#l': [`restaurant:${restaurantId}`],
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const offEvent = hub.subscribe({
|
||||||
|
id: `restaurant-${restaurantId}`,
|
||||||
|
filters: [filter],
|
||||||
|
onEvent: (event) => this.handleEvent(event as NostrEvent),
|
||||||
|
})
|
||||||
|
this.unsubscribers.set(restaurantId, offEvent)
|
||||||
|
this.debug(`subscribed authors=${restaurantPubkey.slice(0, 8)}…`)
|
||||||
|
} catch (err) {
|
||||||
|
// RelayHub throws if not connected. The user can still browse
|
||||||
|
// via REST — this just means no live updates this session.
|
||||||
|
this.debug(`subscribe failed (live overlay disabled): ${String(err)}`)
|
||||||
|
}
|
||||||
|
return () => this.unsubscribe(restaurantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(restaurantId: string): void {
|
||||||
|
const off = this.unsubscribers.get(restaurantId)
|
||||||
|
if (off) {
|
||||||
|
off()
|
||||||
|
this.unsubscribers.delete(restaurantId)
|
||||||
|
this.debug(`unsubscribed ${restaurantId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop all subscriptions and clear the overlay. BaseService
|
||||||
|
* defines this as async; we honor the signature even though our
|
||||||
|
* cleanup is synchronous.
|
||||||
|
*/
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
for (const off of this.unsubscribers.values()) off()
|
||||||
|
this.unsubscribers.clear()
|
||||||
|
this.overlay.clear()
|
||||||
|
this.deleted.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// event handlers //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
private handleEvent(event: NostrEvent): void {
|
||||||
|
if (event.kind === SUB_KIND_LISTING) {
|
||||||
|
this.handleListing(event)
|
||||||
|
} else if (event.kind === SUB_KIND_DELETION) {
|
||||||
|
this.handleDeletion(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleListing(event: NostrEvent): void {
|
||||||
|
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return
|
||||||
|
|
||||||
|
// Skip if we've already seen a newer version of this addressable
|
||||||
|
// event (NIP-33 replaceable semantics — operator-side bug
|
||||||
|
// protection).
|
||||||
|
const existing = this.overlay.get(dTag)
|
||||||
|
if (
|
||||||
|
existing?.nostr_event_created_at &&
|
||||||
|
existing.nostr_event_created_at >= event.created_at
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: MenuItemPatch = {
|
||||||
|
nostr_event_id: event.id,
|
||||||
|
nostr_event_created_at: event.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = event.tags.find((t) => t[0] === 'title')?.[1]
|
||||||
|
if (title) patch.name = title
|
||||||
|
|
||||||
|
const summary = event.tags.find((t) => t[0] === 'summary')?.[1]
|
||||||
|
if (summary) patch.description = summary
|
||||||
|
|
||||||
|
const priceTag = event.tags.find((t) => t[0] === 'price')
|
||||||
|
if (priceTag && priceTag[1]) {
|
||||||
|
const parsed = parseFloat(priceTag[1])
|
||||||
|
if (!Number.isNaN(parsed)) patch.price = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-99 status: 'active' | 'sold'. We map to is_available +
|
||||||
|
// stock=0 so the existing UI badges (sold out / low stock)
|
||||||
|
// render consistently.
|
||||||
|
const statusTag = event.tags.find((t) => t[0] === 'status')?.[1]
|
||||||
|
if (statusTag === 'sold') {
|
||||||
|
patch.is_available = false
|
||||||
|
patch.stock = 0
|
||||||
|
} else if (statusTag === 'active') {
|
||||||
|
patch.is_available = true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay.set(dTag, patch)
|
||||||
|
this.debug(`overlay merge id=${dTag.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDeletion(event: NostrEvent): void {
|
||||||
|
// NIP-09: an `a` tag references the addressable event the author
|
||||||
|
// is deleting. Format: 'kind:pubkey:dTag'.
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] !== 'a' || !tag[1]) continue
|
||||||
|
const parts = tag[1].split(':')
|
||||||
|
if (parts[0] !== String(SUB_KIND_LISTING)) continue
|
||||||
|
const dTag = parts[2]
|
||||||
|
if (dTag) {
|
||||||
|
this.deleted.add(dTag)
|
||||||
|
this.overlay.delete(dTag)
|
||||||
|
this.debug(`deletion id=${dTag.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/modules/restaurant/stores/cart.ts
Normal file
261
src/modules/restaurant/stores/cart.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
/**
|
||||||
|
* 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 the menu item's
|
||||||
|
* declared currency** (e.g. 25 for "25 GTQ" or 100 for "100 sat").
|
||||||
|
* Authoritative sat conversion happens server-side via
|
||||||
|
* `POST /orders/quote`; the cart's value is for display only.
|
||||||
|
*/
|
||||||
|
unit_price: number
|
||||||
|
/** ISO-ish currency code from the menu item, e.g. "GTQ", "USD",
|
||||||
|
* "sat". Used for cart-side display labels. */
|
||||||
|
currency: string
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Per-restaurant subtotal in that restaurant's declared currency. */
|
||||||
|
const restaurantTotals = computed<
|
||||||
|
Record<string, { amount: number; currency: string }>
|
||||||
|
>(() => {
|
||||||
|
const out: Record<string, { amount: number; currency: string }> = {}
|
||||||
|
for (const rid of Object.keys(lines.value)) {
|
||||||
|
const bucket = lines.value[rid]
|
||||||
|
if (!bucket.length) continue
|
||||||
|
out[rid] = {
|
||||||
|
amount: bucket.reduce(
|
||||||
|
(s, l) => s + l.unit_price * l.quantity,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
currency: bucket[0].currency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-currency cart subtotal. Returns null if the cart spans
|
||||||
|
* multiple currencies (e.g. one restaurant priced in GTQ + another
|
||||||
|
* in USD via the future festival aggregator) — the UI then falls
|
||||||
|
* back to per-restaurant subtotals only.
|
||||||
|
*/
|
||||||
|
const grandTotal = computed<{ amount: number; currency: string } | null>(
|
||||||
|
() => {
|
||||||
|
const totals = Object.values(restaurantTotals.value)
|
||||||
|
if (!totals.length) return null
|
||||||
|
const currencies = new Set(totals.map((t) => t.currency))
|
||||||
|
if (currencies.size !== 1) return null
|
||||||
|
return {
|
||||||
|
amount: totals.reduce((s, t) => s + t.amount, 0),
|
||||||
|
currency: totals[0].currency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
restaurantTotals,
|
||||||
|
grandTotal,
|
||||||
|
linesFor,
|
||||||
|
// actions
|
||||||
|
setActiveRestaurant,
|
||||||
|
addLine,
|
||||||
|
setQty,
|
||||||
|
incrementQty,
|
||||||
|
decrementQty,
|
||||||
|
removeLine,
|
||||||
|
clearRestaurant,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
336
src/modules/restaurant/types/restaurant.ts
Normal file
336
src/modules/restaurant/types/restaurant.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
/**
|
||||||
|
* TypeScript types mirroring the Python pydantic models in
|
||||||
|
* ~/dev/shared/extensions/restaurant/models.py.
|
||||||
|
*
|
||||||
|
* Hand-translated (no OpenAPI codegen on day one). Money on orders
|
||||||
|
* and order items is integer **msat** end-to-end, matching the
|
||||||
|
* extension. Display conversion is cosmetic via formatPrice().
|
||||||
|
*
|
||||||
|
* Future-compatibility scaffolding lives here intentionally — see
|
||||||
|
* `OrderStatus`, `MenuItem.extra`, `Restaurant.mode`. Do not tighten
|
||||||
|
* those unless you've shipped the corresponding feature on the
|
||||||
|
* extension side first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// Restaurant //
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export interface OpenHours {
|
||||||
|
// Weekday key '0'..'6' (Mon..Sun) → array of {start,end} 'HH:MM' ranges.
|
||||||
|
schedule: Record<string, Array<{ start: string; end: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialLinks {
|
||||||
|
website?: string | null
|
||||||
|
instagram?: string | null
|
||||||
|
facebook?: string | null
|
||||||
|
twitter?: string | null
|
||||||
|
nostr?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestaurantExtra {
|
||||||
|
notes?: string | null
|
||||||
|
// Plain dict — forward-compatible pass-through. See models.py.
|
||||||
|
fields: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Restaurant {
|
||||||
|
id: string
|
||||||
|
wallet: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description?: string | null
|
||||||
|
currency: string
|
||||||
|
timezone: string
|
||||||
|
location?: string | null
|
||||||
|
geohash?: string | null
|
||||||
|
logo_url?: string | null
|
||||||
|
banner_url?: string | null
|
||||||
|
social_links: SocialLinks
|
||||||
|
open_hours: OpenHours
|
||||||
|
is_open: boolean
|
||||||
|
accepts_cash: boolean
|
||||||
|
accepts_lightning: boolean
|
||||||
|
tip_presets: number[]
|
||||||
|
tax_rate: number
|
||||||
|
printer_endpoint?: string | null
|
||||||
|
nostr_pubkey?: string | null
|
||||||
|
nostr_relays: string[]
|
||||||
|
nostr_event_id?: string | null
|
||||||
|
nostr_event_created_at?: number | null
|
||||||
|
extra: RestaurantExtra
|
||||||
|
time: string // ISO 8601 from extension
|
||||||
|
// Set by the operator (aiolabs/restaurant#2: bar/bistro/full
|
||||||
|
// tiered modes). v1 webapp does not branch on it; future work
|
||||||
|
// may hide / show UI surfaces based on the venue's tier.
|
||||||
|
mode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// Menu tree //
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export interface MenuNodeRow {
|
||||||
|
id: string
|
||||||
|
restaurant_id: string
|
||||||
|
parent_id: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
sort_order: number
|
||||||
|
image_url: string | null
|
||||||
|
depth: number
|
||||||
|
path: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuNode extends MenuNodeRow {
|
||||||
|
// Hydrated only by the /menu endpoint; never persisted.
|
||||||
|
children: MenuNode[]
|
||||||
|
items: MenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// Menu items //
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export interface MenuItemExtra {
|
||||||
|
notes?: string | null
|
||||||
|
// Pass-through for forward-compatible metadata: inventory
|
||||||
|
// (aiolabs/restaurant#3), happy-hour / cost-of-goods (#6),
|
||||||
|
// loyalty (#5), mode-gated badges (#2). v1 reads but never writes.
|
||||||
|
fields: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailabilityWindow {
|
||||||
|
id: string
|
||||||
|
menu_item_id: string
|
||||||
|
weekday: number | null // 0=Mon, 6=Sun, null = every day
|
||||||
|
start_time: string // 'HH:MM'
|
||||||
|
end_time: string // 'HH:MM'
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModifierGroup {
|
||||||
|
id: string
|
||||||
|
menu_item_id: string
|
||||||
|
name: string
|
||||||
|
// 'required' | 'optional' — see services.place_order for semantics.
|
||||||
|
// Kept open so future tier features can extend (#2).
|
||||||
|
kind: string
|
||||||
|
// 'one' | 'many' (radio / multi-select).
|
||||||
|
selection: string
|
||||||
|
min_selections: number
|
||||||
|
max_selections: number | null
|
||||||
|
sort_order: number
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Modifier {
|
||||||
|
id: string
|
||||||
|
group_id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
price_delta: number
|
||||||
|
is_default: boolean
|
||||||
|
sort_order: number
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string
|
||||||
|
restaurant_id: string
|
||||||
|
node_id: string | null
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
sku: string | null
|
||||||
|
images: string[]
|
||||||
|
dietary: string[]
|
||||||
|
allergens: string[]
|
||||||
|
ingredients: string[]
|
||||||
|
calories: number | null
|
||||||
|
sort_order: number
|
||||||
|
is_available: boolean
|
||||||
|
is_featured: boolean
|
||||||
|
stock: number | null
|
||||||
|
low_stock_threshold: number | null
|
||||||
|
nostr_event_id: string | null
|
||||||
|
nostr_event_created_at: number | null
|
||||||
|
extra: MenuItemExtra
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Item with modifier groups + availability windows hydrated.
|
||||||
|
* Returned in the `items` array of `GET /restaurants/{id}/menu`. */
|
||||||
|
export interface EnrichedMenuItem extends MenuItem {
|
||||||
|
modifier_groups: Array<ModifierGroup & { modifiers: Modifier[] }>
|
||||||
|
availability_windows: AvailabilityWindow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// Orders //
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export interface SelectedModifier {
|
||||||
|
group_id?: string | null
|
||||||
|
group_name?: string | null
|
||||||
|
modifier_id?: string | null
|
||||||
|
name: string
|
||||||
|
price_delta: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderItem {
|
||||||
|
menu_item_id: string
|
||||||
|
quantity: number
|
||||||
|
selected_modifiers: SelectedModifier[]
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderExtra {
|
||||||
|
fiat: boolean
|
||||||
|
fiat_currency?: string | null
|
||||||
|
fiat_rate?: number | null
|
||||||
|
refund_address?: string | null
|
||||||
|
// Pass-through, forward-compatible. Loyalty (#5) can ride here:
|
||||||
|
// e.g. { loyalty_credits_msat, loyalty_pubkey }.
|
||||||
|
fields: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrder {
|
||||||
|
restaurant_id: string
|
||||||
|
customer_pubkey?: string | null
|
||||||
|
customer_name?: string | null
|
||||||
|
customer_contact?: string | null
|
||||||
|
items: CreateOrderItem[]
|
||||||
|
tip_msat?: number
|
||||||
|
note?: string | null
|
||||||
|
parent_order_ref?: string | null
|
||||||
|
channel?: 'rest' | 'nostr' | 'kiosk' | 'pos'
|
||||||
|
payment_method?: 'lightning' | 'cash' | 'internal'
|
||||||
|
extra?: OrderExtra
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known order statuses are listed here for UI hint mapping (icons,
|
||||||
|
* colors) — but the type is intentionally **open** so the production
|
||||||
|
* / kitchen workflow (aiolabs/restaurant#4) can introduce new states
|
||||||
|
* without breaking the build. Code that branches on status should
|
||||||
|
* use `KNOWN_ORDER_STATUSES.includes(...)` as a guard before
|
||||||
|
* assuming the styling lookup will resolve.
|
||||||
|
*/
|
||||||
|
export const KNOWN_ORDER_STATUSES = [
|
||||||
|
'pending',
|
||||||
|
'paid',
|
||||||
|
'accepted',
|
||||||
|
'ready',
|
||||||
|
'completed',
|
||||||
|
'canceled',
|
||||||
|
'refunded',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number]
|
||||||
|
export type OrderStatus = string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing labels for order statuses. The extension's raw
|
||||||
|
* status names are operational ('paid' / 'accepted' / 'ready') but
|
||||||
|
* customers prefer human-friendly framing ('Order received' /
|
||||||
|
* 'Cooking' / 'Ready for pickup').
|
||||||
|
*
|
||||||
|
* Future statuses from aiolabs/restaurant#4 (kitchen workflow) —
|
||||||
|
* 'preparing', 'plating', 'at_pass', 'in_service', etc — can land
|
||||||
|
* here as they arrive. Unknown values fall through to the raw
|
||||||
|
* status string titlecased.
|
||||||
|
*/
|
||||||
|
export const FRIENDLY_ORDER_STATUS: Record<string, string> = {
|
||||||
|
pending: 'Awaiting payment',
|
||||||
|
paid: 'Order received',
|
||||||
|
accepted: 'Cooking',
|
||||||
|
ready: 'Ready for pickup',
|
||||||
|
completed: 'Served',
|
||||||
|
canceled: 'Canceled',
|
||||||
|
refunded: 'Refunded',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyOrderStatus(status: OrderStatus): string {
|
||||||
|
if (status in FRIENDLY_ORDER_STATUS) return FRIENDLY_ORDER_STATUS[status]
|
||||||
|
// Unknown status — titlecase the raw key as a graceful fallback.
|
||||||
|
if (!status) return ''
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string
|
||||||
|
restaurant_id: string
|
||||||
|
wallet: string
|
||||||
|
customer_pubkey?: string | null
|
||||||
|
customer_name?: string | null
|
||||||
|
customer_contact?: string | null
|
||||||
|
status: OrderStatus
|
||||||
|
channel: string
|
||||||
|
payment_method: string
|
||||||
|
payment_hash?: string | null
|
||||||
|
bolt11?: string | null
|
||||||
|
subtotal_msat: number
|
||||||
|
tip_msat: number
|
||||||
|
tax_msat: number
|
||||||
|
total_msat: number
|
||||||
|
currency_display: string
|
||||||
|
fiat_amount?: number | null
|
||||||
|
fiat_rate?: number | null
|
||||||
|
note?: string | null
|
||||||
|
parent_order_ref?: string | null
|
||||||
|
paid_at?: string | null
|
||||||
|
accepted_at?: string | null
|
||||||
|
ready_at?: string | null
|
||||||
|
completed_at?: string | null
|
||||||
|
canceled_at?: string | null
|
||||||
|
extra: OrderExtra
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderItemRow {
|
||||||
|
id: string
|
||||||
|
order_id: string
|
||||||
|
menu_item_id: string | null
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
unit_price_msat: number
|
||||||
|
line_total_msat: number
|
||||||
|
selected_modifiers: SelectedModifier[]
|
||||||
|
note: string | null
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderWithItems {
|
||||||
|
order: Order
|
||||||
|
items: OrderItemRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderInvoice {
|
||||||
|
order_id: string
|
||||||
|
payment_hash: string
|
||||||
|
bolt11: string
|
||||||
|
amount_msat: number
|
||||||
|
expires_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of `POST /api/v1/orders`. */
|
||||||
|
export interface PlaceOrderResponse {
|
||||||
|
order: Order
|
||||||
|
invoice: OrderInvoice | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of `GET /api/v1/restaurants/{id}/menu`. */
|
||||||
|
export interface MenuResponse {
|
||||||
|
restaurant: Restaurant
|
||||||
|
tree: MenuNode[]
|
||||||
|
items: EnrichedMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of `POST /api/v1/orders/quote`. */
|
||||||
|
export interface OrderQuote {
|
||||||
|
required_msat: number
|
||||||
|
}
|
||||||
122
src/modules/restaurant/views/CartPage.vue
Normal file
122
src/modules/restaurant/views/CartPage.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<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),
|
||||||
|
total: cart.restaurantTotals[rid] ?? null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function fmt(value: number, currency: string): string {
|
||||||
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
|
||||||
|
}
|
||||||
|
</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">
|
||||||
|
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
|
||||||
|
</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 v-if="cart.grandTotal" 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">
|
||||||
|
{{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-muted-foreground text-center">
|
||||||
|
Cart spans multiple currencies — per-restaurant subtotals above.
|
||||||
|
Lightning sat amount shown at checkout.
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
452
src/modules/restaurant/views/CheckoutPage.vue
Normal file
452
src/modules/restaurant/views/CheckoutPage.vue
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Two-phase checkout:
|
||||||
|
*
|
||||||
|
* Phase 1 — Review:
|
||||||
|
* · cart subtotal in the menu's declared currency (e.g. GTQ)
|
||||||
|
* · live ≈sat preview from POST /orders/quote
|
||||||
|
* · "Place order" CTA → useCheckout.placeOrders()
|
||||||
|
*
|
||||||
|
* Phase 2 — Pay:
|
||||||
|
* · OrderInvoiceCard per placed order (QR + amount + copy +
|
||||||
|
* expiry countdown)
|
||||||
|
* · "Pay from my LNbits wallet" CTA → useCheckout.payAll()
|
||||||
|
* · External-wallet scans are detected via per-order polling
|
||||||
|
* (the extension's invoice listener flips the order to
|
||||||
|
* paid the moment ANY pay path settles).
|
||||||
|
* · When all placed orders show paid, auto-redirect to
|
||||||
|
* /orders/<first-id>.
|
||||||
|
*
|
||||||
|
* Cart lines are cleared per restaurant as each order is observed
|
||||||
|
* paid, so a partial-pay state can be re-checked-out without
|
||||||
|
* duplicates.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Zap,
|
||||||
|
} 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 {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import OrderInvoiceCard from '../components/OrderInvoiceCard.vue'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
|
import { useCheckout, type PlacedOrder } from '../composables/useCheckout'
|
||||||
|
import { friendlyOrderStatus } from '../types/restaurant'
|
||||||
|
import {
|
||||||
|
injectService,
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const cart = useCartStore()
|
||||||
|
const { state, placeOrders, payAll, reset } = useCheckout()
|
||||||
|
const storage = tryInjectService<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
|
||||||
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// review phase //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
function fmt(value: number, currency: string) {
|
||||||
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = computed(() =>
|
||||||
|
cart.restaurantsInCart.map((rid) => ({
|
||||||
|
restaurantId: rid,
|
||||||
|
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||||
|
lines: cart.linesFor(rid),
|
||||||
|
total: cart.restaurantTotals[rid] ?? null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Live ≈sat preview: one /orders/quote per restaurant. */
|
||||||
|
const previewSatPerRestaurant = ref<Record<string, number | null>>({})
|
||||||
|
const previewSatTotal = computed<number | null>(() => {
|
||||||
|
const vals = Object.values(previewSatPerRestaurant.value)
|
||||||
|
if (!vals.length || vals.some((v) => v === null)) return null
|
||||||
|
return (vals as number[]).reduce((s, v) => s + v, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
cart.restaurantsInCart.map((rid) => ({
|
||||||
|
rid,
|
||||||
|
lines: cart.linesFor(rid),
|
||||||
|
})),
|
||||||
|
async (groups) => {
|
||||||
|
if (state.value.step !== 'idle') return // freeze preview after place
|
||||||
|
const next: Record<string, number | null> = {}
|
||||||
|
for (const g of groups) {
|
||||||
|
try {
|
||||||
|
const q = await api.quoteOrder(
|
||||||
|
g.lines.map((l) => ({
|
||||||
|
menu_item_id: l.menu_item_id,
|
||||||
|
quantity: l.quantity,
|
||||||
|
selected_modifiers: l.selected_modifiers,
|
||||||
|
note: l.note ?? undefined,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
next[g.rid] = Math.ceil(q.required_msat / 1000)
|
||||||
|
} catch {
|
||||||
|
next[g.rid] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewSatPerRestaurant.value = next
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// place phase //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!buckets.value.length && !state.value.placedOrders.length) {
|
||||||
|
router.replace('/cart')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onPlaceOrder() {
|
||||||
|
try {
|
||||||
|
await placeOrders()
|
||||||
|
const entries = state.value.placedOrders.map((p) => ({
|
||||||
|
orderId: p.order.id,
|
||||||
|
restaurantId: p.restaurantId,
|
||||||
|
restaurantSlug: p.restaurantSlug,
|
||||||
|
placedAt: Date.now(),
|
||||||
|
totalMsat: p.order.total_msat,
|
||||||
|
}))
|
||||||
|
if (storage && entries.length) {
|
||||||
|
const existing =
|
||||||
|
storage.getUserData<typeof entries>(
|
||||||
|
'restaurant.lastOrders.v1',
|
||||||
|
[]
|
||||||
|
) || []
|
||||||
|
const merged = [...entries, ...existing].slice(0, 50)
|
||||||
|
storage.setUserData('restaurant.lastOrders.v1', merged)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
state.value.step = 'error'
|
||||||
|
state.value.error = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// pay phase + poller //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
const liveStatusByOrderId = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
function statusOf(orderId: string): string {
|
||||||
|
return (
|
||||||
|
liveStatusByOrderId.value[orderId] ||
|
||||||
|
state.value.placedOrders.find((p) => p.order.id === orderId)?.order
|
||||||
|
.status ||
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaid(orderId: string): boolean {
|
||||||
|
const s = statusOf(orderId)
|
||||||
|
return ['paid', 'accepted', 'ready', 'completed'].includes(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPaid = computed(() => {
|
||||||
|
if (!state.value.placedOrders.length) return false
|
||||||
|
return state.value.placedOrders.every((p) => isPaid(p.order.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) return
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!state.value.placedOrders.length) return
|
||||||
|
for (const p of state.value.placedOrders) {
|
||||||
|
if (isPaid(p.order.id)) continue
|
||||||
|
try {
|
||||||
|
const fresh = await api.getOrder(p.order.id)
|
||||||
|
liveStatusByOrderId.value = {
|
||||||
|
...liveStatusByOrderId.value,
|
||||||
|
[p.order.id]: fresh.order.status,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
['paid', 'accepted', 'ready', 'completed'].includes(
|
||||||
|
fresh.order.status
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
cart.clearRestaurant(p.restaurantId)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient — try again next tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(allPaid, (paid) => {
|
||||||
|
if (paid && state.value.placedOrders.length) {
|
||||||
|
stopPolling()
|
||||||
|
setTimeout(() => {
|
||||||
|
const first = state.value.placedOrders[0]
|
||||||
|
if (first) router.push(`/orders/${first.order.id}`)
|
||||||
|
}, 1200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => state.value.placedOrders.length,
|
||||||
|
(n) => {
|
||||||
|
if (n > 0) startPolling()
|
||||||
|
else stopPolling()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
const isPlacing = computed(() =>
|
||||||
|
['quoting', 'placing'].includes(state.value.step)
|
||||||
|
)
|
||||||
|
const isPaying = computed(() => state.value.step === 'paying')
|
||||||
|
|
||||||
|
async function onPayAll() {
|
||||||
|
state.value.error = null
|
||||||
|
try {
|
||||||
|
await payAll()
|
||||||
|
} catch {
|
||||||
|
// useCheckout sets state.error already.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOver() {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderInvoice(p: PlacedOrder) {
|
||||||
|
return p.invoice
|
||||||
|
}
|
||||||
|
</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"
|
||||||
|
:disabled="isPlacing || isPaying"
|
||||||
|
@click="router.back()"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 class="mb-4 text-2xl font-bold text-foreground">Checkout</h1>
|
||||||
|
|
||||||
|
<!-- Phase 1 — Review (before orders are placed) -->
|
||||||
|
<template v-if="!state.placedOrders.length">
|
||||||
|
<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">{{ b.restaurantSlug }}</CardTitle>
|
||||||
|
<span class="font-mono text-sm text-primary">
|
||||||
|
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
|
||||||
|
</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="line in b.lines"
|
||||||
|
:key="line.line_id"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-foreground">
|
||||||
|
{{ line.quantity }}× {{ line.name }}
|
||||||
|
<span
|
||||||
|
v-if="line.selected_modifiers.length"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
· {{ line.selected_modifiers.map((m) => m.name).join(', ') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-xs text-muted-foreground">
|
||||||
|
{{ fmt(line.unit_price * line.quantity, line.currency) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="previewSatPerRestaurant[b.restaurantId] != null"
|
||||||
|
class="mt-1 flex items-center justify-end gap-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Zap class="h-3 w-3" />
|
||||||
|
≈ {{ new Intl.NumberFormat().format(previewSatPerRestaurant[b.restaurantId]!) }} sat
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator class="my-6" />
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="cart.grandTotal" class="flex items-baseline justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">Total</span>
|
||||||
|
<span class="font-mono text-xl font-bold text-foreground">
|
||||||
|
{{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="previewSatTotal != null"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Zap class="h-3.5 w-3.5" />
|
||||||
|
Pay in sats
|
||||||
|
</span>
|
||||||
|
<span class="font-mono font-semibold text-foreground">
|
||||||
|
≈ {{ new Intl.NumberFormat().format(previewSatTotal) }} sat
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
v-if="state.step === 'error' && state.error"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<AlertTitle>Couldn't place order</AlertTitle>
|
||||||
|
<AlertDescription>{{ state.error }}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="isPlacing || !buckets.length"
|
||||||
|
@click="onPlaceOrder"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isPlacing" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<span v-if="state.step === 'quoting'">Quoting…</span>
|
||||||
|
<span v-else-if="state.step === 'placing'">Placing orders…</span>
|
||||||
|
<span v-else>Place order</span>
|
||||||
|
</Button>
|
||||||
|
<p
|
||||||
|
v-if="isPlacing && state.currentRestaurantSlug"
|
||||||
|
class="text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ state.currentRestaurantSlug }} ({{ state.progress.current + 1 }} of {{ state.progress.total }})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Phase 2 — Pay (orders placed, invoice(s) to settle) -->
|
||||||
|
<template v-else>
|
||||||
|
<Alert v-if="allPaid" class="mb-4 border-emerald-500/40">
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||||
|
<AlertTitle>Payment received</AlertTitle>
|
||||||
|
<AlertDescription>Redirecting to your order…</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<p v-if="!allPaid" class="mb-4 text-sm text-muted-foreground">
|
||||||
|
Scan with any Lightning wallet, or tap the button below to
|
||||||
|
pay from your LNbits wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="placed in state.placedOrders"
|
||||||
|
:key="placed.order.id"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm font-semibold text-foreground">
|
||||||
|
{{ placed.restaurantSlug }}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
:variant="isPaid(placed.order.id) ? 'default' : 'outline'"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ friendlyOrderStatus(statusOf(placed.order.id)) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<OrderInvoiceCard
|
||||||
|
v-if="buildOrderInvoice(placed) && !isPaid(placed.order.id)"
|
||||||
|
:invoice="buildOrderInvoice(placed)!"
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
v-else-if="isPaid(placed.order.id)"
|
||||||
|
class="border-emerald-500/40"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||||
|
<AlertTitle>Paid</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
class="h-auto p-0"
|
||||||
|
@click="router.push(`/orders/${placed.order.id}`)"
|
||||||
|
>
|
||||||
|
View order →
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Card v-else>
|
||||||
|
<CardContent class="p-4 text-sm text-muted-foreground">
|
||||||
|
No Lightning invoice — payment is handled out-of-band.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator class="my-6" />
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Alert v-if="state.error" variant="destructive">
|
||||||
|
<AlertTitle>Payment didn't go through</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{{ state.error }} You can still scan the QR with another
|
||||||
|
wallet.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="!allPaid"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="isPaying"
|
||||||
|
@click="onPayAll"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isPaying" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<Zap v-else class="mr-2 h-4 w-4" />
|
||||||
|
Pay from my LNbits wallet
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="!allPaid"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full text-xs text-muted-foreground"
|
||||||
|
@click="startOver"
|
||||||
|
>
|
||||||
|
Cancel and start over
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
85
src/modules/restaurant/views/HomePage.vue
Normal file
85
src/modules/restaurant/views/HomePage.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Landing / discovery.
|
||||||
|
* - If `VITE_RESTAURANT_DEFAULT_SLUG` is configured, redirect to
|
||||||
|
* /r/<slug> on mount.
|
||||||
|
* - Otherwise show a slug input + a "recent venues" list pulled
|
||||||
|
* from STORAGE_SERVICE['restaurant.recentRestaurants.v1'].
|
||||||
|
* (Recent venues are populated by RestaurantPage in commit 4
|
||||||
|
* and used here from commit 7 onward.)
|
||||||
|
*
|
||||||
|
* Festival/aggregator discovery (aiolabs/restaurant#8) lands here in
|
||||||
|
* a follow-up.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Utensils } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import appConfig from '@/app.config'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const defaultSlug = computed<string>(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
appConfig.modules.restaurant as
|
||||||
|
| { config?: { defaultSlug?: string } }
|
||||||
|
| undefined
|
||||||
|
)?.config?.defaultSlug || ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const slug = ref('')
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
const trimmed = slug.value.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
router.push(`/r/${encodeURIComponent(trimmed)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (defaultSlug.value) {
|
||||||
|
router.replace(`/r/${defaultSlug.value}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="mx-auto max-w-md px-4 py-8 text-foreground sm:py-12">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<Utensils class="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<CardTitle class="mt-4 text-2xl">Restaurant — Order</CardTitle>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Open a venue by its slug.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
class="space-y-3"
|
||||||
|
@submit.prevent="go"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="slug"
|
||||||
|
placeholder="e.g. big-jays-bustaurant"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="font-mono"
|
||||||
|
/>
|
||||||
|
<Button type="submit" class="w-full" :disabled="!slug.trim()">
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center text-xs text-muted-foreground">
|
||||||
|
Set <code class="font-mono">VITE_RESTAURANT_DEFAULT_SLUG</code> to
|
||||||
|
auto-redirect on load.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
246
src/modules/restaurant/views/ItemPage.vue
Normal file
246
src/modules/restaurant/views/ItemPage.vue
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
<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 { useCartStore } from '../stores/cart'
|
||||||
|
import type {
|
||||||
|
EnrichedMenuItem,
|
||||||
|
SelectedModifier,
|
||||||
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
|
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--
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAdd = computed(
|
||||||
|
() => !!item.value && !isSoldOut.value && modifiersValid.value
|
||||||
|
)
|
||||||
|
|
||||||
|
function addToCart() {
|
||||||
|
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_price: unitPrice.value,
|
||||||
|
currency: item.value.currency || restaurant.value.currency,
|
||||||
|
quantity: quantity.value,
|
||||||
|
selected_modifiers: selectedModifiers.value,
|
||||||
|
note: note.value || null,
|
||||||
|
})
|
||||||
|
router.push('/cart')
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
279
src/modules/restaurant/views/OrderStatusPage.vue
Normal file
279
src/modules/restaurant/views/OrderStatusPage.vue
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Order status — polls the extension every `orderPollMs` while the
|
||||||
|
* order is still in a non-terminal state. Shows the bolt11
|
||||||
|
* invoice QR for orders still in `pending`, the status pill, the
|
||||||
|
* line items, and timestamps for each transition.
|
||||||
|
*
|
||||||
|
* Future tier-#2 / kitchen-workflow #4 may add ETA, course pacing,
|
||||||
|
* and per-station status — `Order.status` is intentionally an open
|
||||||
|
* string type so this view degrades gracefully on unknown states.
|
||||||
|
*/
|
||||||
|
import { computed, toRef } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
} 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 {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import OrderInvoiceCard from '../components/OrderInvoiceCard.vue'
|
||||||
|
import { useOrder } from '../composables/useOrder'
|
||||||
|
import {
|
||||||
|
KNOWN_ORDER_STATUSES,
|
||||||
|
friendlyOrderStatus,
|
||||||
|
type OrderInvoice,
|
||||||
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const orderId = computed(() => String(route.params.id || ''))
|
||||||
|
const { order, items, isLoading, error, refresh } = useOrder(
|
||||||
|
toRef(() => orderId.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusStyle = computed(() => {
|
||||||
|
const status = order.value?.status
|
||||||
|
const known = (KNOWN_ORDER_STATUSES as readonly string[]).includes(
|
||||||
|
status ?? ''
|
||||||
|
)
|
||||||
|
if (!known) return 'secondary' as const
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'outline' as const
|
||||||
|
case 'paid':
|
||||||
|
case 'accepted':
|
||||||
|
case 'ready':
|
||||||
|
return 'default' as const
|
||||||
|
case 'completed':
|
||||||
|
return 'default' as const
|
||||||
|
case 'canceled':
|
||||||
|
case 'refunded':
|
||||||
|
return 'destructive' as const
|
||||||
|
default:
|
||||||
|
return 'secondary' as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const invoice = computed<OrderInvoice | null>(() => {
|
||||||
|
if (!order.value) return null
|
||||||
|
if (!order.value.bolt11 || !order.value.payment_hash) return null
|
||||||
|
if (order.value.status !== 'pending') return null
|
||||||
|
// The extension's POST /orders response carries the OrderInvoice
|
||||||
|
// separately; once paid, we don't re-render the QR. We rebuild a
|
||||||
|
// best-effort OrderInvoice from the order's own fields here so a
|
||||||
|
// refresh on /orders/:id (e.g. shareable URL) still shows the QR
|
||||||
|
// while still pending.
|
||||||
|
return {
|
||||||
|
order_id: order.value.id,
|
||||||
|
payment_hash: order.value.payment_hash,
|
||||||
|
bolt11: order.value.bolt11,
|
||||||
|
amount_msat: order.value.total_msat,
|
||||||
|
// We don't have the real expires_at on the GET response. Use a
|
||||||
|
// safe far-future placeholder so the countdown shows "expires
|
||||||
|
// in 15:00" optimistically — the OrderInvoiceCard tolerates this.
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 900,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtSat(value: number) {
|
||||||
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string | null | undefined) {
|
||||||
|
if (!iso) return ''
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = computed(() => {
|
||||||
|
if (!order.value) return [] as Array<{ label: string; at: string }>
|
||||||
|
const out: Array<{ label: string; at: string }> = []
|
||||||
|
if (order.value.time) out.push({ label: 'Placed', at: order.value.time })
|
||||||
|
if (order.value.paid_at) out.push({ label: 'Paid', at: order.value.paid_at })
|
||||||
|
if (order.value.accepted_at)
|
||||||
|
out.push({ label: 'Accepted', at: order.value.accepted_at })
|
||||||
|
if (order.value.ready_at) out.push({ label: 'Ready', at: order.value.ready_at })
|
||||||
|
if (order.value.completed_at)
|
||||||
|
out.push({ label: 'Completed', at: order.value.completed_at })
|
||||||
|
if (order.value.canceled_at)
|
||||||
|
out.push({ label: 'Canceled', at: order.value.canceled_at })
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
</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.push('/orders')">
|
||||||
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
|
All orders
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isLoading && !order"
|
||||||
|
class="flex items-center justify-center py-16 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Loading order…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-else-if="error" variant="destructive">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
<AlertTitle>Couldn't load order</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="order">
|
||||||
|
<header class="mb-4 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Order</h1>
|
||||||
|
<p class="font-mono text-xs text-muted-foreground">
|
||||||
|
{{ order.id.slice(0, 12) }}…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge :variant="statusStyle" class="text-sm">
|
||||||
|
{{ friendlyOrderStatus(order.status) }}
|
||||||
|
</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<OrderInvoiceCard
|
||||||
|
v-if="invoice"
|
||||||
|
:invoice="invoice"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
v-else-if="order.status === 'paid'"
|
||||||
|
class="mb-4 border-emerald-500/40"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||||
|
<AlertTitle>Order received</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Payment confirmed — the kitchen will start preparing it
|
||||||
|
shortly.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
v-else-if="order.status === 'accepted'"
|
||||||
|
class="mb-4 border-emerald-500/40"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||||
|
<AlertTitle>Cooking</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Your food is being made.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
v-else-if="order.status === 'ready'"
|
||||||
|
class="mb-4 border-amber-500/40"
|
||||||
|
>
|
||||||
|
<Clock class="h-4 w-4 text-amber-500" />
|
||||||
|
<AlertTitle>Ready for pickup</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Pick up at the counter.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
v-else-if="order.status === 'completed'"
|
||||||
|
class="mb-4 border-emerald-500/40"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||||
|
<AlertTitle>Served</AlertTitle>
|
||||||
|
<AlertDescription>Enjoy! Thanks for ordering.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Items</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="line in items"
|
||||||
|
:key="line.id"
|
||||||
|
class="flex items-start justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-foreground">
|
||||||
|
{{ line.quantity }}× {{ line.name }}
|
||||||
|
<span
|
||||||
|
v-if="line.selected_modifiers.length"
|
||||||
|
class="block text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ line.selected_modifiers.map((m) => m.name).join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="line.note"
|
||||||
|
class="block text-xs italic text-muted-foreground"
|
||||||
|
>
|
||||||
|
Note: {{ line.note }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-xs text-muted-foreground">
|
||||||
|
{{ fmtSat(line.line_total_msat) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="timeline.length" class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Timeline</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol class="space-y-1.5 text-sm">
|
||||||
|
<li
|
||||||
|
v-for="(entry, i) in timeline"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-baseline justify-between"
|
||||||
|
>
|
||||||
|
<span class="text-foreground">{{ entry.label }}</span>
|
||||||
|
<span class="font-mono text-xs text-muted-foreground">
|
||||||
|
{{ fmtTime(entry.at) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Subtotal</span>
|
||||||
|
<span class="font-mono">{{ fmtSat(order.subtotal_msat) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="order.tax_msat" class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Tax</span>
|
||||||
|
<span class="font-mono">{{ fmtSat(order.tax_msat) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="order.tip_msat" class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Tip</span>
|
||||||
|
<span class="font-mono">{{ fmtSat(order.tip_msat) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-t border-border pt-2 text-base font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span class="font-mono">{{ fmtSat(order.total_msat) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
228
src/modules/restaurant/views/OrdersListPage.vue
Normal file
228
src/modules/restaurant/views/OrdersListPage.vue
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Lists past orders the customer has placed from this device.
|
||||||
|
*
|
||||||
|
* Source of truth for the *list* is STORAGE_SERVICE
|
||||||
|
* ['restaurant.lastOrders.v1'] (newest first, cap 50) — appended to
|
||||||
|
* by CheckoutPage. Each stored entry is enough to deep-link to
|
||||||
|
* /orders/:id, but it freezes the order's totals + status at the
|
||||||
|
* moment it was placed.
|
||||||
|
*
|
||||||
|
* On mount we hydrate each row from `RestaurantAPI.getOrder(id)` so
|
||||||
|
* the list shows the *live* status (e.g. an order placed an hour
|
||||||
|
* ago might be `ready` now) and the fiat amount the customer
|
||||||
|
* originally paid in. Missing / 404 orders fall back to the stored
|
||||||
|
* snapshot so a deleted order doesn't poison the page.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ReceiptText, RefreshCw } 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 { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
injectService,
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
import {
|
||||||
|
KNOWN_ORDER_STATUSES,
|
||||||
|
friendlyOrderStatus,
|
||||||
|
type Order,
|
||||||
|
type OrderStatus,
|
||||||
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const storage = tryInjectService<StorageService>(
|
||||||
|
SERVICE_TOKENS.STORAGE_SERVICE
|
||||||
|
)
|
||||||
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
|
|
||||||
|
interface OrderHistoryEntry {
|
||||||
|
orderId: string
|
||||||
|
restaurantId: string
|
||||||
|
restaurantSlug: string
|
||||||
|
placedAt: number
|
||||||
|
totalMsat: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = ref<OrderHistoryEntry[]>([])
|
||||||
|
const liveByOrderId = ref<Record<string, Order | null>>({})
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
|
||||||
|
// Re-fetches every history row's live status + fiat. Used both on
|
||||||
|
// mount and from the manual refresh button — the extension doesn't
|
||||||
|
// push order-status changes today (NIP-17 status DMs are tracked in
|
||||||
|
// aiolabs/restaurant#9), so the customer hits this to pick up
|
||||||
|
// kitchen-side transitions.
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
isRefreshing.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
orders.value.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const { order } = await api.getOrder(entry.orderId)
|
||||||
|
liveByOrderId.value[entry.orderId] = order
|
||||||
|
} catch {
|
||||||
|
liveByOrderId.value[entry.orderId] = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
isRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
orders.value =
|
||||||
|
storage?.getUserData<OrderHistoryEntry[]>(
|
||||||
|
'restaurant.lastOrders.v1',
|
||||||
|
[]
|
||||||
|
) || []
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtSat(value: number) {
|
||||||
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFiat(amount: number, currency: string) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount)
|
||||||
|
} catch {
|
||||||
|
// Unknown / non-ISO currency code — fall back to a plain number.
|
||||||
|
return `${amount.toFixed(2)} ${currency}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(
|
||||||
|
status: OrderStatus | undefined
|
||||||
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
const known = (KNOWN_ORDER_STATUSES as readonly string[]).includes(
|
||||||
|
status ?? ''
|
||||||
|
)
|
||||||
|
if (!known) return 'secondary'
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'outline'
|
||||||
|
case 'paid':
|
||||||
|
case 'accepted':
|
||||||
|
case 'ready':
|
||||||
|
case 'completed':
|
||||||
|
return 'default'
|
||||||
|
case 'canceled':
|
||||||
|
case 'refunded':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number) {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = computed(() => {
|
||||||
|
const groups = new Map<string, OrderHistoryEntry[]>()
|
||||||
|
for (const o of orders.value) {
|
||||||
|
const day = new Date(o.placedAt).toLocaleDateString()
|
||||||
|
if (!groups.has(day)) groups.set(day, [])
|
||||||
|
groups.get(day)!.push(o)
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(([day, items]) => ({
|
||||||
|
day,
|
||||||
|
items,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||||
|
<h1 class="mb-4 text-2xl font-bold text-foreground">Your orders</h1>
|
||||||
|
|
||||||
|
<Alert v-if="!orders.length" class="border-border">
|
||||||
|
<ReceiptText class="h-4 w-4" />
|
||||||
|
<AlertTitle>No orders yet</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Place an order and it'll show up here.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section
|
||||||
|
v-for="group in grouped"
|
||||||
|
:key="group.day"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{{ group.day }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Card
|
||||||
|
v-for="o in group.items"
|
||||||
|
:key="o.orderId"
|
||||||
|
class="cursor-pointer transition-colors hover:bg-accent"
|
||||||
|
@click="router.push(`/orders/${o.orderId}`)"
|
||||||
|
>
|
||||||
|
<CardContent class="p-3 sm:p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="line-clamp-1 font-semibold text-foreground">
|
||||||
|
{{ o.restaurantSlug }}
|
||||||
|
</p>
|
||||||
|
<p class="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{{ o.orderId.slice(0, 12) }}…
|
||||||
|
· {{ fmtTime(o.placedAt) }}
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
v-if="liveByOrderId[o.orderId]"
|
||||||
|
:variant="statusVariant(liveByOrderId[o.orderId]?.status)"
|
||||||
|
class="mt-1.5"
|
||||||
|
>
|
||||||
|
{{ friendlyOrderStatus(liveByOrderId[o.orderId]!.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 text-right">
|
||||||
|
<p class="font-mono text-sm font-semibold text-primary">
|
||||||
|
{{ fmtSat(o.totalMsat) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="liveByOrderId[o.orderId]?.fiat_amount"
|
||||||
|
class="font-mono text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
≈ {{ fmtFiat(
|
||||||
|
liveByOrderId[o.orderId]!.fiat_amount!,
|
||||||
|
liveByOrderId[o.orderId]!.currency_display
|
||||||
|
) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="orders.length"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
:disabled="isRefreshing"
|
||||||
|
aria-label="Refresh orders"
|
||||||
|
class="fixed bottom-20 right-4 z-40 h-12 w-12 rounded-full shadow-lg"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="h-5 w-5"
|
||||||
|
:class="{ 'animate-spin': isRefreshing }"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
137
src/modules/restaurant/views/RestaurantPage.vue
Normal file
137
src/modules/restaurant/views/RestaurantPage.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<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, onBeforeUnmount, toRef, watch } 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'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
|
import {
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const cart = useCartStore()
|
||||||
|
const sync = tryInjectService<RestaurantNostrSync>(
|
||||||
|
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
|
||||||
|
)
|
||||||
|
|
||||||
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
|
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
|
||||||
|
toRef(() => slug.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open the Nostr live-overlay subscription the moment we know the
|
||||||
|
// restaurant's pubkey + id. Close it on route leave / unmount. If
|
||||||
|
// the relay hub isn't connected, sync.subscribe is a no-op (REST
|
||||||
|
// continues to work; the overlay is best-effort polish).
|
||||||
|
let activeRestaurantId: string | null = null
|
||||||
|
watch(restaurant, (r) => {
|
||||||
|
if (!sync) return
|
||||||
|
if (activeRestaurantId && activeRestaurantId !== r?.id) {
|
||||||
|
sync.unsubscribe(activeRestaurantId)
|
||||||
|
activeRestaurantId = null
|
||||||
|
}
|
||||||
|
if (r?.nostr_pubkey && r.id !== activeRestaurantId) {
|
||||||
|
sync.subscribe(r.nostr_pubkey, r.id)
|
||||||
|
activeRestaurantId = r.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (sync && activeRestaurantId) {
|
||||||
|
sync.unsubscribe(activeRestaurantId)
|
||||||
|
activeRestaurantId = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openItem(itemId: string) {
|
||||||
|
router.push(`/r/${slug.value}/item/${itemId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
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_price: it.price,
|
||||||
|
currency: it.currency || restaurant.value.currency,
|
||||||
|
quantity: 1,
|
||||||
|
selected_modifiers: [],
|
||||||
|
note: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</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>
|
||||||
153
src/modules/restaurant/views/SettingsPage.vue
Normal file
153
src/modules/restaurant/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Customer-side preferences. v1 ships:
|
||||||
|
* - currency display toggle (sats / msat)
|
||||||
|
* - optional relay override (comma-separated; restart required
|
||||||
|
* because RelayHub is initialized once at boot)
|
||||||
|
*
|
||||||
|
* Persisted to STORAGE_SERVICE['restaurant.settings.v1'].
|
||||||
|
*
|
||||||
|
* Future tier-#2 may surface mode-gated toggles here (e.g. NIP-17
|
||||||
|
* order intake when #9 ships); the `features:{}` slot in the
|
||||||
|
* module config is the integration point.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Trash2 } 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 { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const storage = tryInjectService<StorageService>(
|
||||||
|
SERVICE_TOKENS.STORAGE_SERVICE
|
||||||
|
)
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
|
interface RestaurantSettings {
|
||||||
|
currencyDisplay: 'sats' | 'msat'
|
||||||
|
relayOverride?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = ref<RestaurantSettings>({
|
||||||
|
currencyDisplay: 'sats',
|
||||||
|
relayOverride: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
settings.value =
|
||||||
|
storage?.getUserData<RestaurantSettings>('restaurant.settings.v1', {
|
||||||
|
currencyDisplay: 'sats',
|
||||||
|
}) || { currencyDisplay: 'sats' }
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
settings,
|
||||||
|
(val) => {
|
||||||
|
storage?.setUserData('restaurant.settings.v1', val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearLocalData() {
|
||||||
|
if (!confirm('Clear cart, recent venues, and order history on this device?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cart.clear()
|
||||||
|
storage?.clearUserData('restaurant.cart.v1')
|
||||||
|
storage?.clearUserData('restaurant.lastOrders.v1')
|
||||||
|
storage?.clearUserData('restaurant.recentRestaurants.v1')
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<h1 class="mb-4 text-2xl font-bold text-foreground">Settings</h1>
|
||||||
|
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Display</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-sm" for="currency-toggle">
|
||||||
|
Show prices in millisats
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="currency-toggle"
|
||||||
|
:model-value="settings.currencyDisplay === 'msat'"
|
||||||
|
@update:model-value="
|
||||||
|
(val) =>
|
||||||
|
(settings.currencyDisplay = val ? 'msat' : 'sats')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Currently:
|
||||||
|
<code class="font-mono">{{ settings.currencyDisplay }}</code>.
|
||||||
|
Order totals from the extension are always msat; this is
|
||||||
|
local display only.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Nostr relays</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<Label for="relay-override" class="text-sm">
|
||||||
|
Override relays (comma-separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="relay-override"
|
||||||
|
v-model.trim="settings.relayOverride"
|
||||||
|
placeholder="wss://relay.example.com, wss://nos.lol"
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Reload the app for changes to take effect — the relay hub
|
||||||
|
initializes once on boot.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Local data</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
@click="clearLocalData"
|
||||||
|
>
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
Clear cart + history
|
||||||
|
</Button>
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
|
Wipes the cart, recent venues, and order history from this
|
||||||
|
device. Doesn't refund or cancel any placed orders.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
46
src/restaurant-app/App.vue
Normal file
46
src/restaurant-app/App.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
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()
|
||||||
|
|
||||||
|
const cartItemCount = computed<number | null>(() =>
|
||||||
|
cart.itemCount > 0 ? cart.itemCount : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const tabs = computed<BottomTab[]>(() => [
|
||||||
|
{ name: 'Browse', icon: Utensils, path: '/' },
|
||||||
|
{
|
||||||
|
name: 'Cart',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
path: '/cart',
|
||||||
|
badge: cartItemCount.value,
|
||||||
|
},
|
||||||
|
{ name: 'Orders', icon: ReceiptText, path: '/orders' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function isActive(path: string): boolean {
|
||||||
|
if (path === '/') {
|
||||||
|
return (
|
||||||
|
route.path === '/' ||
|
||||||
|
route.path.startsWith('/r/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (path === '/cart') {
|
||||||
|
return route.path === '/cart' || route.path === '/checkout'
|
||||||
|
}
|
||||||
|
if (path === '/orders') {
|
||||||
|
return route.path === '/orders' || route.path.startsWith('/orders/')
|
||||||
|
}
|
||||||
|
return route.path.startsWith(path)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||||
|
</template>
|
||||||
73
src/restaurant-app/app.config.ts
Normal file
73
src/restaurant-app/app.config.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { AppConfig } from '@/core/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone Restaurant app configuration.
|
||||||
|
*
|
||||||
|
* Customer-facing surface for the LNbits "restaurant" extension. v1
|
||||||
|
* is single-venue (URL-driven via `/r/:slug`); the bundle ships
|
||||||
|
* REST-only order placement. Festival/aggregator (NIP-51) and
|
||||||
|
* NIP-17 transport are deferred — see issues #8 / #9 on
|
||||||
|
* aiolabs/restaurant.
|
||||||
|
*/
|
||||||
|
export const appConfig: AppConfig = {
|
||||||
|
modules: {
|
||||||
|
base: {
|
||||||
|
name: 'base',
|
||||||
|
enabled: true,
|
||||||
|
lazy: false,
|
||||||
|
config: {
|
||||||
|
nostr: {
|
||||||
|
relays: JSON.parse(
|
||||||
|
import.meta.env.VITE_NOSTR_RELAYS ||
|
||||||
|
'["wss://relay.damus.io", "wss://nos.lol"]'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
pwa: {
|
||||||
|
autoPrompt: true,
|
||||||
|
},
|
||||||
|
imageUpload: {
|
||||||
|
baseUrl:
|
||||||
|
import.meta.env.VITE_PICTRS_BASE_URL ||
|
||||||
|
'https://img.mydomain.com',
|
||||||
|
maxSizeMB: 10,
|
||||||
|
acceptedTypes: [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/avif',
|
||||||
|
'image/gif',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
restaurant: {
|
||||||
|
name: 'restaurant',
|
||||||
|
enabled: true,
|
||||||
|
lazy: false,
|
||||||
|
config: {
|
||||||
|
apiBaseUrl:
|
||||||
|
import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||||
|
defaultSlug: import.meta.env.VITE_RESTAURANT_DEFAULT_SLUG || '',
|
||||||
|
orderPollMs: 5000,
|
||||||
|
currencyDisplay: 'sats' as const,
|
||||||
|
// Reserved for tier-gated UI (aiolabs/restaurant#2:
|
||||||
|
// bar/bistro/full operator modes). Future contributors set
|
||||||
|
// e.g. { inventoryPanel: true, loyaltyRewards: true } to
|
||||||
|
// unlock surfaces gated on the operator's tier. Empty in v1.
|
||||||
|
features: {} as Record<string, boolean>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
features: {
|
||||||
|
pwa: true,
|
||||||
|
pushNotifications: true,
|
||||||
|
electronApp: false,
|
||||||
|
developmentMode: import.meta.env.DEV,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default appConfig
|
||||||
125
src/restaurant-app/app.ts
Normal file
125
src/restaurant-app/app.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { pluginManager } from '@/core/plugin-manager'
|
||||||
|
import { eventBus } from '@/core/event-bus'
|
||||||
|
import { container } from '@/core/di-container'
|
||||||
|
|
||||||
|
import appConfig from './app.config'
|
||||||
|
import baseModule from '@/modules/base'
|
||||||
|
import restaurantModule from '@/modules/restaurant'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import '@/assets/index.css'
|
||||||
|
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||||
|
import {
|
||||||
|
installLenientAuthGuard,
|
||||||
|
markAuthReady,
|
||||||
|
catchAllRoute,
|
||||||
|
} from '@/lib/router-helpers'
|
||||||
|
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||||
|
|
||||||
|
export async function createAppInstance() {
|
||||||
|
console.log('Starting Restaurant app...')
|
||||||
|
|
||||||
|
acceptTokenFromUrl('Restaurant')
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
const moduleRoutes = [
|
||||||
|
...(baseModule.routes || []),
|
||||||
|
...(restaurantModule.routes || []),
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
// The restaurant module owns `/` (HomePage handles redirect to
|
||||||
|
// the configured default slug or shows a discovery prompt); no
|
||||||
|
// top-level redirect like market needs.
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component:
|
||||||
|
import.meta.env.VITE_DEMO_MODE === 'true'
|
||||||
|
? () => import('@/pages/LoginDemo.vue')
|
||||||
|
: () => import('@/pages/Login.vue'),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
...moduleRoutes,
|
||||||
|
catchAllRoute,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
installLenientAuthGuard(router)
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(i18n)
|
||||||
|
|
||||||
|
const defaultLocale = import.meta.env
|
||||||
|
.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||||
|
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||||
|
await changeLocale(defaultLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManager.init(app, router)
|
||||||
|
|
||||||
|
const moduleRegistrations = []
|
||||||
|
|
||||||
|
if (appConfig.modules.base.enabled) {
|
||||||
|
moduleRegistrations.push(
|
||||||
|
pluginManager.register(baseModule, appConfig.modules.base)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appConfig.modules.restaurant?.enabled) {
|
||||||
|
moduleRegistrations.push(
|
||||||
|
pluginManager.register(restaurantModule, appConfig.modules.restaurant)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(moduleRegistrations)
|
||||||
|
await pluginManager.installAll()
|
||||||
|
|
||||||
|
// Dynamic import: useAuthService depends on services registered by
|
||||||
|
// pluginManager.installAll() (LNbits API).
|
||||||
|
const { auth } = await import('@/composables/useAuthService')
|
||||||
|
await auth.initialize()
|
||||||
|
markAuthReady(auth)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, _vm, info) => {
|
||||||
|
console.error('Global error:', err, info)
|
||||||
|
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appConfig.features.developmentMode) {
|
||||||
|
;(window as any).__pluginManager = pluginManager
|
||||||
|
;(window as any).__eventBus = eventBus
|
||||||
|
;(window as any).__container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Restaurant app initialized')
|
||||||
|
return { app, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startApp() {
|
||||||
|
try {
|
||||||
|
const { app } = await createAppInstance()
|
||||||
|
app.mount('#app')
|
||||||
|
console.log('Restaurant app started!')
|
||||||
|
eventBus.emit('app:started', {}, 'app')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start Restaurant app:', error)
|
||||||
|
document.getElementById('app')!.innerHTML = `
|
||||||
|
<div style="padding: 20px; text-align: center; color: red;">
|
||||||
|
<h1>Failed to Start</h1>
|
||||||
|
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||||
|
<p>Please refresh the page.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/restaurant-app/main.ts
Normal file
20
src/restaurant-app/main.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { startApp } from './app'
|
||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
|
cleanupStaleDevServiceWorkers()
|
||||||
|
|
||||||
|
const intervalMS = 60 * 60 * 1000
|
||||||
|
registerSW({
|
||||||
|
onRegistered(r) {
|
||||||
|
r && setInterval(() => {
|
||||||
|
r.update()
|
||||||
|
}, intervalMS)
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log('Restaurant app ready to work offline')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
startApp()
|
||||||
132
vite.restaurant.config.ts
Normal file
132
vite.restaurant.config.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
|
|
||||||
|
function restaurantHtmlPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'restaurant-html-rewrite',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
|
if (
|
||||||
|
req.url &&
|
||||||
|
!req.url.startsWith('/@') &&
|
||||||
|
!req.url.startsWith('/src/') &&
|
||||||
|
!req.url.startsWith('/node_modules/') &&
|
||||||
|
!path.includes('.')
|
||||||
|
) {
|
||||||
|
req.url = '/restaurant.html'
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite config for the standalone Restaurant customer app.
|
||||||
|
*
|
||||||
|
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||||
|
* VITE_BASE_PATH=/restaurant/ → app.${domain}/restaurant/ (shared auth)
|
||||||
|
* (default: /) → restaurant.${domain} (standalone subdomain)
|
||||||
|
*
|
||||||
|
* The companion server is the LNbits "restaurant" extension at
|
||||||
|
* ~/dev/shared/extensions/restaurant. v1 ships single-venue (URL-driven
|
||||||
|
* via /r/:slug); festival/aggregator and NIP-17 transport are tracked
|
||||||
|
* in repo issues #8 and #9 on aiolabs/restaurant.
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
|
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||||
|
cacheDir: 'node_modules/.vite-restaurant',
|
||||||
|
server: {
|
||||||
|
port: 5186,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
restaurantHtmlPlugin(),
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
devOptions: { enabled: false },
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
|
navigateFallback: 'restaurant.html',
|
||||||
|
navigateFallbackAllowlist: [
|
||||||
|
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeAssets: [
|
||||||
|
'favicon.ico',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
'mask-icon.svg',
|
||||||
|
'icon-192.png',
|
||||||
|
'icon-512.png',
|
||||||
|
'icon-maskable-192.png',
|
||||||
|
'icon-maskable-512.png',
|
||||||
|
],
|
||||||
|
manifest: {
|
||||||
|
name: 'Restaurant — Order',
|
||||||
|
short_name: 'Restaurant',
|
||||||
|
description: 'Order from your local Nostr-native restaurant with Lightning payments',
|
||||||
|
// Green to differentiate from market red. PDF tile is purple
|
||||||
|
// (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png).
|
||||||
|
theme_color: '#16a34a',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
start_url: process.env.VITE_BASE_PATH || '/',
|
||||||
|
scope: process.env.VITE_BASE_PATH || '/',
|
||||||
|
id: 'restaurant-app',
|
||||||
|
categories: ['food', 'shopping'],
|
||||||
|
lang: 'en',
|
||||||
|
icons: [
|
||||||
|
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||||
|
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ViteImageOptimizer({
|
||||||
|
jpg: { quality: 80 },
|
||||||
|
png: { quality: 80 },
|
||||||
|
webp: { lossless: true },
|
||||||
|
}),
|
||||||
|
mode === 'analyze' &&
|
||||||
|
visualizer({
|
||||||
|
open: true,
|
||||||
|
filename: 'dist-restaurant/stats.html',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||||
|
'@/app.config': fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)),
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-restaurant',
|
||||||
|
rollupOptions: {
|
||||||
|
input: 'restaurant.html',
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||||
|
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||||
|
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 1000,
|
||||||
|
},
|
||||||
|
}))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue