Compare commits
8 commits
31312688b5
...
15545c9b5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 15545c9b5e | |||
| 1d815652c4 | |||
| 705a94b475 | |||
| 10abfca555 | |||
| f2045c511d | |||
| 34de6434e9 | |||
| 30d7d1c3cb | |||
| 7e95a558b4 |
1 changed files with 11 additions and 123 deletions
|
|
@ -2,44 +2,27 @@
|
||||||
/**
|
/**
|
||||||
* Lists past orders the customer has placed from this device.
|
* Lists past orders the customer has placed from this device.
|
||||||
*
|
*
|
||||||
* Source of truth for the *list* is STORAGE_SERVICE
|
* Source of truth is STORAGE_SERVICE['restaurant.lastOrders.v1']
|
||||||
* ['restaurant.lastOrders.v1'] (newest first, cap 50) — appended to
|
* (newest first, cap 50) — appended to by CheckoutPage. Each
|
||||||
* by CheckoutPage. Each stored entry is enough to deep-link to
|
* entry is enough to display + deep-link to /orders/:id; the
|
||||||
* /orders/:id, but it freezes the order's totals + status at the
|
* detail page re-fetches the live order over REST so we don't
|
||||||
* moment it was placed.
|
* store stale status here.
|
||||||
*
|
|
||||||
* 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 { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ReceiptText, RefreshCw } from 'lucide-vue-next'
|
import { ReceiptText } from 'lucide-vue-next'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
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 { Card, CardContent } from '@/components/ui/card'
|
||||||
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'
|
|
||||||
import {
|
|
||||||
KNOWN_ORDER_STATUSES,
|
|
||||||
friendlyOrderStatus,
|
|
||||||
type Order,
|
|
||||||
type OrderStatus,
|
|
||||||
} from '../types/restaurant'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const storage = tryInjectService<StorageService>(
|
const storage = tryInjectService<StorageService>(
|
||||||
SERVICE_TOKENS.STORAGE_SERVICE
|
SERVICE_TOKENS.STORAGE_SERVICE
|
||||||
)
|
)
|
||||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
|
||||||
|
|
||||||
interface OrderHistoryEntry {
|
interface OrderHistoryEntry {
|
||||||
orderId: string
|
orderId: string
|
||||||
|
|
@ -50,81 +33,19 @@ interface OrderHistoryEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
const orders = ref<OrderHistoryEntry[]>([])
|
const orders = ref<OrderHistoryEntry[]>([])
|
||||||
const liveByOrderId = ref<Record<string, Order | null>>({})
|
|
||||||
const isRefreshing = ref(false)
|
|
||||||
|
|
||||||
// Re-fetches every history row's live status + fiat. Used both on
|
onMounted(() => {
|
||||||
// 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 =
|
orders.value =
|
||||||
storage?.getUserData<OrderHistoryEntry[]>(
|
storage?.getUserData<OrderHistoryEntry[]>(
|
||||||
'restaurant.lastOrders.v1',
|
'restaurant.lastOrders.v1',
|
||||||
[]
|
[]
|
||||||
) || []
|
) || []
|
||||||
await refresh()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function fmtSat(value: number) {
|
function fmtSat(value: number) {
|
||||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
|
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) {
|
function fmtTime(ts: number) {
|
||||||
return new Date(ts).toLocaleString()
|
return new Date(ts).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +93,7 @@ const grouped = computed(() => {
|
||||||
@click="router.push(`/orders/${o.orderId}`)"
|
@click="router.push(`/orders/${o.orderId}`)"
|
||||||
>
|
>
|
||||||
<CardContent class="p-3 sm:p-4">
|
<CardContent class="p-3 sm:p-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="line-clamp-1 font-semibold text-foreground">
|
<p class="line-clamp-1 font-semibold text-foreground">
|
||||||
{{ o.restaurantSlug }}
|
{{ o.restaurantSlug }}
|
||||||
|
|
@ -181,48 +102,15 @@ const grouped = computed(() => {
|
||||||
{{ o.orderId.slice(0, 12) }}…
|
{{ o.orderId.slice(0, 12) }}…
|
||||||
· {{ fmtTime(o.placedAt) }}
|
· {{ fmtTime(o.placedAt) }}
|
||||||
</p>
|
</p>
|
||||||
<Badge
|
|
||||||
v-if="liveByOrderId[o.orderId]"
|
|
||||||
:variant="statusVariant(liveByOrderId[o.orderId]?.status)"
|
|
||||||
class="mt-1.5"
|
|
||||||
>
|
|
||||||
{{ friendlyOrderStatus(liveByOrderId[o.orderId]!.status) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 text-right">
|
<span class="shrink-0 font-mono text-sm font-semibold text-primary">
|
||||||
<p class="font-mono text-sm font-semibold text-primary">
|
|
||||||
{{ fmtSat(o.totalMsat) }}
|
{{ fmtSat(o.totalMsat) }}
|
||||||
</p>
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue