feat(restaurant): orders list + settings
views/OrdersListPage.vue — grouped by day, source of truth is
STORAGE_SERVICE['restaurant.lastOrders.v1'] (newest first, cap
50). Each row deep-links to /orders/:id where the detail page
re-fetches the live order over REST so stale status never
displays here.
views/SettingsPage.vue — customer-side preferences persisted to
STORAGE_SERVICE['restaurant.settings.v1']:
- currencyDisplay toggle ('sats' | 'msat'). Local display only,
the extension is always msat-canonical.
- relayOverride input (comma-separated). Reload required since
RelayHub initializes once on boot.
- 'Clear local data' destructive button — wipes cart, history,
recent venues but does NOT refund/cancel placed orders.
Routes added: /orders, /settings.
Verified: vue-tsc -b clean against the whole webapp.
This commit is contained in:
parent
940b36ba79
commit
a7f2ded8b2
3 changed files with 281 additions and 0 deletions
|
|
@ -106,6 +106,18 @@ export const restaurantModule: ModulePlugin = {
|
||||||
component: () => import('./views/OrderStatusPage.vue'),
|
component: () => import('./views/OrderStatusPage.vue'),
|
||||||
meta: { requiresAuth: false, title: 'Order' },
|
meta: { requiresAuth: false, title: 'Order' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/orders',
|
||||||
|
name: 'restaurant-orders',
|
||||||
|
component: () => import('./views/OrdersListPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Orders' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'restaurant-settings',
|
||||||
|
component: () => import('./views/SettingsPage.vue'),
|
||||||
|
meta: { requiresAuth: false, title: 'Settings' },
|
||||||
|
},
|
||||||
] as RouteRecordRaw[],
|
] as RouteRecordRaw[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
116
src/modules/restaurant/views/OrdersListPage.vue
Normal file
116
src/modules/restaurant/views/OrdersListPage.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ReceiptText } from 'lucide-vue-next'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const storage = tryInjectService<StorageService>(
|
||||||
|
SERVICE_TOKENS.STORAGE_SERVICE
|
||||||
|
)
|
||||||
|
|
||||||
|
interface OrderHistoryEntry {
|
||||||
|
orderId: string
|
||||||
|
restaurantId: string
|
||||||
|
restaurantSlug: string
|
||||||
|
placedAt: number
|
||||||
|
totalMsat: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = ref<OrderHistoryEntry[]>([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
orders.value =
|
||||||
|
storage?.getUserData<OrderHistoryEntry[]>(
|
||||||
|
'restaurant.lastOrders.v1',
|
||||||
|
[]
|
||||||
|
) || []
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtSat(value: number) {
|
||||||
|
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value / 1000)} sat`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number) {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = computed(() => {
|
||||||
|
const groups = new Map<string, OrderHistoryEntry[]>()
|
||||||
|
for (const o of orders.value) {
|
||||||
|
const day = new Date(o.placedAt).toLocaleDateString()
|
||||||
|
if (!groups.has(day)) groups.set(day, [])
|
||||||
|
groups.get(day)!.push(o)
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(([day, items]) => ({
|
||||||
|
day,
|
||||||
|
items,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||||
|
<h1 class="mb-4 text-2xl font-bold text-foreground">Your orders</h1>
|
||||||
|
|
||||||
|
<Alert v-if="!orders.length" class="border-border">
|
||||||
|
<ReceiptText class="h-4 w-4" />
|
||||||
|
<AlertTitle>No orders yet</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Place an order and it'll show up here.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section
|
||||||
|
v-for="group in grouped"
|
||||||
|
:key="group.day"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{{ group.day }}
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Card
|
||||||
|
v-for="o in group.items"
|
||||||
|
:key="o.orderId"
|
||||||
|
class="cursor-pointer transition-colors hover:bg-accent"
|
||||||
|
@click="router.push(`/orders/${o.orderId}`)"
|
||||||
|
>
|
||||||
|
<CardContent class="p-3 sm:p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="line-clamp-1 font-semibold text-foreground">
|
||||||
|
{{ o.restaurantSlug }}
|
||||||
|
</p>
|
||||||
|
<p class="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{{ o.orderId.slice(0, 12) }}…
|
||||||
|
· {{ fmtTime(o.placedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 font-mono text-sm font-semibold text-primary">
|
||||||
|
{{ fmtSat(o.totalMsat) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
153
src/modules/restaurant/views/SettingsPage.vue
Normal file
153
src/modules/restaurant/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Customer-side preferences. v1 ships:
|
||||||
|
* - currency display toggle (sats / msat)
|
||||||
|
* - optional relay override (comma-separated; restart required
|
||||||
|
* because RelayHub is initialized once at boot)
|
||||||
|
*
|
||||||
|
* Persisted to STORAGE_SERVICE['restaurant.settings.v1'].
|
||||||
|
*
|
||||||
|
* Future tier-#2 may surface mode-gated toggles here (e.g. NIP-17
|
||||||
|
* order intake when #9 ships); the `features:{}` slot in the
|
||||||
|
* module config is the integration point.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { StorageService } from '@/core/services/StorageService'
|
||||||
|
import { useCartStore } from '../stores/cart'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const storage = tryInjectService<StorageService>(
|
||||||
|
SERVICE_TOKENS.STORAGE_SERVICE
|
||||||
|
)
|
||||||
|
const cart = useCartStore()
|
||||||
|
|
||||||
|
interface RestaurantSettings {
|
||||||
|
currencyDisplay: 'sats' | 'msat'
|
||||||
|
relayOverride?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = ref<RestaurantSettings>({
|
||||||
|
currencyDisplay: 'sats',
|
||||||
|
relayOverride: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
settings.value =
|
||||||
|
storage?.getUserData<RestaurantSettings>('restaurant.settings.v1', {
|
||||||
|
currencyDisplay: 'sats',
|
||||||
|
}) || { currencyDisplay: 'sats' }
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
settings,
|
||||||
|
(val) => {
|
||||||
|
storage?.setUserData('restaurant.settings.v1', val)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearLocalData() {
|
||||||
|
if (!confirm('Clear cart, recent venues, and order history on this device?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cart.clear()
|
||||||
|
storage?.clearUserData('restaurant.cart.v1')
|
||||||
|
storage?.clearUserData('restaurant.lastOrders.v1')
|
||||||
|
storage?.clearUserData('restaurant.recentRestaurants.v1')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-2xl px-4 pb-24 pt-3 sm:py-6">
|
||||||
|
<Button variant="ghost" size="sm" class="mb-3" @click="router.back()">
|
||||||
|
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 class="mb-4 text-2xl font-bold text-foreground">Settings</h1>
|
||||||
|
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Display</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-sm" for="currency-toggle">
|
||||||
|
Show prices in millisats
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="currency-toggle"
|
||||||
|
:model-value="settings.currencyDisplay === 'msat'"
|
||||||
|
@update:model-value="
|
||||||
|
(val) =>
|
||||||
|
(settings.currencyDisplay = val ? 'msat' : 'sats')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Currently:
|
||||||
|
<code class="font-mono">{{ settings.currencyDisplay }}</code>.
|
||||||
|
Order totals from the extension are always msat; this is
|
||||||
|
local display only.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Nostr relays</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<Label for="relay-override" class="text-sm">
|
||||||
|
Override relays (comma-separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="relay-override"
|
||||||
|
v-model.trim="settings.relayOverride"
|
||||||
|
placeholder="wss://relay.example.com, wss://nos.lol"
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Reload the app for changes to take effect — the relay hub
|
||||||
|
initializes once on boot.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Local data</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
@click="clearLocalData"
|
||||||
|
>
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
Clear cart + history
|
||||||
|
</Button>
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
|
Wipes the cart, recent venues, and order history from this
|
||||||
|
device. Doesn't refund or cancel any placed orders.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue