feat(restaurant): checkout + order placement + status polling

End-to-end customer order flow against the restaurant extension.

composables/useOrder(orderId) — polls GET /orders/{id} every
orderPollMs (5s default) while status is non-terminal. Refetches
immediately on VisibilityService.onVisible so a backgrounded tab
catches up on resume. Cleans the interval on scope dispose.
KNOWN_ORDER_STATUSES is the closed list; the type stays open so
new statuses from aiolabs/restaurant#4 land without breaking.

composables/useCheckout() — orchestrates the full flow:
  1. quoteOrder per restaurant in the cart
  2. pre-flight balance check (wallet.balance.value, sat -> msat)
  3. placeOrder per restaurant -> { order, invoice }
  4. WalletService.sendPayment(bolt11) per invoice
  5. clearRestaurant(rid) on success
buildCreateOrder is the single point CreateOrder is constructed;
loyalty (aiolabs/restaurant#5) and NIP-17 transport (#9) both
plug in here without touching the rest of the flow.

components/OrderInvoiceCard.vue — bolt11 QR via qrcode lib,
copy-to-clipboard, expires-in countdown. White-bg QR for scanner
contrast regardless of theme (pure UX call — humans don't read
QRs, cameras do).

views/CheckoutPage.vue — review + total + 'Pay & place order'
CTA. Progress indicator shows current restaurant + N of M during
the multi-restaurant loop. Empty-cart guard redirects to /cart.
On success, stores placed orders in
'restaurant.lastOrders.v1' (capped at 50, newest first) and
navigates to /orders/<firstId>.

views/OrderStatusPage.vue — status pill with semantic Badge
variants, conditional bolt11 QR when status='pending', success
alert when paid/accepted/ready, line items with modifier summary,
timeline of transitions, money breakdown (subtotal / tax / tip /
total). Polls live via useOrder.

Routes added: /checkout, /orders/:id.

Money convention: cart.unit_msat stores per-unit values directly
from MenuItem.price (declared currency, not msat). The extension
itself msat-ifies amounts on POST /orders/quote and /orders. The
checkout's pre-flight balance check converts wallet sats -> msat
before comparing to the quote's required_msat. Display strings
divide by 1000 only when reading order.*_msat fields back from
the extension.

Design: shadcn-vue throughout (Alert, Badge, Button, Card,
Separator) + Tailwind 4 + theme-aware semantic classes
(bg-card, text-foreground, text-muted-foreground, text-primary,
text-destructive, border-border, with the one exception of the
QR card's white background, justified inline).

Verified: vue-tsc -b clean.
This commit is contained in:
Padreug 2026-05-11 17:32:04 +02:00
commit 940b36ba79
6 changed files with 984 additions and 0 deletions

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

View file

@ -0,0 +1,271 @@
/**
* useCheckout orchestrates the place-order + pay-bolt11 sequence
* for every restaurant currently in the cart.
*
* v1 ships REST-only:
* for each restaurant in the cart:
* 1. quote (msat required)
* 2. balance pre-check (sum across all restaurants)
* 3. placeOrder { order, invoice }
* 4. WalletService.sendPayment(bolt11)
*
* 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
* already.
*
* NIP-17 transport (aiolabs/restaurant#9) plugs in here later as a
* `transport: 'rest' | 'nostr'` option that gift-wraps the
* CreateOrder instead of POSTing it. The `buildCreateOrder`
* helper is the single point both transports build through, so
* adding loyalty (aiolabs/restaurant#5) is also a one-function
* change rather than touching every call site.
*/
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 CheckoutResult {
placedOrders: PlacedOrder[]
paidPaymentHashes: string[]
}
export interface CheckoutState {
step: 'idle' | 'quoting' | 'placing' | 'paying' | 'done' | 'error'
progress: { current: number; total: number }
currentRestaurantSlug: string | null
error: string | null
}
export interface UseCheckoutReturn {
state: Ref<CheckoutState>
checkout: () => Promise<CheckoutResult>
}
/**
* 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. Today loyalty is unconfigured so the
* extra block stays at its defaults.
*/
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',
}
}
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, and `LnbitsAPI`
// is already registered by base. The customer's adminkey lives
// on AuthService.user.wallets[0].adminkey.
const apiBaseUrl =
(
appConfig.modules.restaurant as
| { config?: { apiBaseUrl?: string } }
| undefined
)?.config?.apiBaseUrl || ''
async function payBolt11(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}`
)
}
}
const state = ref<CheckoutState>({
step: 'idle',
progress: { current: 0, total: 0 },
currentRestaurantSlug: null,
error: null,
})
async function checkout(): Promise<CheckoutResult> {
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 = {
step: 'quoting',
progress: { current: 0, total: buckets.length },
currentRestaurantSlug: null,
error: null,
}
// 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. Pre-flight balance check using AuthService's cached wallet
// balance (LNbits's user object carries balance_msat per wallet).
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)
state.value = {
step: 'error',
progress: { current: 0, total: buckets.length },
currentRestaurantSlug: null,
error: `Insufficient balance. Need ${needSat} sat, have ${haveSat} sat.`,
}
throw new Error(state.value.error!)
}
}
if (!wallet0?.adminkey) {
state.value = {
step: 'error',
progress: { current: 0, total: buckets.length },
currentRestaurantSlug: null,
error: 'No wallet available — please log in first.',
}
throw new Error(state.value.error!)
}
// 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,
})
}
// 4. Pay each bolt11 sequentially. If a payment fails, the
// earlier successes are still placed-and-paid — best-effort
// is the v1 model (see plan; HODL atomicity is a future
// issue).
state.value.step = 'paying'
const paidHashes: string[] = []
for (let i = 0; i < placed.length; i++) {
const p = placed[i]
state.value.currentRestaurantSlug = p.restaurantSlug
state.value.progress = { current: i, total: placed.length }
if (!p.invoice) continue // cash orders skip payment
try {
await payBolt11(p.invoice.bolt11, wallet0.adminkey)
if (p.invoice.payment_hash) {
paidHashes.push(p.invoice.payment_hash)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
state.value = {
step: 'error',
progress: { current: i, total: placed.length },
currentRestaurantSlug: p.restaurantSlug,
error: msg,
}
throw err
}
}
// 5. Clear the paid lines from the cart.
for (const p of placed) {
cart.clearRestaurant(p.restaurantId)
}
state.value = {
step: 'done',
progress: { current: placed.length, total: placed.length },
currentRestaurantSlug: null,
error: null,
}
return { placedOrders: placed, paidPaymentHashes: paidHashes }
}
return { state, checkout }
}

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

View file

@ -94,6 +94,18 @@ export const restaurantModule: ModulePlugin = {
component: () => import('./views/CartPage.vue'), component: () => import('./views/CartPage.vue'),
meta: { requiresAuth: false, title: 'Cart' }, 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' },
},
] as RouteRecordRaw[], ] as RouteRecordRaw[],
} }

View file

@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* Review + pre-flight quote + place orders + pay bolt11s.
*
* Multi-restaurant ready (loops every restaurant in the cart) but
* v1 typically has a single bucket. On success: clear cart, route
* to /orders/<firstOrderId>. On failure: surface the error inline.
*/
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, Loader2, CheckCircle2 } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } 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 { useCartStore } from '../stores/cart'
import { useCheckout } from '../composables/useCheckout'
import {
tryInjectService,
SERVICE_TOKENS,
} from '@/core/di-container'
import type { StorageService } from '@/core/services/StorageService'
const router = useRouter()
const cart = useCartStore()
const { state, checkout } = useCheckout()
const storage = tryInjectService<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
const isSubmitting = computed(
() => state.value.step !== 'idle' && state.value.step !== 'done' && state.value.step !== 'error'
)
function fmtSat(value: number) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat`
}
const buckets = computed(() =>
cart.restaurantsInCart.map((rid) => ({
restaurantId: rid,
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
lines: cart.linesFor(rid),
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0,
}))
)
const placedOrders = ref<
Array<{ orderId: string; restaurantSlug: string; placedAt: number; totalMsat: number; restaurantId: string }>
>([])
onMounted(() => {
// If cart is empty (e.g. user landed here via back/forward), kick
// them to /cart rather than showing an empty screen.
if (!buckets.value.length) {
router.replace('/cart')
}
})
async function placeOrder() {
try {
const result = await checkout()
// Persist the placed orders so the OrdersListPage (commit 7)
// can render them.
const entries = result.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)
}
placedOrders.value = entries
// Land on the first order's status page.
if (entries.length) {
router.push(`/orders/${entries[0].orderId}`)
}
} catch (err) {
// state.value.step === 'error' and state.value.error already set
// by useCheckout. View renders it below.
console.warn('Checkout failed:', err)
}
}
</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="isSubmitting"
@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>
<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">
{{ fmtSat(b.totalMsat) }}
</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">
{{ fmtSat(line.unit_msat * line.quantity) }}
</span>
</div>
</CardContent>
</Card>
</div>
<Separator class="my-6" />
<div class="space-y-3">
<div 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">
{{ fmtSat(cart.grandTotalMsat) }}
</span>
</div>
<Alert v-if="state.step === 'error' && state.error" variant="destructive">
<AlertTitle>Checkout failed</AlertTitle>
<AlertDescription>{{ state.error }}</AlertDescription>
</Alert>
<Alert v-if="state.step === 'done'" class="border-emerald-500/40">
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
<AlertTitle>Order placed</AlertTitle>
<AlertDescription>Redirecting to status</AlertDescription>
</Alert>
<Button
size="lg"
class="w-full"
:disabled="isSubmitting || !buckets.length"
@click="placeOrder"
>
<Loader2
v-if="isSubmitting"
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-if="state.step === 'paying'">Paying invoices</span>
<span v-else-if="state.step === 'done'">Done</span>
<span v-else>Pay & place order</span>
</Button>
<p
v-if="isSubmitting && state.currentRestaurantSlug"
class="text-center text-xs text-muted-foreground"
>
{{ state.currentRestaurantSlug }} ({{ state.progress.current + 1 }} of {{ state.progress.total }})
</p>
</div>
</main>
</template>

View file

@ -0,0 +1,257 @@
<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,
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">
{{ order.status }}
</Badge>
</header>
<OrderInvoiceCard
v-if="invoice"
:invoice="invoice"
class="mb-4"
/>
<Alert
v-else-if="['paid', 'accepted'].includes(order.status)"
class="mb-4 border-emerald-500/40"
>
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
<AlertTitle>Payment received</AlertTitle>
<AlertDescription>
The kitchen is on it.
</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</AlertTitle>
<AlertDescription>
Pick up at the counter.
</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>