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

View file

@ -2,44 +2,27 @@
/**
* Lists past orders the customer has placed from this device.
*
* Source of truth for the *list* is STORAGE_SERVICE
* ['restaurant.lastOrders.v1'] (newest first, cap 50) appended to
* by CheckoutPage. Each stored entry is enough to deep-link to
* /orders/:id, but it freezes the order's totals + status at the
* moment it was placed.
*
* On mount we hydrate each row from `RestaurantAPI.getOrder(id)` so
* the list shows the *live* status (e.g. an order placed an hour
* ago might be `ready` now) and the fiat amount the customer
* originally paid in. Missing / 404 orders fall back to the stored
* snapshot so a deleted order doesn't poison the page.
* 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, RefreshCw } from 'lucide-vue-next'
import { ReceiptText } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
injectService,
tryInjectService,
SERVICE_TOKENS,
} from '@/core/di-container'
import type { StorageService } from '@/core/services/StorageService'
import type { RestaurantAPI } from '../services/RestaurantAPI'
import {
KNOWN_ORDER_STATUSES,
friendlyOrderStatus,
type Order,
type OrderStatus,
} from '../types/restaurant'
const router = useRouter()
const storage = tryInjectService<StorageService>(
SERVICE_TOKENS.STORAGE_SERVICE
)
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
interface OrderHistoryEntry {
orderId: string
@ -50,81 +33,19 @@ interface OrderHistoryEntry {
}
const orders = ref<OrderHistoryEntry[]>([])
const liveByOrderId = ref<Record<string, Order | null>>({})
const isRefreshing = ref(false)
// Re-fetches every history row's live status + fiat. Used both on
// mount and from the manual refresh button the extension doesn't
// push order-status changes today (NIP-17 status DMs are tracked in
// aiolabs/restaurant#9), so the customer hits this to pick up
// kitchen-side transitions.
async function refresh(): Promise<void> {
isRefreshing.value = true
try {
await Promise.all(
orders.value.map(async (entry) => {
try {
const { order } = await api.getOrder(entry.orderId)
liveByOrderId.value[entry.orderId] = order
} catch {
liveByOrderId.value[entry.orderId] = null
}
})
)
} finally {
isRefreshing.value = false
}
}
onMounted(async () => {
onMounted(() => {
orders.value =
storage?.getUserData<OrderHistoryEntry[]>(
'restaurant.lastOrders.v1',
[]
) || []
await refresh()
})
function fmtSat(value: number) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
}
function fmtFiat(amount: number, currency: string) {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(amount)
} catch {
// Unknown / non-ISO currency code fall back to a plain number.
return `${amount.toFixed(2)} ${currency}`
}
}
function statusVariant(
status: OrderStatus | undefined
): 'default' | 'secondary' | 'outline' | 'destructive' {
const known = (KNOWN_ORDER_STATUSES as readonly string[]).includes(
status ?? ''
)
if (!known) return 'secondary'
switch (status) {
case 'pending':
return 'outline'
case 'paid':
case 'accepted':
case 'ready':
case 'completed':
return 'default'
case 'canceled':
case 'refunded':
return 'destructive'
default:
return 'secondary'
}
}
function fmtTime(ts: number) {
return new Date(ts).toLocaleString()
}
@ -172,7 +93,7 @@ const grouped = computed(() => {
@click="router.push(`/orders/${o.orderId}`)"
>
<CardContent class="p-3 sm:p-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="line-clamp-1 font-semibold text-foreground">
{{ o.restaurantSlug }}
@ -181,48 +102,15 @@ const grouped = computed(() => {
{{ o.orderId.slice(0, 12) }}
· {{ fmtTime(o.placedAt) }}
</p>
<Badge
v-if="liveByOrderId[o.orderId]"
:variant="statusVariant(liveByOrderId[o.orderId]?.status)"
class="mt-1.5"
>
{{ friendlyOrderStatus(liveByOrderId[o.orderId]!.status) }}
</Badge>
</div>
<div class="shrink-0 text-right">
<p class="font-mono text-sm font-semibold text-primary">
<span class="shrink-0 font-mono text-sm font-semibold text-primary">
{{ fmtSat(o.totalMsat) }}
</p>
<p
v-if="liveByOrderId[o.orderId]?.fiat_amount"
class="font-mono text-[10px] text-muted-foreground"
>
{{ fmtFiat(
liveByOrderId[o.orderId]!.fiat_amount!,
liveByOrderId[o.orderId]!.currency_display
) }}
</p>
</div>
</span>
</div>
</CardContent>
</Card>
</div>
</section>
</template>
<Button
v-if="orders.length"
variant="default"
size="icon"
:disabled="isRefreshing"
aria-label="Refresh orders"
class="fixed bottom-20 right-4 z-40 h-12 w-12 rounded-full shadow-lg"
@click="refresh"
>
<RefreshCw
class="h-5 w-5"
:class="{ 'animate-spin': isRefreshing }"
/>
</Button>
</main>
</template>