feat(restaurant): customer-facing restaurant bundle (v1) #54
6 changed files with 984 additions and 0 deletions
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.
commit
940b36ba79
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>
|
||||
271
src/modules/restaurant/composables/useCheckout.ts
Normal file
271
src/modules/restaurant/composables/useCheckout.ts
Normal 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 }
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,18 @@ export const restaurantModule: ModulePlugin = {
|
|||
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' },
|
||||
},
|
||||
] as RouteRecordRaw[],
|
||||
}
|
||||
|
||||
|
|
|
|||
186
src/modules/restaurant/views/CheckoutPage.vue
Normal file
186
src/modules/restaurant/views/CheckoutPage.vue
Normal 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>
|
||||
257
src/modules/restaurant/views/OrderStatusPage.vue
Normal file
257
src/modules/restaurant/views/OrderStatusPage.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue