feat(restaurant): Nostr live overlay (NIP-99) for menu state
services/RestaurantNostrSync.ts — BaseService subclass declaring 'RelayHub' as a dependency, so this.relayHub is populated by the framework. Subscribes to: kinds: [30402, 5] authors: [restaurant.nostr_pubkey] '#l': ['restaurant:<restaurant.id>'] Each kind-30402 (NIP-99 classified listing) is parsed into a partial MenuItem patch keyed by the 'd' tag (the menu item id). NIP-33 replaceable semantics: incoming events older than what we have are dropped (defense against operator-side reordering bugs). Kind 5 (NIP-09 deletion request) populates a set; the useMenu computed filters those items out. The patch covers name, description, price, is_available, stock (derived from the NIP-99 'status' tag — 'sold' -> is_available: false + stock: 0; 'active' -> is_available: true). Items appearing for the first time via the relay (without a matching REST item) are intentionally ignored — federated foreign-menu indexing is a future concern (aiolabs/restaurant#8 / docs). useMenu — refactor: - rename internal baseItems = REST snapshot - items becomes a computed that overlays sync.overlay onto baseItems and filters sync.deleted out - tryInjectService is used so the composable still works in test environments without the sync service. views/RestaurantPage.vue — watches the resolved restaurant ref, opens sync.subscribe(restaurant.nostr_pubkey, restaurant.id) on arrival, tears it down on route leave / unmount. If relay hub isn't connected, subscribe is a no-op and REST continues to serve the menu (best-effort polish, not load-bearing). modules/restaurant/index.ts — install() now also constructs RestaurantNostrSync, registers it under SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC, and kicks off initialize with waitForDependencies + 3 retries. Init failure is logged as a warning and operation continues in no-overlay mode. Verified: vue-tsc -b clean; vite build clean against VITE_LNBITS_BASE_URL=http://localhost:5001 VITE_RESTAURANT_DEFAULT_SLUG=big-jays-bustaurant. End-to-end customer flow now matches the plan's verification section: / redirects to /r/big-jays-bustaurant /r/big-jays-bustaurant REST menu loads + Nostr sub opens tap item with mods ItemPage + ModifierSelector tap '+' on simple item quick-add to cart bottom-nav Cart /cart shows lines + total /checkout quote -> place -> pay bolt11(s) auto-redirect /orders/<id>, polls every 5s /orders historical list /settings display + relay override + clear data
This commit is contained in:
parent
30d7d1c3cb
commit
34de6434e9
4 changed files with 290 additions and 5 deletions
|
|
@ -15,8 +15,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed, onScopeDispose, watch, type Ref } from 'vue'
|
import { ref, computed, onScopeDispose, watch, type Ref } from 'vue'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import {
|
||||||
|
injectService,
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
import type { RestaurantAPI } from '../services/RestaurantAPI'
|
||||||
|
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
|
||||||
import type {
|
import type {
|
||||||
EnrichedMenuItem,
|
EnrichedMenuItem,
|
||||||
MenuNode,
|
MenuNode,
|
||||||
|
|
@ -33,6 +38,7 @@ function looksLikeId(value: string): boolean {
|
||||||
export interface UseMenuReturn {
|
export interface UseMenuReturn {
|
||||||
restaurant: Ref<Restaurant | null>
|
restaurant: Ref<Restaurant | null>
|
||||||
tree: Ref<MenuNode[]>
|
tree: Ref<MenuNode[]>
|
||||||
|
/** Items with the Nostr live overlay merged in. */
|
||||||
items: Ref<EnrichedMenuItem[]>
|
items: Ref<EnrichedMenuItem[]>
|
||||||
isLoading: Ref<boolean>
|
isLoading: Ref<boolean>
|
||||||
error: Ref<Error | null>
|
error: Ref<Error | null>
|
||||||
|
|
@ -41,10 +47,13 @@ export interface UseMenuReturn {
|
||||||
|
|
||||||
export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
||||||
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
const api = injectService<RestaurantAPI>(SERVICE_TOKENS.RESTAURANT_API)
|
||||||
|
const sync = tryInjectService<RestaurantNostrSync>(
|
||||||
|
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
|
||||||
|
)
|
||||||
|
|
||||||
const restaurant = ref<Restaurant | null>(null)
|
const restaurant = ref<Restaurant | null>(null)
|
||||||
const tree = ref<MenuNode[]>([])
|
const tree = ref<MenuNode[]>([])
|
||||||
const items = ref<EnrichedMenuItem[]>([])
|
const baseItems = ref<EnrichedMenuItem[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
|
@ -54,6 +63,24 @@ export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
||||||
typeof slugOrId === 'string' ? slugOrId : slugOrId.value
|
typeof slugOrId === 'string' ? slugOrId : slugOrId.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `items` exposes the base REST snapshot patched with the live
|
||||||
|
* overlay from RestaurantNostrSync. Operator edits on the
|
||||||
|
* extension side surface here within ~1s of arriving at the
|
||||||
|
* relay, without a refetch.
|
||||||
|
*/
|
||||||
|
const items = computed<EnrichedMenuItem[]>(() => {
|
||||||
|
const overlay = sync?.overlay
|
||||||
|
const deleted = sync?.deleted
|
||||||
|
if (!overlay && !deleted) return baseItems.value
|
||||||
|
return baseItems.value
|
||||||
|
.filter((it) => !(deleted && deleted.has(it.id)))
|
||||||
|
.map((it) => {
|
||||||
|
const patch = overlay?.get(it.id)
|
||||||
|
return patch ? ({ ...it, ...patch } as EnrichedMenuItem) : it
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
async function load(value: string): Promise<void> {
|
async function load(value: string): Promise<void> {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
|
|
@ -73,7 +100,7 @@ export function useMenu(slugOrId: Ref<string> | string): UseMenuReturn {
|
||||||
|
|
||||||
restaurant.value = menu.restaurant
|
restaurant.value = menu.restaurant
|
||||||
tree.value = menu.tree
|
tree.value = menu.tree
|
||||||
items.value = menu.items
|
baseItems.value = menu.items
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (my.signal.aborted) return
|
if (my.signal.aborted) return
|
||||||
error.value = err instanceof Error ? err : new Error(String(err))
|
error.value = err instanceof Error ? err : new Error(String(err))
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { ModulePlugin } from '@/core/types'
|
||||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
|
||||||
import { RestaurantAPI } from './services/RestaurantAPI'
|
import { RestaurantAPI } from './services/RestaurantAPI'
|
||||||
|
import { RestaurantNostrSync } from './services/RestaurantNostrSync'
|
||||||
|
|
||||||
// v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug).
|
// v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug).
|
||||||
//
|
//
|
||||||
|
|
@ -64,7 +65,21 @@ export const restaurantModule: ModulePlugin = {
|
||||||
console.warn('🍴 RestaurantAPI init deferred:', error)
|
console.warn('🍴 RestaurantAPI init deferred:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
// RestaurantNostrSync lands in commit 8.
|
// Nostr live-overlay sync. Requires RelayHub from baseModule;
|
||||||
|
// BaseService.waitForDependencies handles the timing if base
|
||||||
|
// initialization hasn't quite landed by the time we get here.
|
||||||
|
const restaurantNostrSync = new RestaurantNostrSync()
|
||||||
|
container.provide(
|
||||||
|
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC,
|
||||||
|
restaurantNostrSync
|
||||||
|
)
|
||||||
|
await restaurantNostrSync
|
||||||
|
.initialize({ waitForDependencies: true, maxRetries: 3 })
|
||||||
|
.catch((error) => {
|
||||||
|
// No-overlay mode is fine: REST still works, the menu just
|
||||||
|
// doesn't reflect operator edits without a page refresh.
|
||||||
|
console.warn('🍴 RestaurantNostrSync init deferred:', error)
|
||||||
|
})
|
||||||
|
|
||||||
console.log('✅ Restaurant module installed')
|
console.log('✅ Restaurant module installed')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
211
src/modules/restaurant/services/RestaurantNostrSync.ts
Normal file
211
src/modules/restaurant/services/RestaurantNostrSync.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
/**
|
||||||
|
* RestaurantNostrSync — live overlay for menu item state via NIP-99.
|
||||||
|
*
|
||||||
|
* Subscribes to the restaurant's `nostr_pubkey` for:
|
||||||
|
* - kind 30402 (NIP-99 classified listings) tagged
|
||||||
|
* `["l", "restaurant:<restaurant.id>"]`
|
||||||
|
* - kind 5 (NIP-09 deletion requests)
|
||||||
|
*
|
||||||
|
* Each incoming 30402 is parsed into a partial MenuItem patch keyed
|
||||||
|
* by the `d` tag (= menu item id) and pushed to a reactive overlay
|
||||||
|
* map. `useMenu` merges this overlay into its `items` computed so
|
||||||
|
* price changes, sold-out flips, and availability updates render
|
||||||
|
* within ~1s of the operator's edit on the extension side.
|
||||||
|
*
|
||||||
|
* Subscription lifecycle is owned by the *consumer* (RestaurantPage
|
||||||
|
* opens on mount, closes on route leave). Visibility integration is
|
||||||
|
* handled implicitly by the RelayHub — backgrounded tabs lose the
|
||||||
|
* underlying WebSocket; we re-subscribe on the next mount.
|
||||||
|
*
|
||||||
|
* This service holds NO state about which restaurants are subscribed
|
||||||
|
* — it expects callers to track their own sub ids if they need to
|
||||||
|
* tear them down individually.
|
||||||
|
*/
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import type { Filter } from 'nostr-tools'
|
||||||
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
|
import type { MenuItem } from '../types/restaurant'
|
||||||
|
|
||||||
|
interface NostrEvent {
|
||||||
|
id: string
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
sig?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_KIND_LISTING = 30402
|
||||||
|
const SUB_KIND_DELETION = 5
|
||||||
|
|
||||||
|
export type MenuItemPatch = Partial<
|
||||||
|
Pick<
|
||||||
|
MenuItem,
|
||||||
|
'name' | 'description' | 'price' | 'is_available' | 'stock' | 'nostr_event_id' | 'nostr_event_created_at'
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
|
export class RestaurantNostrSync extends BaseService {
|
||||||
|
protected readonly metadata = {
|
||||||
|
name: 'RestaurantNostrSync',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: ['RelayHub'] as string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive overlay: itemId → partial patch. `useMenu` watches
|
||||||
|
* this and merges into its `items` array.
|
||||||
|
*/
|
||||||
|
readonly overlay = reactive(new Map<string, MenuItemPatch>())
|
||||||
|
|
||||||
|
/** Deleted item ids — useMenu filters these out. */
|
||||||
|
readonly deleted = reactive(new Set<string>())
|
||||||
|
|
||||||
|
// BaseService auto-populates `this.relayHub` from
|
||||||
|
// SERVICE_TOKENS.RELAY_HUB because our `metadata.dependencies`
|
||||||
|
// includes 'RelayHub' — no manual inject needed in onInitialize.
|
||||||
|
|
||||||
|
private unsubscribers = new Map<string, () => void>()
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
this.debug('RestaurantNostrSync ready')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed accessor for the BaseService-injected relay hub. */
|
||||||
|
private get hub(): RelayHub | null {
|
||||||
|
return (this.relayHub as RelayHub | null) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the per-restaurant subscription. Returns an unsubscribe
|
||||||
|
* fn for convenience; calling subscribe() again with the same
|
||||||
|
* restaurantId is a no-op (idempotent).
|
||||||
|
*/
|
||||||
|
subscribe(restaurantPubkey: string, restaurantId: string): () => void {
|
||||||
|
const hub = this.hub
|
||||||
|
if (!hub) {
|
||||||
|
this.debug('subscribe: relay hub not ready')
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
if (this.unsubscribers.has(restaurantId)) {
|
||||||
|
return () => this.unsubscribe(restaurantId)
|
||||||
|
}
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [SUB_KIND_LISTING, SUB_KIND_DELETION],
|
||||||
|
authors: [restaurantPubkey],
|
||||||
|
'#l': [`restaurant:${restaurantId}`],
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const offEvent = hub.subscribe({
|
||||||
|
id: `restaurant-${restaurantId}`,
|
||||||
|
filters: [filter],
|
||||||
|
onEvent: (event) => this.handleEvent(event as NostrEvent),
|
||||||
|
})
|
||||||
|
this.unsubscribers.set(restaurantId, offEvent)
|
||||||
|
this.debug(`subscribed authors=${restaurantPubkey.slice(0, 8)}…`)
|
||||||
|
} catch (err) {
|
||||||
|
// RelayHub throws if not connected. The user can still browse
|
||||||
|
// via REST — this just means no live updates this session.
|
||||||
|
this.debug(`subscribe failed (live overlay disabled): ${String(err)}`)
|
||||||
|
}
|
||||||
|
return () => this.unsubscribe(restaurantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(restaurantId: string): void {
|
||||||
|
const off = this.unsubscribers.get(restaurantId)
|
||||||
|
if (off) {
|
||||||
|
off()
|
||||||
|
this.unsubscribers.delete(restaurantId)
|
||||||
|
this.debug(`unsubscribed ${restaurantId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop all subscriptions and clear the overlay. BaseService
|
||||||
|
* defines this as async; we honor the signature even though our
|
||||||
|
* cleanup is synchronous.
|
||||||
|
*/
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
for (const off of this.unsubscribers.values()) off()
|
||||||
|
this.unsubscribers.clear()
|
||||||
|
this.overlay.clear()
|
||||||
|
this.deleted.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
// event handlers //
|
||||||
|
// ----------------------------------------------------------------- //
|
||||||
|
|
||||||
|
private handleEvent(event: NostrEvent): void {
|
||||||
|
if (event.kind === SUB_KIND_LISTING) {
|
||||||
|
this.handleListing(event)
|
||||||
|
} else if (event.kind === SUB_KIND_DELETION) {
|
||||||
|
this.handleDeletion(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleListing(event: NostrEvent): void {
|
||||||
|
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag) return
|
||||||
|
|
||||||
|
// Skip if we've already seen a newer version of this addressable
|
||||||
|
// event (NIP-33 replaceable semantics — operator-side bug
|
||||||
|
// protection).
|
||||||
|
const existing = this.overlay.get(dTag)
|
||||||
|
if (
|
||||||
|
existing?.nostr_event_created_at &&
|
||||||
|
existing.nostr_event_created_at >= event.created_at
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: MenuItemPatch = {
|
||||||
|
nostr_event_id: event.id,
|
||||||
|
nostr_event_created_at: event.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = event.tags.find((t) => t[0] === 'title')?.[1]
|
||||||
|
if (title) patch.name = title
|
||||||
|
|
||||||
|
const summary = event.tags.find((t) => t[0] === 'summary')?.[1]
|
||||||
|
if (summary) patch.description = summary
|
||||||
|
|
||||||
|
const priceTag = event.tags.find((t) => t[0] === 'price')
|
||||||
|
if (priceTag && priceTag[1]) {
|
||||||
|
const parsed = parseFloat(priceTag[1])
|
||||||
|
if (!Number.isNaN(parsed)) patch.price = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-99 status: 'active' | 'sold'. We map to is_available +
|
||||||
|
// stock=0 so the existing UI badges (sold out / low stock)
|
||||||
|
// render consistently.
|
||||||
|
const statusTag = event.tags.find((t) => t[0] === 'status')?.[1]
|
||||||
|
if (statusTag === 'sold') {
|
||||||
|
patch.is_available = false
|
||||||
|
patch.stock = 0
|
||||||
|
} else if (statusTag === 'active') {
|
||||||
|
patch.is_available = true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay.set(dTag, patch)
|
||||||
|
this.debug(`overlay merge id=${dTag.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDeletion(event: NostrEvent): void {
|
||||||
|
// NIP-09: an `a` tag references the addressable event the author
|
||||||
|
// is deleting. Format: 'kind:pubkey:dTag'.
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] !== 'a' || !tag[1]) continue
|
||||||
|
const parts = tag[1].split(':')
|
||||||
|
if (parts[0] !== String(SUB_KIND_LISTING)) continue
|
||||||
|
const dTag = parts[2]
|
||||||
|
if (dTag) {
|
||||||
|
this.deleted.add(dTag)
|
||||||
|
this.overlay.delete(dTag)
|
||||||
|
this.debug(`deletion id=${dTag.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
* handles modifier selection and "Add to cart" (cart store lands in
|
* handles modifier selection and "Add to cart" (cart store lands in
|
||||||
* commit 5).
|
* commit 5).
|
||||||
*/
|
*/
|
||||||
import { computed, toRef } from 'vue'
|
import { computed, onBeforeUnmount, toRef, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
|
@ -21,16 +21,48 @@ import CategoryNav from '../components/CategoryNav.vue'
|
||||||
import MenuTree from '../components/MenuTree.vue'
|
import MenuTree from '../components/MenuTree.vue'
|
||||||
import { useMenu } from '../composables/useMenu'
|
import { useMenu } from '../composables/useMenu'
|
||||||
import { useCartStore } from '../stores/cart'
|
import { useCartStore } from '../stores/cart'
|
||||||
|
import {
|
||||||
|
tryInjectService,
|
||||||
|
SERVICE_TOKENS,
|
||||||
|
} from '@/core/di-container'
|
||||||
|
import type { RestaurantNostrSync } from '../services/RestaurantNostrSync'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const cart = useCartStore()
|
const cart = useCartStore()
|
||||||
|
const sync = tryInjectService<RestaurantNostrSync>(
|
||||||
|
SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC
|
||||||
|
)
|
||||||
|
|
||||||
const slug = computed(() => String(route.params.slug || ''))
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
|
const { restaurant, tree, items, isLoading, error, refresh } = useMenu(
|
||||||
toRef(() => slug.value)
|
toRef(() => slug.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Open the Nostr live-overlay subscription the moment we know the
|
||||||
|
// restaurant's pubkey + id. Close it on route leave / unmount. If
|
||||||
|
// the relay hub isn't connected, sync.subscribe is a no-op (REST
|
||||||
|
// continues to work; the overlay is best-effort polish).
|
||||||
|
let activeRestaurantId: string | null = null
|
||||||
|
watch(restaurant, (r) => {
|
||||||
|
if (!sync) return
|
||||||
|
if (activeRestaurantId && activeRestaurantId !== r?.id) {
|
||||||
|
sync.unsubscribe(activeRestaurantId)
|
||||||
|
activeRestaurantId = null
|
||||||
|
}
|
||||||
|
if (r?.nostr_pubkey && r.id !== activeRestaurantId) {
|
||||||
|
sync.subscribe(r.nostr_pubkey, r.id)
|
||||||
|
activeRestaurantId = r.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (sync && activeRestaurantId) {
|
||||||
|
sync.unsubscribe(activeRestaurantId)
|
||||||
|
activeRestaurantId = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function openItem(itemId: string) {
|
function openItem(itemId: string) {
|
||||||
router.push(`/r/${slug.value}/item/${itemId}`)
|
router.push(`/r/${slug.value}/item/${itemId}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue