Compare commits
5 commits
15545c9b5e
...
31312688b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 31312688b5 | |||
| 77c81d8323 | |||
| e01e595df7 | |||
| a7f2ded8b2 | |||
| 940b36ba79 |
16 changed files with 2083 additions and 33 deletions
|
|
@ -10,7 +10,6 @@ import type { CartLine } from '../stores/cart'
|
|||
|
||||
const props = defineProps<{
|
||||
line: CartLine
|
||||
currencyHint?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -24,14 +23,12 @@ const modifierSummary = computed(() =>
|
|||
)
|
||||
|
||||
const lineTotal = computed(() => {
|
||||
// unit_msat is base + modifier delta snapshot; the cart store
|
||||
// currently stores it as a sat-major number (price * 1000-less
|
||||
// because the extension's `price` is already in the declared
|
||||
// currency, not msat — see useCheckout's buildCreateOrder for the
|
||||
// canonical conversion at order-place time).
|
||||
// Displayed in the menu item's currency (e.g. GTQ). Authoritative
|
||||
// sat conversion happens server-side at /orders/quote — the
|
||||
// checkout page surfaces that as a "≈ X sat" badge.
|
||||
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
|
||||
const total = props.line.unit_msat * props.line.quantity
|
||||
return `${fmt.format(total)} ${props.currencyHint || 'sat'}`
|
||||
const total = props.line.unit_price * props.line.quantity
|
||||
return `${fmt.format(total)} ${props.line.currency}`
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
104
src/modules/restaurant/components/OrderInvoiceCard.vue
Normal file
104
src/modules/restaurant/components/OrderInvoiceCard.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Renders a bolt11 invoice as a copyable QR + amount + expiry.
|
||||
* Used on OrderStatusPage when the order is still pending.
|
||||
*/
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { Copy, Check } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { OrderInvoice } from '../types/restaurant'
|
||||
|
||||
const props = defineProps<{
|
||||
invoice: OrderInvoice
|
||||
}>()
|
||||
|
||||
const qrDataUrl = ref<string>('')
|
||||
const copied = ref(false)
|
||||
const expiresInSec = ref<number>(0)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function render() {
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(props.invoice.bolt11, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
scale: 6,
|
||||
// Theme-aware colors are tricky for QR (must stay
|
||||
// high-contrast for scanners). Stick with pure white bg /
|
||||
// black fg regardless of theme — QRs are read by camera not
|
||||
// by humans.
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to render bolt11 QR:', err)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.invoice.bolt11, render, { immediate: true })
|
||||
|
||||
function updateCountdown() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
expiresInSec.value = Math.max(0, props.invoice.expires_at - now)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCountdown()
|
||||
countdownTimer = setInterval(updateCountdown, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
|
||||
function copy() {
|
||||
void navigator.clipboard.writeText(props.invoice.bolt11)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1500)
|
||||
}
|
||||
|
||||
function fmtCountdown(seconds: number) {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardContent class="space-y-3 p-4 sm:p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-semibold text-foreground">Lightning invoice</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">
|
||||
<span v-if="expiresInSec > 0">expires in {{ fmtCountdown(expiresInSec) }}</span>
|
||||
<span v-else class="text-destructive">expired</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="bolt11 invoice QR code"
|
||||
class="block h-56 w-56 sm:h-64 sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-muted-foreground">Amount</span>
|
||||
<span class="font-mono text-sm font-semibold text-primary">
|
||||
{{ Math.ceil(invoice.amount_msat / 1000) }} sat
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full font-mono text-xs"
|
||||
@click="copy"
|
||||
>
|
||||
<Check v-if="copied" class="mr-2 h-3.5 w-3.5" />
|
||||
<Copy v-else class="mr-2 h-3.5 w-3.5" />
|
||||
{{ copied ? 'Copied' : 'Copy invoice' }}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
309
src/modules/restaurant/composables/useCheckout.ts
Normal file
309
src/modules/restaurant/composables/useCheckout.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* useCheckout — drives the customer's place-and-pay flow against
|
||||
* the restaurant extension.
|
||||
*
|
||||
* v1 ships REST-only:
|
||||
* 1. quote (msat required)
|
||||
* 2. balance pre-check (sum across all restaurants in the cart)
|
||||
* 3. POST /orders → { order, invoice }
|
||||
* 4. (optional, customer choice) POST /api/v1/payments to settle
|
||||
* the bolt11 from the customer's LNbits wallet. They can also
|
||||
* skip this step and scan the QR with any other wallet — the
|
||||
* extension's invoice listener marks the order paid either way.
|
||||
*
|
||||
* Split into two distinct actions so the UI can render the QR codes
|
||||
* between place and pay, giving the customer the option to scan
|
||||
* with an external wallet rather than auto-paying from LNbits:
|
||||
*
|
||||
* placeOrders() — runs steps 1-3, populates `state.value.placedOrders`
|
||||
* payOrder(idx) — runs step 4 for one placed-order index
|
||||
*
|
||||
* The festival aggregator (aiolabs/restaurant#8) exercises this same
|
||||
* path with N > 1 restaurants in the cart. v1 happens to ship a UI
|
||||
* where N == 1, but the orchestration is multi-restaurant ready.
|
||||
*
|
||||
* NIP-17 transport (aiolabs/restaurant#9) plugs in via the
|
||||
* `buildCreateOrder` helper — single point both REST and Nostr
|
||||
* transports construct CreateOrder. Loyalty (#5) injects its
|
||||
* pass-through fields the same way.
|
||||
*/
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import appConfig from '@/app.config'
|
||||
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||
import { useCartStore, type CartLine } from '../stores/cart'
|
||||
import type {
|
||||
CreateOrder,
|
||||
CreateOrderItem,
|
||||
Order,
|
||||
OrderInvoice,
|
||||
PlaceOrderResponse,
|
||||
} from '../types/restaurant'
|
||||
|
||||
export interface PlacedOrder {
|
||||
restaurantId: string
|
||||
restaurantSlug: string
|
||||
order: Order
|
||||
invoice: OrderInvoice | null
|
||||
}
|
||||
|
||||
export interface CheckoutState {
|
||||
step:
|
||||
| 'idle'
|
||||
| 'quoting'
|
||||
| 'placing'
|
||||
| 'placed'
|
||||
| 'paying'
|
||||
| 'paid'
|
||||
| 'error'
|
||||
progress: { current: number; total: number }
|
||||
currentRestaurantSlug: string | null
|
||||
placedOrders: PlacedOrder[]
|
||||
/** Set of `placedOrders[i].order.id` that have been auto-paid
|
||||
* via LNbits in this session. External-wallet payments don't
|
||||
* populate this — they're detected via the per-order poller in
|
||||
* CheckoutPage. */
|
||||
paidOrderIds: Set<string>
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface UseCheckoutReturn {
|
||||
state: Ref<CheckoutState>
|
||||
/** Run quote → balance precheck → POST /orders for every cart
|
||||
* bucket. Returns the placed orders (also persisted in state). */
|
||||
placeOrders: () => Promise<PlacedOrder[]>
|
||||
/** Pay one already-placed order's bolt11 from the customer's
|
||||
* LNbits wallet. Idempotent — calling twice on the same order
|
||||
* is a no-op after the first success. */
|
||||
payOrder: (placedIndex: number) => Promise<void>
|
||||
/** Pay every unpaid placed-order in sequence. Best-effort: if
|
||||
* one fails, earlier successes stay paid. */
|
||||
payAll: () => Promise<void>
|
||||
/** Reset to idle and drop placed/paid state. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The single point CreateOrder is built — keeps loyalty (#5), NIP-17
|
||||
* transport (#9), and tip overrides one-place changes rather than
|
||||
* touching the whole flow.
|
||||
*/
|
||||
function buildCreateOrder(
|
||||
restaurantId: string,
|
||||
customerPubkey: string | undefined,
|
||||
lines: CartLine[]
|
||||
): CreateOrder {
|
||||
const items: CreateOrderItem[] = lines.map((l) => ({
|
||||
menu_item_id: l.menu_item_id,
|
||||
quantity: l.quantity,
|
||||
selected_modifiers: l.selected_modifiers,
|
||||
note: l.note ?? undefined,
|
||||
}))
|
||||
// Loyalty (#5) future-extension point: when implemented, inject
|
||||
// { loyalty_credits_msat, loyalty_pubkey } into extra.fields here.
|
||||
return {
|
||||
restaurant_id: restaurantId,
|
||||
customer_pubkey: customerPubkey || null,
|
||||
items,
|
||||
channel: 'rest',
|
||||
payment_method: 'lightning',
|
||||
}
|
||||
}
|
||||
|
||||
function blankState(): CheckoutState {
|
||||
return {
|
||||
step: 'idle',
|
||||
progress: { current: 0, total: 0 },
|
||||
currentRestaurantSlug: null,
|
||||
placedOrders: [],
|
||||
paidOrderIds: new Set(),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCheckout(): UseCheckoutReturn {
|
||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||
const cart = useCartStore()
|
||||
const { user } = useAuth()
|
||||
|
||||
// We talk to LNbits's payments endpoint directly rather than
|
||||
// pulling in the whole `wallet` module — the restaurant-app
|
||||
// bundle is a customer surface, not a wallet UI.
|
||||
const apiBaseUrl =
|
||||
(
|
||||
appConfig.modules.restaurant as
|
||||
| { config?: { apiBaseUrl?: string } }
|
||||
| undefined
|
||||
)?.config?.apiBaseUrl || ''
|
||||
|
||||
const state = ref<CheckoutState>(blankState())
|
||||
|
||||
function reset(): void {
|
||||
state.value = blankState()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- //
|
||||
// place //
|
||||
// ----------------------------------------------------------------- //
|
||||
|
||||
async function placeOrders(): Promise<PlacedOrder[]> {
|
||||
const buckets = cart.restaurantsInCart.map((rid) => ({
|
||||
restaurantId: rid,
|
||||
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||
lines: cart.linesFor(rid),
|
||||
}))
|
||||
if (!buckets.length) {
|
||||
throw new Error('Cart is empty')
|
||||
}
|
||||
|
||||
state.value = {
|
||||
...blankState(),
|
||||
step: 'quoting',
|
||||
progress: { current: 0, total: buckets.length },
|
||||
}
|
||||
|
||||
// 1. Quote per restaurant.
|
||||
const quotes: Array<{
|
||||
restaurantId: string
|
||||
restaurantSlug: string
|
||||
lines: CartLine[]
|
||||
msat: number
|
||||
}> = []
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const b = buckets[i]
|
||||
state.value.currentRestaurantSlug = b.restaurantSlug
|
||||
state.value.progress = { current: i, total: buckets.length }
|
||||
const quote = await api.quoteOrder(
|
||||
b.lines.map((l) => ({
|
||||
menu_item_id: l.menu_item_id,
|
||||
quantity: l.quantity,
|
||||
selected_modifiers: l.selected_modifiers,
|
||||
note: l.note ?? undefined,
|
||||
}))
|
||||
)
|
||||
quotes.push({ ...b, msat: quote.required_msat })
|
||||
}
|
||||
|
||||
// 2. Balance pre-check.
|
||||
const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0)
|
||||
const wallet0 = user.value?.wallets?.[0]
|
||||
if (wallet0 && typeof wallet0.balance_msat === 'number') {
|
||||
if (wallet0.balance_msat < totalMsatRequired) {
|
||||
const needSat = Math.ceil(totalMsatRequired / 1000)
|
||||
const haveSat = Math.floor(wallet0.balance_msat / 1000)
|
||||
// Not fatal — the customer may still want to scan the QR
|
||||
// and pay from an external wallet. Surface a warning but
|
||||
// continue.
|
||||
console.warn(
|
||||
`[restaurant] LNbits wallet balance is below the cart total ` +
|
||||
`(have ${haveSat} sat, need ${needSat} sat). Auto-pay will ` +
|
||||
`fail; scan the QR with an external wallet to settle.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Place orders.
|
||||
state.value.step = 'placing'
|
||||
const placed: PlacedOrder[] = []
|
||||
for (let i = 0; i < quotes.length; i++) {
|
||||
const q = quotes[i]
|
||||
state.value.currentRestaurantSlug = q.restaurantSlug
|
||||
state.value.progress = { current: i, total: quotes.length }
|
||||
const payload = buildCreateOrder(
|
||||
q.restaurantId,
|
||||
user.value?.pubkey,
|
||||
q.lines
|
||||
)
|
||||
const result: PlaceOrderResponse = await api.placeOrder(payload)
|
||||
placed.push({
|
||||
restaurantId: q.restaurantId,
|
||||
restaurantSlug: q.restaurantSlug,
|
||||
order: result.order,
|
||||
invoice: result.invoice,
|
||||
})
|
||||
}
|
||||
|
||||
state.value.step = 'placed'
|
||||
state.value.placedOrders = placed
|
||||
state.value.currentRestaurantSlug = null
|
||||
return placed
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- //
|
||||
// pay //
|
||||
// ----------------------------------------------------------------- //
|
||||
|
||||
async function payBolt11Raw(
|
||||
bolt11: string,
|
||||
adminkey: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': adminkey,
|
||||
},
|
||||
body: JSON.stringify({ out: true, bolt11 }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
try {
|
||||
const body = await response.json()
|
||||
if (body?.detail) detail = body.detail
|
||||
} catch {
|
||||
/* body wasn't JSON */
|
||||
}
|
||||
throw new Error(`Payment failed: ${response.status} ${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function payOrder(placedIndex: number): Promise<void> {
|
||||
const placed = state.value.placedOrders[placedIndex]
|
||||
if (!placed) throw new Error(`No placed order at index ${placedIndex}`)
|
||||
if (!placed.invoice) return // cash orders skip payment
|
||||
if (state.value.paidOrderIds.has(placed.order.id)) return // already paid
|
||||
|
||||
const adminkey = user.value?.wallets?.[0]?.adminkey
|
||||
if (!adminkey) {
|
||||
throw new Error('No wallet available — please log in first.')
|
||||
}
|
||||
|
||||
state.value.step = 'paying'
|
||||
state.value.currentRestaurantSlug = placed.restaurantSlug
|
||||
try {
|
||||
await payBolt11Raw(placed.invoice.bolt11, adminkey)
|
||||
// Set semantics keeps `paidOrderIds` from re-renders; rebuild
|
||||
// it on update so Vue picks up the change.
|
||||
state.value.paidOrderIds = new Set([
|
||||
...state.value.paidOrderIds,
|
||||
placed.order.id,
|
||||
])
|
||||
// Bump to 'paid' only when every placed order is paid.
|
||||
if (
|
||||
state.value.placedOrders.every((p) =>
|
||||
state.value.paidOrderIds.has(p.order.id)
|
||||
)
|
||||
) {
|
||||
state.value.step = 'paid'
|
||||
} else {
|
||||
state.value.step = 'placed'
|
||||
}
|
||||
} catch (err) {
|
||||
state.value.step = 'error'
|
||||
state.value.error = err instanceof Error ? err.message : String(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function payAll(): Promise<void> {
|
||||
for (let i = 0; i < state.value.placedOrders.length; i++) {
|
||||
const p = state.value.placedOrders[i]
|
||||
if (!p.invoice) continue
|
||||
if (state.value.paidOrderIds.has(p.order.id)) continue
|
||||
await payOrder(i)
|
||||
}
|
||||
}
|
||||
|
||||
return { state, placeOrders, payOrder, payAll, reset }
|
||||
}
|
||||
|
|
@ -15,8 +15,13 @@
|
|||
*/
|
||||
|
||||
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 { RestaurantNostrSync } from '../services/RestaurantNostrSync'
|
||||
import type {
|
||||
EnrichedMenuItem,
|
||||
MenuNode,
|
||||
|
|
@ -33,6 +38,7 @@ function looksLikeId(value: string): boolean {
|
|||
export interface UseMenuReturn {
|
||||
restaurant: Ref<Restaurant | null>
|
||||
tree: Ref<MenuNode[]>
|
||||
/** Items with the Nostr live overlay merged in. */
|
||||
items: Ref<EnrichedMenuItem[]>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
|
|
@ -41,10 +47,13 @@ export interface UseMenuReturn {
|
|||
|
||||
export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||
const sync = tryInjectService<RestaurantNostrSync>(
|
||||
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
|
||||
)
|
||||
|
||||
const restaurant = ref<Restaurant | null>(null)
|
||||
const tree = ref<MenuNode[]>([])
|
||||
const items = ref<EnrichedMenuItem[]>([])
|
||||
const baseItems = ref<EnrichedMenuItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
|
|
@ -54,6 +63,24 @@ export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
|||
typeof slugOrId === 'string' ? slugOrId : slugOrId.value
|
||||
)
|
||||
|
||||
/**
|
||||
* `items` exposes the base REST snapshot patched with the live
|
||||
* overlay from RestaurantNostrSync. Operator edits on the
|
||||
* extension side surface here within ~1s of arriving at the
|
||||
* relay, without a refetch.
|
||||
*/
|
||||
const items = computed<EnrichedMenuItem[]>(() => {
|
||||
const overlay = sync?.overlay
|
||||
const deleted = sync?.deleted
|
||||
if (!overlay && !deleted) return baseItems.value
|
||||
return baseItems.value
|
||||
.filter((it) => !(deleted && deleted.has(it.id)))
|
||||
.map((it) => {
|
||||
const patch = overlay?.get(it.id)
|
||||
return patch ? ({ ...it, ...patch } as EnrichedMenuItem) : it
|
||||
})
|
||||
})
|
||||
|
||||
async function load(value: string): Promise<void> {
|
||||
if (!value) return
|
||||
abortController?.abort()
|
||||
|
|
@ -73,7 +100,7 @@ export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
|||
|
||||
restaurant.value = menu.restaurant
|
||||
tree.value = menu.tree
|
||||
items.value = menu.items
|
||||
baseItems.value = menu.items
|
||||
} catch (err) {
|
||||
if (my.signal.aborted) return
|
||||
error.value = err instanceof Error ? err : new Error(String(err))
|
||||
|
|
|
|||
154
src/modules/restaurant/composables/useOrder.ts
Normal file
154
src/modules/restaurant/composables/useOrder.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* useOrder(orderId) — polls the restaurant extension for status
|
||||
* updates on a single order.
|
||||
*
|
||||
* Polls every `orderPollMs` (from app.config.modules.restaurant)
|
||||
* while status is in a non-terminal state. Resets to an immediate
|
||||
* fetch on `VisibilityService.onVisible` so a backgrounded tab
|
||||
* catches up the moment it comes back. Cleans the interval on
|
||||
* scope dispose.
|
||||
*
|
||||
* Status is treated as an open string (see KNOWN_ORDER_STATUSES)
|
||||
* — production / kitchen workflow (aiolabs/restaurant#4) may
|
||||
* introduce new states.
|
||||
*/
|
||||
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import {
|
||||
injectService,
|
||||
tryInjectService,
|
||||
SERVICE_TOKENS,
|
||||
} from '@/core/di-container'
|
||||
import appConfig from '@/app.config'
|
||||
import type { VisibilityService } from '@/core/services/VisibilityService'
|
||||
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||
import type {
|
||||
Order,
|
||||
OrderItemRow,
|
||||
OrderStatus,
|
||||
} from '../types/restaurant'
|
||||
|
||||
const TERMINAL_STATUSES: OrderStatus[] = [
|
||||
'completed',
|
||||
'canceled',
|
||||
'refunded',
|
||||
]
|
||||
|
||||
export interface UseOrderReturn {
|
||||
order: Ref<Order | null>
|
||||
items: Ref<OrderItemRow[]>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useOrder(orderId: Ref<string> | string): UseOrderReturn {
|
||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||
const visibility = tryInjectService<VisibilityService>(
|
||||
SERVICE_TOKENS.VISIBILITY_SERVICE
|
||||
)
|
||||
|
||||
const pollMs =
|
||||
(
|
||||
appConfig.modules.restaurant as
|
||||
| { config?: { orderPollMs?: number } }
|
||||
| undefined
|
||||
)?.config?.orderPollMs ?? 5000
|
||||
|
||||
const order = ref<Order | null>(null)
|
||||
const items = ref<OrderItemRow[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let unregisterVisibility: (() => void) | null = null
|
||||
|
||||
function targetId(): string {
|
||||
return typeof orderId === 'string' ? orderId : orderId.value
|
||||
}
|
||||
|
||||
async function fetchOnce(): Promise<void> {
|
||||
const id = targetId()
|
||||
if (!id) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await api.getOrder(id)
|
||||
order.value = data.order
|
||||
items.value = data.items
|
||||
error.value = null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err : new Error(String(err))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(status: OrderStatus | undefined): boolean {
|
||||
return !!status && TERMINAL_STATUSES.includes(status)
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (timer) return
|
||||
timer = setInterval(async () => {
|
||||
if (isTerminal(order.value?.status)) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
await fetchOnce()
|
||||
}, pollMs)
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch immediately when the tab becomes visible again — useful
|
||||
// for mobile where polling pauses during background. The
|
||||
// VisibilityService takes (name, onResume, onPause) and returns
|
||||
// an unregister fn.
|
||||
if (visibility) {
|
||||
unregisterVisibility = visibility.registerService(
|
||||
`useOrder-${typeof orderId === 'string' ? orderId : 'ref'}`,
|
||||
async () => {
|
||||
await fetchOnce()
|
||||
if (
|
||||
!isTerminal((order.value as Order | null)?.status)
|
||||
) {
|
||||
startPolling()
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
// pause polling while hidden — saves battery on mobile
|
||||
stopPolling()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => targetId(),
|
||||
async (id) => {
|
||||
stopPolling()
|
||||
order.value = null
|
||||
items.value = []
|
||||
if (!id) return
|
||||
await fetchOnce()
|
||||
if (!isTerminal((order.value as Order | null)?.status)) startPolling()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopPolling()
|
||||
unregisterVisibility?.()
|
||||
})
|
||||
|
||||
return {
|
||||
order,
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchOnce,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { ModulePlugin } from '@/core/types'
|
|||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||
|
||||
import { RestaurantAPI } from './services/RestaurantAPI'
|
||||
import { RestaurantNostrSync } from './services/RestaurantNostrSync'
|
||||
|
||||
// v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug).
|
||||
//
|
||||
|
|
@ -64,7 +65,21 @@ export const restaurantModule: ModulePlugin = {
|
|||
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')
|
||||
},
|
||||
|
|
@ -94,6 +109,30 @@ export const restaurantModule: ModulePlugin = {
|
|||
component: () => import('./views/CartPage.vue'),
|
||||
meta: { requiresAuth: false, title: 'Cart' },
|
||||
},
|
||||
{
|
||||
path: '/checkout',
|
||||
name: 'restaurant-checkout',
|
||||
component: () => import('./views/CheckoutPage.vue'),
|
||||
meta: { requiresAuth: false, title: 'Checkout' },
|
||||
},
|
||||
{
|
||||
path: '/orders/:id',
|
||||
name: 'restaurant-order',
|
||||
component: () => import('./views/OrderStatusPage.vue'),
|
||||
meta: { requiresAuth: false, title: 'Order' },
|
||||
},
|
||||
{
|
||||
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[],
|
||||
}
|
||||
|
||||
|
|
|
|||
211
src/modules/restaurant/services/RestaurantNostrSync.ts
Normal file
211
src/modules/restaurant/services/RestaurantNostrSync.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* RestaurantNostrSync — live overlay for menu item state via NIP-99.
|
||||
*
|
||||
* Subscribes to the restaurant's `nostr_pubkey` for:
|
||||
* - kind 30402 (NIP-99 classified listings) tagged
|
||||
* `["l", "restaurant:<restaurant.id>"]`
|
||||
* - kind 5 (NIP-09 deletion requests)
|
||||
*
|
||||
* Each incoming 30402 is parsed into a partial MenuItem patch keyed
|
||||
* by the `d` tag (= menu item id) and pushed to a reactive overlay
|
||||
* map. `useMenu` merges this overlay into its `items` computed so
|
||||
* price changes, sold-out flips, and availability updates render
|
||||
* within ~1s of the operator's edit on the extension side.
|
||||
*
|
||||
* Subscription lifecycle is owned by the *consumer* (RestaurantPage
|
||||
* opens on mount, closes on route leave). Visibility integration is
|
||||
* handled implicitly by the RelayHub — backgrounded tabs lose the
|
||||
* underlying WebSocket; we re-subscribe on the next mount.
|
||||
*
|
||||
* This service holds NO state about which restaurants are subscribed
|
||||
* — it expects callers to track their own sub ids if they need to
|
||||
* tear them down individually.
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import type { Filter } from 'nostr-tools'
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||
import type { MenuItem } from '../types/restaurant'
|
||||
|
||||
interface NostrEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig?: string
|
||||
}
|
||||
|
||||
const SUB_KIND_LISTING = 30402
|
||||
const SUB_KIND_DELETION = 5
|
||||
|
||||
export type MenuItemPatch = Partial<
|
||||
Pick<
|
||||
MenuItem,
|
||||
'name' | 'description' | 'price' | 'is_available' | 'stock' | 'nostr_event_id' | 'nostr_event_created_at'
|
||||
>
|
||||
>
|
||||
|
||||
export class RestaurantNostrSync extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'RestaurantNostrSync',
|
||||
version: '1.0.0',
|
||||
dependencies: ['RelayHub'] as string[],
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive overlay: itemId → partial patch. `useMenu` watches
|
||||
* this and merges into its `items` array.
|
||||
*/
|
||||
readonly overlay = reactive(new Map<string, MenuItemPatch>())
|
||||
|
||||
/** Deleted item ids — useMenu filters these out. */
|
||||
readonly deleted = reactive(new Set<string>())
|
||||
|
||||
// BaseService auto-populates `this.relayHub` from
|
||||
// SERVICE_TOKENS.RELAY_HUB because our `metadata.dependencies`
|
||||
// includes 'RelayHub' — no manual inject needed in onInitialize.
|
||||
|
||||
private unsubscribers = new Map<string, () => void>()
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
this.debug('RestaurantNostrSync ready')
|
||||
}
|
||||
|
||||
/** Typed accessor for the BaseService-injected relay hub. */
|
||||
private get hub(): RelayHub | null {
|
||||
return (this.relayHub as RelayHub | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the per-restaurant subscription. Returns an unsubscribe
|
||||
* fn for convenience; calling subscribe() again with the same
|
||||
* restaurantId is a no-op (idempotent).
|
||||
*/
|
||||
subscribe(restaurantPubkey: string, restaurantId: string): () => void {
|
||||
const hub = this.hub
|
||||
if (!hub) {
|
||||
this.debug('subscribe: relay hub not ready')
|
||||
return () => {}
|
||||
}
|
||||
if (this.unsubscribers.has(restaurantId)) {
|
||||
return () => this.unsubscribe(restaurantId)
|
||||
}
|
||||
const filter: Filter = {
|
||||
kinds: [SUB_KIND_LISTING, SUB_KIND_DELETION],
|
||||
authors: [restaurantPubkey],
|
||||
'#l': [`restaurant:${restaurantId}`],
|
||||
}
|
||||
try {
|
||||
const offEvent = hub.subscribe({
|
||||
id: `restaurant-${restaurantId}`,
|
||||
filters: [filter],
|
||||
onEvent: (event) => this.handleEvent(event as NostrEvent),
|
||||
})
|
||||
this.unsubscribers.set(restaurantId, offEvent)
|
||||
this.debug(`subscribed authors=${restaurantPubkey.slice(0, 8)}…`)
|
||||
} catch (err) {
|
||||
// RelayHub throws if not connected. The user can still browse
|
||||
// via REST — this just means no live updates this session.
|
||||
this.debug(`subscribe failed (live overlay disabled): ${String(err)}`)
|
||||
}
|
||||
return () => this.unsubscribe(restaurantId)
|
||||
}
|
||||
|
||||
unsubscribe(restaurantId: string): void {
|
||||
const off = this.unsubscribers.get(restaurantId)
|
||||
if (off) {
|
||||
off()
|
||||
this.unsubscribers.delete(restaurantId)
|
||||
this.debug(`unsubscribed ${restaurantId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all subscriptions and clear the overlay. BaseService
|
||||
* defines this as async; we honor the signature even though our
|
||||
* cleanup is synchronous.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
for (const off of this.unsubscribers.values()) off()
|
||||
this.unsubscribers.clear()
|
||||
this.overlay.clear()
|
||||
this.deleted.clear()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- //
|
||||
// event handlers //
|
||||
// ----------------------------------------------------------------- //
|
||||
|
||||
private handleEvent(event: NostrEvent): void {
|
||||
if (event.kind === SUB_KIND_LISTING) {
|
||||
this.handleListing(event)
|
||||
} else if (event.kind === SUB_KIND_DELETION) {
|
||||
this.handleDeletion(event)
|
||||
}
|
||||
}
|
||||
|
||||
private handleListing(event: NostrEvent): void {
|
||||
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]
|
||||
if (!dTag) return
|
||||
|
||||
// Skip if we've already seen a newer version of this addressable
|
||||
// event (NIP-33 replaceable semantics — operator-side bug
|
||||
// protection).
|
||||
const existing = this.overlay.get(dTag)
|
||||
if (
|
||||
existing?.nostr_event_created_at &&
|
||||
existing.nostr_event_created_at >= event.created_at
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const patch: MenuItemPatch = {
|
||||
nostr_event_id: event.id,
|
||||
nostr_event_created_at: event.created_at,
|
||||
}
|
||||
|
||||
const title = event.tags.find((t) => t[0] === 'title')?.[1]
|
||||
if (title) patch.name = title
|
||||
|
||||
const summary = event.tags.find((t) => t[0] === 'summary')?.[1]
|
||||
if (summary) patch.description = summary
|
||||
|
||||
const priceTag = event.tags.find((t) => t[0] === 'price')
|
||||
if (priceTag && priceTag[1]) {
|
||||
const parsed = parseFloat(priceTag[1])
|
||||
if (!Number.isNaN(parsed)) patch.price = parsed
|
||||
}
|
||||
|
||||
// NIP-99 status: 'active' | 'sold'. We map to is_available +
|
||||
// stock=0 so the existing UI badges (sold out / low stock)
|
||||
// render consistently.
|
||||
const statusTag = event.tags.find((t) => t[0] === 'status')?.[1]
|
||||
if (statusTag === 'sold') {
|
||||
patch.is_available = false
|
||||
patch.stock = 0
|
||||
} else if (statusTag === 'active') {
|
||||
patch.is_available = true
|
||||
}
|
||||
|
||||
this.overlay.set(dTag, patch)
|
||||
this.debug(`overlay merge id=${dTag.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
private handleDeletion(event: NostrEvent): void {
|
||||
// NIP-09: an `a` tag references the addressable event the author
|
||||
// is deleting. Format: 'kind:pubkey:dTag'.
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'a' || !tag[1]) continue
|
||||
const parts = tag[1].split(':')
|
||||
if (parts[0] !== String(SUB_KIND_LISTING)) continue
|
||||
const dTag = parts[2]
|
||||
if (dTag) {
|
||||
this.deleted.add(dTag)
|
||||
this.overlay.delete(dTag)
|
||||
this.debug(`deletion id=${dTag.slice(0, 8)}…`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,8 +30,16 @@ export interface CartLine {
|
|||
/** Snapshot at add-time so the cart still renders if the menu
|
||||
* item is later edited / deleted. */
|
||||
name: string
|
||||
/** Base price + selected modifier deltas, in 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
|
||||
selected_modifiers: SelectedModifier[]
|
||||
note?: string | null
|
||||
|
|
@ -91,19 +99,42 @@ export const useCartStore = defineStore('restaurant-cart', () => {
|
|||
return n
|
||||
})
|
||||
|
||||
const restaurantTotalsMsat = computed<Record<string, number>>(() => {
|
||||
const out: Record<string, number> = {}
|
||||
/** Per-restaurant subtotal in that restaurant's declared currency. */
|
||||
const restaurantTotals = computed<
|
||||
Record<string, { amount: number; currency: string }>
|
||||
>(() => {
|
||||
const out: Record<string, { amount: number; currency: string }> = {}
|
||||
for (const rid of Object.keys(lines.value)) {
|
||||
out[rid] = lines.value[rid].reduce(
|
||||
(s, l) => s + l.unit_msat * l.quantity,
|
||||
const bucket = lines.value[rid]
|
||||
if (!bucket.length) continue
|
||||
out[rid] = {
|
||||
amount: bucket.reduce(
|
||||
(s, l) => s + l.unit_price * l.quantity,
|
||||
0
|
||||
)
|
||||
),
|
||||
currency: bucket[0].currency,
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
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[] {
|
||||
|
|
@ -214,8 +245,8 @@ export const useCartStore = defineStore('restaurant-cart', () => {
|
|||
// getters
|
||||
restaurantsInCart,
|
||||
itemCount,
|
||||
restaurantTotalsMsat,
|
||||
grandTotalMsat,
|
||||
restaurantTotals,
|
||||
grandTotal,
|
||||
linesFor,
|
||||
// actions
|
||||
setActiveRestaurant,
|
||||
|
|
|
|||
|
|
@ -233,6 +233,34 @@ export const KNOWN_ORDER_STATUSES = [
|
|||
export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number]
|
||||
export type OrderStatus = string
|
||||
|
||||
/**
|
||||
* Customer-facing labels for order statuses. The extension's raw
|
||||
* status names are operational ('paid' / 'accepted' / 'ready') but
|
||||
* customers prefer human-friendly framing ('Order received' /
|
||||
* 'Cooking' / 'Ready for pickup').
|
||||
*
|
||||
* Future statuses from aiolabs/restaurant#4 (kitchen workflow) —
|
||||
* 'preparing', 'plating', 'at_pass', 'in_service', etc — can land
|
||||
* here as they arrive. Unknown values fall through to the raw
|
||||
* status string titlecased.
|
||||
*/
|
||||
export const FRIENDLY_ORDER_STATUS: Record<string, string> = {
|
||||
pending: 'Awaiting payment',
|
||||
paid: 'Order received',
|
||||
accepted: 'Cooking',
|
||||
ready: 'Ready for pickup',
|
||||
completed: 'Served',
|
||||
canceled: 'Canceled',
|
||||
refunded: 'Refunded',
|
||||
}
|
||||
|
||||
export function friendlyOrderStatus(status: OrderStatus): string {
|
||||
if (status in FRIENDLY_ORDER_STATUS) return FRIENDLY_ORDER_STATUS[status]
|
||||
// Unknown status — titlecase the raw key as a graceful fallback.
|
||||
if (!status) return ''
|
||||
return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
restaurant_id: string
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ const buckets = computed(() =>
|
|||
// a separate fetch for the cart page header.
|
||||
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||
lines: cart.linesFor(rid),
|
||||
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0,
|
||||
total: cart.restaurantTotals[rid] ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
function fmtSat(value: number): string {
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat`
|
||||
function fmt(value: number, currency: string): string {
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ function fmtSat(value: number): string {
|
|||
</button>
|
||||
</CardTitle>
|
||||
<span class="font-mono text-sm text-primary">
|
||||
{{ fmtSat(b.totalMsat) }}
|
||||
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -91,12 +91,16 @@ function fmtSat(value: number): string {
|
|||
<Separator class="my-6" />
|
||||
|
||||
<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="font-mono text-lg font-semibold text-foreground">
|
||||
{{ fmtSat(cart.grandTotalMsat) }}
|
||||
{{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted-foreground text-center">
|
||||
Cart spans multiple currencies — per-restaurant subtotals above.
|
||||
Lightning sat amount shown at checkout.
|
||||
</p>
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
|
|
|
|||
452
src/modules/restaurant/views/CheckoutPage.vue
Normal file
452
src/modules/restaurant/views/CheckoutPage.vue
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Two-phase checkout:
|
||||
*
|
||||
* Phase 1 — Review:
|
||||
* · cart subtotal in the menu's declared currency (e.g. GTQ)
|
||||
* · live ≈sat preview from POST /orders/quote
|
||||
* · "Place order" CTA → useCheckout.placeOrders()
|
||||
*
|
||||
* Phase 2 — Pay:
|
||||
* · OrderInvoiceCard per placed order (QR + amount + copy +
|
||||
* expiry countdown)
|
||||
* · "Pay from my LNbits wallet" CTA → useCheckout.payAll()
|
||||
* · External-wallet scans are detected via per-order polling
|
||||
* (the extension's invoice listener flips the order to
|
||||
* paid the moment ANY pay path settles).
|
||||
* · When all placed orders show paid, auto-redirect to
|
||||
* /orders/<first-id>.
|
||||
*
|
||||
* Cart lines are cleared per restaurant as each order is observed
|
||||
* paid, so a partial-pay state can be re-checked-out without
|
||||
* duplicates.
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import OrderInvoiceCard from '../components/OrderInvoiceCard.vue'
|
||||
import { useCartStore } from '../stores/cart'
|
||||
import { useCheckout, type PlacedOrder } from '../composables/useCheckout'
|
||||
import { friendlyOrderStatus } from '../types/restaurant'
|
||||
import {
|
||||
injectService,
|
||||
tryInjectService,
|
||||
SERVICE_TOKENS,
|
||||
} from '@/core/di-container'
|
||||
import type { StorageService } from '@/core/services/StorageService'
|
||||
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||
|
||||
const router = useRouter()
|
||||
const cart = useCartStore()
|
||||
const { state, placeOrders, payAll, reset } = useCheckout()
|
||||
const storage = tryInjectService<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
|
||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||
|
||||
// ----------------------------------------------------------------- //
|
||||
// review phase //
|
||||
// ----------------------------------------------------------------- //
|
||||
|
||||
function fmt(value: number, currency: string) {
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
|
||||
}
|
||||
|
||||
const buckets = computed(() =>
|
||||
cart.restaurantsInCart.map((rid) => ({
|
||||
restaurantId: rid,
|
||||
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||
lines: cart.linesFor(rid),
|
||||
total: cart.restaurantTotals[rid] ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
/** Live ≈sat preview: one /orders/quote per restaurant. */
|
||||
const previewSatPerRestaurant = ref<Record<string, number | null>>({})
|
||||
const previewSatTotal = computed<number | null>(() => {
|
||||
const vals = Object.values(previewSatPerRestaurant.value)
|
||||
if (!vals.length || vals.some((v) => v === null)) return null
|
||||
return (vals as number[]).reduce((s, v) => s + v, 0)
|
||||
})
|
||||
|
||||
watch(
|
||||
() =>
|
||||
cart.restaurantsInCart.map((rid) => ({
|
||||
rid,
|
||||
lines: cart.linesFor(rid),
|
||||
})),
|
||||
async (groups) => {
|
||||
if (state.value.step !== 'idle') return // freeze preview after place
|
||||
const next: Record<string, number | null> = {}
|
||||
for (const g of groups) {
|
||||
try {
|
||||
const q = await api.quoteOrder(
|
||||
g.lines.map((l) => ({
|
||||
menu_item_id: l.menu_item_id,
|
||||
quantity: l.quantity,
|
||||
selected_modifiers: l.selected_modifiers,
|
||||
note: l.note ?? undefined,
|
||||
}))
|
||||
)
|
||||
next[g.rid] = Math.ceil(q.required_msat / 1000)
|
||||
} catch {
|
||||
next[g.rid] = null
|
||||
}
|
||||
}
|
||||
previewSatPerRestaurant.value = next
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------- //
|
||||
// place phase //
|
||||
// ----------------------------------------------------------------- //
|
||||
|
||||
onMounted(() => {
|
||||
if (!buckets.value.length && !state.value.placedOrders.length) {
|
||||
router.replace('/cart')
|
||||
}
|
||||
})
|
||||
|
||||
async function onPlaceOrder() {
|
||||
try {
|
||||
await placeOrders()
|
||||
const entries = state.value.placedOrders.map((p) => ({
|
||||
orderId: p.order.id,
|
||||
restaurantId: p.restaurantId,
|
||||
restaurantSlug: p.restaurantSlug,
|
||||
placedAt: Date.now(),
|
||||
totalMsat: p.order.total_msat,
|
||||
}))
|
||||
if (storage && entries.length) {
|
||||
const existing =
|
||||
storage.getUserData<typeof entries>(
|
||||
'restaurant.lastOrders.v1',
|
||||
[]
|
||||
) || []
|
||||
const merged = [...entries, ...existing].slice(0, 50)
|
||||
storage.setUserData('restaurant.lastOrders.v1', merged)
|
||||
}
|
||||
} catch (err) {
|
||||
state.value.step = 'error'
|
||||
state.value.error = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- //
|
||||
// pay phase + poller //
|
||||
// ----------------------------------------------------------------- //
|
||||
|
||||
const liveStatusByOrderId = ref<Record<string, string>>({})
|
||||
|
||||
function statusOf(orderId: string): string {
|
||||
return (
|
||||
liveStatusByOrderId.value[orderId] ||
|
||||
state.value.placedOrders.find((p) => p.order.id === orderId)?.order
|
||||
.status ||
|
||||
'pending'
|
||||
)
|
||||
}
|
||||
|
||||
function isPaid(orderId: string): boolean {
|
||||
const s = statusOf(orderId)
|
||||
return ['paid', 'accepted', 'ready', 'completed'].includes(s)
|
||||
}
|
||||
|
||||
const allPaid = computed(() => {
|
||||
if (!state.value.placedOrders.length) return false
|
||||
return state.value.placedOrders.every((p) => isPaid(p.order.id))
|
||||
})
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!state.value.placedOrders.length) return
|
||||
for (const p of state.value.placedOrders) {
|
||||
if (isPaid(p.order.id)) continue
|
||||
try {
|
||||
const fresh = await api.getOrder(p.order.id)
|
||||
liveStatusByOrderId.value = {
|
||||
...liveStatusByOrderId.value,
|
||||
[p.order.id]: fresh.order.status,
|
||||
}
|
||||
if (
|
||||
['paid', 'accepted', 'ready', 'completed'].includes(
|
||||
fresh.order.status
|
||||
)
|
||||
) {
|
||||
cart.clearRestaurant(p.restaurantId)
|
||||
}
|
||||
} catch {
|
||||
// transient — try again next tick
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(allPaid, (paid) => {
|
||||
if (paid && state.value.placedOrders.length) {
|
||||
stopPolling()
|
||||
setTimeout(() => {
|
||||
const first = state.value.placedOrders[0]
|
||||
if (first) router.push(`/orders/${first.order.id}`)
|
||||
}, 1200)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => state.value.placedOrders.length,
|
||||
(n) => {
|
||||
if (n > 0) startPolling()
|
||||
else stopPolling()
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
const isPlacing = computed(() =>
|
||||
['quoting', 'placing'].includes(state.value.step)
|
||||
)
|
||||
const isPaying = computed(() => state.value.step === 'paying')
|
||||
|
||||
async function onPayAll() {
|
||||
state.value.error = null
|
||||
try {
|
||||
await payAll()
|
||||
} catch {
|
||||
// useCheckout sets state.error already.
|
||||
}
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
reset()
|
||||
}
|
||||
|
||||
function buildOrderInvoice(p: PlacedOrder) {
|
||||
return p.invoice
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mb-3"
|
||||
:disabled="isPlacing || isPaying"
|
||||
@click="router.back()"
|
||||
>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<h1 class="mb-4 text-2xl font-bold text-foreground">Checkout</h1>
|
||||
|
||||
<!-- Phase 1 — Review (before orders are placed) -->
|
||||
<template v-if="!state.placedOrders.length">
|
||||
<div class="space-y-4">
|
||||
<Card v-for="b in buckets" :key="b.restaurantId">
|
||||
<CardHeader class="flex flex-row items-baseline justify-between space-y-0">
|
||||
<CardTitle class="text-base">{{ b.restaurantSlug }}</CardTitle>
|
||||
<span class="font-mono text-sm text-primary">
|
||||
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-1">
|
||||
<div
|
||||
v-for="line in b.lines"
|
||||
:key="line.line_id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-foreground">
|
||||
{{ line.quantity }}× {{ line.name }}
|
||||
<span
|
||||
v-if="line.selected_modifiers.length"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
· {{ line.selected_modifiers.map((m) => m.name).join(', ') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{{ fmt(line.unit_price * line.quantity, line.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="previewSatPerRestaurant[b.restaurantId] != null"
|
||||
class="mt-1 flex items-center justify-end gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Zap class="h-3 w-3" />
|
||||
≈ {{ new Intl.NumberFormat().format(previewSatPerRestaurant[b.restaurantId]!) }} sat
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-if="cart.grandTotal" class="flex items-baseline justify-between">
|
||||
<span class="text-sm text-muted-foreground">Total</span>
|
||||
<span class="font-mono text-xl font-bold text-foreground">
|
||||
{{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="previewSatTotal != null"
|
||||
class="flex items-center justify-between rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Zap class="h-3.5 w-3.5" />
|
||||
Pay in sats
|
||||
</span>
|
||||
<span class="font-mono font-semibold text-foreground">
|
||||
≈ {{ new Intl.NumberFormat().format(previewSatTotal) }} sat
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
v-if="state.step === 'error' && state.error"
|
||||
variant="destructive"
|
||||
>
|
||||
<AlertTitle>Couldn't place order</AlertTitle>
|
||||
<AlertDescription>{{ state.error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:disabled="isPlacing || !buckets.length"
|
||||
@click="onPlaceOrder"
|
||||
>
|
||||
<Loader2 v-if="isPlacing" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<span v-if="state.step === 'quoting'">Quoting…</span>
|
||||
<span v-else-if="state.step === 'placing'">Placing orders…</span>
|
||||
<span v-else>Place order</span>
|
||||
</Button>
|
||||
<p
|
||||
v-if="isPlacing && state.currentRestaurantSlug"
|
||||
class="text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ state.currentRestaurantSlug }} ({{ state.progress.current + 1 }} of {{ state.progress.total }})
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Phase 2 — Pay (orders placed, invoice(s) to settle) -->
|
||||
<template v-else>
|
||||
<Alert v-if="allPaid" class="mb-4 border-emerald-500/40">
|
||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||
<AlertTitle>Payment received</AlertTitle>
|
||||
<AlertDescription>Redirecting to your order…</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<p v-if="!allPaid" class="mb-4 text-sm text-muted-foreground">
|
||||
Scan with any Lightning wallet, or tap the button below to
|
||||
pay from your LNbits wallet.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="placed in state.placedOrders"
|
||||
:key="placed.order.id"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-foreground">
|
||||
{{ placed.restaurantSlug }}
|
||||
</span>
|
||||
<Badge
|
||||
:variant="isPaid(placed.order.id) ? 'default' : 'outline'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ friendlyOrderStatus(statusOf(placed.order.id)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<OrderInvoiceCard
|
||||
v-if="buildOrderInvoice(placed) && !isPaid(placed.order.id)"
|
||||
:invoice="buildOrderInvoice(placed)!"
|
||||
/>
|
||||
<Alert
|
||||
v-else-if="isPaid(placed.order.id)"
|
||||
class="border-emerald-500/40"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||
<AlertTitle>Paid</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-auto p-0"
|
||||
@click="router.push(`/orders/${placed.order.id}`)"
|
||||
>
|
||||
View order →
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Card v-else>
|
||||
<CardContent class="p-4 text-sm text-muted-foreground">
|
||||
No Lightning invoice — payment is handled out-of-band.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<div class="space-y-3">
|
||||
<Alert v-if="state.error" variant="destructive">
|
||||
<AlertTitle>Payment didn't go through</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ state.error }} You can still scan the QR with another
|
||||
wallet.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
v-if="!allPaid"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:disabled="isPaying"
|
||||
@click="onPayAll"
|
||||
>
|
||||
<Loader2 v-if="isPaying" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Zap v-else class="mr-2 h-4 w-4" />
|
||||
Pay from my LNbits wallet
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="!allPaid"
|
||||
variant="ghost"
|
||||
class="w-full text-xs text-muted-foreground"
|
||||
@click="startOver"
|
||||
>
|
||||
Cancel and start over
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
|
@ -87,7 +87,8 @@ function addToCart() {
|
|||
restaurant_slug: restaurant.value.slug,
|
||||
menu_item_id: item.value.id,
|
||||
name: item.value.name,
|
||||
unit_msat: unitPrice.value,
|
||||
unit_price: unitPrice.value,
|
||||
currency: item.value.currency || restaurant.value.currency,
|
||||
quantity: quantity.value,
|
||||
selected_modifiers: selectedModifiers.value,
|
||||
note: note.value || null,
|
||||
|
|
|
|||
279
src/modules/restaurant/views/OrderStatusPage.vue
Normal file
279
src/modules/restaurant/views/OrderStatusPage.vue
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Order status — polls the extension every `orderPollMs` while the
|
||||
* order is still in a non-terminal state. Shows the bolt11
|
||||
* invoice QR for orders still in `pending`, the status pill, the
|
||||
* line items, and timestamps for each transition.
|
||||
*
|
||||
* Future tier-#2 / kitchen-workflow #4 may add ETA, course pacing,
|
||||
* and per-station status — `Order.status` is intentionally an open
|
||||
* string type so this view degrades gracefully on unknown states.
|
||||
*/
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
} from 'lucide-vue-next'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import OrderInvoiceCard from '../components/OrderInvoiceCard.vue'
|
||||
import { useOrder } from '../composables/useOrder'
|
||||
import {
|
||||
KNOWN_ORDER_STATUSES,
|
||||
friendlyOrderStatus,
|
||||
type OrderInvoice,
|
||||
} from '../types/restaurant'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const orderId = computed(() => String(route.params.id || ''))
|
||||
const { order, items, isLoading, error, refresh } = useOrder(
|
||||
toRef(() => orderId.value)
|
||||
)
|
||||
|
||||
const statusStyle = computed(() => {
|
||||
const status = order.value?.status
|
||||
const known = (KNOWN_ORDER_STATUSES as readonly string[]).includes(
|
||||
status ?? ''
|
||||
)
|
||||
if (!known) return 'secondary' as const
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'outline' as const
|
||||
case 'paid':
|
||||
case 'accepted':
|
||||
case 'ready':
|
||||
return 'default' as const
|
||||
case 'completed':
|
||||
return 'default' as const
|
||||
case 'canceled':
|
||||
case 'refunded':
|
||||
return 'destructive' as const
|
||||
default:
|
||||
return 'secondary' as const
|
||||
}
|
||||
})
|
||||
|
||||
const invoice = computed<OrderInvoice | null>(() => {
|
||||
if (!order.value) return null
|
||||
if (!order.value.bolt11 || !order.value.payment_hash) return null
|
||||
if (order.value.status !== 'pending') return null
|
||||
// The extension's POST /orders response carries the OrderInvoice
|
||||
// separately; once paid, we don't re-render the QR. We rebuild a
|
||||
// best-effort OrderInvoice from the order's own fields here so a
|
||||
// refresh on /orders/:id (e.g. shareable URL) still shows the QR
|
||||
// while still pending.
|
||||
return {
|
||||
order_id: order.value.id,
|
||||
payment_hash: order.value.payment_hash,
|
||||
bolt11: order.value.bolt11,
|
||||
amount_msat: order.value.total_msat,
|
||||
// We don't have the real expires_at on the GET response. Use a
|
||||
// safe far-future placeholder so the countdown shows "expires
|
||||
// in 15:00" optimistically — the OrderInvoiceCard tolerates this.
|
||||
expires_at: Math.floor(Date.now() / 1000) + 900,
|
||||
}
|
||||
})
|
||||
|
||||
function fmtSat(value: number) {
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
|
||||
}
|
||||
|
||||
function fmtTime(iso: string | null | undefined) {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
const timeline = computed(() => {
|
||||
if (!order.value) return [] as Array<{ label: string; at: string }>
|
||||
const out: Array<{ label: string; at: string }> = []
|
||||
if (order.value.time) out.push({ label: 'Placed', at: order.value.time })
|
||||
if (order.value.paid_at) out.push({ label: 'Paid', at: order.value.paid_at })
|
||||
if (order.value.accepted_at)
|
||||
out.push({ label: 'Accepted', at: order.value.accepted_at })
|
||||
if (order.value.ready_at) out.push({ label: 'Ready', at: order.value.ready_at })
|
||||
if (order.value.completed_at)
|
||||
out.push({ label: 'Completed', at: order.value.completed_at })
|
||||
if (order.value.canceled_at)
|
||||
out.push({ label: 'Canceled', at: order.value.canceled_at })
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||
<Button variant="ghost" size="sm" class="mb-3" @click="router.push('/orders')">
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
All orders
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="isLoading && !order"
|
||||
class="flex items-center justify-center py-16 text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading order…
|
||||
</div>
|
||||
|
||||
<Alert v-else-if="error" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertTitle>Couldn't load order</AlertTitle>
|
||||
<AlertDescription class="mt-2">
|
||||
<p>{{ error.message }}</p>
|
||||
<Button variant="outline" size="sm" class="mt-3" @click="refresh">
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<template v-else-if="order">
|
||||
<header class="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Order</h1>
|
||||
<p class="font-mono text-xs text-muted-foreground">
|
||||
{{ order.id.slice(0, 12) }}…
|
||||
</p>
|
||||
</div>
|
||||
<Badge :variant="statusStyle" class="text-sm">
|
||||
{{ friendlyOrderStatus(order.status) }}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<OrderInvoiceCard
|
||||
v-if="invoice"
|
||||
:invoice="invoice"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
v-else-if="order.status === 'paid'"
|
||||
class="mb-4 border-emerald-500/40"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||
<AlertTitle>Order received</AlertTitle>
|
||||
<AlertDescription>
|
||||
Payment confirmed — the kitchen will start preparing it
|
||||
shortly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
v-else-if="order.status === 'accepted'"
|
||||
class="mb-4 border-emerald-500/40"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||
<AlertTitle>Cooking</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your food is being made.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
v-else-if="order.status === 'ready'"
|
||||
class="mb-4 border-amber-500/40"
|
||||
>
|
||||
<Clock class="h-4 w-4 text-amber-500" />
|
||||
<AlertTitle>Ready for pickup</AlertTitle>
|
||||
<AlertDescription>
|
||||
Pick up at the counter.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
v-else-if="order.status === 'completed'"
|
||||
class="mb-4 border-emerald-500/40"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||
<AlertTitle>Served</AlertTitle>
|
||||
<AlertDescription>Enjoy! Thanks for ordering.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-1">
|
||||
<div
|
||||
v-for="line in items"
|
||||
:key="line.id"
|
||||
class="flex items-start justify-between text-sm"
|
||||
>
|
||||
<span class="text-foreground">
|
||||
{{ line.quantity }}× {{ line.name }}
|
||||
<span
|
||||
v-if="line.selected_modifiers.length"
|
||||
class="block text-xs text-muted-foreground"
|
||||
>
|
||||
{{ line.selected_modifiers.map((m) => m.name).join(', ') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="line.note"
|
||||
class="block text-xs italic text-muted-foreground"
|
||||
>
|
||||
Note: {{ line.note }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{{ fmtSat(line.line_total_msat) }}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="timeline.length" class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol class="space-y-1.5 text-sm">
|
||||
<li
|
||||
v-for="(entry, i) in timeline"
|
||||
:key="i"
|
||||
class="flex items-baseline justify-between"
|
||||
>
|
||||
<span class="text-foreground">{{ entry.label }}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{{ fmtTime(entry.at) }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Subtotal</span>
|
||||
<span class="font-mono">{{ fmtSat(order.subtotal_msat) }}</span>
|
||||
</div>
|
||||
<div v-if="order.tax_msat" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Tax</span>
|
||||
<span class="font-mono">{{ fmtSat(order.tax_msat) }}</span>
|
||||
</div>
|
||||
<div v-if="order.tip_msat" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Tip</span>
|
||||
<span class="font-mono">{{ fmtSat(order.tip_msat) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-border pt-2 text-base font-semibold">
|
||||
<span>Total</span>
|
||||
<span class="font-mono">{{ fmtSat(order.total_msat) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
228
src/modules/restaurant/views/OrdersListPage.vue
Normal file
228
src/modules/restaurant/views/OrdersListPage.vue
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Lists past orders the customer has placed from this device.
|
||||
*
|
||||
* Source of truth for the *list* is STORAGE_SERVICE
|
||||
* ['restaurant.lastOrders.v1'] (newest first, cap 50) — appended to
|
||||
* by CheckoutPage. Each stored entry is enough to deep-link to
|
||||
* /orders/:id, but it freezes the order's totals + status at the
|
||||
* moment it was placed.
|
||||
*
|
||||
* On mount we hydrate each row from `RestaurantAPI.getOrder(id)` so
|
||||
* the list shows the *live* status (e.g. an order placed an hour
|
||||
* ago might be `ready` now) and the fiat amount the customer
|
||||
* originally paid in. Missing / 404 orders fall back to the stored
|
||||
* snapshot so a deleted order doesn't poison the page.
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ReceiptText, RefreshCw } from 'lucide-vue-next'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
injectService,
|
||||
tryInjectService,
|
||||
SERVICE_TOKENS,
|
||||
} from '@/core/di-container'
|
||||
import type { StorageService } from '@/core/services/StorageService'
|
||||
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||
import {
|
||||
KNOWN_ORDER_STATUSES,
|
||||
friendlyOrderStatus,
|
||||
type Order,
|
||||
type OrderStatus,
|
||||
} from '../types/restaurant'
|
||||
|
||||
const router = useRouter()
|
||||
const storage = tryInjectService<StorageService>(
|
||||
SERVICE_TOKENS.STORAGE_SERVICE
|
||||
)
|
||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||
|
||||
interface OrderHistoryEntry {
|
||||
orderId: string
|
||||
restaurantId: string
|
||||
restaurantSlug: string
|
||||
placedAt: number
|
||||
totalMsat: number
|
||||
}
|
||||
|
||||
const orders = ref<OrderHistoryEntry[]>([])
|
||||
const liveByOrderId = ref<Record<string, Order | null>>({})
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
// Re-fetches every history row's live status + fiat. Used both on
|
||||
// mount and from the manual refresh button — the extension doesn't
|
||||
// push order-status changes today (NIP-17 status DMs are tracked in
|
||||
// aiolabs/restaurant#9), so the customer hits this to pick up
|
||||
// kitchen-side transitions.
|
||||
async function refresh(): Promise<void> {
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
await Promise.all(
|
||||
orders.value.map(async (entry) => {
|
||||
try {
|
||||
const { order } = await api.getOrder(entry.orderId)
|
||||
liveByOrderId.value[entry.orderId] = order
|
||||
} catch {
|
||||
liveByOrderId.value[entry.orderId] = null
|
||||
}
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
orders.value =
|
||||
storage?.getUserData<OrderHistoryEntry[]>(
|
||||
'restaurant.lastOrders.v1',
|
||||
[]
|
||||
) || []
|
||||
await refresh()
|
||||
})
|
||||
|
||||
function fmtSat(value: number) {
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
|
||||
}
|
||||
|
||||
function fmtFiat(amount: number, currency: string) {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
} catch {
|
||||
// Unknown / non-ISO currency code — fall back to a plain number.
|
||||
return `${amount.toFixed(2)} ${currency}`
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(
|
||||
status: OrderStatus | undefined
|
||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
const known = (KNOWN_ORDER_STATUSES as readonly string[]).includes(
|
||||
status ?? ''
|
||||
)
|
||||
if (!known) return 'secondary'
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'outline'
|
||||
case 'paid':
|
||||
case 'accepted':
|
||||
case 'ready':
|
||||
case 'completed':
|
||||
return 'default'
|
||||
case 'canceled':
|
||||
case 'refunded':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts: number) {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
const grouped = computed(() => {
|
||||
const groups = new Map<string, OrderHistoryEntry[]>()
|
||||
for (const o of orders.value) {
|
||||
const day = new Date(o.placedAt).toLocaleDateString()
|
||||
if (!groups.has(day)) groups.set(day, [])
|
||||
groups.get(day)!.push(o)
|
||||
}
|
||||
return Array.from(groups.entries()).map(([day, items]) => ({
|
||||
day,
|
||||
items,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||
<h1 class="mb-4 text-2xl font-bold text-foreground">Your orders</h1>
|
||||
|
||||
<Alert v-if="!orders.length" class="border-border">
|
||||
<ReceiptText class="h-4 w-4" />
|
||||
<AlertTitle>No orders yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
Place an order and it'll show up here.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="group in grouped"
|
||||
:key="group.day"
|
||||
class="mb-6"
|
||||
>
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{{ group.day }}
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
<Card
|
||||
v-for="o in group.items"
|
||||
:key="o.orderId"
|
||||
class="cursor-pointer transition-colors hover:bg-accent"
|
||||
@click="router.push(`/orders/${o.orderId}`)"
|
||||
>
|
||||
<CardContent class="p-3 sm:p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="line-clamp-1 font-semibold text-foreground">
|
||||
{{ o.restaurantSlug }}
|
||||
</p>
|
||||
<p class="font-mono text-[10px] text-muted-foreground">
|
||||
{{ o.orderId.slice(0, 12) }}…
|
||||
· {{ fmtTime(o.placedAt) }}
|
||||
</p>
|
||||
<Badge
|
||||
v-if="liveByOrderId[o.orderId]"
|
||||
:variant="statusVariant(liveByOrderId[o.orderId]?.status)"
|
||||
class="mt-1.5"
|
||||
>
|
||||
{{ friendlyOrderStatus(liveByOrderId[o.orderId]!.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="font-mono text-sm font-semibold text-primary">
|
||||
{{ fmtSat(o.totalMsat) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="liveByOrderId[o.orderId]?.fiat_amount"
|
||||
class="font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
≈ {{ fmtFiat(
|
||||
liveByOrderId[o.orderId]!.fiat_amount!,
|
||||
liveByOrderId[o.orderId]!.currency_display
|
||||
) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
v-if="orders.length"
|
||||
variant="default"
|
||||
size="icon"
|
||||
:disabled="isRefreshing"
|
||||
aria-label="Refresh orders"
|
||||
class="fixed bottom-20 right-4 z-40 h-12 w-12 rounded-full shadow-lg"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-5 w-5"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</Button>
|
||||
</main>
|
||||
</template>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
* handles modifier selection and "Add to cart" (cart store lands in
|
||||
* commit 5).
|
||||
*/
|
||||
import { computed, toRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, toRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
|
@ -21,16 +21,48 @@ import CategoryNav from '../components/CategoryNav.vue'
|
|||
import MenuTree from '../components/MenuTree.vue'
|
||||
import { useMenu } from '../composables/useMenu'
|
||||
import { useCartStore } from '../stores/cart'
|
||||
import {
|
||||
tryInjectService,
|
||||
SERVICE_TOKENS,
|
||||
} from '@/core/di-container'
|
||||
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cart = useCartStore()
|
||||
const sync = tryInjectService<RestaurantNostrSync>(
|
||||
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
|
||||
)
|
||||
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
|
||||
toRef(() => slug.value)
|
||||
)
|
||||
|
||||
// Open the Nostr live-overlay subscription the moment we know the
|
||||
// restaurant's pubkey + id. Close it on route leave / unmount. If
|
||||
// the relay hub isn't connected, sync.subscribe is a no-op (REST
|
||||
// continues to work; the overlay is best-effort polish).
|
||||
let activeRestaurantId: string | null = null
|
||||
watch(restaurant, (r) => {
|
||||
if (!sync) return
|
||||
if (activeRestaurantId && activeRestaurantId !== r?.id) {
|
||||
sync.unsubscribe(activeRestaurantId)
|
||||
activeRestaurantId = null
|
||||
}
|
||||
if (r?.nostr_pubkey && r.id !== activeRestaurantId) {
|
||||
sync.subscribe(r.nostr_pubkey, r.id)
|
||||
activeRestaurantId = r.id
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (sync && activeRestaurantId) {
|
||||
sync.unsubscribe(activeRestaurantId)
|
||||
activeRestaurantId = null
|
||||
}
|
||||
})
|
||||
|
||||
function openItem(itemId: string) {
|
||||
router.push(`/r/${slug.value}/item/${itemId}`)
|
||||
}
|
||||
|
|
@ -52,7 +84,8 @@ function quickAdd(itemId: string) {
|
|||
restaurant_slug: restaurant.value.slug,
|
||||
menu_item_id: it.id,
|
||||
name: it.name,
|
||||
unit_msat: it.price,
|
||||
unit_price: it.price,
|
||||
currency: it.currency || restaurant.value.currency,
|
||||
quantity: 1,
|
||||
selected_modifiers: [],
|
||||
note: null,
|
||||
|
|
|
|||
153
src/modules/restaurant/views/SettingsPage.vue
Normal file
153
src/modules/restaurant/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Customer-side preferences. v1 ships:
|
||||
* - currency display toggle (sats / msat)
|
||||
* - optional relay override (comma-separated; restart required
|
||||
* because RelayHub is initialized once at boot)
|
||||
*
|
||||
* Persisted to STORAGE_SERVICE['restaurant.settings.v1'].
|
||||
*
|
||||
* Future tier-#2 may surface mode-gated toggles here (e.g. NIP-17
|
||||
* order intake when #9 ships); the `features:{}` slot in the
|
||||
* module config is the integration point.
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
tryInjectService,
|
||||
SERVICE_TOKENS,
|
||||
} from '@/core/di-container'
|
||||
import type { StorageService } from '@/core/services/StorageService'
|
||||
import { useCartStore } from '../stores/cart'
|
||||
|
||||
const router = useRouter()
|
||||
const storage = tryInjectService<StorageService>(
|
||||
SERVICE_TOKENS.STORAGE_SERVICE
|
||||
)
|
||||
const cart = useCartStore()
|
||||
|
||||
interface RestaurantSettings {
|
||||
currencyDisplay: 'sats' | 'msat'
|
||||
relayOverride?: string
|
||||
}
|
||||
|
||||
const settings = ref<RestaurantSettings>({
|
||||
currencyDisplay: 'sats',
|
||||
relayOverride: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
settings.value =
|
||||
storage?.getUserData<RestaurantSettings>('restaurant.settings.v1', {
|
||||
currencyDisplay: 'sats',
|
||||
}) || { currencyDisplay: 'sats' }
|
||||
})
|
||||
|
||||
watch(
|
||||
settings,
|
||||
(val) => {
|
||||
storage?.setUserData('restaurant.settings.v1', val)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function clearLocalData() {
|
||||
if (!confirm('Clear cart, recent venues, and order history on this device?')) {
|
||||
return
|
||||
}
|
||||
cart.clear()
|
||||
storage?.clearUserData('restaurant.cart.v1')
|
||||
storage?.clearUserData('restaurant.lastOrders.v1')
|
||||
storage?.clearUserData('restaurant.recentRestaurants.v1')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||
<Button variant="ghost" size="sm" class="mb-3" @click="router.back()">
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<h1 class="mb-4 text-2xl font-bold text-foreground">Settings</h1>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Display</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm" for="currency-toggle">
|
||||
Show prices in millisats
|
||||
</Label>
|
||||
<Switch
|
||||
id="currency-toggle"
|
||||
:model-value="settings.currencyDisplay === 'msat'"
|
||||
@update:model-value="
|
||||
(val) =>
|
||||
(settings.currencyDisplay = val ? 'msat' : 'sats')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Currently:
|
||||
<code class="font-mono">{{ settings.currencyDisplay }}</code>.
|
||||
Order totals from the extension are always msat; this is
|
||||
local display only.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Nostr relays</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<Label for="relay-override" class="text-sm">
|
||||
Override relays (comma-separated)
|
||||
</Label>
|
||||
<Input
|
||||
id="relay-override"
|
||||
v-model.trim="settings.relayOverride"
|
||||
placeholder="wss://relay.example.com, wss://nos.lol"
|
||||
class="font-mono text-xs"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Reload the app for changes to take effect — the relay hub
|
||||
initializes once on boot.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Local data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="clearLocalData"
|
||||
>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Clear cart + history
|
||||
</Button>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Wipes the cart, recent venues, and order history from this
|
||||
device. Doesn't refund or cancel any placed orders.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue