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:
Padreug 2026-05-11 17:40:27 +02:00
commit 34de6434e9
4 changed files with 290 additions and 5 deletions

View file

@ -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))

View file

@ -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')
}, },

View 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)}`)
}
}
}
}

View file

@ -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}`)
} }