fix(restaurant): cart displays in menu currency; checkout previews live sat

Cart was storing the menu item's price under a misnamed field
`unit_msat` and labeling it 'sat' in the UI — so Big Jay's
25-GTQ Coffee showed as '25 sat' in the cart with no fiat
conversion. The numbers were purely cosmetic (real money math
goes through POST /orders/quote server-side) but misleading.

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

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

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

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

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

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

Vue-tsc clean; vite build clean.
This commit is contained in:
Padreug 2026-05-11 17:57:21 +02:00
commit f2045c511d
6 changed files with 136 additions and 37 deletions

View file

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

View file

@ -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,
0
)
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,

View file

@ -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"

View file

@ -6,9 +6,9 @@
* v1 typically has a single bucket. On success: clear cart, route
* to /orders/<firstOrderId>. On failure: surface the error inline.
*/
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, Loader2, CheckCircle2 } from 'lucide-vue-next'
import { ArrowLeft, Loader2, CheckCircle2, Zap } from 'lucide-vue-next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
@ -21,22 +21,25 @@ import { Separator } from '@/components/ui/separator'
import { useCartStore } from '../stores/cart'
import { useCheckout } from '../composables/useCheckout'
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, checkout } = useCheckout()
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'
)
function fmtSat(value: number) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} sat`
function fmt(value: number, currency: string) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`
}
const buckets = computed(() =>
@ -44,10 +47,53 @@ const buckets = computed(() =>
restaurantId: rid,
restaurantSlug: cart.linesFor(rid)[0]?.restaurant_slug ?? '',
lines: cart.linesFor(rid),
totalMsat: cart.restaurantTotalsMsat[rid] ?? 0,
total: cart.restaurantTotals[rid] ?? null,
}))
)
/**
* Live sat preview: a single /orders/quote per restaurant the
* moment the cart is loaded. Surfaces the actual Lightning amount
* the customer will pay, separately from the menu-currency
* subtotal. Recomputes whenever the cart changes.
*/
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) => {
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 {
// Best-effort preview only; if the quote endpoint hiccups
// we just don't show the sat estimate. The real quote runs
// again inside useCheckout when the customer clicks pay.
next[g.rid] = null
}
}
previewSatPerRestaurant.value = next
},
{ immediate: true, deep: true }
)
const placedOrders = ref<
Array<{ orderId: string; restaurantSlug: string; placedAt: number; totalMsat: number; restaurantId: string }>
>([])
@ -112,7 +158,7 @@ async function placeOrder() {
<CardHeader class="flex flex-row items-baseline justify-between space-y-0">
<CardTitle class="text-base">{{ b.restaurantSlug }}</CardTitle>
<span class="font-mono text-sm text-primary">
{{ fmtSat(b.totalMsat) }}
{{ b.total ? fmt(b.total.amount, b.total.currency) : '—' }}
</span>
</CardHeader>
<CardContent class="space-y-1">
@ -131,9 +177,16 @@ async function placeOrder() {
</span>
</span>
<span class="font-mono text-xs text-muted-foreground">
{{ fmtSat(line.unit_msat * line.quantity) }}
{{ 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>
@ -141,10 +194,22 @@ async function placeOrder() {
<Separator class="my-6" />
<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="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>
</div>

View file

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

View file

@ -84,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,