feat(restaurant): customer-facing restaurant bundle (v1) #54
9 changed files with 638 additions and 250 deletions
feat(restaurant): UX polish — currency display, two-phase checkout, friendly status
Three v1 smoke-test follow-ups that all touch CheckoutPage.vue,
bundled rather than scattered across the planned commits:
stores/cart.ts + CartLineItem.vue + CartPage.vue:
- rename CartLine.unit_msat → unit_price (the field never was
in msat — it carried the menu-item's declared currency)
- add CartLine.currency snapshot; getters now return
{ amount, currency } shapes
- grandTotal returns null for multi-currency carts (future
festival aggregator); UI falls back to per-bucket subtotals
views/CheckoutPage.vue:
- same display rename throughout
- live ≈sat preview via /orders/quote on cart change
- two-phase flow: review → place → render bolt11 QR(s) + copy
button → pay all (LNbits wallet) OR scan with external wallet
- per-placed-order poller picks up external-wallet payments
views/OrderStatusPage.vue + CheckoutPage.vue + types/restaurant.ts:
- customer-friendly labels via FRIENDLY_ORDER_STATUS map
('Order received' / 'Cooking' / 'Ready for pickup' / 'Served')
- open OrderStatus type with KNOWN_ORDER_STATUSES const for
UI hint mapping; unknown statuses fall through gracefully
Verified end-to-end against Big Jay's: GTQ-priced items display in
GTQ throughout cart + checkout with live sat preview, bolt11 QR
scannable by external wallets, status transitions visible without
page reload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
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.
commit
77c81d8323
|
|
@ -10,7 +10,6 @@ import type { CartLine } from '../stores/cart'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
line: CartLine
|
line: CartLine
|
||||||
currencyHint?: string
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -24,14 +23,12 @@ const modifierSummary = computed(() =>
|
||||||
)
|
)
|
||||||
|
|
||||||
const lineTotal = computed(() => {
|
const lineTotal = computed(() => {
|
||||||
// unit_msat is base + modifier delta snapshot; the cart store
|
// Displayed in the menu item's currency (e.g. GTQ). Authoritative
|
||||||
// currently stores it as a sat-major number (price * 1000-less
|
// sat conversion happens server-side at /orders/quote — the
|
||||||
// because the extension's `price` is already in the declared
|
// checkout page surfaces that as a "≈ X sat" badge.
|
||||||
// currency, not msat — see useCheckout's buildCreateOrder for the
|
|
||||||
// canonical conversion at order-place time).
|
|
||||||
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
|
const fmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 })
|
||||||
const total = props.line.unit_msat * props.line.quantity
|
const total = props.line.unit_price * props.line.quantity
|
||||||
return `${fmt.format(total)} ${props.currencyHint || 'sat'}`
|
return `${fmt.format(total)} ${props.line.currency}`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* useCheckout — orchestrates the place-order + pay-bolt11 sequence
|
* useCheckout — drives the customer's place-and-pay flow against
|
||||||
* for every restaurant currently in the cart.
|
* the restaurant extension.
|
||||||
*
|
*
|
||||||
* v1 ships REST-only:
|
* v1 ships REST-only:
|
||||||
* for each restaurant in the cart:
|
|
||||||
* 1. quote (msat required)
|
* 1. quote (msat required)
|
||||||
* 2. balance pre-check (sum across all restaurants)
|
* 2. balance pre-check (sum across all restaurants in the cart)
|
||||||
* 3. placeOrder → { order, invoice }
|
* 3. POST /orders → { order, invoice }
|
||||||
* 4. WalletService.sendPayment(bolt11)
|
* 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.
|
||||||
*
|
*
|
||||||
* The festival aggregator (aiolabs/restaurant#8) exercises this
|
* Split into two distinct actions so the UI can render the QR codes
|
||||||
* same path with N > 1 restaurants in the cart. v1 happens to ship
|
* between place and pay, giving the customer the option to scan
|
||||||
* a UI where N == 1, but the orchestration is multi-restaurant
|
* with an external wallet rather than auto-paying from LNbits:
|
||||||
* already.
|
|
||||||
*
|
*
|
||||||
* NIP-17 transport (aiolabs/restaurant#9) plugs in here later as a
|
* placeOrders() — runs steps 1-3, populates `state.value.placedOrders`
|
||||||
* `transport: 'rest' | 'nostr'` option that gift-wraps the
|
* payOrder(idx) — runs step 4 for one placed-order index
|
||||||
* CreateOrder instead of POSTing it. The `buildCreateOrder`
|
*
|
||||||
* helper is the single point both transports build through, so
|
* The festival aggregator (aiolabs/restaurant#8) exercises this same
|
||||||
* adding loyalty (aiolabs/restaurant#5) is also a one-function
|
* path with N > 1 restaurants in the cart. v1 happens to ship a UI
|
||||||
* change rather than touching every call site.
|
* 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 { ref, type Ref } from 'vue'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
|
@ -42,28 +48,46 @@ export interface PlacedOrder {
|
||||||
invoice: OrderInvoice | null
|
invoice: OrderInvoice | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutResult {
|
|
||||||
placedOrders: PlacedOrder[]
|
|
||||||
paidPaymentHashes: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutState {
|
export interface CheckoutState {
|
||||||
step: 'idle' | 'quoting' | 'placing' | 'paying' | 'done' | 'error'
|
step:
|
||||||
|
| 'idle'
|
||||||
|
| 'quoting'
|
||||||
|
| 'placing'
|
||||||
|
| 'placed'
|
||||||
|
| 'paying'
|
||||||
|
| 'paid'
|
||||||
|
| 'error'
|
||||||
progress: { current: number; total: number }
|
progress: { current: number; total: number }
|
||||||
currentRestaurantSlug: string | null
|
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
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCheckoutReturn {
|
export interface UseCheckoutReturn {
|
||||||
state: Ref<CheckoutState>
|
state: Ref<CheckoutState>
|
||||||
checkout: () => Promise<CheckoutResult>
|
/** 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
|
* The single point CreateOrder is built — keeps loyalty (#5), NIP-17
|
||||||
* transport (#9), and tip overrides one-place changes rather than
|
* transport (#9), and tip overrides one-place changes rather than
|
||||||
* touching the whole flow. Today loyalty is unconfigured so the
|
* touching the whole flow.
|
||||||
* extra block stays at its defaults.
|
|
||||||
*/
|
*/
|
||||||
function buildCreateOrder(
|
function buildCreateOrder(
|
||||||
restaurantId: string,
|
restaurantId: string,
|
||||||
|
|
@ -76,7 +100,6 @@ function buildCreateOrder(
|
||||||
selected_modifiers: l.selected_modifiers,
|
selected_modifiers: l.selected_modifiers,
|
||||||
note: l.note ?? undefined,
|
note: l.note ?? undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Loyalty (#5) future-extension point: when implemented, inject
|
// Loyalty (#5) future-extension point: when implemented, inject
|
||||||
// { loyalty_credits_msat, loyalty_pubkey } into extra.fields here.
|
// { loyalty_credits_msat, loyalty_pubkey } into extra.fields here.
|
||||||
return {
|
return {
|
||||||
|
|
@ -88,6 +111,17 @@ function buildCreateOrder(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blankState(): CheckoutState {
|
||||||
|
return {
|
||||||
|
step: 'idle',
|
||||||
|
progress: { current: 0, total: 0 },
|
||||||
|
currentRestaurantSlug: null,
|
||||||
|
placedOrders: [],
|
||||||
|
paidOrderIds: new Set(),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useCheckout(): UseCheckoutReturn {
|
export function useCheckout(): UseCheckoutReturn {
|
||||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
const cart = useCartStore()
|
const cart = useCartStore()
|
||||||
|
|
@ -95,9 +129,7 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
|
|
||||||
// We talk to LNbits's payments endpoint directly rather than
|
// We talk to LNbits's payments endpoint directly rather than
|
||||||
// pulling in the whole `wallet` module — the restaurant-app
|
// pulling in the whole `wallet` module — the restaurant-app
|
||||||
// bundle is a customer surface, not a wallet UI, and `LnbitsAPI`
|
// bundle is a customer surface, not a wallet UI.
|
||||||
// is already registered by base. The customer's adminkey lives
|
|
||||||
// on AuthService.user.wallets[0].adminkey.
|
|
||||||
const apiBaseUrl =
|
const apiBaseUrl =
|
||||||
(
|
(
|
||||||
appConfig.modules.restaurant as
|
appConfig.modules.restaurant as
|
||||||
|
|
@ -105,52 +137,30 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
| undefined
|
| undefined
|
||||||
)?.config?.apiBaseUrl || ''
|
)?.config?.apiBaseUrl || ''
|
||||||
|
|
||||||
async function payBolt11(bolt11: string, adminkey: string): Promise<void> {
|
const state = ref<CheckoutState>(blankState())
|
||||||
const response = await fetch(`${apiBaseUrl}/api/v1/payments`, {
|
|
||||||
method: 'POST',
|
function reset(): void {
|
||||||
headers: {
|
state.value = blankState()
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Api-Key': adminkey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ out: true, bolt11 }),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
let detail = response.statusText
|
|
||||||
try {
|
|
||||||
const body = await response.json()
|
|
||||||
if (body?.detail) detail = body.detail
|
|
||||||
} catch {
|
|
||||||
/* body wasn't JSON */
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Payment failed: ${response.status} ${detail}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = ref<CheckoutState>({
|
// ----------------------------------------------------------------- //
|
||||||
step: 'idle',
|
// place //
|
||||||
progress: { current: 0, total: 0 },
|
// ----------------------------------------------------------------- //
|
||||||
currentRestaurantSlug: null,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function checkout(): Promise<CheckoutResult> {
|
async function placeOrders(): Promise<PlacedOrder[]> {
|
||||||
const buckets = cart.restaurantsInCart.map((rid) => ({
|
const buckets = cart.restaurantsInCart.map((rid) => ({
|
||||||
restaurantId: rid,
|
restaurantId: rid,
|
||||||
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||||
lines: cart.linesFor(rid),
|
lines: cart.linesFor(rid),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (!buckets.length) {
|
if (!buckets.length) {
|
||||||
throw new Error('Cart is empty')
|
throw new Error('Cart is empty')
|
||||||
}
|
}
|
||||||
|
|
||||||
state.value = {
|
state.value = {
|
||||||
|
...blankState(),
|
||||||
step: 'quoting',
|
step: 'quoting',
|
||||||
progress: { current: 0, total: buckets.length },
|
progress: { current: 0, total: buckets.length },
|
||||||
currentRestaurantSlug: null,
|
|
||||||
error: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Quote per restaurant.
|
// 1. Quote per restaurant.
|
||||||
|
|
@ -175,31 +185,22 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
quotes.push({ ...b, msat: quote.required_msat })
|
quotes.push({ ...b, msat: quote.required_msat })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Pre-flight balance check using AuthService's cached wallet
|
// 2. Balance pre-check.
|
||||||
// balance (LNbits's user object carries balance_msat per wallet).
|
|
||||||
const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0)
|
const totalMsatRequired = quotes.reduce((s, q) => s + q.msat, 0)
|
||||||
const wallet0 = user.value?.wallets?.[0]
|
const wallet0 = user.value?.wallets?.[0]
|
||||||
if (wallet0 && typeof wallet0.balance_msat === 'number') {
|
if (wallet0 && typeof wallet0.balance_msat === 'number') {
|
||||||
if (wallet0.balance_msat < totalMsatRequired) {
|
if (wallet0.balance_msat < totalMsatRequired) {
|
||||||
const needSat = Math.ceil(totalMsatRequired / 1000)
|
const needSat = Math.ceil(totalMsatRequired / 1000)
|
||||||
const haveSat = Math.floor(wallet0.balance_msat / 1000)
|
const haveSat = Math.floor(wallet0.balance_msat / 1000)
|
||||||
state.value = {
|
// Not fatal — the customer may still want to scan the QR
|
||||||
step: 'error',
|
// and pay from an external wallet. Surface a warning but
|
||||||
progress: { current: 0, total: buckets.length },
|
// continue.
|
||||||
currentRestaurantSlug: null,
|
console.warn(
|
||||||
error: `Insufficient balance. Need ${needSat} sat, have ${haveSat} sat.`,
|
`[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.`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw new Error(state.value.error!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!wallet0?.adminkey) {
|
|
||||||
state.value = {
|
|
||||||
step: 'error',
|
|
||||||
progress: { current: 0, total: buckets.length },
|
|
||||||
currentRestaurantSlug: null,
|
|
||||||
error: 'No wallet available — please log in first.',
|
|
||||||
}
|
|
||||||
throw new Error(state.value.error!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Place orders.
|
// 3. Place orders.
|
||||||
|
|
@ -223,49 +224,86 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Pay each bolt11 sequentially. If a payment fails, the
|
state.value.step = 'placed'
|
||||||
// earlier successes are still placed-and-paid — best-effort
|
state.value.placedOrders = placed
|
||||||
// is the v1 model (see plan; HODL atomicity is a future
|
state.value.currentRestaurantSlug = null
|
||||||
// issue).
|
return placed
|
||||||
state.value.step = 'paying'
|
}
|
||||||
const paidHashes: string[] = []
|
|
||||||
for (let i = 0; i < placed.length; i++) {
|
|
||||||
const p = placed[i]
|
|
||||||
state.value.currentRestaurantSlug = p.restaurantSlug
|
|
||||||
state.value.progress = { current: i, total: placed.length }
|
|
||||||
if (!p.invoice) continue // cash orders skip payment
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// 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 {
|
try {
|
||||||
await payBolt11(p.invoice.bolt11, wallet0.adminkey)
|
const body = await response.json()
|
||||||
if (p.invoice.payment_hash) {
|
if (body?.detail) detail = body.detail
|
||||||
paidHashes.push(p.invoice.payment_hash)
|
} 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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
state.value.step = 'error'
|
||||||
state.value = {
|
state.value.error = err instanceof Error ? err.message : String(err)
|
||||||
step: 'error',
|
|
||||||
progress: { current: i, total: placed.length },
|
|
||||||
currentRestaurantSlug: p.restaurantSlug,
|
|
||||||
error: msg,
|
|
||||||
}
|
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Clear the paid lines from the cart.
|
async function payAll(): Promise<void> {
|
||||||
for (const p of placed) {
|
for (let i = 0; i < state.value.placedOrders.length; i++) {
|
||||||
cart.clearRestaurant(p.restaurantId)
|
const p = state.value.placedOrders[i]
|
||||||
|
if (!p.invoice) continue
|
||||||
|
if (state.value.paidOrderIds.has(p.order.id)) continue
|
||||||
|
await payOrder(i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.value = {
|
return { state, placeOrders, payOrder, payAll, reset }
|
||||||
step: 'done',
|
|
||||||
progress: { current: placed.length, total: placed.length },
|
|
||||||
currentRestaurantSlug: null,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
return { placedOrders: placed, paidPaymentHashes: paidHashes }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { state, checkout }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,16 @@ export interface CartLine {
|
||||||
/** Snapshot at add-time so the cart still renders if the menu
|
/** Snapshot at add-time so the cart still renders if the menu
|
||||||
* item is later edited / deleted. */
|
* item is later edited / deleted. */
|
||||||
name: string
|
name: string
|
||||||
/** Base price + selected modifier deltas, in msat. */
|
/**
|
||||||
unit_msat: number
|
* Base price + selected modifier deltas, **in the menu item's
|
||||||
|
* declared currency** (e.g. 25 for "25 GTQ" or 100 for "100 sat").
|
||||||
|
* Authoritative sat conversion happens server-side via
|
||||||
|
* `POST /orders/quote`; the cart's value is for display only.
|
||||||
|
*/
|
||||||
|
unit_price: number
|
||||||
|
/** ISO-ish currency code from the menu item, e.g. "GTQ", "USD",
|
||||||
|
* "sat". Used for cart-side display labels. */
|
||||||
|
currency: string
|
||||||
quantity: number
|
quantity: number
|
||||||
selected_modifiers: SelectedModifier[]
|
selected_modifiers: SelectedModifier[]
|
||||||
note?: string | null
|
note?: string | null
|
||||||
|
|
@ -91,19 +99,42 @@ export const useCartStore = defineStore('restaurant-cart', () => {
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
|
|
||||||
const restaurantTotalsMsat = computed<Record<string, number>>(() => {
|
/** Per-restaurant subtotal in that restaurant's declared currency. */
|
||||||
const out: Record<string, number> = {}
|
const restaurantTotals = computed<
|
||||||
|
Record<string, { amount: number; currency: string }>
|
||||||
|
>(() => {
|
||||||
|
const out: Record<string, { amount: number; currency: string }> = {}
|
||||||
for (const rid of Object.keys(lines.value)) {
|
for (const rid of Object.keys(lines.value)) {
|
||||||
out[rid] = lines.value[rid].reduce(
|
const bucket = lines.value[rid]
|
||||||
(s, l) => s + l.unit_msat * l.quantity,
|
if (!bucket.length) continue
|
||||||
|
out[rid] = {
|
||||||
|
amount: bucket.reduce(
|
||||||
|
(s, l) => s + l.unit_price * l.quantity,
|
||||||
0
|
0
|
||||||
)
|
),
|
||||||
|
currency: bucket[0].currency,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const grandTotalMsat = computed<number>(() =>
|
/**
|
||||||
Object.values(restaurantTotalsMsat.value).reduce((s, v) => s + v, 0)
|
* Single-currency cart subtotal. Returns null if the cart spans
|
||||||
|
* multiple currencies (e.g. one restaurant priced in GTQ + another
|
||||||
|
* in USD via the future festival aggregator) — the UI then falls
|
||||||
|
* back to per-restaurant subtotals only.
|
||||||
|
*/
|
||||||
|
const grandTotal = computed<{ amount: number; currency: string } | null>(
|
||||||
|
() => {
|
||||||
|
const totals = Object.values(restaurantTotals.value)
|
||||||
|
if (!totals.length) return null
|
||||||
|
const currencies = new Set(totals.map((t) => t.currency))
|
||||||
|
if (currencies.size !== 1) return null
|
||||||
|
return {
|
||||||
|
amount: totals.reduce((s, t) => s + t.amount, 0),
|
||||||
|
currency: totals[0].currency,
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function linesFor(restaurantId: string): CartLine[] {
|
function linesFor(restaurantId: string): CartLine[] {
|
||||||
|
|
@ -214,8 +245,8 @@ export const useCartStore = defineStore('restaurant-cart', () => {
|
||||||
// getters
|
// getters
|
||||||
restaurantsInCart,
|
restaurantsInCart,
|
||||||
itemCount,
|
itemCount,
|
||||||
restaurantTotalsMsat,
|
restaurantTotals,
|
||||||
grandTotalMsat,
|
grandTotal,
|
||||||
linesFor,
|
linesFor,
|
||||||
// actions
|
// actions
|
||||||
setActiveRestaurant,
|
setActiveRestaurant,
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,34 @@ export const KNOWN_ORDER_STATUSES = [
|
||||||
export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number]
|
export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number]
|
||||||
export type OrderStatus = string
|
export type OrderStatus = string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing labels for order statuses. The extension's raw
|
||||||
|
* status names are operational ('paid' / 'accepted' / 'ready') but
|
||||||
|
* customers prefer human-friendly framing ('Order received' /
|
||||||
|
* 'Cooking' / 'Ready for pickup').
|
||||||
|
*
|
||||||
|
* Future statuses from aiolabs/restaurant#4 (kitchen workflow) —
|
||||||
|
* 'preparing', 'plating', 'at_pass', 'in_service', etc — can land
|
||||||
|
* here as they arrive. Unknown values fall through to the raw
|
||||||
|
* status string titlecased.
|
||||||
|
*/
|
||||||
|
export const FRIENDLY_ORDER_STATUS: Record<string, string> = {
|
||||||
|
pending: 'Awaiting payment',
|
||||||
|
paid: 'Order received',
|
||||||
|
accepted: 'Cooking',
|
||||||
|
ready: 'Ready for pickup',
|
||||||
|
completed: 'Served',
|
||||||
|
canceled: 'Canceled',
|
||||||
|
refunded: 'Refunded',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyOrderStatus(status: OrderStatus): string {
|
||||||
|
if (status in FRIENDLY_ORDER_STATUS) return FRIENDLY_ORDER_STATUS[status]
|
||||||
|
// Unknown status — titlecase the raw key as a graceful fallback.
|
||||||
|
if (!status) return ''
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: string
|
id: string
|
||||||
restaurant_id: string
|
restaurant_id: string
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ const buckets = computed(() =>
|
||||||
// a separate fetch for the cart page header.
|
// a separate fetch for the cart page header.
|
||||||
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||||
lines: cart.linesFor(rid),
|
lines: cart.linesFor(rid),
|
||||||
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0,
|
total: cart.restaurantTotals[rid] ?? null,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
function fmtSat(value: number): string {
|
function fmt(value: number, currency: string): string {
|
||||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat`
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ function fmtSat(value: number): string {
|
||||||
</button>
|
</button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<span class="font-mono text-sm text-primary">
|
<span class="font-mono text-sm text-primary">
|
||||||
{{ fmtSat(b.totalMsat) }}
|
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
|
||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -91,12 +91,16 @@ function fmtSat(value: number): string {
|
||||||
<Separator class="my-6" />
|
<Separator class="my-6" />
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div v-if="cart.grandTotal" class="flex items-center justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Subtotal</span>
|
<span class="text-sm text-muted-foreground">Subtotal</span>
|
||||||
<span class="font-mono text-lg font-semibold text-foreground">
|
<span class="font-mono text-lg font-semibold text-foreground">
|
||||||
{{ fmtSat(cart.grandTotalMsat) }}
|
{{ fmt(cart.grandTotal.amount, cart.grandTotal.currency) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else class="text-xs text-muted-foreground text-center">
|
||||||
|
Cart spans multiple currencies — per-restaurant subtotals above.
|
||||||
|
Lightning sat amount shown at checkout.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,40 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Review + pre-flight quote + place orders + pay bolt11s.
|
* Two-phase checkout:
|
||||||
*
|
*
|
||||||
* Multi-restaurant ready (loops every restaurant in the cart) but
|
* Phase 1 — Review:
|
||||||
* v1 typically has a single bucket. On success: clear cart, route
|
* · cart subtotal in the menu's declared currency (e.g. GTQ)
|
||||||
* to /orders/<firstOrderId>. On failure: surface the error inline.
|
* · 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, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, Loader2, CheckCircle2 } from 'lucide-vue-next'
|
import {
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
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 { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -18,25 +43,30 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import OrderInvoiceCard from '../components/OrderInvoiceCard.vue'
|
||||||
import { useCartStore } from '../stores/cart'
|
import { useCartStore } from '../stores/cart'
|
||||||
import { useCheckout } from '../composables/useCheckout'
|
import { useCheckout, type PlacedOrder } from '../composables/useCheckout'
|
||||||
|
import { friendlyOrderStatus } from '../types/restaurant'
|
||||||
import {
|
import {
|
||||||
|
injectService,
|
||||||
tryInjectService,
|
tryInjectService,
|
||||||
SERVICE_TOKENS,
|
SERVICE_TOKENS,
|
||||||
} from '@/core/di-container'
|
} from '@/core/di-container'
|
||||||
import type { StorageService } from '@/core/services/StorageService'
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const cart = useCartStore()
|
const cart = useCartStore()
|
||||||
const { state, checkout } = useCheckout()
|
const { state, placeOrders, payAll, reset } = useCheckout()
|
||||||
const storage = tryInjectService<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
|
const storage = tryInjectService<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
|
||||||
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
|
|
||||||
const isSubmitting = computed(
|
// ----------------------------------------------------------------- //
|
||||||
() => state.value.step !== 'idle' && state.value.step !== 'done' && state.value.step !== 'error'
|
// review phase //
|
||||||
)
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
function fmtSat(value: number) {
|
function fmt(value: number, currency: string) {
|
||||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat`
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const buckets = computed(() =>
|
const buckets = computed(() =>
|
||||||
|
|
@ -44,28 +74,61 @@ const buckets = computed(() =>
|
||||||
restaurantId: rid,
|
restaurantId: rid,
|
||||||
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
|
||||||
lines: cart.linesFor(rid),
|
lines: cart.linesFor(rid),
|
||||||
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0,
|
total: cart.restaurantTotals[rid] ?? null,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
const placedOrders = ref<
|
/** Live ≈sat preview: one /orders/quote per restaurant. */
|
||||||
Array<{ orderId: string; restaurantSlug: string; placedAt: number; totalMsat: number; restaurantId: string }>
|
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(() => {
|
onMounted(() => {
|
||||||
// If cart is empty (e.g. user landed here via back/forward), kick
|
if (!buckets.value.length && !state.value.placedOrders.length) {
|
||||||
// them to /cart rather than showing an empty screen.
|
|
||||||
if (!buckets.value.length) {
|
|
||||||
router.replace('/cart')
|
router.replace('/cart')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function placeOrder() {
|
async function onPlaceOrder() {
|
||||||
try {
|
try {
|
||||||
const result = await checkout()
|
await placeOrders()
|
||||||
// Persist the placed orders so the OrdersListPage (commit 7)
|
const entries = state.value.placedOrders.map((p) => ({
|
||||||
// can render them.
|
|
||||||
const entries = result.placedOrders.map((p) => ({
|
|
||||||
orderId: p.order.id,
|
orderId: p.order.id,
|
||||||
restaurantId: p.restaurantId,
|
restaurantId: p.restaurantId,
|
||||||
restaurantSlug: p.restaurantSlug,
|
restaurantSlug: p.restaurantSlug,
|
||||||
|
|
@ -74,22 +137,120 @@ async function placeOrder() {
|
||||||
}))
|
}))
|
||||||
if (storage && entries.length) {
|
if (storage && entries.length) {
|
||||||
const existing =
|
const existing =
|
||||||
storage.getUserData<typeof entries>('restaurant.lastOrders.v1', []) ||
|
storage.getUserData<typeof entries>(
|
||||||
|
'restaurant.lastOrders.v1',
|
||||||
[]
|
[]
|
||||||
|
) || []
|
||||||
const merged = [...entries, ...existing].slice(0, 50)
|
const merged = [...entries, ...existing].slice(0, 50)
|
||||||
storage.setUserData('restaurant.lastOrders.v1', merged)
|
storage.setUserData('restaurant.lastOrders.v1', merged)
|
||||||
}
|
}
|
||||||
placedOrders.value = entries
|
|
||||||
// Land on the first order's status page.
|
|
||||||
if (entries.length) {
|
|
||||||
router.push(`/orders/${entries[0].orderId}`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// state.value.step === 'error' and state.value.error already set
|
state.value.step = 'error'
|
||||||
// by useCheckout. View renders it below.
|
state.value.error = err instanceof Error ? err.message : String(err)
|
||||||
console.warn('Checkout failed:', 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -98,7 +259,7 @@ async function placeOrder() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
:disabled="isSubmitting"
|
:disabled="isPlacing || isPaying"
|
||||||
@click="router.back()"
|
@click="router.back()"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
|
|
@ -107,12 +268,14 @@ async function placeOrder() {
|
||||||
|
|
||||||
<h1 class="mb-4 text-2xl font-bold text-foreground">Checkout</h1>
|
<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">
|
<div class="space-y-4">
|
||||||
<Card v-for="b in buckets" :key="b.restaurantId">
|
<Card v-for="b in buckets" :key="b.restaurantId">
|
||||||
<CardHeader class="flex flex-row items-baseline justify-between space-y-0">
|
<CardHeader class="flex flex-row items-baseline justify-between space-y-0">
|
||||||
<CardTitle class="text-base">{{ b.restaurantSlug }}</CardTitle>
|
<CardTitle class="text-base">{{ b.restaurantSlug }}</CardTitle>
|
||||||
<span class="font-mono text-sm text-primary">
|
<span class="font-mono text-sm text-primary">
|
||||||
{{ fmtSat(b.totalMsat) }}
|
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
|
||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-1">
|
<CardContent class="space-y-1">
|
||||||
|
|
@ -131,9 +294,16 @@ async function placeOrder() {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-xs text-muted-foreground">
|
<span class="font-mono text-xs text-muted-foreground">
|
||||||
{{ fmtSat(line.unit_msat * line.quantity) }}
|
{{ fmt(line.unit_price * line.quantity, line.currency) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,46 +311,142 @@ async function placeOrder() {
|
||||||
<Separator class="my-6" />
|
<Separator class="my-6" />
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-baseline justify-between">
|
<div v-if="cart.grandTotal" class="flex items-baseline justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Total</span>
|
<span class="text-sm text-muted-foreground">Total</span>
|
||||||
<span class="font-mono text-xl font-bold text-foreground">
|
<span class="font-mono text-xl font-bold text-foreground">
|
||||||
{{ fmtSat(cart.grandTotalMsat) }}
|
{{ 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Alert v-if="state.step === 'error' && state.error" variant="destructive">
|
<Alert
|
||||||
<AlertTitle>Checkout failed</AlertTitle>
|
v-if="state.step === 'error' && state.error"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<AlertTitle>Couldn't place order</AlertTitle>
|
||||||
<AlertDescription>{{ state.error }}</AlertDescription>
|
<AlertDescription>{{ state.error }}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Alert v-if="state.step === 'done'" class="border-emerald-500/40">
|
|
||||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
|
||||||
<AlertTitle>Order placed</AlertTitle>
|
|
||||||
<AlertDescription>Redirecting to status…</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="isSubmitting || !buckets.length"
|
:disabled="isPlacing || !buckets.length"
|
||||||
@click="placeOrder"
|
@click="onPlaceOrder"
|
||||||
>
|
>
|
||||||
<Loader2
|
<Loader2 v-if="isPlacing" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
v-if="isSubmitting"
|
|
||||||
class="mr-2 h-4 w-4 animate-spin"
|
|
||||||
/>
|
|
||||||
<span v-if="state.step === 'quoting'">Quoting…</span>
|
<span v-if="state.step === 'quoting'">Quoting…</span>
|
||||||
<span v-else-if="state.step === 'placing'">Placing orders…</span>
|
<span v-else-if="state.step === 'placing'">Placing orders…</span>
|
||||||
<span v-else-if="state.step === 'paying'">Paying invoices…</span>
|
<span v-else>Place order</span>
|
||||||
<span v-else-if="state.step === 'done'">Done</span>
|
|
||||||
<span v-else>Pay & place order</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<p
|
<p
|
||||||
v-if="isSubmitting && state.currentRestaurantSlug"
|
v-if="isPlacing && state.currentRestaurantSlug"
|
||||||
class="text-center text-xs text-muted-foreground"
|
class="text-center text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ state.currentRestaurantSlug }} ({{ state.progress.current + 1 }} of {{ state.progress.total }})
|
{{ state.currentRestaurantSlug }} ({{ state.progress.current + 1 }} of {{ state.progress.total }})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,8 @@ function addToCart() {
|
||||||
restaurant_slug: restaurant.value.slug,
|
restaurant_slug: restaurant.value.slug,
|
||||||
menu_item_id: item.value.id,
|
menu_item_id: item.value.id,
|
||||||
name: item.value.name,
|
name: item.value.name,
|
||||||
unit_msat: unitPrice.value,
|
unit_price: unitPrice.value,
|
||||||
|
currency: item.value.currency || restaurant.value.currency,
|
||||||
quantity: quantity.value,
|
quantity: quantity.value,
|
||||||
selected_modifiers: selectedModifiers.value,
|
selected_modifiers: selectedModifiers.value,
|
||||||
note: note.value || null,
|
note: note.value || null,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import OrderInvoiceCard from '../components/OrderInvoiceCard.vue'
|
||||||
import { useOrder } from '../composables/useOrder'
|
import { useOrder } from '../composables/useOrder'
|
||||||
import {
|
import {
|
||||||
KNOWN_ORDER_STATUSES,
|
KNOWN_ORDER_STATUSES,
|
||||||
|
friendlyOrderStatus,
|
||||||
type OrderInvoice,
|
type OrderInvoice,
|
||||||
} from '../types/restaurant'
|
} from '../types/restaurant'
|
||||||
|
|
||||||
|
|
@ -150,7 +151,7 @@ const timeline = computed(() => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge :variant="statusStyle" class="text-sm">
|
<Badge :variant="statusStyle" class="text-sm">
|
||||||
{{ order.status }}
|
{{ friendlyOrderStatus(order.status) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -161,13 +162,25 @@ const timeline = computed(() => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
v-else-if="['paid', 'accepted'].includes(order.status)"
|
v-else-if="order.status === 'paid'"
|
||||||
class="mb-4 border-emerald-500/40"
|
class="mb-4 border-emerald-500/40"
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
<CheckCircle2 class="h-4 w-4 text-emerald-500" />
|
||||||
<AlertTitle>Payment received</AlertTitle>
|
<AlertTitle>Order received</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
The kitchen is on it.
|
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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|
@ -176,12 +189,21 @@ const timeline = computed(() => {
|
||||||
class="mb-4 border-amber-500/40"
|
class="mb-4 border-amber-500/40"
|
||||||
>
|
>
|
||||||
<Clock class="h-4 w-4 text-amber-500" />
|
<Clock class="h-4 w-4 text-amber-500" />
|
||||||
<AlertTitle>Ready</AlertTitle>
|
<AlertTitle>Ready for pickup</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Pick up at the counter.
|
Pick up at the counter.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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">
|
<Card class="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base">Items</CardTitle>
|
<CardTitle class="text-base">Items</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,8 @@ function quickAdd(itemId: string) {
|
||||||
restaurant_slug: restaurant.value.slug,
|
restaurant_slug: restaurant.value.slug,
|
||||||
menu_item_id: it.id,
|
menu_item_id: it.id,
|
||||||
name: it.name,
|
name: it.name,
|
||||||
unit_msat: it.price,
|
unit_price: it.price,
|
||||||
|
currency: it.currency || restaurant.value.currency,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
selected_modifiers: [],
|
selected_modifiers: [],
|
||||||
note: null,
|
note: null,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue