feat(orders-list): live status badge + fiat amount + manual refresh

Three follow-ups to the v1 orders-list page that emerged once the
extension started transitioning orders through 'paid → accepted →
ready' from the KDS:

views/OrdersListPage.vue:
  - hydrate each entry from api.getOrder(id) on mount so the row
    reflects the live status (via friendlyOrderStatus) rather than
    the snapshot at place-time
  - surface the order's original fiat_amount + currency_display
    alongside the sat total
  - floating bottom-right FAB refresh button — the extension has no
    push channel for order status today (aiolabs/restaurant#9 will
    replace this with NIP-17 status DMs), so customers need an
    explicit way to pick up kitchen-side transitions without a
    full page reload. Bottom-right positioning avoids the global
    hub nav button at top-right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-11 18:34:31 +02:00
commit 31312688b5

View file

@ -2,27 +2,44 @@
/**
* Lists past orders the customer has placed from this device.
*
* Source of truth is STORAGE_SERVICE['restaurant.lastOrders.v1']
* (newest first, cap 50) appended to by CheckoutPage. Each
* entry is enough to display + deep-link to /orders/:id; the
* detail page re-fetches the live order over REST so we don't
* store stale status here.
* 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 } from 'lucide-vue-next'
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
@ -33,19 +50,81 @@ interface OrderHistoryEntry {
}
const orders = ref<OrderHistoryEntry[]>([])
const liveByOrderId = ref<Record<string, Order | null>>({})
const isRefreshing = ref(false)
onMounted(() => {
// 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()
}
@ -93,7 +172,7 @@ const grouped = computed(() => {
@click="router.push(`/orders/${o.orderId}`)"
>
<CardContent class="p-3 sm:p-4">
<div class="flex items-center justify-between">
<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 }}
@ -102,15 +181,48 @@ const grouped = computed(() => {
{{ o.orderId.slice(0, 12) }}
· {{ fmtTime(o.placedAt) }}
</p>
<Badge
v-if="liveByOrderId[o.orderId]"
:variant="statusVariant(liveByOrderId[o.orderId]?.status)"
class="mt-1.5"
>
{{ friendlyOrderStatus(liveByOrderId[o.orderId]!.status) }}
</Badge>
</div>
<span class="shrink-0 font-mono text-sm font-semibold text-primary">
<div class="shrink-0 text-right">
<p class="font-mono text-sm font-semibold text-primary">
{{ fmtSat(o.totalMsat) }}
</span>
</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>