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:
parent
34de6434e9
commit
f2045c511d
6 changed files with 136 additions and 37 deletions
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
0
|
out[rid] = {
|
||||||
)
|
amount: bucket.reduce(
|
||||||
|
(s, l) => s + l.unit_price * l.quantity,
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
* v1 typically has a single bucket. On success: clear cart, route
|
* v1 typically has a single bucket. On success: clear cart, route
|
||||||
* to /orders/<firstOrderId>. On failure: surface the error inline.
|
* 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 { 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,22 +21,25 @@ import { Separator } from '@/components/ui/separator'
|
||||||
import { useCartStore } from '../stores/cart'
|
import { useCartStore } from '../stores/cart'
|
||||||
import { useCheckout } from '../composables/useCheckout'
|
import { useCheckout } from '../composables/useCheckout'
|
||||||
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, checkout } = 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(
|
const isSubmitting = computed(
|
||||||
() => state.value.step !== 'idle' && state.value.step !== 'done' && state.value.step !== 'error'
|
() => state.value.step !== 'idle' && state.value.step !== 'done' && state.value.step !== 'error'
|
||||||
)
|
)
|
||||||
|
|
||||||
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,10 +47,53 @@ 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,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<
|
const placedOrders = ref<
|
||||||
Array<{ orderId: string; restaurantSlug: string; placedAt: number; totalMsat: number; restaurantId: string }>
|
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">
|
<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 +177,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,10 +194,22 @@ 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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