From 89a778e98bf8882665093f3c9bc1a12b2cc5f4f4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 16:43:23 +0200 Subject: [PATCH 01/26] =?UTF-8?q?feat(chatelet):=20guest=20module=20?= =?UTF-8?q?=E2=80=94=20ChateletApiService=20+=20browse/availability=20view?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice-1 part B of the chatelet guest booking UI (aiolabs/webapp #141). - ChateletApiService: plain HTTP wrapper (events TicketApiService pattern) → chatelet's public guest endpoints (/public/rooms, /availability). Unauth by default; swaps HTTP→kind-21000 RPC once aiolabs/lnbits#64 lands, no view change. - useChatelet composable + pinia store (DI via tryInjectService, never direct import). - Views: ChateletPage (room grid) + ChateletDetailPage (details + date-range availability check with sats/fiat quote; Book button stubbed for slice 2). - SERVICE_TOKENS.CHATELET_API. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- src/core/di-container.ts | 3 + .../chatelet/composables/useChatelet.ts | 38 ++++++ src/modules/chatelet/index.ts | 54 ++++++++ .../chatelet/services/ChateletApiService.ts | 69 ++++++++++ src/modules/chatelet/stores/chatelet.ts | 9 ++ src/modules/chatelet/types/room.ts | 33 +++++ .../chatelet/views/ChateletDetailPage.vue | 123 ++++++++++++++++++ src/modules/chatelet/views/ChateletPage.vue | 80 ++++++++++++ 8 files changed, 409 insertions(+) create mode 100644 src/modules/chatelet/composables/useChatelet.ts create mode 100644 src/modules/chatelet/index.ts create mode 100644 src/modules/chatelet/services/ChateletApiService.ts create mode 100644 src/modules/chatelet/stores/chatelet.ts create mode 100644 src/modules/chatelet/types/room.ts create mode 100644 src/modules/chatelet/views/ChateletDetailPage.vue create mode 100644 src/modules/chatelet/views/ChateletPage.vue diff --git a/src/core/di-container.ts b/src/core/di-container.ts index f406812..a038a43 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -175,6 +175,9 @@ export const SERVICE_TOKENS = { // Restaurant services RESTAURANT_API: Symbol('restaurantAPI'), RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'), + + // Chatelet (room rentals) services + CHATELET_API: Symbol('chateletAPI'), } as const // Type-safe injection helpers diff --git a/src/modules/chatelet/composables/useChatelet.ts b/src/modules/chatelet/composables/useChatelet.ts new file mode 100644 index 0000000..c93fc6d --- /dev/null +++ b/src/modules/chatelet/composables/useChatelet.ts @@ -0,0 +1,38 @@ +import { storeToRefs } from 'pinia' +import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container' +import type { ChateletApiService } from '../services/ChateletApiService' +import type { AvailabilityResult, Room } from '../types/room' +import { useChateletStore } from '../stores/chatelet' + +export function useChatelet() { + const store = useChateletStore() + const { rooms, isLoading } = storeToRefs(store) + // DI, never a direct import (workspace rule). + const api = tryInjectService(SERVICE_TOKENS.CHATELET_API) + + async function loadRooms(): Promise { + if (!api) return + isLoading.value = true + try { + store.rooms = await api.getPublicRooms() + } finally { + isLoading.value = false + } + } + + async function getRoom(id: string): Promise { + if (!api) return null + return api.getPublicRoom(id) + } + + async function checkAvailability( + roomId: string, + checkIn: string, + checkOut: string, + ): Promise { + if (!api) return null + return api.checkAvailability(roomId, checkIn, checkOut) + } + + return { rooms, isLoading, loadRooms, getRoom, checkAvailability } +} diff --git a/src/modules/chatelet/index.ts b/src/modules/chatelet/index.ts new file mode 100644 index 0000000..60653c9 --- /dev/null +++ b/src/modules/chatelet/index.ts @@ -0,0 +1,54 @@ +import { createModulePlugin } from '@/core/base/BaseModulePlugin' +import { SERVICE_TOKENS } from '@/core/di-container' +import { ChateletApiService, type ChateletApiConfig } from './services/ChateletApiService' + +export interface ChateletModuleConfig { + apiConfig: ChateletApiConfig +} + +/** + * Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals + * extension. Slice 1: read-only discovery + availability over HTTP. + */ +export const chateletModule = createModulePlugin({ + name: 'chatelet', + version: '1.0.0', + dependencies: ['base'], + + routes: [ + { + path: '/chatelet', + name: 'chatelet', + component: () => import('./views/ChateletPage.vue'), + meta: { + title: (import.meta.env.VITE_APP_NAME as string) || 'Chatelet', + requiresAuth: false, + }, + }, + { + path: '/chatelet/:id', + name: 'chatelet-detail', + component: () => import('./views/ChateletDetailPage.vue'), + meta: { title: 'Room', requiresAuth: false }, + }, + ], + + onInstall: async (_app, options) => { + const config = options?.config as ChateletModuleConfig | undefined + if (!config) { + throw new Error('Chatelet module requires configuration') + } + const { container } = await import('@/core/di-container') + const api = new ChateletApiService(config.apiConfig) + container.provide(SERVICE_TOKENS.CHATELET_API, api) + }, + + onUninstall: async () => { + const { container } = await import('@/core/di-container') + container.remove(SERVICE_TOKENS.CHATELET_API) + }, +}) + +export default chateletModule + +export type { Room, AvailabilityResult } from './types/room' diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts new file mode 100644 index 0000000..015252a --- /dev/null +++ b/src/modules/chatelet/services/ChateletApiService.ts @@ -0,0 +1,69 @@ +import type { AvailabilityResult, Room } from '../types/room' + +export interface ChateletApiConfig { + baseUrl: string + apiKey: string +} + +/** + * HTTP client for the LNbits `chatelet` extension's guest surface. + * + * Plain config-wrapper (same pattern as events' TicketApiService) — not a + * BaseService. Slice 1 is read-only guest browsing over public endpoints; + * booking (POST /bookings) + status polling arrive in slice 2. + * + * Transport note: this is HTTP-for-now. The extension also exposes a + * kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but + * the webapp's RPC client is disabled post-lnbits#9 (see aiolabs/lnbits#64). + * When that's revived, only this class swaps HTTP→RPC — no view changes. + */ +export class ChateletApiService { + constructor(private config: ChateletApiConfig) {} + + /** Active rooms for guest browsing (operator-private fields stripped server-side). */ + async getPublicRooms(): Promise { + return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' }) + } + + async getPublicRoom(id: string): Promise { + return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' }) + } + + async checkAvailability( + roomId: string, + checkIn: string, + checkOut: string, + ): Promise { + return this.request('/chatelet/api/v1/availability', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ room_id: roomId, check_in: checkIn, check_out: checkOut }), + }) + } + + private async request(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + accept: 'application/json', + // Guest endpoints are unauthenticated; only send a key if one is set. + ...(this.config.apiKey ? { 'X-API-KEY': this.config.apiKey } : {}), + ...((init.headers as Record) ?? {}), + } + + const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers }) + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: `Request failed: ${path}` })) + const message = + typeof error.detail === 'string' + ? error.detail + : Array.isArray(error.detail) + ? (error.detail[0]?.msg ?? 'Request failed') + : 'Request failed' + throw new Error(message) + } + + return response.json() + } +} diff --git a/src/modules/chatelet/stores/chatelet.ts b/src/modules/chatelet/stores/chatelet.ts new file mode 100644 index 0000000..86a857f --- /dev/null +++ b/src/modules/chatelet/stores/chatelet.ts @@ -0,0 +1,9 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Room } from '../types/room' + +export const useChateletStore = defineStore('chatelet', () => { + const rooms = ref([]) + const isLoading = ref(false) + return { rooms, isLoading } +}) diff --git a/src/modules/chatelet/types/room.ts b/src/modules/chatelet/types/room.ts new file mode 100644 index 0000000..6800d6a --- /dev/null +++ b/src/modules/chatelet/types/room.ts @@ -0,0 +1,33 @@ +// Public shapes returned by the chatelet extension's guest endpoints. +// Note: operator-private fields (wallet, checkin_instructions) are stripped +// server-side by public_room_dict and never reach the client. + +export interface Room { + id: string + title: string + description: string + price_amount: number + price_currency: string + price_frequency: string + max_guests: number + min_nights: number + amenities: string[] + location: string + geohash: string + images: string[] + status: string + listing_event_id: string | null + created_at: string + updated_at: string +} + +export interface AvailabilityResult { + room_id: string + check_in: string + check_out: string + available: boolean + nights: number + quote_sat: number | null + quote_fiat: number | null + currency: string | null +} diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue new file mode 100644 index 0000000..8b8f7e7 --- /dev/null +++ b/src/modules/chatelet/views/ChateletDetailPage.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/modules/chatelet/views/ChateletPage.vue b/src/modules/chatelet/views/ChateletPage.vue new file mode 100644 index 0000000..04418ff --- /dev/null +++ b/src/modules/chatelet/views/ChateletPage.vue @@ -0,0 +1,80 @@ + + + From 33cb75ac778f128efc59d9134cd53eb02acdc44b Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 16:43:24 +0200 Subject: [PATCH 02/26] feat(chatelet): standalone app scaffold (second Vite entry) chatelet.html + src/chatelet-app/{main,app,App,app.config,views/SettingsPage} + vite.chatelet.config.ts (port 5188, cacheDir .vite-chatelet, dist-chatelet, manifest id aiolabs-chatelet), mirroring the events standalone. package.json gains dev:/build:/preview:chatelet + entries in dev:all and build:demo (VITE_BASE_PATH=/chatelet/). Verified: build:chatelet typechecks + bundles clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- chatelet.html | 19 ++++ package.json | 7 +- src/chatelet-app/App.vue | 16 +++ src/chatelet-app/app.config.ts | 54 +++++++++ src/chatelet-app/app.ts | 128 +++++++++++++++++++++ src/chatelet-app/main.ts | 24 ++++ src/chatelet-app/views/SettingsPage.vue | 55 +++++++++ vite.chatelet.config.ts | 142 ++++++++++++++++++++++++ 8 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 chatelet.html create mode 100644 src/chatelet-app/App.vue create mode 100644 src/chatelet-app/app.config.ts create mode 100644 src/chatelet-app/app.ts create mode 100644 src/chatelet-app/main.ts create mode 100644 src/chatelet-app/views/SettingsPage.vue create mode 100644 vite.chatelet.config.ts diff --git a/chatelet.html b/chatelet.html new file mode 100644 index 0000000..b142652 --- /dev/null +++ b/chatelet.html @@ -0,0 +1,19 @@ + + + + + + + + + + + %VITE_APP_NAME% + + + + +
+ + + diff --git a/package.json b/package.json index d40f262..8604d9e 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,11 @@ "dev:restaurant": "vite --host --config vite.restaurant.config.ts", "build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts", "preview:restaurant": "vite preview --host --config vite.restaurant.config.ts", - "dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"", - "build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant", + "dev:chatelet": "vite --host --config vite.chatelet.config.ts", + "build:chatelet": "vue-tsc -b && vite build --config vite.chatelet.config.ts", + "preview:chatelet": "vite preview --host --config vite.chatelet.config.ts", + "dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant,chatelet -c blue,magenta,cyan,yellow,green,blue,red,gray,green,cyan \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\" \"npm:dev:chatelet\"", + "build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant && VITE_BASE_PATH=/chatelet/ npm run build:chatelet", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:build": "vue-tsc -b && vite build && electron-builder", "electron:package": "electron-builder", diff --git a/src/chatelet-app/App.vue b/src/chatelet-app/App.vue new file mode 100644 index 0000000..0b1977e --- /dev/null +++ b/src/chatelet-app/App.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/chatelet-app/app.config.ts b/src/chatelet-app/app.config.ts new file mode 100644 index 0000000..da75c6f --- /dev/null +++ b/src/chatelet-app/app.config.ts @@ -0,0 +1,54 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone chatelet app configuration. + * Enables base + chatelet modules only. + */ +export const appConfig: AppConfig = { + modules: { + base: { + name: 'base', + enabled: true, + lazy: false, + config: { + nostr: { + relays: JSON.parse( + import.meta.env.VITE_NOSTR_RELAYS || + '["wss://relay.damus.io", "wss://nos.lol"]', + ), + }, + auth: { + sessionTimeout: 24 * 60 * 60 * 1000, + }, + pwa: { + autoPrompt: true, + }, + imageUpload: { + baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com', + maxSizeMB: 10, + acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'], + }, + }, + }, + chatelet: { + name: 'chatelet', + enabled: true, + lazy: false, + config: { + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', + apiKey: import.meta.env.VITE_API_KEY || '', + }, + }, + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV, + }, +} + +export default appConfig diff --git a/src/chatelet-app/app.ts b/src/chatelet-app/app.ts new file mode 100644 index 0000000..13a76af --- /dev/null +++ b/src/chatelet-app/app.ts @@ -0,0 +1,128 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { pluginManager } from '@/core/plugin-manager' +import { eventBus } from '@/core/event-bus' +import { container } from '@/core/di-container' + +import appConfig from './app.config' +import baseModule from '@/modules/base' +import chateletModule from '@/modules/chatelet' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' +import { acceptTokenFromUrl } from '@/lib/url-token' + +const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Chatelet' +const APP_LABEL = + APP_NAME.toLowerCase() === 'chatelet' ? 'Chatelet' : `Chatelet (${APP_NAME})` + +/** + * Initialize the standalone chatelet app (guest room booking). + */ +export async function createAppInstance() { + console.log(`🚀 Starting ${APP_LABEL}...`) + + // Accept token from URL before anything else (cross-subdomain auth relay) + acceptTokenFromUrl(APP_NAME) + + const app = createApp(App) + + const moduleRoutes = [ + ...(baseModule.routes || []), + ...(chateletModule.routes || []), + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { path: '/', redirect: '/chatelet' }, + { + path: '/login', + name: 'login', + component: + import.meta.env.VITE_DEMO_MODE === 'true' + ? () => import('@/pages/LoginDemo.vue') + : () => import('@/pages/Login.vue'), + meta: { requiresAuth: false }, + }, + ...moduleRoutes, + { + path: '/settings', + name: 'settings', + component: () => import('./views/SettingsPage.vue'), + meta: { requiresAuth: false }, + }, + catchAllRoute, + ], + }) + + installLenientAuthGuard(router) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + pluginManager.init(app, router) + + const moduleRegistrations = [] + if (appConfig.modules.base.enabled) { + moduleRegistrations.push( + pluginManager.register(baseModule, appConfig.modules.base), + ) + } + if (appConfig.modules.chatelet?.enabled) { + moduleRegistrations.push( + pluginManager.register(chateletModule, appConfig.modules.chatelet), + ) + } + + await Promise.all(moduleRegistrations) + await pluginManager.installAll() + + const { auth } = await import('@/composables/useAuthService') + await auth.initialize() + markAuthReady(auth) + + app.config.errorHandler = (err, _vm, info) => { + console.error('Global error:', err, info) + eventBus.emit('app:error', { error: err, info }, 'app') + } + + if (appConfig.features.developmentMode) { + ;(window as any).__pluginManager = pluginManager + ;(window as any).__eventBus = eventBus + ;(window as any).__container = container + } + + console.log(`✅ ${APP_LABEL} initialized`) + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log(`🎉 ${APP_LABEL} started!`) + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error(`💥 Failed to start ${APP_LABEL}:`, error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/chatelet-app/main.ts b/src/chatelet-app/main.ts new file mode 100644 index 0000000..790c248 --- /dev/null +++ b/src/chatelet-app/main.ts @@ -0,0 +1,24 @@ +import { startApp } from './app' +import { registerSW } from 'virtual:pwa-register' +import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' +import 'vue-sonner/style.css' + +cleanupStaleDevServiceWorkers() + +// PWA service worker with periodic updates +const intervalMS = 60 * 60 * 1000 // 1 hour +registerSW({ + onRegistered(r) { + r && + setInterval(() => { + r.update() + }, intervalMS) + }, + onOfflineReady() { + console.log( + `${(import.meta.env.VITE_APP_NAME as string) || 'Chatelet'} ready to work offline`, + ) + }, +}) + +startApp() diff --git a/src/chatelet-app/views/SettingsPage.vue b/src/chatelet-app/views/SettingsPage.vue new file mode 100644 index 0000000..22fcde0 --- /dev/null +++ b/src/chatelet-app/views/SettingsPage.vue @@ -0,0 +1,55 @@ + + + diff --git a/vite.chatelet.config.ts b/vite.chatelet.config.ts new file mode 100644 index 0000000..0d69fb8 --- /dev/null +++ b/vite.chatelet.config.ts @@ -0,0 +1,142 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig, type Plugin } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' +import { visualizer } from 'rollup-plugin-visualizer' +import { + brand, + brandAlias, + brandAppBannerAliasEntry, + brandAppLogoAliasEntry, + brandAssetsPlugin, + brandHubLogoAliasEntry, + brandManifestName, + resolveAppBanner, +} from './vite-branding' + +/** + * SPA fallback: rewrite dev-server requests to chatelet.html. + */ +function chateletHtmlPlugin(): Plugin { + return { + name: 'chatelet-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + const path = req.url ? req.url.split('?')[0] : '' + if ( + req.url && + !req.url.startsWith('/@') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/node_modules/') && + !path.includes('.') + ) { + req.url = '/chatelet.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone chatelet app (guest room booking). + * + * Set VITE_BASE_PATH to deploy under a path prefix: + * VITE_BASE_PATH=/chatelet/ → app.domain/chatelet/ (shared auth) + * (default: /) → standalone subdomain + */ +const APP_NAME = brandManifestName() +process.env.VITE_APP_NAME = APP_NAME +process.env.VITE_APP_BANNER = resolveAppBanner('chatelet') ? '1' : '' + +export default defineConfig(({ mode }) => ({ + base: process.env.VITE_BASE_PATH || '/', + cacheDir: 'node_modules/.vite-chatelet', + server: { + port: 5188, + strictPort: true, + }, + plugins: [ + brandAssetsPlugin(), + chateletHtmlPlugin(), + vue(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { + enabled: false, + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'chatelet.html', + navigateFallbackAllowlist: [ + new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), + ], + }, + includeAssets: [ + 'icons/favicon.ico', + 'icons/apple-touch-icon.png', + 'icons/icon-192.png', + 'icons/icon-512.png', + 'icons/icon-maskable-192.png', + 'icons/icon-maskable-512.png', + ], + manifest: { + name: APP_NAME, + short_name: brand.shortName ?? APP_NAME, + description: 'Book a room', + theme_color: brand.themeColor ?? '#1f2937', + background_color: brand.backgroundColor ?? '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: process.env.VITE_BASE_PATH || '/', + scope: process.env.VITE_BASE_PATH || '/', + id: 'aiolabs-chatelet', + categories: ['travel', 'lifestyle'], + icons: [ + { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ViteImageOptimizer({ + jpg: { quality: 80 }, + png: { quality: 80 }, + webp: { lossless: true }, + }), + mode === 'analyze' && + visualizer({ + open: true, + filename: 'dist-chatelet/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: [ + brandAppLogoAliasEntry('chatelet'), + brandAppBannerAliasEntry('chatelet'), + brandHubLogoAliasEntry(), + ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), + { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, + ], + }, + build: { + outDir: 'dist-chatelet', + rollupOptions: { + input: 'chatelet.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +})) From 0c5b57749951aab5460112b32792927c50b8097a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 5 Sep 2026 19:51:04 +0200 Subject: [PATCH 03/26] fix(events): read buyer id from auth service in fiat checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fiat path pulled `currentUser` off the LNbits API client, which never exposed it — only `getAccessToken()`. `userId` was therefore always undefined and every Stripe/fiat "Continue" click failed with "Missing user id" before the request was ever sent. Use `useAuth()`'s `currentUser`, the same source the Lightning path already goes through. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YLt6zWfXA1hM8zYQdpbeyn --- src/modules/events/components/PurchaseTicketDialog.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index 0d13601..a9543f1 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -36,7 +36,7 @@ interface Emits { const props = defineProps() const emit = defineEmits() -const { isAuthenticated, userDisplay } = useAuth() +const { isAuthenticated, userDisplay, currentUser } = useAuth() const { isLoading, error, @@ -201,8 +201,7 @@ async function handlePurchase() { const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const accessToken = lnbitsAPI?.getAccessToken?.() || '' const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService - const currentUser = (lnbitsAPI?.currentUser?.value) || null - const userId = currentUser?.id + const userId = currentUser.value?.id if (!userId) { fiatError.value = 'Missing user id' return From f85848a50b03423783cef56a11ba59bc4df59041 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:30:47 +0200 Subject: [PATCH 04/26] feat(events): guest identity, frontend_url and public ticket read in the ticket API Prepare the webapp for account-less ticket purchases against events ext v1.6.1-aio.8: - requestTicket takes an options object: user_id (+ optional email) for logged-in buyers, name + email for guests, Authorization only when a token exists, and frontend_url so the backend builds the Stripe return URLs and the emailed ticket link under this app's root. - getTicket (anonymous PublicTicket) and getPublicEvent for the return dialog and the public ticket page. - resendTicketEmail maps the structured TicketResendResult (legacy bare ticket tolerated). - NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods; effectivePaymentMethods() mirrors the backend rule. - renderTicketQr: level-H ticket:// QR with the brand logo centred, shared by every ticket surface; useTicketPurchase accepts a guest contact. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- .../events/composables/useTicketPurchase.ts | 44 ++++-- src/modules/events/lib/frontendRoot.spec.ts | 16 +++ src/modules/events/lib/frontendRoot.ts | 20 +++ src/modules/events/lib/paymentMethods.spec.ts | 21 +++ src/modules/events/lib/paymentMethods.ts | 22 +++ src/modules/events/lib/ticketQr.ts | 61 ++++++++ .../events/services/TicketApiService.spec.ts | 92 ++++++++++++ .../events/services/TicketApiService.ts | 133 +++++++++++++----- src/modules/events/types/event.ts | 3 + src/modules/events/types/nip52.spec.ts | 35 +++++ src/modules/events/types/nip52.ts | 8 ++ src/modules/events/types/ticket.ts | 51 ++++++- 12 files changed, 450 insertions(+), 56 deletions(-) create mode 100644 src/modules/events/lib/frontendRoot.spec.ts create mode 100644 src/modules/events/lib/frontendRoot.ts create mode 100644 src/modules/events/lib/paymentMethods.spec.ts create mode 100644 src/modules/events/lib/paymentMethods.ts create mode 100644 src/modules/events/lib/ticketQr.ts create mode 100644 src/modules/events/services/TicketApiService.spec.ts create mode 100644 src/modules/events/types/nip52.spec.ts diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index 3accb7b..ea763d1 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -6,6 +6,22 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { PaymentService } from '@/core/services/PaymentService' import type { TicketApiService } from '../services/TicketApiService' import { useOwnedTickets } from './useOwnedTickets' +import { frontendRoot } from '../lib/frontendRoot' +import { renderTicketQr } from '../lib/ticketQr' + +/** Guest identity for a purchase without an LNbits account. */ +export interface PurchaseContact { + name: string + email: string +} + +export interface PurchaseTicketOptions { + quantity?: number + /** Guest buyer (no account). Required when not authenticated. */ + contact?: PurchaseContact + /** Delivery email for a logged-in buyer (guests carry it in `contact`). */ + email?: string +} export function useTicketPurchase() { const { isAuthenticated, currentUser } = useAuth() @@ -64,11 +80,7 @@ export function useTicketPurchase() { async function generateTicketQRCode(ticketId: string) { try { - const ticketUrl = `ticket://${ticketId}` - const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, { - width: 128, - margin: 2 - }) + const dataUrl = await renderTicketQr(ticketId, { width: 160 }) ticketQRCode.value = dataUrl return dataUrl } catch (error) { @@ -128,9 +140,13 @@ export function useTicketPurchase() { async function purchaseTicketForEvent( eventId: string, - options: { quantity?: number } = {}, + options: PurchaseTicketOptions = {}, ) { - if (!canPurchase.value || !currentUser.value) { + // Guests are identified by name + email; logged-in buyers by user id. + // Either is enough for a paid ticket — free tickets stay login-gated + // in the dialog (events#29: no anonymous free issuance). + const isGuest = !canPurchase.value || !currentUser.value + if (isGuest && !options.contact) { throw new Error('User must be authenticated to purchase tickets') } @@ -150,12 +166,14 @@ export function useTicketPurchase() { const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const accessToken = lnbitsAPI?.getAccessToken?.() || '' - const invoice = await ticketApi.requestTicket( - eventId, - currentUser.value!.id, - accessToken, - { quantity: options.quantity }, - ) + const invoice = await ticketApi.requestTicket(eventId, { + userId: isGuest ? undefined : currentUser.value!.id, + name: isGuest ? options.contact?.name : undefined, + email: isGuest ? options.contact?.email : options.email, + accessToken: isGuest ? undefined : accessToken, + frontendUrl: frontendRoot(), + quantity: options.quantity, + }) // Free tickets (price 0 / 100%-off promo): the backend already // issued + marked them paid, so there's no invoice to settle. Skip diff --git a/src/modules/events/lib/frontendRoot.spec.ts b/src/modules/events/lib/frontendRoot.spec.ts new file mode 100644 index 0000000..13f5a68 --- /dev/null +++ b/src/modules/events/lib/frontendRoot.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { frontendRoot } from './frontendRoot' + +describe('frontendRoot', () => { + it('joins origin and base path without a trailing slash', () => { + expect(frontendRoot('https://demo.aiolabs.dev', '/events/')).toBe('https://demo.aiolabs.dev/events') + }) + + it('collapses a root base to the bare origin', () => { + expect(frontendRoot('http://localhost:5173', '/')).toBe('http://localhost:5173') + }) + + it('tolerates a base without leading slash', () => { + expect(frontendRoot('https://app.ariege.io', 'oyez/')).toBe('https://app.ariege.io/oyez') + }) +}) diff --git a/src/modules/events/lib/frontendRoot.ts b/src/modules/events/lib/frontendRoot.ts new file mode 100644 index 0000000..6232e68 --- /dev/null +++ b/src/modules/events/lib/frontendRoot.ts @@ -0,0 +1,20 @@ +/** + * This app's public root — origin + Vite base path, no trailing slash. + * + * Sent to the events extension as `frontend_url` so the backend can + * compose the Stripe success / cancel URLs and the emailed ticket link + * under the webapp (e.g. `https://demo.aiolabs.dev/events`, + * `https://app.ariege.io/oyez`) instead of the LNbits host. The + * extension appends the same path shapes the LNbits UI uses + * (`/events/{event_id}`, `/events/ticket/{ticket_id}`), which the events + * router serves under that base. + * + * Parameters exist for tests; callers use the defaults. + */ +export function frontendRoot( + origin: string = window.location.origin, + base: string = import.meta.env.BASE_URL ?? '/', +): string { + const path = base.startsWith('/') ? base : `/${base}` + return `${origin}${path}`.replace(/\/+$/, '') +} diff --git a/src/modules/events/lib/paymentMethods.spec.ts b/src/modules/events/lib/paymentMethods.spec.ts new file mode 100644 index 0000000..72c80f2 --- /dev/null +++ b/src/modules/events/lib/paymentMethods.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { effectivePaymentMethods } from './paymentMethods' + +describe('effectivePaymentMethods', () => { + it('falls back to the legacy allowFiat rule when no list is published', () => { + expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning']) + expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat']) + expect(effectivePaymentMethods(undefined)).toEqual(['lightning']) + }) + + it('honours an explicit list, including fiat-only events', () => { + expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat']) + expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] })) + .toEqual(['lightning', 'fiat']) + }) + + it('drops unknown rails and falls back when nothing known remains', () => { + expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning']) + expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat']) + }) +}) diff --git a/src/modules/events/lib/paymentMethods.ts b/src/modules/events/lib/paymentMethods.ts new file mode 100644 index 0000000..70e08e9 --- /dev/null +++ b/src/modules/events/lib/paymentMethods.ts @@ -0,0 +1,22 @@ +export type PaymentRailId = 'lightning' | 'fiat' + +const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat'] + +/** + * Rails a buyer may use for an event. + * + * Mirrors `effective_payment_methods()` in the events extension: an + * explicit `paymentMethods` list wins; otherwise the legacy rule applies + * — Lightning always, fiat only when the organizer set `allowFiat`. + * Unknown rail ids are dropped so a future backend value can't render + * a button the dialog has no handler for. + */ +export function effectivePaymentMethods( + info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined, +): PaymentRailId[] { + const explicit = (info?.paymentMethods ?? []).filter( + (m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m), + ) + if (explicit.length > 0) return explicit + return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning'] +} diff --git a/src/modules/events/lib/ticketQr.ts b/src/modules/events/lib/ticketQr.ts new file mode 100644 index 0000000..51790fa --- /dev/null +++ b/src/modules/events/lib/ticketQr.ts @@ -0,0 +1,61 @@ +import brandLogo from '@brand/logo.png' + +/** + * Render the scannable `ticket://` QR every ticket surface uses + * (purchase success, Stripe return dialog, ticket page, My Tickets). + * + * Error-correction level H (30 % recoverable) leaves room for the brand + * logo the events extension also composites into its emailed PNG, so + * the in-app and emailed images look alike. The overlay is best-effort: + * if canvas or the logo asset is unavailable the plain QR is returned. + */ +export async function renderTicketQr( + ticketId: string, + options: { width?: number; logo?: boolean } = {}, +): Promise { + const width = options.width ?? 200 + const qrcode = await import('qrcode') + const plain = await qrcode.toDataURL(`ticket://${ticketId}`, { + width, + margin: 2, + errorCorrectionLevel: 'H', + color: { dark: '#000000', light: '#FFFFFF' }, + }) + if (options.logo === false) return plain + try { + return await overlayLogo(plain, width) + } catch { + return plain + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error(`Could not load ${src}`)) + img.src = src + }) +} + +async function overlayLogo(qrDataUrl: string, width: number): Promise { + if (typeof document === 'undefined') return qrDataUrl + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = width + const ctx = canvas.getContext('2d') + if (!ctx) return qrDataUrl + + const [qr, logo] = await Promise.all([loadImage(qrDataUrl), loadImage(brandLogo)]) + ctx.drawImage(qr, 0, 0, width, width) + + // Logo ≤ 20 % of the QR width on a white pad ≈ 26 % — well inside the + // 30 % damage budget of level H, matching the backend's PNG endpoint. + const logoSize = Math.round(width * 0.2) + const padSize = Math.round(width * 0.26) + const center = width / 2 + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(center - padSize / 2, center - padSize / 2, padSize, padSize) + ctx.drawImage(logo, center - logoSize / 2, center - logoSize / 2, logoSize, logoSize) + return canvas.toDataURL('image/png') +} diff --git a/src/modules/events/services/TicketApiService.spec.ts b/src/modules/events/services/TicketApiService.spec.ts new file mode 100644 index 0000000..999ac80 --- /dev/null +++ b/src/modules/events/services/TicketApiService.spec.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TicketApiService } from './TicketApiService' + +const BASE = 'https://lnbits.test' + +function okJson(body: unknown) { + return { ok: true, json: async () => body } as Response +} + +describe('TicketApiService.requestTicket', () => { + const fetchMock = vi.fn() + let api: TicketApiService + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) + }) + afterEach(() => vi.unstubAllGlobals()) + + function lastCall() { + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record } + } + + it('sends name + email and no user_id / Authorization for guests', async () => { + fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] })) + const res = await api.requestTicket('evt', { + name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events', + paymentMethod: 'fiat', quantity: 2, + }) + const { url, body, headers } = lastCall() + expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`) + expect(body).toEqual({ + name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events', + payment_method: 'fiat', quantity: 2, + }) + expect(body.user_id).toBeUndefined() + expect(headers.Authorization).toBeUndefined() + expect(headers['X-API-KEY']).toBe('inst-key') + expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] }) + }) + + it('sends user_id + email with a Bearer token for logged-in buyers', async () => { + fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) + await api.requestTicket('evt', { + userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173', + }) + const { body, headers } = lastCall() + expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' }) + expect(headers.Authorization).toBe('Bearer tok') + }) + + it('surfaces the backend detail string as the error message', async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response) + await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' })) + .rejects.toThrow('Payment method not enabled for this event.') + }) +}) + +describe('TicketApiService.getTicket / resendTicketEmail', () => { + const fetchMock = vi.fn() + let api: TicketApiService + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('maps the anonymous PublicTicket shape', async () => { + fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' })) + const t = await api.getTicket('tid') + expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`) + expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' }) + }) + + it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => { + const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} } + fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } })) + const r = await api.resendTicketEmail('tid', 'admin') + expect(r.ticket.id).toBe('tid') + expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' }) + expect(r.nostr.attempted).toBe(false) + + fetchMock.mockResolvedValueOnce(okJson(ticket)) + const legacy = await api.resendTicketEmail('tid', 'admin') + expect(legacy.ticket.id).toBe('tid') + expect(legacy.email).toEqual({ attempted: false, sent: false, error: null }) + }) +}) diff --git a/src/modules/events/services/TicketApiService.ts b/src/modules/events/services/TicketApiService.ts index ae7476d..9a2da22 100644 --- a/src/modules/events/services/TicketApiService.ts +++ b/src/modules/events/services/TicketApiService.ts @@ -3,8 +3,10 @@ import type { EventTicketExtra, CreateTicketRequest, PaymentMethod, + PublicTicket, TicketPurchaseInvoice, TicketPaymentStatus, + TicketResendResult, TicketedEvent, CreateEventRequest, } from '../types/ticket' @@ -14,6 +16,27 @@ export interface TicketApiConfig { apiKey: string } +/** Options for `TicketApiService.requestTicket`. */ +export interface RequestTicketOptions { + /** LNbits user id — logged-in buyers. */ + userId?: string + /** Guest buyer name (required together with `email` when no `userId`). */ + name?: string + /** Delivery email — required for guests, optional for logged-in buyers. */ + email?: string + /** Bearer token; forwarded only when present. */ + accessToken?: string + /** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */ + frontendUrl?: string + paymentMethod?: PaymentMethod + fiatProvider?: string + promoCode?: string + refundAddress?: string + nostrIdentifier?: string + /** Number of tickets to buy on this invoice. Backend caps at 10. */ + quantity?: number +} + /** * Database-backed ticketing API service. * Talks to the LNbits events extension for ticket inventory, @@ -57,29 +80,27 @@ export class TicketApiService { * = follow-the-URL string from the configured fiat provider). The * `isFiat` flag is the discriminator. * - * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into - * the fiat path (requires the event to have `allow_fiat=true`). - * `fiatProvider` is optional — backend picks the user's configured - * default when omitted. + * Identity: pass `userId` for a logged-in buyer (plus `email` so the + * ticket can be delivered by mail), or `name` + `email` for a guest. + * The endpoint itself is anonymous; `accessToken` is only forwarded + * when present. * - * Additional ticket metadata (promo code, refund address, nostr - * identifier for DM delivery) can be supplied via `options`. + * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into + * the fiat path (the event must have that rail enabled — the backend + * 400s otherwise). `fiatProvider` is optional — backend picks the + * organizer's configured default when omitted. `frontendUrl` is this + * app's root (see `lib/frontendRoot.ts`) and drives the Stripe + * return URLs + emailed ticket link. */ async requestTicket( eventId: string, - userId: string, - accessToken: string, - options: { - paymentMethod?: PaymentMethod - fiatProvider?: string - promoCode?: string - refundAddress?: string - nostrIdentifier?: string - /** Number of tickets to buy on this invoice. Backend caps at 10. */ - quantity?: number - } = {}, + options: RequestTicketOptions = {}, ): Promise { - const body: CreateTicketRequest = { user_id: userId } + const body: CreateTicketRequest = {} + if (options.userId) body.user_id = options.userId + if (options.name) body.name = options.name + if (options.email) body.email = options.email + if (options.frontendUrl) body.frontend_url = options.frontendUrl if (options.paymentMethod) body.payment_method = options.paymentMethod if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.promoCode) body.promo_code = options.promoCode @@ -87,14 +108,14 @@ export class TicketApiService { if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.quantity && options.quantity > 1) body.quantity = options.quantity + const headers: Record = { 'Content-Type': 'application/json' } + if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}` + const data = await this.request( `/events/api/v1/tickets/${eventId}`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, + headers, body: JSON.stringify(body), } ) @@ -112,6 +133,33 @@ export class TicketApiService { } } + /** + * Anonymous read of one ticket — paid / registered state only, no + * PII. Used by the Stripe return dialog to poll settlement and by + * the public ticket page the emailed link opens. + */ + async getTicket(ticketId: string): Promise { + const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' }) + return { + eventId: t.event, + name: t.name ?? null, + registered: Boolean(t.registered), + paid: Boolean(t.paid), + time: t.time, + regTimestamp: t.reg_timestamp, + } + } + + /** + * Anonymous public event detail (`PublicEvent`). Note the backend + * answers 410 for sold-out / closed events — callers that only need + * a display name should try the Nostr store first and treat a + * failure here as non-fatal. + */ + async getPublicEvent(eventId: string): Promise { + return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' }) + } + /** * Check whether a ticket payment has been confirmed. */ @@ -226,33 +274,44 @@ export class TicketApiService { /** * Resend the ticket confirmation email for a paid ticket. Requires * the event's wallet admin key (organizer-only). Returns the updated - * Ticket with the `email_notification_sent` flag refreshed. + * Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8; + * older extensions returned the bare Ticket, which is tolerated). * - * Endpoint added upstream in v1.6.1 (PR #51). + * Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8. */ async resendTicketEmail( ticketId: string, adminKey: string, - ): Promise { - const t = await this.request( + ): Promise { + const r = await this.request( `/events/api/v1/tickets/${ticketId}/resend-email`, { method: 'POST', headers: { 'X-API-KEY': adminKey }, } ) + const t = r.ticket ?? r + const delivery = (d: any) => ({ + attempted: Boolean(d?.attempted), + sent: Boolean(d?.sent), + error: d?.error ?? null, + }) return { - id: t.id, - wallet: t.wallet, - eventId: t.event, - name: t.name, - email: t.email, - userId: t.user_id, - registered: t.registered, - paid: t.paid, - time: t.time, - regTimestamp: t.reg_timestamp, - extra: t.extra as EventTicketExtra | undefined, + ticket: { + id: t.id, + wallet: t.wallet, + eventId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + extra: t.extra as EventTicketExtra | undefined, + }, + email: delivery(r.email), + nostr: delivery(r.nostr), } } diff --git a/src/modules/events/types/event.ts b/src/modules/events/types/event.ts index 88762b3..72d7c4f 100644 --- a/src/modules/events/types/event.ts +++ b/src/modules/events/types/event.ts @@ -84,6 +84,8 @@ export interface EventTicketInfo { allowFiat: boolean /** Fiat settle currency when allowFiat is true. */ fiatCurrency?: string + /** Rails the organizer enabled (see TicketTags.paymentMethods). */ + paymentMethods?: string[] } function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined { @@ -98,6 +100,7 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined, allowFiat: ticket.allowFiat, fiatCurrency: ticket.fiatCurrency, + paymentMethods: ticket.paymentMethods, } } diff --git a/src/modules/events/types/nip52.spec.ts b/src/modules/events/types/nip52.spec.ts new file mode 100644 index 0000000..cc95583 --- /dev/null +++ b/src/modules/events/types/nip52.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { parseCalendarTimeEvent, NIP52_KINDS } from './nip52' +import type { NostrEvent } from 'nostr-tools' + +function makeEvent(extraTags: string[][]): NostrEvent { + return { + kind: NIP52_KINDS.CALENDAR_TIME_EVENT, + id: 'id', + pubkey: 'pk', + created_at: 1_700_000_000, + content: '', + sig: '', + tags: [ + ['d', 'evt1'], + ['title', 'Test'], + ['start', '1700000000'], + ['tickets_currency', 'EUR'], + ['tickets_price', '20'], + ...extraTags, + ], + } as NostrEvent +} + +describe('parseTicketTags — tickets_payment_methods', () => { + it('parses the comma list into paymentMethods', () => { + const parsed = parseCalendarTimeEvent(makeEvent([['tickets_payment_methods', 'lightning, fiat']])) + expect(parsed?.ticket?.paymentMethods).toEqual(['lightning', 'fiat']) + }) + + it('leaves paymentMethods undefined when the tag is absent (legacy events)', () => { + const parsed = parseCalendarTimeEvent(makeEvent([['tickets_allow_fiat', 'true']])) + expect(parsed?.ticket?.allowFiat).toBe(true) + expect(parsed?.ticket?.paymentMethods).toBeUndefined() + }) +}) diff --git a/src/modules/events/types/nip52.ts b/src/modules/events/types/nip52.ts index b87db35..de2d8c1 100644 --- a/src/modules/events/types/nip52.ts +++ b/src/modules/events/types/nip52.ts @@ -36,6 +36,10 @@ export interface TicketTags { allowFiat: boolean /** Fiat settle currency when allowFiat is true. */ fiatCurrency?: string + /** Rails the organizer enabled, from `tickets_payment_methods` + * ("lightning,fiat"). Undefined on events published before the + * tag existed — callers fall back to the allowFiat rule. */ + paymentMethods?: string[] } /** @@ -137,6 +141,7 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined { const soldStr = getTagValue(tags, 'tickets_sold') const priceStr = getTagValue(tags, 'tickets_price') const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat') + const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods') return { available: availableStr != null ? Number(availableStr) : undefined, @@ -145,6 +150,9 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined { currency, allowFiat: allowFiatStr === 'true', fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'), + paymentMethods: paymentMethodsStr != null + ? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean) + : undefined, } } diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index 3c0842e..d42b3c2c 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -26,6 +26,10 @@ export interface EventExtra { nostr_notifications: boolean notification_subject: string notification_body: string + /** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent + * means the legacy rule: Lightning always, fiat iff `allow_fiat`. + * Same shape as upstream v2's `extra.payment_methods`. */ + payment_methods?: string[] } export interface EventTicketExtra { @@ -165,17 +169,22 @@ export interface CreateEventRequest { } /** - * Body for `POST /tickets/{event_id}`. Either `user_id` OR the - * `name`+`email` pair is required (backend root_validator enforces - * mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM - * delivery when the event has nostr_notifications enabled. The - * `payment_method` + `fiat_provider` pair selects between Lightning - * and fiat checkout. + * Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in + * buyer, optionally with `email` for ticket delivery) OR the + * `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer + * into Nostr DM delivery when the event has nostr_notifications + * enabled. The `payment_method` + `fiat_provider` pair selects between + * Lightning and fiat checkout. `frontend_url` is this app's root so the + * backend can build the Stripe return URLs and the emailed ticket link + * under the webapp instead of the LNbits host. */ export interface CreateTicketRequest { name?: string email?: string user_id?: string + /** App root (origin + base path, no trailing slash). Origin must be + * on the LNbits instance's CORS allowlist or the backend 400s. */ + frontend_url?: string promo_code?: string refund_address?: string nostr_identifier?: string @@ -184,3 +193,33 @@ export interface CreateTicketRequest { /** Number of tickets on this invoice (backend bounds 1..10). */ quantity?: number } + +/** + * Anonymous view of a ticket from `GET /tickets/{ticket_id}`. The + * backend deliberately omits id / email / user_id; the id is whatever + * the caller asked for. + */ +export interface PublicTicket { + eventId: string + name: string | null + registered: boolean + paid: boolean + time: string + regTimestamp: string +} + +export interface NotificationDeliveryResult { + attempted: boolean + sent: boolean + error: string | null +} + +/** + * Structured response of `POST /tickets/{ticket_id}/resend-email` + * (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8). + */ +export interface TicketResendResult { + ticket: EventTicket + email: NotificationDeliveryResult + nostr: NotificationDeliveryResult +} From 891b5072f8e74d0da737168245ebbb9b56a54c84 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:35:45 +0200 Subject: [PATCH 05/26] feat(events): guest checkout in the purchase dialog with delivery email Buyers no longer need an account to buy a paid ticket. The dialog gains a "Your details" block (name for guests, email + confirm-email for everyone, prefilled from the account email) as a vee-validate/zod form, and builds its rail buttons from the event's published payment methods so an organizer can run card-only events. - Fiat: same-tab redirect to the provider right after the POST, with the pending checkout (ticket ids, email) remembered in localStorage for the return page; the two-step "open in new tab" panel is gone. - Lightning: guests get the bolt11 QR + "Open in wallet" and settle via the existing poll; the LNbits-wallet button stays login-only. - Free tickets remain login-gated (events#29). - Guest success state renders the ticket QRs inline with the "emailed to" note, since guests have no My Tickets. i18n keys added under events.purchase / checkout / ticket (en, es, fr). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- src/i18n/locales/en.ts | 45 ++ src/i18n/locales/es.ts | 45 ++ src/i18n/locales/fr.ts | 45 ++ src/i18n/types.ts | 45 ++ .../components/PurchaseTicketDialog.vue | 419 ++++++++++++------ src/modules/events/lib/pendingCheckout.ts | 38 ++ 6 files changed, 498 insertions(+), 139 deletions(-) create mode 100644 src/modules/events/lib/pendingCheckout.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index dbb408c..b718299 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -129,6 +129,51 @@ const messages: LocaleMessages = { pending: 'Pending', registered: 'Registered', }, + purchase: { + card: 'Card', + continueTo: 'Continue to {provider} checkout', + fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.", + noCheckoutUrl: 'Fiat provider did not return a checkout URL.', + fiatFailed: 'Card checkout failed', + loginRequiredTitle: 'Login required', + loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.', + contact: { + title: 'Your details', + name: 'Name', + namePlaceholder: 'Name on the ticket', + nameRequired: 'Name is required', + email: 'Email', + emailHint: 'Your ticket will be sent to this address.', + emailInvalid: 'Enter a valid email address', + confirmEmail: 'Confirm email', + emailMismatch: 'Email addresses do not match', + }, + }, + checkout: { + title: 'Confirming your payment…', + paidTitle: 'Payment received', + processing: 'Waiting for the payment to settle. This usually takes a few seconds.', + stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.', + stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.', + emailedTo: 'We emailed your ticket to {email}.', + cancelled: 'Payment cancelled — no seats were reserved.', + viewTicket: 'Open ticket page', + viewMyTickets: 'View My Tickets', + ticketN: 'Ticket {n} of {total}', + close: 'Close', + }, + ticket: { + title: 'Your ticket', + loading: 'Loading ticket…', + notFound: 'Ticket not found', + event: 'Event', + holder: 'Name', + paid: 'Paid', + pending: 'Payment pending', + registered: 'Checked in', + showAtDoor: 'Show this QR at the door.', + backToEvent: 'Back to event', + }, nav: { feed: 'Home', calendar: 'Calendar', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9ca8958..7410846 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -129,6 +129,51 @@ const messages: LocaleMessages = { pending: 'Pendiente', registered: 'Registrado', }, + purchase: { + card: 'Tarjeta', + continueTo: 'Continuar al pago con {provider}', + fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.', + noCheckoutUrl: 'El proveedor no devolvió una URL de pago.', + fiatFailed: 'El pago con tarjeta falló', + loginRequiredTitle: 'Inicio de sesión requerido', + loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.', + contact: { + title: 'Tus datos', + name: 'Nombre', + namePlaceholder: 'Nombre en el boleto', + nameRequired: 'El nombre es obligatorio', + email: 'Correo electrónico', + emailHint: 'Tu boleto se enviará a esta dirección.', + emailInvalid: 'Introduce un correo electrónico válido', + confirmEmail: 'Confirmar correo', + emailMismatch: 'Los correos no coinciden', + }, + }, + checkout: { + title: 'Confirmando tu pago…', + paidTitle: 'Pago recibido', + processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.', + stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.', + stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.', + emailedTo: 'Enviamos tu boleto a {email}.', + cancelled: 'Pago cancelado — no se reservó ningún asiento.', + viewTicket: 'Abrir página del boleto', + viewMyTickets: 'Ver mis boletos', + ticketN: 'Boleto {n} de {total}', + close: 'Cerrar', + }, + ticket: { + title: 'Tu boleto', + loading: 'Cargando boleto…', + notFound: 'Boleto no encontrado', + event: 'Evento', + holder: 'Nombre', + paid: 'Pagado', + pending: 'Pago pendiente', + registered: 'Registrado', + showAtDoor: 'Muestra este QR en la entrada.', + backToEvent: 'Volver al evento', + }, nav: { feed: 'Inicio', calendar: 'Calendario', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index ef37aaa..21392ec 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -129,6 +129,51 @@ const messages: LocaleMessages = { pending: 'En attente', registered: 'Enregistré', }, + purchase: { + card: 'Carte', + continueTo: 'Continuer vers le paiement {provider}', + fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.', + noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.", + fiatFailed: 'Le paiement par carte a échoué', + loginRequiredTitle: 'Connexion requise', + loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.', + contact: { + title: 'Vos coordonnées', + name: 'Nom', + namePlaceholder: 'Nom sur le billet', + nameRequired: 'Le nom est obligatoire', + email: 'E-mail', + emailHint: 'Votre billet sera envoyé à cette adresse.', + emailInvalid: 'Saisissez une adresse e-mail valide', + confirmEmail: "Confirmer l'e-mail", + emailMismatch: 'Les adresses e-mail ne correspondent pas', + }, + }, + checkout: { + title: 'Confirmation de votre paiement…', + paidTitle: 'Paiement reçu', + processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.', + stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.', + stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.', + emailedTo: 'Nous avons envoyé votre billet à {email}.', + cancelled: 'Paiement annulé — aucune place réservée.', + viewTicket: 'Ouvrir la page du billet', + viewMyTickets: 'Voir mes billets', + ticketN: 'Billet {n} sur {total}', + close: 'Fermer', + }, + ticket: { + title: 'Votre billet', + loading: 'Chargement du billet…', + notFound: 'Billet introuvable', + event: 'Événement', + holder: 'Nom', + paid: 'Payé', + pending: 'Paiement en attente', + registered: 'Enregistré', + showAtDoor: "Présentez ce QR à l'entrée.", + backToEvent: "Retour à l'événement", + }, nav: { feed: 'Accueil', calendar: 'Calendrier', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index ccce8f7..61a8f32 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -104,6 +104,51 @@ export interface LocaleMessages { pending: string registered: string } + purchase: { + card: string + continueTo: string + fiatRedirectHint: string + noCheckoutUrl: string + fiatFailed: string + loginRequiredTitle: string + loginRequiredFree: string + contact: { + title: string + name: string + namePlaceholder: string + nameRequired: string + email: string + emailHint: string + emailInvalid: string + confirmEmail: string + emailMismatch: string + } + } + checkout: { + title: string + paidTitle: string + processing: string + stillProcessing: string + stillProcessingNoEmail: string + emailedTo: string + cancelled: string + viewTicket: string + viewMyTickets: string + ticketN: string + close: string + } + ticket: { + title: string + loading: string + notFound: string + event: string + holder: string + paid: string + pending: string + registered: string + showAtDoor: string + backToEvent: string + } nav: { feed: string calendar: string diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index a9543f1..53eac52 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -1,13 +1,26 @@ + + diff --git a/src/modules/events/views/EventDetailPage.vue b/src/modules/events/views/EventDetailPage.vue index 10fdbc8..ee08ae8 100644 --- a/src/modules/events/views/EventDetailPage.vue +++ b/src/modules/events/views/EventDetailPage.vue @@ -14,6 +14,8 @@ import { useEventDetail } from '../composables/useEventDetail' import BookmarkButton from '../components/BookmarkButton.vue' import OrganizerCard from '../components/OrganizerCard.vue' import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' +import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue' +import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout' import { useAuth } from '@/composables/useAuthService' import { useEventsStore } from '../stores/events' import { useOwnedTickets } from '../composables/useOwnedTickets' @@ -116,6 +118,7 @@ const purchaseEvent = computed(() => { currency: a.ticketInfo.currency, allow_fiat: a.ticketInfo.allowFiat, fiat_currency: a.ticketInfo.fiatCurrency, + payment_methods: a.ticketInfo.paymentMethods, } }) @@ -142,7 +145,10 @@ const isPast = computed(() => { const showPurchaseDialog = ref(false) function openPurchaseDialog() { - if (!isAuthenticated.value) { + // Paid tickets are open to guests (name + email identity). Free + // claims still need an account so seats can't be drained anonymously. + const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0 + if (!isAuthenticated.value && isFreeEvent) { toastService.info(t('events.detail.loginToBuyTickets'), { action: { label: t('events.detail.logIn'), @@ -154,6 +160,43 @@ function openPurchaseDialog() { showPurchaseDialog.value = true } +// --- Return from the fiat provider --------------------------------- +// Stripe sends the buyer back here with ?checkout=success&tickets=a,b +// (ids pre-generated by the extension before the session was created) +// or ?checkout=cancelled. The query is stripped immediately so the +// ticket ids don't linger in history; the localStorage record written +// before the redirect is the fallback source and carries the email. +const showReturnDialog = ref(false) +const returnTicketIds = ref([]) +const returnEmail = ref(undefined) + +function handleCheckoutReturn() { + const status = route.query.checkout + if (status !== 'success' && status !== 'cancelled') return + + const pending = readPendingCheckout() + const samePurchase = pending?.eventId === eventId + void router.replace({ query: {} }) + + if (status === 'cancelled') { + clearPendingCheckout() + toastService.info(t('events.checkout.cancelled')) + return + } + + const fromQuery = String(route.query.tickets ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [] + if (ids.length === 0) return + returnTicketIds.value = ids + returnEmail.value = samePurchase ? pending?.email : undefined + showReturnDialog.value = true +} + +onMounted(handleCheckoutReturn) + // Re-fetch the user's tickets when the purchase dialog closes (the // buyer may have just paid). The inventory side updates automatically // via the relay republish from the events extension. @@ -366,6 +409,14 @@ function goToMyTickets() { @update:is-open="showPurchaseDialog = $event" /> + +
From b853fbce4b1e36db4bcc1c4fc470f209413cc0b7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:39:42 +0200 Subject: [PATCH 07/26] feat(events): public ticket page at /events/ticket/:ticketId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target of the emailed ticket link the extension now builds under the webapp root. Anonymous, like the LNbits UI's own /events/ticket/{id}: reads the PublicTicket, resolves the event name (Nostr store → scoped relay query → public REST as a last resort), and renders the logo QR with paid / pending / checked-in state. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- src/modules/events/index.ts | 12 ++ src/modules/events/views/TicketPage.vue | 155 ++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/modules/events/views/TicketPage.vue diff --git a/src/modules/events/index.ts b/src/modules/events/index.ts index e592b7e..0a4d4a3 100644 --- a/src/modules/events/index.ts +++ b/src/modules/events/index.ts @@ -51,6 +51,18 @@ export const eventsModule = createModulePlugin({ requiresAuth: false, }, }, + { + // Public ticket page — target of the emailed ticket link. Listed + // before /events/:id for readability; the router can't confuse the + // two anyway (different segment counts). + path: '/events/ticket/:ticketId', + name: 'event-ticket', + component: () => import('./views/TicketPage.vue'), + meta: { + title: 'Ticket', + requiresAuth: false, + }, + }, { path: '/events/:id', name: 'event-detail', diff --git a/src/modules/events/views/TicketPage.vue b/src/modules/events/views/TicketPage.vue new file mode 100644 index 0000000..cb6e9e3 --- /dev/null +++ b/src/modules/events/views/TicketPage.vue @@ -0,0 +1,155 @@ + + + From 5d3ebcc5e49795f63e72c88b3aa925e22398b01e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:43:10 +0200 Subject: [PATCH 08/26] feat(events): per-event payment methods in the create/edit dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "Also accept fiat" switch with a Lightning / Card checkbox group so an organizer can switch Lightning off for a card-only sale. Sends extra.payment_methods (upstream v2 shape) and keeps allow_fiat in sync (fiat in list ⇒ allow_fiat=true) for older readers. Card stays disabled with a tooltip when the organizer has no fiat provider; the fiat-currency picker appears when Card is on and the price is in sats. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- .../events/components/CreateEventDialog.vue | 171 +++++++++++++++--- 1 file changed, 141 insertions(+), 30 deletions(-) diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index 3f50231..ee8f487 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -41,7 +41,15 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import ImageUpload from '@/modules/base/components/ImageUpload.vue' import DatePicker from '@/modules/base/components/DatePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue' -import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue' +import { Checkbox } from '@/components/ui/checkbox' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useFiatProviders } from '@/modules/base/composables/useFiatProviders' +import { CreditCard } from 'lucide-vue-next' import { Alert, AlertDescription } from '@/components/ui/alert' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' @@ -124,7 +132,11 @@ const formSchema = toTypedSchema( event_end_time: z.string().optional().default(''), location: z.string().max(500).optional().default(''), currency: z.string().default("sat"), - allow_fiat: z.boolean().default(false), + // Rails buyers may use. `allow_fiat` on the wire is derived from + // this list (fiat ∈ list) so older extensions / clients keep working. + payment_methods: z + .array(z.enum(['lightning', 'fiat'])) + .min(1, 'Enable at least one payment method'), fiat_currency: z.string().default("USD"), amount_tickets: z.number().min(0).max(100000).default(0), price_per_ticket: z.number().min(0).default(0), @@ -152,7 +164,7 @@ const formSchema = toTypedSchema( // mirror themselves into fiat_currency automatically. The events // extension uses 'sat' and 'sats' interchangeably — accept both. const isSat = v.currency === 'sat' || v.currency === 'sats' - if (v.allow_fiat && isSat && !v.fiat_currency) { + if (v.payment_methods.includes('fiat') && isSat && !v.fiat_currency) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fiat_currency'], @@ -173,7 +185,7 @@ const form = useForm({ event_end_time: '', location: '', currency: 'sat', - allow_fiat: false, + payment_methods: ['lightning'], fiat_currency: 'USD', amount_tickets: 0, price_per_ticket: 0, @@ -184,6 +196,42 @@ const form = useForm({ } }) +/** Rails an existing event has on; legacy events (no list) derive + * from allow_fiat. */ +function paymentMethodsOf(event: TicketedEvent): Array<'lightning' | 'fiat'> { + const explicit = (event.extra?.payment_methods ?? []).filter( + (m): m is 'lightning' | 'fiat' => m === 'lightning' || m === 'fiat', + ) + if (explicit.length > 0) return explicit + return event.allow_fiat ? ['lightning', 'fiat'] : ['lightning'] +} + +const { hasAnyProvider, refresh: refreshFiatProviders } = useFiatProviders() +// Refresh once so the Card checkbox reflects providers the organizer may +// have just configured in LNbits. +refreshFiatProviders() + +const PAYMENT_METHOD_OPTIONS = [ + { id: 'lightning' as const, label: 'Lightning', icon: Zap, hint: 'Pay with any Lightning wallet' }, + { id: 'fiat' as const, label: 'Card', icon: CreditCard, hint: 'Card or bank through your configured fiat provider' }, +] + +function isSatDenomination(d: string | undefined): boolean { + return d === 'sat' || d === 'sats' +} + +// When the price is denominated in a fiat currency, the rail currency +// MUST match it — silently mirror so the backend payload stays consistent. +watch( + () => form.values.currency, + (d) => { + if (d && !isSatDenomination(d) && form.values.fiat_currency !== d) { + form.setFieldValue('fiat_currency', d) + } + }, + { immediate: true }, +) + interface BannerImage extends UploadedImage { isPrimary: boolean } @@ -243,7 +291,7 @@ async function populateFromEvent(event: TicketedEvent) { event_end_time: end.time, location: event.location ?? '', currency: event.currency ?? 'sat', - allow_fiat: event.allow_fiat ?? false, + payment_methods: paymentMethodsOf(event), fiat_currency: event.fiat_currency ?? 'USD', amount_tickets: event.amount_tickets ?? 0, price_per_ticket: event.price_per_ticket ?? 0, @@ -354,11 +402,12 @@ const onSubmit = form.handleSubmit(async (formValues) => { eventData.banner = null } if (formValues.currency) eventData.currency = formValues.currency - // allow_fiat always sends so a true→false flip propagates on edit; - // fiat_currency only sends when fiat is on (no point persisting a - // rail-currency the backend won't use). - eventData.allow_fiat = formValues.allow_fiat - if (formValues.allow_fiat && formValues.fiat_currency) { + // allow_fiat always sends (derived from the rail list) so a + // true→false flip propagates on edit; fiat_currency only sends when + // fiat is on (no point persisting a rail-currency the backend won't use). + const acceptsFiat = formValues.payment_methods.includes('fiat') + eventData.allow_fiat = acceptsFiat + if (acceptsFiat && formValues.fiat_currency) { eventData.fiat_currency = formValues.fiat_currency } if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets @@ -371,6 +420,7 @@ const onSubmit = form.handleSubmit(async (formValues) => { // survive the round-trip. eventData.extra = { ...(props.event?.extra ?? {}), + payment_methods: formValues.payment_methods, email_notifications: formValues.email_notifications, nostr_notifications: formValues.nostr_notifications, notification_subject: formValues.notification_subject, @@ -660,26 +710,87 @@ const handleOpenChange = (open: boolean) => {
-
-
-

Payment methods

-

- Lightning is always available. Enable fiat to also accept - card and bank payments through your configured provider. -

-
-
- - Lightning — always on -
- -
+ + +
+ Payment methods + + Pick the rails buyers can pay with. Turn Lightning off for a + card-only sale. + +
+
+ + + + + + + + + + + + Your LNbits user has no fiat provider configured. Open + LNbits → Account → Fiat providers and add Stripe, PayPal, + or Square to enable card payments. + + + + + +
+ +
+ {{ opt.label }} +

{{ opt.hint }}

+
+
+
+
+
+ +
+
+ + + + Fiat currency + + + + Currency card buyers are charged in. + + + @@ -319,13 +352,6 @@ function goToMyTickets() { - - - -
-

{{ event.description }}

-
- -
+ need a Buy CTA for their own event. Placed ABOVE the + description on purpose: the buy action is the reason most + visitors open the page, and a long description must not + push it below the fold (a sticky bar covers the rest). --> +
- {{ ownedPaidCount > 0 - ? t('events.detail.buyAnotherTicket', 'Buy another ticket') - : t('events.detail.buyTicket', 'Buy ticket') }} - - {{ event.ticketInfo.price === 0 - ? t('events.detail.free') - : `${event.ticketInfo.price} ${event.ticketInfo.currency}` }} - + {{ buyLabel }} + {{ priceLabel }}

@@ -402,6 +429,13 @@ function goToMyTickets() {

+ + + +
+

{{ event.description }}

+
+ + + +
+
+
+

{{ event.title }}

+

+ + {{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }} + + + {{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} + + + {{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }} + +

+
+ + +
+
+
+
From 82f7e5659a50531ff7030e69c87fea754ce5b2f1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 9 Sep 2026 13:16:53 +0200 Subject: [PATCH 14/26] feat(layout): publish the bottom nav's rendered height as --bottom-nav-height BottomNav measures itself (ResizeObserver) and writes `--bottom-nav-height` on ; index.css declares the 0px default. Any element that must sit flush above the nav pins with `bottom: var(--bottom-nav-height)` instead of re-deriving `3.5rem + env(safe-area-inset-bottom)` and drifting when the device's inset, border or font scaling differs from that guess. The restaurant item page's add-to-cart bar is migrated to it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- src/assets/index.css | 4 ++++ src/components/layout/BottomNav.vue | 19 +++++++++++++++++++ src/modules/restaurant/views/ItemPage.vue | 9 ++++----- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/assets/index.css b/src/assets/index.css index d4da37b..990e6ee 100644 --- a/src/assets/index.css +++ b/src/assets/index.css @@ -96,6 +96,10 @@ /* Default palette: Catppuccin (Latte for light, Mocha for dark). Other palettes are scoped via :root[data-theme=""] in themes/*.css. */ :root { + /* Rendered height of the fixed BottomNav (h-14 + safe-area + border), kept + current by BottomNav.vue. Pin anything that must sit flush above the nav + with `bottom: var(--bottom-nav-height)`; 0 when no nav is mounted. */ + --bottom-nav-height: 0px; --background: oklch(0.9578 0.0058 264.5321); --foreground: oklch(0.4355 0.0430 279.3250); --card: oklch(1.0000 0 0); diff --git a/src/components/layout/BottomNav.vue b/src/components/layout/BottomNav.vue index e23fd98..465437c 100644 --- a/src/components/layout/BottomNav.vue +++ b/src/components/layout/BottomNav.vue @@ -1,6 +1,8 @@