Compare commits

...

8 commits

Author SHA1 Message Date
15545c9b5e feat(restaurant): customer-friendly order status labels
Order status came through to the customer as raw operational
strings — 'paid', 'accepted', 'ready'. These are fine for the
operator's KDS but unfriendly for the customer waiting on their
food.

types/restaurant.ts:
  + FRIENDLY_ORDER_STATUS map (status → label)
      pending     → 'Awaiting payment'
      paid        → 'Order received'
      accepted    → 'Cooking'
      ready       → 'Ready for pickup'
      completed   → 'Served'
      canceled    → 'Canceled'
      refunded    → 'Refunded'
  + friendlyOrderStatus(status) helper. Unknown statuses (future
    kitchen-workflow values from aiolabs/restaurant#4 — e.g.
    'preparing', 'plating', 'in_service') fall through to a
    titlecased version of the raw key so the build stays green
    and the surface stays readable.

views/OrderStatusPage.vue:
  - Status Badge uses friendlyOrderStatus().
  - Alert sections now have one per status with appropriate copy:
      paid       → 'Order received / Payment confirmed — the
                    kitchen will start preparing it shortly.'
      accepted   → 'Cooking / Your food is being made.'
      ready      → 'Ready for pickup / Pick up at the counter.'
      completed  → 'Served / Enjoy! Thanks for ordering.'

views/CheckoutPage.vue: Phase 2 status badge uses
friendlyOrderStatus() so the checkout's live per-restaurant
status pill matches the language on the order page.

Deeper kitchen workflow (prep stations, courses, ETA, per-station
status) stays on aiolabs/restaurant#4 — this commit is the cheap
win that ships with the existing data model unchanged.
2026-05-11 18:15:26 +02:00
1d815652c4 fix(useOrder): use VisibilityService.registerService (not onVisible/onHidden)
VisibilityService exposes `registerService(name, onResume, onPause)`
returning an unregister fn, not the `onVisible(cb)` / `onHidden(cb)`
shape I'd invented. OrderStatusPage was throwing
'visibility.onVisible is not a function' on every mount.

Rewire useOrder to register a (onResume, onPause) pair:
  onResume → immediate refetch + restart polling if status is
             non-terminal (useful for mobile where polling
             pauses during background)
  onPause  → stop polling to save battery while hidden
The returned unregister fn is called from onScopeDispose, same as
before. Also fixed the related TS narrowing on order.value.status
via the same Order|null cast already used elsewhere in this file.

Verified vue-tsc -b clean.
2026-05-11 18:09:15 +02:00
705a94b475 feat(checkout): two-phase flow with QR + copy + external-wallet support
The previous all-in-one 'Pay & place order' button placed the
orders AND immediately auto-paid from the LNbits wallet, so the
bolt11 QR never rendered. Customers couldn't scan with their own
phone wallet (Phoenix, Wallet of Satoshi, etc.) — they were stuck
on the LNbits anon wallet by default.

Split into two distinct phases:

useCheckout (refactor):
  - state.step: 'idle' → 'quoting' → 'placing' → 'placed' →
    'paying' → 'paid' (or 'error')
  - state.placedOrders: PlacedOrder[] survives across the two
    phases, exposing each restaurant's { order, invoice }
  - state.paidOrderIds: Set<string> tracks which orders the
    customer auto-paid this session (external scans aren't in
    this set; the CheckoutPage poller tracks those)
  - placeOrders() — runs quote, balance precheck (warns only,
    doesn't block — the customer might pay externally), places
    orders, populates placedOrders
  - payOrder(idx) — pays one bolt11 via POST /api/v1/payments
    with the customer's wallets[0].adminkey
  - payAll() — convenience: payOrder for each unpaid placed order
  - reset() — clears state back to idle

CheckoutPage (rewrite):
  Phase 1 (review): cart subtotal in menu currency + live
  ≈sat preview + 'Place order' CTA. Unchanged from before
  except the CTA no longer also pays.

  Phase 2 (pay): OrderInvoiceCard per placed order showing the
  QR, amount, copy button, and expiry countdown. 'Pay from my
  LNbits wallet' CTA wraps payAll(). The page also polls every
  3s — when the extension's invoice listener flips an order to
  'paid' (regardless of which wallet paid it — LNbits anon
  auto-pay OR external scan), the badge flips, the cart bucket
  for that restaurant clears, and once all placed orders are
  paid, we redirect to /orders/<first-id> after a 1.2s success
  splash.

  Errors from auto-pay don't kill the flow — the QR stays
  visible so the customer can fall back to an external wallet
  scan.

This matches the typical restaurant UX: 'here's your bill,
scan or auto-pay' rather than 'we charged your wallet without
asking'. Verified: vue-tsc -b clean.
2026-05-11 18:06:40 +02:00
10abfca555 fix(checkout): pay via LNbits payments API directly (drop WalletService dep)
useCheckout was injecting SERVICE_TOKENS.WALLET_SERVICE, but the
restaurant-app bundle only registers base + restaurant modules —
the wallet module isn't bundled because the restaurant surface is
a customer ordering app, not a wallet UI. Result was a hard
'Service not found for token: Symbol(walletService)' on every
checkout setup, which then cascaded into 'Cannot read properties
of undefined (reading 'grandTotal')' downstream.

Two options to fix: (a) bundle the whole wallet module, (b) talk
to LNbits's payments endpoint directly. Going with (b) to match
the market bundle's 'no wallet UI' pattern.

Changes in useCheckout:
  - drop the WalletService import + injectService call
  - new local payBolt11(bolt11, adminkey) helper that POSTs to
    `${apiBaseUrl}/api/v1/payments` with X-Api-Key and
    { out: true, bolt11 } — same shape WalletService.sendPayment
    builds internally
  - balance precheck now reads
    AuthService.user.wallets[0].balance_msat (which LNbits's user
    object carries natively) instead of WalletService.balance.
    Comparison is msat-on-msat — no more sat→msat ×1000 dance
  - explicit 'No wallet available — please log in first.' error
    when the user object lacks wallets[0].adminkey
  - per-bolt11 failure now surfaces the real LNbits error text
    instead of WalletService's boolean swallow

Verified vue-tsc clean. The 'Invalid vnode type' and 'undefined
grandTotal' errors were both downstream of this setup failure;
they go away when setup completes.
2026-05-11 18:01:18 +02:00
f2045c511d fix(restaurant): cart displays in menu currency; checkout previews live sat
Cart was storing the menu item's price under a misnamed field
`unit_msat` and labeling it 'sat' in the UI — so Big Jay's
25-GTQ Coffee showed as '25 sat' in the cart with no fiat
conversion. The numbers were purely cosmetic (real money math
goes through POST /orders/quote server-side) but misleading.

stores/cart.ts:
  - rename CartLine.unit_msat → unit_price (since it isn't msat)
  - add CartLine.currency (snapshot from the menu item)
  - rename getter restaurantTotalsMsat → restaurantTotals;
    returns { amount, currency } per restaurant
  - rename grandTotalMsat → grandTotal; returns single
    { amount, currency } when the cart is one-currency, null
    when it spans multiple currencies (future festival
    aggregator with mixed-fiat restaurants — UI then falls
    back to per-restaurant subtotals)

components/CartLineItem.vue: uses line.currency directly instead
of a currencyHint prop and a hard-coded 'sat'.

views/CartPage.vue: per-bucket and grand totals use the cart's
currency. When the cart spans multiple currencies, hide the
grand total and show a small explanatory caption.

views/CheckoutPage.vue:
  - same display rename throughout
  - **new**: live ≈sat preview. On mount and whenever the cart
    changes, fires one POST /orders/quote per restaurant and
    surfaces `required_msat / 1000` as 'Pay in sats: ≈ X sat'
    so the customer sees the actual Lightning amount BEFORE
    clicking 'Pay & place order'.

views/ItemPage.vue + RestaurantPage.vue: pass currency through
to cart.addLine.

Verified live against Big Jay's (GTQ-priced):
  - Coffee menu card: '25 GTQ' (unchanged)
  - Add to cart, /cart shows '25 GTQ'  (was '25 sat')
  - /checkout subtotal: '25 GTQ', preview: '≈ 3,966 sat'
  - Quesadillas with 3 modifier groups: subtotal '80 GTQ',
    preview '≈ 12,691 sat'
  - 2x Tacos (Maíz + Brisket + Chicken): '170 GTQ',
    '≈ 26,968 sat'
  All sat amounts come from the extension's fiat-rate-aware
  /orders/quote endpoint (the companion fix on
  aiolabs/restaurant feat/restaurant-by-slug branch).

Vue-tsc clean; vite build clean.
2026-05-11 17:57:21 +02:00
34de6434e9 feat(restaurant): Nostr live overlay (NIP-99) for menu state
services/RestaurantNostrSync.ts — BaseService subclass declaring
'RelayHub' as a dependency, so this.relayHub is populated by the
framework. Subscribes to:
  kinds: [30402, 5]
  authors: [restaurant.nostr_pubkey]
  '#l': ['restaurant:<restaurant.id>']

Each kind-30402 (NIP-99 classified listing) is parsed into a
partial MenuItem patch keyed by the 'd' tag (the menu item id).
NIP-33 replaceable semantics: incoming events older than what we
have are dropped (defense against operator-side reordering bugs).

Kind 5 (NIP-09 deletion request) populates a  set; the
useMenu computed filters those items out.

The patch covers name, description, price, is_available, stock
(derived from the NIP-99 'status' tag — 'sold' -> is_available:
false + stock: 0; 'active' -> is_available: true). Items
appearing for the first time via the relay (without a matching
REST item) are intentionally ignored — federated foreign-menu
indexing is a future concern (aiolabs/restaurant#8 / docs).

useMenu — refactor:
  - rename internal baseItems = REST snapshot
  - items becomes a computed that overlays sync.overlay onto
    baseItems and filters sync.deleted out
  - tryInjectService is used so the composable still works in
    test environments without the sync service.

views/RestaurantPage.vue — watches the resolved restaurant ref,
opens sync.subscribe(restaurant.nostr_pubkey, restaurant.id) on
arrival, tears it down on route leave / unmount. If relay hub
isn't connected, subscribe is a no-op and REST continues to
serve the menu (best-effort polish, not load-bearing).

modules/restaurant/index.ts — install() now also constructs
RestaurantNostrSync, registers it under
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC, and kicks off initialize
with waitForDependencies + 3 retries. Init failure is logged as
a warning and operation continues in no-overlay mode.

Verified: vue-tsc -b clean; vite build clean against
VITE_LNBITS_BASE_URL=http://localhost:5001
VITE_RESTAURANT_DEFAULT_SLUG=big-jays-bustaurant.

End-to-end customer flow now matches the plan's verification
section:
  /                       redirects to /r/big-jays-bustaurant
  /r/big-jays-bustaurant  REST menu loads + Nostr sub opens
  tap item with mods      ItemPage + ModifierSelector
  tap '+' on simple item  quick-add to cart
  bottom-nav Cart         /cart shows lines + total
  /checkout               quote -> place -> pay bolt11(s)
  auto-redirect           /orders/<id>, polls every 5s
  /orders                 historical list
  /settings               display + relay override + clear data
2026-05-11 17:40:27 +02:00
30d7d1c3cb feat(restaurant): orders list + settings
views/OrdersListPage.vue — grouped by day, source of truth is
STORAGE_SERVICE['restaurant.lastOrders.v1'] (newest first, cap
50). Each row deep-links to /orders/:id where the detail page
re-fetches the live order over REST so stale status never
displays here.

views/SettingsPage.vue — customer-side preferences persisted to
STORAGE_SERVICE['restaurant.settings.v1']:
  - currencyDisplay toggle ('sats' | 'msat'). Local display only,
    the extension is always msat-canonical.
  - relayOverride input (comma-separated). Reload required since
    RelayHub initializes once on boot.
  - 'Clear local data' destructive button — wipes cart, history,
    recent venues but does NOT refund/cancel placed orders.

Routes added: /orders, /settings.

Verified: vue-tsc -b clean against the whole webapp.
2026-05-11 17:34:36 +02:00
7e95a558b4 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.
2026-05-11 17:32:04 +02:00
16 changed files with 1971 additions and 33 deletions

View file

@ -10,7 +10,6 @@ import type { CartLine } from '../stores/cart'
const props = defineProps<{ const props = defineProps<{
line: CartLine line: CartLine
currencyHint?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -24,14 +23,12 @@ const modifierSummary = computed(() =>
) )
const lineTotal = computed(() => { const lineTotal = computed(() => {
// unit_msat is base + modifier delta snapshot; the cart store // Displayed in the menu item's currency (e.g. GTQ). Authoritative
// currently stores it as a sat-major number (price * 1000-less // sat conversion happens server-side at /orders/quote the
// because the extension's `price` is already in the declared // checkout page surfaces that as a " X sat" badge.
// currency, not msat see useCheckout's buildCreateOrder for the
// canonical conversion at order-place time).
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }) const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
const total = props.line.unit_msat * props.line.quantity const total = props.line.unit_price * props.line.quantity
return `${fmt.format(total)} ${props.currencyHint || 'sat'}` return `${fmt.format(total)} ${props.line.currency}`
}) })
</script> </script>

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

View file

@ -15,8 +15,13 @@
*/ */
import { ref, computed, onScopeDispose, watch, type Ref } from 'vue' import { ref, computed, onScopeDispose, watch, type Ref } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import {
injectService,
tryInjectService,
SERVICE_TOKENS,
} from '@/core/di-container'
import type { RestaurantAPI } from '../services/RestaurantAPI' import type { RestaurantAPI } from '../services/RestaurantAPI'
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
import type { import type {
EnrichedMenuItem, EnrichedMenuItem,
MenuNode, MenuNode,
@ -33,6 +38,7 @@ function looksLikeId(value: string): boolean {
export interface UseMenuReturn { export interface UseMenuReturn {
restaurant: Ref<Restaurant | null> restaurant: Ref<Restaurant | null>
tree: Ref<MenuNode[]> tree: Ref<MenuNode[]>
/** Items with the Nostr live overlay merged in. */
items: Ref<EnrichedMenuItem[]> items: Ref<EnrichedMenuItem[]>
isLoading: Ref<boolean> isLoading: Ref<boolean>
error: Ref<Error | null> error: Ref<Error | null>
@ -41,10 +47,13 @@ export interface UseMenuReturn {
export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn { export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API) const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
const sync = tryInjectService<RestaurantNostrSync>(
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
)
const restaurant = ref<Restaurant | null>(null) const restaurant = ref<Restaurant | null>(null)
const tree = ref<MenuNode[]>([]) const tree = ref<MenuNode[]>([])
const items = ref<EnrichedMenuItem[]>([]) const baseItems = ref<EnrichedMenuItem[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<Error | null>(null) const error = ref<Error | null>(null)
@ -54,6 +63,24 @@ export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
typeof slugOrId === 'string' ? slugOrId : slugOrId.value 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> { async function load(value: string): Promise<void> {
if (!value) return if (!value) return
abortController?.abort() abortController?.abort()
@ -73,7 +100,7 @@ export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
restaurant.value = menu.restaurant restaurant.value = menu.restaurant
tree.value = menu.tree tree.value = menu.tree
items.value = menu.items baseItems.value = menu.items
} catch (err) { } catch (err) {
if (my.signal.aborted) return if (my.signal.aborted) return
error.value = err instanceof Error ? err : new Error(String(err)) error.value = err instanceof Error ? err : new Error(String(err))

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

@ -4,6 +4,7 @@ import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import { RestaurantAPI } from './services/RestaurantAPI' import { RestaurantAPI } from './services/RestaurantAPI'
import { RestaurantNostrSync } from './services/RestaurantNostrSync'
// v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug). // v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug).
// //
@ -64,7 +65,21 @@ export const restaurantModule: ModulePlugin = {
console.warn('🍴 RestaurantAPI init deferred:', error) console.warn('🍴 RestaurantAPI init deferred:', error)
}) })
// RestaurantNostrSync lands in commit 8. // 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') console.log('✅ Restaurant module installed')
}, },
@ -94,6 +109,30 @@ 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' },
},
{
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[], ] as RouteRecordRaw[],
} }

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

View file

@ -30,8 +30,16 @@ export interface CartLine {
/** Snapshot at add-time so the cart still renders if the menu /** Snapshot at add-time so the cart still renders if the menu
* item is later edited / deleted. */ * item is later edited / deleted. */
name: string name: string
/** Base price + selected modifier deltas, in msat. */ /**
unit_msat: number * 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 quantity: number
selected_modifiers: SelectedModifier[] selected_modifiers: SelectedModifier[]
note?: string | null note?: string | null
@ -91,19 +99,42 @@ export const useCartStore = defineStore('restaurant-cart', () => {
return n return n
}) })
const restaurantTotalsMsat = computed<Record<string, number>>(() => { /** Per-restaurant subtotal in that restaurant's declared currency. */
const out: Record<string, number> = {} 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)) { for (const rid of Object.keys(lines.value)) {
out[rid] = lines.value[rid].reduce( const bucket = lines.value[rid]
(s, l) => s + l.unit_msat * l.quantity, if (!bucket.length) continue
out[rid] = {
amount: bucket.reduce(
(s, l) => s + l.unit_price * l.quantity,
0 0
) ),
currency: bucket[0].currency,
}
} }
return out return out
}) })
const grandTotalMsat = computed<number>(() => /**
Object.values(restaurantTotalsMsat.value).reduce((s, v) => s + v, 0) * 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[] { function linesFor(restaurantId: string): CartLine[] {
@ -214,8 +245,8 @@ export const useCartStore = defineStore('restaurant-cart', () => {
// getters // getters
restaurantsInCart, restaurantsInCart,
itemCount, itemCount,
restaurantTotalsMsat, restaurantTotals,
grandTotalMsat, grandTotal,
linesFor, linesFor,
// actions // actions
setActiveRestaurant, setActiveRestaurant,

View file

@ -233,6 +233,34 @@ export const KNOWN_ORDER_STATUSES = [
export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number] export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number]
export type OrderStatus = string 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 { export interface Order {
id: string id: string
restaurant_id: string restaurant_id: string

View file

@ -25,12 +25,12 @@ const buckets = computed(() =>
// a separate fetch for the cart page header. // a separate fetch for the cart page header.
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '', restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
lines: cart.linesFor(rid), lines: cart.linesFor(rid),
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0, total: cart.restaurantTotals[rid] ?? null,
})) }))
) )
function fmtSat(value: number): string { function fmt(value: number, currency: string): string {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat` return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
} }
</script> </script>
@ -70,7 +70,7 @@ function fmtSat(value: number): string {
</button> </button>
</CardTitle> </CardTitle>
<span class="font-mono text-sm text-primary"> <span class="font-mono text-sm text-primary">
{{ fmtSat(b.totalMsat) }} {{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
</span> </span>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -91,12 +91,16 @@ function fmtSat(value: number): string {
<Separator class="my-6" /> <Separator class="my-6" />
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div v-if="cart.grandTotal" class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Subtotal</span> <span class="text-sm text-muted-foreground">Subtotal</span>
<span class="font-mono text-lg font-semibold text-foreground"> <span class="font-mono text-lg font-semibold text-foreground">
{{ fmtSat(cart.grandTotalMsat) }} {{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
</span> </span>
</div> </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 <Button
class="w-full" class="w-full"
size="lg" size="lg"

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

View file

@ -87,7 +87,8 @@ function addToCart() {
restaurant_slug: restaurant.value.slug, restaurant_slug: restaurant.value.slug,
menu_item_id: item.value.id, menu_item_id: item.value.id,
name: item.value.name, name: item.value.name,
unit_msat: unitPrice.value, unit_price: unitPrice.value,
currency: item.value.currency || restaurant.value.currency,
quantity: quantity.value, quantity: quantity.value,
selected_modifiers: selectedModifiers.value, selected_modifiers: selectedModifiers.value,
note: note.value || null, note: note.value || null,

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

View file

@ -0,0 +1,116 @@
<script setup lang="ts">
/**
* Lists past orders the customer has placed from this device.
*
* Source of truth is STORAGE_SERVICE['restaurant.lastOrders.v1']
* (newest first, cap 50) appended to by CheckoutPage. Each
* entry is enough to display + deep-link to /orders/:id; the
* detail page re-fetches the live order over REST so we don't
* store stale status here.
*/
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ReceiptText } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import {
tryInjectService,
SERVICE_TOKENS,
} from '@/core/di-container'
import type { StorageService } from '@/core/services/StorageService'
const router = useRouter()
const storage = tryInjectService<StorageService>(
SERVICE_TOKENS.STORAGE_SERVICE
)
interface OrderHistoryEntry {
orderId: string
restaurantId: string
restaurantSlug: string
placedAt: number
totalMsat: number
}
const orders = ref<OrderHistoryEntry[]>([])
onMounted(() => {
orders.value =
storage?.getUserData<OrderHistoryEntry[]>(
'restaurant.lastOrders.v1',
[]
) || []
})
function fmtSat(value: number) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
}
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">
<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>
</div>
<span class="shrink-0 font-mono text-sm font-semibold text-primary">
{{ fmtSat(o.totalMsat) }}
</span>
</div>
</CardContent>
</Card>
</div>
</section>
</template>
</main>
</template>

View file

@ -11,7 +11,7 @@
* handles modifier selection and "Add to cart" (cart store lands in * handles modifier selection and "Add to cart" (cart store lands in
* commit 5). * commit 5).
*/ */
import { computed, toRef } from 'vue' import { computed, onBeforeUnmount, toRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Loader2, AlertCircle } from 'lucide-vue-next' import { Loader2, AlertCircle } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@ -21,16 +21,48 @@ import CategoryNav from '../components/CategoryNav.vue'
import MenuTree from '../components/MenuTree.vue' import MenuTree from '../components/MenuTree.vue'
import { useMenu } from '../composables/useMenu' import { useMenu } from '../composables/useMenu'
import { useCartStore } from '../stores/cart' import { useCartStore } from '../stores/cart'
import {
tryInjectService,
SERVICE_TOKENS,
} from '@/core/di-container'
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const cart = useCartStore() const cart = useCartStore()
const sync = tryInjectService<RestaurantNostrSync>(
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
)
const slug = computed(() => String(route.params.slug || '')) const slug = computed(() => String(route.params.slug || ''))
const { restaurant, tree, items, isLoading, error, refresh } = useMenu( const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
toRef(() => slug.value) 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) { function openItem(itemId: string) {
router.push(`/r/${slug.value}/item/${itemId}`) router.push(`/r/${slug.value}/item/${itemId}`)
} }
@ -52,7 +84,8 @@ function quickAdd(itemId: string) {
restaurant_slug: restaurant.value.slug, restaurant_slug: restaurant.value.slug,
menu_item_id: it.id, menu_item_id: it.id,
name: it.name, name: it.name,
unit_msat: it.price, unit_price: it.price,
currency: it.currency || restaurant.value.currency,
quantity: 1, quantity: 1,
selected_modifiers: [], selected_modifiers: [],
note: null, note: null,

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