diff --git a/src/modules/restaurant/composables/useMenu.ts b/src/modules/restaurant/composables/useMenu.ts index b35e178..2c5d706 100644 --- a/src/modules/restaurant/composables/useMenu.ts +++ b/src/modules/restaurant/composables/useMenu.ts @@ -15,8 +15,13 @@ */ 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 { RestaurantNostrSync } from '../services/RestaurantNostrSync' import type { EnrichedMenuItem, MenuNode, @@ -33,6 +38,7 @@ function looksLikeId(value: string): boolean { export interface UseMenuReturn { restaurant: Ref tree: Ref + /** Items with the Nostr live overlay merged in. */ items: Ref isLoading: Ref error: Ref @@ -41,10 +47,13 @@ export interface UseMenuReturn { export function useMenu(slugOrId: Ref | string): UseMenuReturn { const api = injectService(SERVICE_TOKENS.RESTAURANT_API) + const sync = tryInjectService( + SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC + ) const restaurant = ref(null) const tree = ref([]) - const items = ref([]) + const baseItems = ref([]) const isLoading = ref(false) const error = ref(null) @@ -54,6 +63,24 @@ export function useMenu(slugOrId: Ref | string): UseMenuReturn { 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(() => { + 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 { if (!value) return abortController?.abort() @@ -73,7 +100,7 @@ export function useMenu(slugOrId: Ref | string): UseMenuReturn { restaurant.value = menu.restaurant tree.value = menu.tree - items.value = menu.items + baseItems.value = menu.items } catch (err) { if (my.signal.aborted) return error.value = err instanceof Error ? err : new Error(String(err)) diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index 643a61f..3b57b9b 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -4,6 +4,7 @@ import type { ModulePlugin } from '@/core/types' import { container, SERVICE_TOKENS } from '@/core/di-container' import { RestaurantAPI } from './services/RestaurantAPI' +import { RestaurantNostrSync } from './services/RestaurantNostrSync' // v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug). // @@ -64,7 +65,21 @@ export const restaurantModule: ModulePlugin = { 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') }, diff --git a/src/modules/restaurant/services/RestaurantNostrSync.ts b/src/modules/restaurant/services/RestaurantNostrSync.ts new file mode 100644 index 0000000..c3e7166 --- /dev/null +++ b/src/modules/restaurant/services/RestaurantNostrSync.ts @@ -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:"]` + * - 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()) + + /** Deleted item ids — useMenu filters these out. */ + readonly deleted = reactive(new Set()) + + // 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 void>() + + protected async onInitialize(): Promise { + 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 { + 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)}…`) + } + } + } +} diff --git a/src/modules/restaurant/views/RestaurantPage.vue b/src/modules/restaurant/views/RestaurantPage.vue index abbdb50..3d97877 100644 --- a/src/modules/restaurant/views/RestaurantPage.vue +++ b/src/modules/restaurant/views/RestaurantPage.vue @@ -11,7 +11,7 @@ * handles modifier selection and "Add to cart" (cart store lands in * commit 5). */ -import { computed, toRef } from 'vue' +import { computed, onBeforeUnmount, toRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { Loader2, AlertCircle } from 'lucide-vue-next' 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 { useMenu } from '../composables/useMenu' import { useCartStore } from '../stores/cart' +import { + tryInjectService, + SERVICE_TOKENS, +} from '@/core/di-container' +import type { RestaurantNostrSync } from '../services/RestaurantNostrSync' const route = useRoute() const router = useRouter() const cart = useCartStore() +const sync = tryInjectService( + SERVICE_TOKENS.RESTAURANT_NOSTR_SYNC +) const slug = computed(() => String(route.params.slug || '')) const { restaurant, tree, items, isLoading, error, refresh } = useMenu( 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) { router.push(`/r/${slug.value}/item/${itemId}`) }