Compare commits
27 commits
33cb75ac77
...
65770ad9be
| Author | SHA1 | Date | |
|---|---|---|---|
| 65770ad9be | |||
| 3720dbc35a | |||
| 654be4bf8a | |||
| 8b5baeb06d | |||
| 7dbe04afa3 | |||
| a1b9aaf2a2 | |||
| 4162e0af99 | |||
| 262e674f9f | |||
| a68fed916d | |||
| 0a21c092ff | |||
| 9c0abfae1f | |||
| 4faa57f87a | |||
| b080d802c5 | |||
| 6e4e2f3d0c | |||
| 82f7e5659a | |||
| 9eb75a2017 | |||
| cd70e65425 | |||
| 1e43e8ed0a | |||
| 411ac0c93c | |||
| cbd4159f72 | |||
| 89a1fcb030 | |||
| 5d3ebcc5e4 | |||
| b853fbce4b | |||
| bd78e3227d | |||
| 891b5072f8 | |||
| f85848a50b | |||
| 0c5b577499 |
53 changed files with 3598 additions and 288 deletions
19
chatelet.html
Normal file
19
chatelet.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>%VITE_APP_NAME%</title>
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||
<meta name="description" content="Book a room">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/chatelet-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
76
docs/02-modules/events-module/guest-checkout.md
Normal file
76
docs/02-modules/events-module/guest-checkout.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Events — guest checkout, fiat return flow and ticket delivery
|
||||
|
||||
Buyers can purchase **paid** tickets without an LNbits account. This note is
|
||||
the wire contract between the webapp and the `aiolabs/events` extension
|
||||
(≥ v1.6.1-aio.8) for that flow. Free tickets still require a login
|
||||
(events#29: no anonymous free issuance).
|
||||
|
||||
## Identity on `POST /events/api/v1/tickets/{event_id}`
|
||||
|
||||
| Buyer | Body fields | Auth header |
|
||||
|---|---|---|
|
||||
| Logged in | `user_id` + `email` (delivery address, prefilled from the account, editable) | `Authorization: Bearer <token>` |
|
||||
| Guest | `name` + `email` | none (the endpoint is anonymous; the instance `X-API-KEY` is always sent) |
|
||||
|
||||
Every purchase also sends `frontend_url` — this app's root
|
||||
(`window.location.origin + import.meta.env.BASE_URL`, no trailing slash;
|
||||
`src/modules/events/lib/frontendRoot.ts`). The extension only accepts it when
|
||||
its origin is on the LNbits `LNBITS_CORS_ALLOWED_ORIGINS` allowlist (or the
|
||||
LNbits base / custom-frontend origin); otherwise the request fails with
|
||||
`frontend_url origin is not allowed.` and the dialog shows that message.
|
||||
|
||||
`payment_method` is `lightning` or `fiat`. The event's enabled rails come from
|
||||
the NIP-52 tag `tickets_payment_methods` (`"lightning,fiat"`); when the tag is
|
||||
absent the legacy rule applies (Lightning always, fiat iff
|
||||
`tickets_allow_fiat`). See `lib/paymentMethods.ts`. A rail that is off is
|
||||
rejected by the backend with `Payment method not enabled for this event.`
|
||||
|
||||
## Return from the fiat provider
|
||||
|
||||
The extension composes both Stripe URLs under `frontend_url`:
|
||||
|
||||
| Purpose | URL |
|
||||
|---|---|
|
||||
| success | `<root>/events/{event_id}?checkout=success&tickets=<id1,id2>` |
|
||||
| cancel | `<root>/events/{event_id}?checkout=cancelled` |
|
||||
|
||||
Before redirecting (same tab, `window.location.assign`), the purchase dialog
|
||||
writes `localStorage['events:pending-checkout']` =
|
||||
`{ eventId, paymentHash, ticketIds, email, ts }` (`lib/pendingCheckout.ts`).
|
||||
`EventDetailPage` strips the query on arrival, then:
|
||||
|
||||
- `success` → opens `CheckoutReturnDialog`, which polls the anonymous
|
||||
`GET /events/api/v1/tickets/{ticket_id}` for every id every 2 s (60 s budget,
|
||||
retry button) until all are `paid`, then renders one QR per ticket, the
|
||||
"emailed to" note (email from the localStorage record) and a link to the
|
||||
ticket page. Logged-in buyers also get `useOwnedTickets().refresh()`.
|
||||
- `cancelled` → info toast, record cleared. Nothing was charged; the
|
||||
extension purges unpaid rows after 24 h.
|
||||
|
||||
## Public ticket page
|
||||
|
||||
`/events/ticket/:ticketId` (`views/TicketPage.vue`, `requiresAuth: false`) is
|
||||
where the emailed link lands. It reads the `PublicTicket` (no id / email /
|
||||
user_id in the payload; the id in the URL is the door credential, exactly as
|
||||
in the LNbits UI), resolves the event name (Nostr store → scoped relay query
|
||||
→ public REST, which 410s on sold-out events and is treated as non-fatal) and
|
||||
renders the QR.
|
||||
|
||||
## QR rendering
|
||||
|
||||
All `ticket://<id>` QRs (purchase success, return dialog, ticket page, My
|
||||
Tickets) go through `lib/ticketQr.ts`: error-correction level H with the
|
||||
brand logo (`@brand/logo.png`) centred at ≤ 20 % width on a white pad, so the
|
||||
in-app image matches the PNG the extension embeds in the ticket email
|
||||
(`GET /events/api/v1/qr/{ticket_id}`). Canvas failures fall back to the plain
|
||||
QR.
|
||||
|
||||
## Organizer side
|
||||
|
||||
`CreateEventDialog` publishes `extra.payment_methods` (Lightning / Card
|
||||
checkboxes; at least one) and keeps `allow_fiat` in sync for older readers.
|
||||
Card is disabled with a tooltip when the organizer's LNbits user has no fiat
|
||||
provider (`useFiatProviders().hasAnyProvider`).
|
||||
|
||||
`resendTicketEmail` returns the structured `TicketResendResult`
|
||||
(`{ ticket, email: {attempted, sent, error}, nostr: {...} }`).
|
||||
|
|
@ -182,6 +182,13 @@ interface ModulePlugin {
|
|||
|
||||
**See:** [[market-module/index|📖 Market Module Documentation]]
|
||||
|
||||
### **Events Module** 🎟️
|
||||
**Purpose:** Ticketed events (Nostr NIP-52 feed + LNbits events extension)
|
||||
**Location:** `src/modules/events/`
|
||||
**Dependencies:** `['base']`
|
||||
|
||||
**See:** [[events-module/guest-checkout|📖 Guest checkout, fiat return flow and ticket delivery]]
|
||||
|
||||
## Module Development
|
||||
|
||||
### **Creating a New Module**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@
|
|||
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
|
||||
Other palettes are scoped via :root[data-theme="<name>"] 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);
|
||||
|
|
|
|||
16
src/chatelet-app/App.vue
Normal file
16
src/chatelet-app/App.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
|
||||
// Chatelet's in-page navigation is route-driven (browse → detail); the shell
|
||||
// only contributes the always-on Profile entry on the bottom row.
|
||||
const tabs: BottomTab[] = []
|
||||
|
||||
function isActive(_path: string): boolean {
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||
</template>
|
||||
54
src/chatelet-app/app.config.ts
Normal file
54
src/chatelet-app/app.config.ts
Normal file
|
|
@ -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
|
||||
128
src/chatelet-app/app.ts
Normal file
128
src/chatelet-app/app.ts
Normal file
|
|
@ -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 = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
24
src/chatelet-app/main.ts
Normal file
24
src/chatelet-app/main.ts
Normal file
|
|
@ -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()
|
||||
55
src/chatelet-app/views/SettingsPage.vue
Normal file
55
src/chatelet-app/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
|
||||
const { theme, setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
const isAuthenticated = computed(() => auth.isAuthenticated.value)
|
||||
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-foreground">Theme</span>
|
||||
<Button variant="outline" size="sm" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
<span class="ml-2">{{ theme === 'dark' ? 'Light' : 'Dark' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div v-if="isAuthenticated" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">Signed in as</p>
|
||||
<p class="text-sm text-foreground break-all">{{ userPubkey }}</p>
|
||||
<Button variant="outline" size="sm" @click="handleLogout">
|
||||
<LogOut class="h-4 w-4 mr-2" /> Log out
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button variant="outline" size="sm" @click="router.push('/login')">
|
||||
<LogIn class="h-4 w-4 mr-2" /> Log in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Component } from 'vue'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
|
||||
export interface BottomTab {
|
||||
/** Translated label shown under the icon. */
|
||||
|
|
@ -33,6 +35,22 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Publish the nav's rendered height (h-14 + safe-area padding + border,
|
||||
// whatever the device makes of it) as `--bottom-nav-height` on <html>, so
|
||||
// anything that needs to sit flush above the nav — sticky action bars,
|
||||
// toasts — can use `bottom: var(--bottom-nav-height)` instead of
|
||||
// re-deriving the CSS and drifting when insets or font scaling differ.
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
function publishHeight() {
|
||||
const h = navRef.value?.getBoundingClientRect().height ?? 0
|
||||
document.documentElement.style.setProperty('--bottom-nav-height', `${Math.round(h)}px`)
|
||||
}
|
||||
useResizeObserver(navRef, publishHeight)
|
||||
onMounted(publishHeight)
|
||||
onBeforeUnmount(() => {
|
||||
document.documentElement.style.setProperty('--bottom-nav-height', '0px')
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function onTabClick(tab: BottomTab) {
|
||||
|
|
@ -51,6 +69,7 @@ function isTabActive(tab: BottomTab): boolean {
|
|||
|
||||
<template>
|
||||
<nav
|
||||
ref="navRef"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ const messages: LocaleMessages = {
|
|||
events: {
|
||||
title: 'Events',
|
||||
createNew: 'Create Event',
|
||||
create: {
|
||||
promo: {
|
||||
title: 'Promo codes',
|
||||
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.',
|
||||
empty: 'No promo codes yet.',
|
||||
code: 'Code',
|
||||
codePlaceholder: 'EARLYBIRD',
|
||||
discount: 'Discount %',
|
||||
maxUses: 'Max uses',
|
||||
active: 'Active',
|
||||
used: 'Used {count}',
|
||||
add: 'Add code',
|
||||
removeLabel: 'Remove code {code}',
|
||||
errors: {
|
||||
required: 'Enter a code',
|
||||
duplicate: 'Duplicate code',
|
||||
percentRange: 'Between 1 and 100',
|
||||
maxUsesMin: 'Must be 1 or more',
|
||||
},
|
||||
},
|
||||
},
|
||||
noEvents: 'No events found',
|
||||
filters: {
|
||||
all: 'All',
|
||||
|
|
@ -129,6 +150,65 @@ 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.',
|
||||
promo: {
|
||||
label: 'Promo code',
|
||||
placeholder: 'Enter code',
|
||||
apply: 'Apply',
|
||||
remove: 'Remove',
|
||||
checking: 'Checking…',
|
||||
applied: '{code} −{percent}%',
|
||||
discountLine: 'Discount ({code})',
|
||||
invalid: 'This code is not valid for this purchase.',
|
||||
unverified: 'Could not verify the code — it will be applied at checkout if valid.',
|
||||
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.',
|
||||
},
|
||||
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.',
|
||||
spamHint: 'If it has not arrived a few minutes after paying, check your spam folder.',
|
||||
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',
|
||||
retry: 'Check again',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ const messages: LocaleMessages = {
|
|||
events: {
|
||||
title: 'Eventos',
|
||||
createNew: 'Crear evento',
|
||||
create: {
|
||||
promo: {
|
||||
title: 'Códigos promocionales',
|
||||
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.',
|
||||
empty: 'Aún no hay códigos promocionales.',
|
||||
code: 'Código',
|
||||
codePlaceholder: 'EARLYBIRD',
|
||||
discount: 'Descuento %',
|
||||
maxUses: 'Usos máx.',
|
||||
active: 'Activo',
|
||||
used: 'Usado {count}',
|
||||
add: 'Añadir código',
|
||||
removeLabel: 'Eliminar el código {code}',
|
||||
errors: {
|
||||
required: 'Introduce un código',
|
||||
duplicate: 'Código duplicado',
|
||||
percentRange: 'Entre 1 y 100',
|
||||
maxUsesMin: 'Debe ser 1 o más',
|
||||
},
|
||||
},
|
||||
},
|
||||
noEvents: 'No se encontraron eventos',
|
||||
filters: {
|
||||
all: 'Todas',
|
||||
|
|
@ -129,6 +150,65 @@ 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.',
|
||||
promo: {
|
||||
label: 'Código promocional',
|
||||
placeholder: 'Introduce el código',
|
||||
apply: 'Aplicar',
|
||||
remove: 'Quitar',
|
||||
checking: 'Comprobando…',
|
||||
applied: '{code} −{percent}%',
|
||||
discountLine: 'Descuento ({code})',
|
||||
invalid: 'Este código no es válido para esta compra.',
|
||||
unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.',
|
||||
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.',
|
||||
},
|
||||
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.',
|
||||
spamHint: 'Si no llega unos minutos después de pagar, revisa tu carpeta de spam.',
|
||||
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',
|
||||
retry: 'Comprobar de nuevo',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ const messages: LocaleMessages = {
|
|||
events: {
|
||||
title: 'Événements',
|
||||
createNew: 'Créer un événement',
|
||||
create: {
|
||||
promo: {
|
||||
title: 'Codes promo',
|
||||
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.',
|
||||
empty: 'Aucun code promo pour le moment.',
|
||||
code: 'Code',
|
||||
codePlaceholder: 'EARLYBIRD',
|
||||
discount: 'Remise %',
|
||||
maxUses: 'Utilisations max.',
|
||||
active: 'Actif',
|
||||
used: 'Utilisé {count}',
|
||||
add: 'Ajouter un code',
|
||||
removeLabel: 'Supprimer le code {code}',
|
||||
errors: {
|
||||
required: 'Saisissez un code',
|
||||
duplicate: 'Code en double',
|
||||
percentRange: 'Entre 1 et 100',
|
||||
maxUsesMin: 'Doit être 1 ou plus',
|
||||
},
|
||||
},
|
||||
},
|
||||
noEvents: 'Aucun événement trouvé',
|
||||
filters: {
|
||||
all: 'Tout',
|
||||
|
|
@ -129,6 +150,65 @@ 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.',
|
||||
promo: {
|
||||
label: 'Code promo',
|
||||
placeholder: 'Saisir le code',
|
||||
apply: 'Appliquer',
|
||||
remove: 'Retirer',
|
||||
checking: 'Vérification…',
|
||||
applied: '{code} −{percent}%',
|
||||
discountLine: 'Remise ({code})',
|
||||
invalid: "Ce code n'est pas valable pour cet achat.",
|
||||
unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
|
||||
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.',
|
||||
},
|
||||
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.',
|
||||
spamHint: 'S’il n’est pas arrivé quelques minutes après le paiement, vérifiez votre dossier spam.',
|
||||
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',
|
||||
retry: 'Vérifier à nouveau',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ export interface LocaleMessages {
|
|||
events?: {
|
||||
title: string
|
||||
createNew: string
|
||||
create: {
|
||||
promo: {
|
||||
title: string
|
||||
hint: string
|
||||
empty: string
|
||||
code: string
|
||||
codePlaceholder: string
|
||||
discount: string
|
||||
maxUses: string
|
||||
active: string
|
||||
used: string
|
||||
add: string
|
||||
removeLabel: string
|
||||
errors: {
|
||||
required: string
|
||||
duplicate: string
|
||||
percentRange: string
|
||||
maxUsesMin: string
|
||||
}
|
||||
}
|
||||
}
|
||||
noEvents: string
|
||||
filters: {
|
||||
all: string
|
||||
|
|
@ -104,6 +125,65 @@ export interface LocaleMessages {
|
|||
pending: string
|
||||
registered: string
|
||||
}
|
||||
purchase: {
|
||||
card: string
|
||||
continueTo: string
|
||||
fiatRedirectHint: string
|
||||
noCheckoutUrl: string
|
||||
fiatFailed: string
|
||||
loginRequiredTitle: string
|
||||
loginRequiredFree: string
|
||||
promo: {
|
||||
label: string
|
||||
placeholder: string
|
||||
apply: string
|
||||
remove: string
|
||||
checking: string
|
||||
applied: string
|
||||
discountLine: string
|
||||
invalid: string
|
||||
unverified: string
|
||||
loginRequiredDiscount: string
|
||||
}
|
||||
contact: {
|
||||
title: string
|
||||
name: string
|
||||
namePlaceholder: string
|
||||
nameRequired: string
|
||||
email: string
|
||||
emailHint: string
|
||||
spamHint: 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
|
||||
retry: 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
|
||||
|
|
|
|||
38
src/modules/chatelet/composables/useChatelet.ts
Normal file
38
src/modules/chatelet/composables/useChatelet.ts
Normal file
|
|
@ -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<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||
|
||||
async function loadRooms(): Promise<void> {
|
||||
if (!api) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
store.rooms = await api.getPublicRooms()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoom(id: string): Promise<Room | null> {
|
||||
if (!api) return null
|
||||
return api.getPublicRoom(id)
|
||||
}
|
||||
|
||||
async function checkAvailability(
|
||||
roomId: string,
|
||||
checkIn: string,
|
||||
checkOut: string,
|
||||
): Promise<AvailabilityResult | null> {
|
||||
if (!api) return null
|
||||
return api.checkAvailability(roomId, checkIn, checkOut)
|
||||
}
|
||||
|
||||
return { rooms, isLoading, loadRooms, getRoom, checkAvailability }
|
||||
}
|
||||
54
src/modules/chatelet/index.ts
Normal file
54
src/modules/chatelet/index.ts
Normal file
|
|
@ -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'
|
||||
69
src/modules/chatelet/services/ChateletApiService.ts
Normal file
69
src/modules/chatelet/services/ChateletApiService.ts
Normal file
|
|
@ -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<Room[]> {
|
||||
return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' })
|
||||
}
|
||||
|
||||
async getPublicRoom(id: string): Promise<Room> {
|
||||
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
|
||||
}
|
||||
|
||||
async checkAvailability(
|
||||
roomId: string,
|
||||
checkIn: string,
|
||||
checkOut: string,
|
||||
): Promise<AvailabilityResult> {
|
||||
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<any> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, string>) ?? {}),
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
9
src/modules/chatelet/stores/chatelet.ts
Normal file
9
src/modules/chatelet/stores/chatelet.ts
Normal file
|
|
@ -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<Room[]>([])
|
||||
const isLoading = ref(false)
|
||||
return { rooms, isLoading }
|
||||
})
|
||||
33
src/modules/chatelet/types/room.ts
Normal file
33
src/modules/chatelet/types/room.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
149
src/modules/chatelet/views/ChateletDetailPage.vue
Normal file
149
src/modules/chatelet/views/ChateletDetailPage.vue
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { addDays, format } from 'date-fns'
|
||||
import { MapPin, Users } from 'lucide-vue-next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||
import { useChatelet } from '../composables/useChatelet'
|
||||
import type { AvailabilityResult, Room } from '../types/room'
|
||||
|
||||
const route = useRoute()
|
||||
const { getRoom, checkAvailability } = useChatelet()
|
||||
|
||||
const room = ref<Room | null>(null)
|
||||
const loading = ref(true)
|
||||
const checkIn = ref('')
|
||||
const checkOut = ref('')
|
||||
const checking = ref(false)
|
||||
const result = ref<AvailabilityResult | null>(null)
|
||||
|
||||
// Same YYYY-MM-DD wire format the DatePicker speaks; the availability
|
||||
// endpoint takes the same strings.
|
||||
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
|
||||
|
||||
// Check-out must be at least the next morning. Bumping the floor also
|
||||
// clears a check-out that the new check-in has overtaken.
|
||||
const minCheckOut = computed(() =>
|
||||
checkIn.value
|
||||
? format(addDays(new Date(checkIn.value + 'T00:00:00'), 1), 'yyyy-MM-dd')
|
||||
: today.value,
|
||||
)
|
||||
|
||||
watch(checkIn, () => {
|
||||
if (checkOut.value && checkOut.value < minCheckOut.value) checkOut.value = ''
|
||||
result.value = null
|
||||
})
|
||||
watch(checkOut, () => {
|
||||
result.value = null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
room.value = await getRoom(route.params.id as string)
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load room')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function onCheck() {
|
||||
if (!room.value || !checkIn.value || !checkOut.value) return
|
||||
checking.value = true
|
||||
result.value = null
|
||||
try {
|
||||
result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value)
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Availability check failed')
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-2xl">
|
||||
<div v-if="loading" class="text-muted-foreground py-12">Loading…</div>
|
||||
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<img
|
||||
v-if="room.images?.length"
|
||||
:src="room.images[0]"
|
||||
:alt="room.title"
|
||||
class="w-full h-56 object-cover rounded-lg"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{{ room.title }}</h1>
|
||||
<p
|
||||
v-if="room.location"
|
||||
class="text-muted-foreground flex items-center gap-1 mt-1"
|
||||
>
|
||||
<MapPin class="h-4 w-4" /> {{ room.location }}
|
||||
</p>
|
||||
<p class="text-muted-foreground flex items-center gap-1">
|
||||
<Users class="h-4 w-4" /> up to {{ room.max_guests }} guests · min
|
||||
{{ room.min_nights }} night(s)
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-foreground mt-2">
|
||||
{{ room.price_amount }} {{ room.price_currency }} / {{ room.price_frequency }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1">
|
||||
<Badge v-for="a in room.amenities" :key="a" variant="outline">{{ a }}</Badge>
|
||||
</div>
|
||||
|
||||
<p v-if="room.description" class="text-foreground whitespace-pre-line">
|
||||
{{ room.description }}
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-4 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>Check-in</Label>
|
||||
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" />
|
||||
</div>
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label>Check-out</Label>
|
||||
<DatePicker
|
||||
v-model="checkOut"
|
||||
:min="minCheckOut"
|
||||
:disabled="!checkIn"
|
||||
placeholder="Check-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="!checkIn || !checkOut || checking"
|
||||
@click="onCheck"
|
||||
>
|
||||
{{ checking ? 'Checking…' : 'Check availability' }}
|
||||
</Button>
|
||||
|
||||
<div v-if="result" class="text-sm pt-1">
|
||||
<template v-if="result.available">
|
||||
<p class="text-foreground">
|
||||
Available for {{ result.nights }} night(s) —
|
||||
<span class="font-semibold">{{ result.quote_sat }} sats</span>
|
||||
<span v-if="result.quote_fiat" class="text-muted-foreground">
|
||||
(≈ {{ result.quote_fiat }} {{ result.currency }})
|
||||
</span>
|
||||
</p>
|
||||
<Button class="w-full mt-3" disabled>Book — coming soon</Button>
|
||||
</template>
|
||||
<p v-else class="text-destructive">Not available for those dates.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
80
src/modules/chatelet/views/ChateletPage.vue
Normal file
80
src/modules/chatelet/views/ChateletPage.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { MapPin, Users } from 'lucide-vue-next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useChatelet } from '../composables/useChatelet'
|
||||
import type { Room } from '../types/room'
|
||||
|
||||
const router = useRouter()
|
||||
const { rooms, isLoading, loadRooms } = useChatelet()
|
||||
|
||||
onMounted(loadRooms)
|
||||
|
||||
function open(id: string) {
|
||||
router.push(`/chatelet/${id}`)
|
||||
}
|
||||
|
||||
function priceLabel(r: Room): string {
|
||||
return `${r.price_amount} ${r.price_currency} / ${r.price_frequency}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">Rooms</h1>
|
||||
|
||||
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton v-for="n in 4" :key="n" class="h-64 w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="rooms.length === 0" class="text-muted-foreground text-center py-16">
|
||||
No rooms available right now.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||
<Card
|
||||
v-for="room in rooms"
|
||||
:key="room.id"
|
||||
class="cursor-pointer overflow-hidden hover:border-ring transition-colors"
|
||||
@click="open(room.id)"
|
||||
>
|
||||
<img
|
||||
v-if="room.images?.length"
|
||||
:src="room.images[0]"
|
||||
:alt="room.title"
|
||||
class="h-40 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-40 w-full bg-muted flex items-center justify-center text-muted-foreground text-sm"
|
||||
>
|
||||
No image
|
||||
</div>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-semibold text-foreground">{{ room.title }}</h3>
|
||||
<Badge variant="secondary" class="shrink-0">{{ priceLabel(room) }}</Badge>
|
||||
</div>
|
||||
<p
|
||||
v-if="room.location"
|
||||
class="text-sm text-muted-foreground mt-1 flex items-center gap-1"
|
||||
>
|
||||
<MapPin class="h-3.5 w-3.5" /> {{ room.location }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<Users class="h-3.5 w-3.5" /> up to {{ room.max_guests }} guests
|
||||
</p>
|
||||
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1 mt-2">
|
||||
<Badge v-for="a in room.amenities.slice(0, 4)" :key="a" variant="outline">
|
||||
{{ a }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal file
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Ticket, Loader2, Clock } from 'lucide-vue-next'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
import { renderTicketQr } from '../lib/ticketQr'
|
||||
import { clearPendingCheckout } from '../lib/pendingCheckout'
|
||||
|
||||
/**
|
||||
* Shown when the buyer lands back on the event page from the fiat
|
||||
* provider with `?checkout=success&tickets=…`. Polls the anonymous
|
||||
* ticket endpoint until every row is paid (Stripe waits for our
|
||||
* webhook before redirecting, so the first poll usually already
|
||||
* says paid), then renders one scannable QR per ticket.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
eventId: string
|
||||
ticketIds: string[]
|
||||
email?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:isOpen', value: boolean): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
|
||||
const POLL_MS = 2000
|
||||
const TIMEOUT_MS = 60_000
|
||||
|
||||
type Phase = 'checking' | 'paid' | 'timeout'
|
||||
const phase = ref<Phase>('checking')
|
||||
const qrCodes = ref<Record<string, string>>({})
|
||||
let timer: number | null = null
|
||||
let startedAt = 0
|
||||
|
||||
const paidCount = ref(0)
|
||||
|
||||
async function checkOnce(): Promise<boolean> {
|
||||
const results = await Promise.all(
|
||||
props.ticketIds.map((id) => ticketApi.getTicket(id).then((t) => t.paid).catch(() => false)),
|
||||
)
|
||||
paidCount.value = results.filter(Boolean).length
|
||||
return results.length > 0 && results.every(Boolean)
|
||||
}
|
||||
|
||||
async function onPaid() {
|
||||
phase.value = 'paid'
|
||||
stop()
|
||||
clearPendingCheckout()
|
||||
if (isAuthenticated.value) void refreshOwnedTickets()
|
||||
const map: Record<string, string> = {}
|
||||
for (const id of props.ticketIds) {
|
||||
try {
|
||||
map[id] = await renderTicketQr(id, { width: 200 })
|
||||
} catch {
|
||||
// leave the slot empty; the ticket page link still works
|
||||
}
|
||||
}
|
||||
qrCodes.value = map
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (phase.value !== 'checking') return
|
||||
try {
|
||||
if (await checkOnce()) {
|
||||
await onPaid()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// transient network error — keep polling until the timeout
|
||||
}
|
||||
if (Date.now() - startedAt > TIMEOUT_MS) {
|
||||
phase.value = 'timeout'
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
phase.value = 'checking'
|
||||
qrCodes.value = {}
|
||||
startedAt = Date.now()
|
||||
void tick()
|
||||
timer = window.setInterval(() => void tick(), POLL_MS)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.isOpen, props.ticketIds.join(',')] as const,
|
||||
([open]) => {
|
||||
if (open && props.ticketIds.length > 0) start()
|
||||
else stop()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
function close() {
|
||||
stop()
|
||||
emit('update:isOpen', false)
|
||||
}
|
||||
|
||||
const title = computed(() =>
|
||||
phase.value === 'paid' ? t('events.checkout.paidTitle') : t('events.checkout.title'),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="isOpen" @update:open="close">
|
||||
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Ticket class="w-5 h-5" />
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="phase === 'paid' && email">
|
||||
{{ t('events.checkout.emailedTo', { email }) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="phase === 'checking'" class="py-8 flex flex-col items-center gap-3 text-center">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('events.checkout.processing') }}</p>
|
||||
<p v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
|
||||
{{ paidCount }} / {{ ticketIds.length }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'timeout'" class="py-6 flex flex-col items-center gap-3 text-center">
|
||||
<Clock class="w-10 h-10 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ email
|
||||
? t('events.checkout.stillProcessing', { email })
|
||||
: t('events.checkout.stillProcessingNoEmail') }}
|
||||
</p>
|
||||
<Button variant="outline" @click="start">{{ t('events.checkout.retry') }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-4 space-y-3">
|
||||
<div
|
||||
v-for="(id, idx) in ticketIds"
|
||||
:key="id"
|
||||
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
|
||||
>
|
||||
<span v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
|
||||
{{ t('events.checkout.ticketN', { n: idx + 1, total: ticketIds.length }) }}
|
||||
</span>
|
||||
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||
<img v-if="qrCodes[id]" :src="qrCodes[id]" :alt="`Ticket ${id}`" class="block h-48 w-48" />
|
||||
<div v-else class="h-48 w-48 flex items-center justify-center">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
|
||||
{{ t('events.checkout.viewTicket') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground text-center">{{ t('events.ticket.showAtDoor') }}</p>
|
||||
<div class="space-y-2 pt-1">
|
||||
<Button v-if="isAuthenticated" class="w-full" @click="() => { close(); $router.push('/my-tickets') }">
|
||||
{{ t('events.checkout.viewMyTickets') }}
|
||||
</Button>
|
||||
<Button variant="outline" class="w-full" @click="close">{{ t('events.checkout.close') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Bell, ChevronDown } from 'lucide-vue-next'
|
||||
import { Bell, ChevronDown, Tag } from 'lucide-vue-next'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -41,12 +41,28 @@ 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'
|
||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
||||
import { ALL_CATEGORIES } from '../types/category'
|
||||
import PromoCodesEditor from './PromoCodesEditor.vue'
|
||||
import {
|
||||
fromWirePromoCodes,
|
||||
hasPromoErrors,
|
||||
toWirePromoCodes,
|
||||
validatePromoRows,
|
||||
type PromoCodeRow,
|
||||
} from '../lib/promoCodes'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -124,7 +140,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 +172,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 +193,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 +204,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
|
||||
}
|
||||
|
|
@ -204,6 +260,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
|
|||
// edit-mode population with start-date side effects mid-setValues.
|
||||
const isPopulating = ref(false)
|
||||
const notificationsOpen = ref(false)
|
||||
const promoOpen = ref(false)
|
||||
|
||||
// Auto-mirror end date to start: when the user picks a start date,
|
||||
// surface that same date in the end-date picker so a one-day event
|
||||
|
|
@ -229,6 +286,10 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
|
|||
const availableCurrencies = ref<string[]>(['sat'])
|
||||
const loadingCurrencies = ref(false)
|
||||
const selectedCategories = ref<string[]>([])
|
||||
// Promo codes live outside vee-validate like categories: a plain list
|
||||
// with its own pure validator (`validatePromoRows`), gating submit below.
|
||||
const promoRows = ref<PromoCodeRow[]>([])
|
||||
const promoErrors = computed(() => validatePromoRows(promoRows.value))
|
||||
|
||||
async function populateFromEvent(event: TicketedEvent) {
|
||||
isPopulating.value = true
|
||||
|
|
@ -243,7 +304,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,
|
||||
|
|
@ -253,6 +314,10 @@ async function populateFromEvent(event: TicketedEvent) {
|
|||
notification_body: event.extra?.notification_body ?? '',
|
||||
})
|
||||
selectedCategories.value = [...(event.categories ?? [])]
|
||||
// Codes come only on the organizer-authenticated record (public
|
||||
// responses omit them) — useMyEvents / EventDetailPage hand us that one.
|
||||
promoRows.value = fromWirePromoCodes(event.extra?.promo_codes)
|
||||
promoOpen.value = promoRows.value.length > 0
|
||||
if (event.banner) {
|
||||
// Re-render the stored banner via its pict-rs file ID. delete_token
|
||||
// is intentionally empty: we don't own the original upload's token
|
||||
|
|
@ -291,11 +356,13 @@ watch(() => props.open, async (isOpen) => {
|
|||
}
|
||||
} else {
|
||||
selectedCategories.value = []
|
||||
promoRows.value = []
|
||||
promoOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const { resetForm, meta } = form
|
||||
const isFormValid = computed(() => meta.value.valid)
|
||||
const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value))
|
||||
const isLoading = ref(false)
|
||||
|
||||
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
|
||||
|
|
@ -354,27 +421,31 @@ 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
|
||||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||
|
||||
// Notification config goes inside the `extra` envelope. On edit
|
||||
// overlay onto the existing event.extra so unrelated fields the
|
||||
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
|
||||
// survive the round-trip.
|
||||
// Notification config + promo codes go inside the `extra` envelope.
|
||||
// On edit overlay onto the existing event.extra so fields only the
|
||||
// LNbits admin UI sets (conditional, min_tickets) survive the
|
||||
// round-trip. promo_codes is always sent explicitly: what the
|
||||
// organizer sees in the editor is the truth, an empty list clears.
|
||||
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,
|
||||
notification_body: formValues.notification_body,
|
||||
promo_codes: toWirePromoCodes(promoRows.value),
|
||||
}
|
||||
|
||||
if (isEditMode.value) {
|
||||
|
|
@ -405,6 +476,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
|
||||
resetForm()
|
||||
selectedCategories.value = []
|
||||
promoRows.value = []
|
||||
bannerImages.value = []
|
||||
emit('update:open', false)
|
||||
} catch (error) {
|
||||
|
|
@ -424,6 +496,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
if (!open && !isLoading.value) {
|
||||
resetForm()
|
||||
selectedCategories.value = []
|
||||
promoRows.value = []
|
||||
bannerImages.value = []
|
||||
}
|
||||
emit('update:open', open)
|
||||
|
|
@ -660,26 +733,87 @@ const handleOpenChange = (open: boolean) => {
|
|||
</div>
|
||||
|
||||
<!-- ── Payment methods ──────────────────────────────────── -->
|
||||
<div class="space-y-3">
|
||||
<FormField name="payment_methods">
|
||||
<FormItem class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Payment methods</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Lightning is always available. Enable fiat to also accept
|
||||
card and bank payments through your configured provider.
|
||||
</p>
|
||||
<FormLabel class="text-sm font-medium">Payment methods</FormLabel>
|
||||
<FormDescription class="text-xs">
|
||||
Pick the rails buyers can pay with. Turn Lightning off for a
|
||||
card-only sale.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Lightning — always on</span>
|
||||
</div>
|
||||
<FiatToggleField
|
||||
allow-fiat-field="allow_fiat"
|
||||
fiat-currency-field="fiat_currency"
|
||||
:denomination="form.values.currency ?? 'sat'"
|
||||
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
||||
<div class="space-y-2">
|
||||
<FormField
|
||||
v-for="opt in PAYMENT_METHOD_OPTIONS"
|
||||
:key="opt.id"
|
||||
v-slot="{ value, handleChange }"
|
||||
type="checkbox"
|
||||
:value="opt.id"
|
||||
:unchecked-value="false"
|
||||
name="payment_methods"
|
||||
>
|
||||
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3">
|
||||
<FormControl>
|
||||
<TooltipProvider v-if="opt.id === 'fiat' && !hasAnyProvider" :delay-duration="200">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex">
|
||||
<Checkbox :model-value="false" disabled />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs">
|
||||
Your LNbits user has no fiat provider configured. Open
|
||||
LNbits → Account → Fiat providers and add Stripe, PayPal,
|
||||
or Square to enable card payments.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Checkbox
|
||||
v-else
|
||||
:model-value="Array.isArray(value) && value.includes(opt.id)"
|
||||
:disabled="isLoading"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<component :is="opt.icon" class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<FormLabel class="font-normal">{{ opt.label }}</FormLabel>
|
||||
<p class="text-xs text-muted-foreground">{{ opt.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="fiat_currency">
|
||||
<FormItem
|
||||
v-show="(form.values.payment_methods ?? []).includes('fiat') && isSatDenomination(form.values.currency)"
|
||||
>
|
||||
<FormLabel>Fiat currency</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField" :disabled="isLoading">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="USD" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="c in availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
||||
:key="c"
|
||||
:value="c"
|
||||
>
|
||||
{{ c }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription class="text-xs">Currency card buyers are charged in.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Ticket buyer notifications (collapsible). The backend
|
||||
sends email + NIP-04 Nostr DM confirmations on
|
||||
|
|
@ -743,6 +877,27 @@ const handleOpenChange = (open: boolean) => {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Promo codes (collapsible). Percent discounts with an
|
||||
optional redemption cap; the backend hides the list from
|
||||
public responses and enforces active/max_uses at purchase. -->
|
||||
<Collapsible v-model:open="promoOpen">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Tag class="w-4 h-4" />
|
||||
{{ t('events.create.promo.title') }}
|
||||
<Badge v-if="promoRows.length > 0" variant="secondary" class="text-[10px] px-1.5">
|
||||
{{ promoRows.length }}
|
||||
</Badge>
|
||||
</span>
|
||||
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': promoOpen }" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="pt-2">
|
||||
<PromoCodesEditor v-model="promoRows" :disabled="isLoading" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Actions. Stack full-width on narrow viewports so a wide
|
||||
localized "Submit Event" label can't push the Cancel
|
||||
button out of the dialog. Submit reads top-down on
|
||||
|
|
|
|||
|
|
@ -72,8 +72,14 @@ const isNonApproved = computed(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- `min-w-0`: the card is a grid item, and a grid item's default
|
||||
`min-width: auto` resolves to its min-content width. The nowrap
|
||||
`truncate` rows below (location, organizer) contribute their
|
||||
full text width to that, so a long address would widen the
|
||||
whole column past the viewport on phones. Letting the item
|
||||
shrink is what makes `truncate` actually truncate. -->
|
||||
<Card
|
||||
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
class="relative min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
@click="emit('click', event)"
|
||||
>
|
||||
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
||||
|
|
|
|||
153
src/modules/events/components/PromoCodesEditor.vue
Normal file
153
src/modules/events/components/PromoCodesEditor.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { normalizePromoCode, validatePromoRows, type PromoCodeRow } from '../lib/promoCodes'
|
||||
|
||||
/**
|
||||
* Organizer-side promo code editor: one row per code with percentage,
|
||||
* optional redemption cap and an active switch. Plain `v-model` over a
|
||||
* `PromoCodeRow[]` (the parent keeps it as a ref next to its vee-validate
|
||||
* form, the same way categories are handled); validation is the pure
|
||||
* `validatePromoRows` so the parent can gate submit on the same rules.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: PromoCodeRow[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [rows: PromoCodeRow[]] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const errors = computed(() => validatePromoRows(props.modelValue))
|
||||
|
||||
function update(index: number, patch: Partial<PromoCodeRow>) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||
)
|
||||
}
|
||||
|
||||
function add() {
|
||||
emit('update:modelValue', [
|
||||
...props.modelValue,
|
||||
{ code: '', discount_percent: null, max_uses: null, active: true },
|
||||
])
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
/** Empty input → null (so "unset" never becomes 0); otherwise a number. */
|
||||
function toNumberOrNull(value: string | number): number | null {
|
||||
const str = String(value).trim()
|
||||
if (str === '') return null
|
||||
const n = Number(str)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-muted-foreground">{{ t('events.create.promo.hint') }}</p>
|
||||
|
||||
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
|
||||
{{ t('events.create.promo.empty') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(row, index) in modelValue"
|
||||
:key="index"
|
||||
class="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_5.5rem_5.5rem] gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label :for="`promo-code-${index}`" class="text-xs">{{ t('events.create.promo.code') }}</Label>
|
||||
<Input
|
||||
:id="`promo-code-${index}`"
|
||||
:model-value="row.code"
|
||||
:placeholder="t('events.create.promo.codePlaceholder')"
|
||||
class="font-mono uppercase"
|
||||
autocapitalize="characters"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { code: normalizePromoCode(String($event)) })"
|
||||
/>
|
||||
<p v-if="errors[index]?.code" class="text-xs text-destructive">
|
||||
{{ t(errors[index].code!) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label :for="`promo-pct-${index}`" class="text-xs">{{ t('events.create.promo.discount') }}</Label>
|
||||
<Input
|
||||
:id="`promo-pct-${index}`"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
:model-value="row.discount_percent ?? ''"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { discount_percent: toNumberOrNull($event) })"
|
||||
/>
|
||||
<p v-if="errors[index]?.discount_percent" class="text-xs text-destructive">
|
||||
{{ t(errors[index].discount_percent!) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label :for="`promo-max-${index}`" class="text-xs">{{ t('events.create.promo.maxUses') }}</Label>
|
||||
<Input
|
||||
:id="`promo-max-${index}`"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
:model-value="row.max_uses ?? ''"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { max_uses: toNumberOrNull($event) })"
|
||||
/>
|
||||
<p v-if="errors[index]?.max_uses" class="text-xs text-destructive">
|
||||
{{ t(errors[index].max_uses!) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:id="`promo-active-${index}`"
|
||||
:model-value="row.active"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { active: Boolean($event) })"
|
||||
/>
|
||||
<Label :for="`promo-active-${index}`" class="text-xs">{{ t('events.create.promo.active') }}</Label>
|
||||
<span v-if="(row.used_count ?? 0) > 0" class="text-xs text-muted-foreground">
|
||||
· {{ t('events.create.promo.used', { count: row.used_count }) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('events.create.promo.removeLabel', { code: row.code || '…' })"
|
||||
@click="remove(index)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" class="gap-1.5" :disabled="disabled" @click="add">
|
||||
<Plus class="w-4 h-4" />
|
||||
{{ t('events.create.promo.add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||
import { usePromoCode } from '../composables/usePromoCode'
|
||||
import { normalizePromoCode } from '../lib/promoCodes'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
|
||||
import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail, Tag, X } from 'lucide-vue-next'
|
||||
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
||||
import PaymentMethodSelector, {
|
||||
type PaymentMethod as PaymentMethodEntry,
|
||||
|
|
@ -15,6 +31,9 @@ import PaymentMethodSelector, {
|
|||
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
|
||||
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
||||
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
|
||||
import { effectivePaymentMethods } from '../lib/paymentMethods'
|
||||
import { frontendRoot } from '../lib/frontendRoot'
|
||||
import { PENDING_CHECKOUT_KEY, type PendingCheckout } from '../lib/pendingCheckout'
|
||||
|
||||
interface Props {
|
||||
event: {
|
||||
|
|
@ -25,6 +44,9 @@ interface Props {
|
|||
/** Whether the event accepts fiat payments. From v1.4.0+ */
|
||||
allow_fiat?: boolean
|
||||
fiat_currency?: string
|
||||
/** Rails the organizer enabled (`tickets_payment_methods`). Absent
|
||||
* on legacy events — falls back to the allow_fiat rule. */
|
||||
payment_methods?: string[]
|
||||
}
|
||||
isOpen: boolean
|
||||
}
|
||||
|
|
@ -35,8 +57,9 @@ interface Emits {
|
|||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isAuthenticated, userDisplay } = useAuth()
|
||||
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
|
|
@ -45,7 +68,6 @@ const {
|
|||
qrCode,
|
||||
isPaymentPending,
|
||||
isPayingWithWallet,
|
||||
canPurchase,
|
||||
userWallets,
|
||||
hasWalletWithBalance,
|
||||
purchaseTicketForEvent,
|
||||
|
|
@ -54,7 +76,9 @@ const {
|
|||
resetPaymentState,
|
||||
cleanup,
|
||||
purchasedTicketIds,
|
||||
showTicketQR
|
||||
ticketQRCodes,
|
||||
showTicketQR,
|
||||
finalizePurchasedTickets
|
||||
} = useTicketPurchase()
|
||||
|
||||
const MAX_QUANTITY = 10
|
||||
|
|
@ -75,6 +99,52 @@ const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
|
|||
// and label the CTA "Get ticket" instead of "Proceed".
|
||||
const isFree = computed(() => props.event.price_per_ticket <= 0)
|
||||
|
||||
// Guests may buy paid tickets (name + email identity); free claims stay
|
||||
// login-gated so an anonymous script can't drain a free event's
|
||||
// capacity (events#29).
|
||||
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
|
||||
|
||||
// --- Promo code -----------------------------------------------------
|
||||
// Server-validated preview (`POST /promo/validate`); the purchase
|
||||
// endpoint re-checks the code anyway, so the client never trusts its
|
||||
// own arithmetic for what gets charged.
|
||||
const promo = usePromoCode(() => props.event.id, quantity)
|
||||
const {
|
||||
input: promoInput,
|
||||
state: promoState,
|
||||
isChecking: promoChecking,
|
||||
codeToSend: promoCodeToSend,
|
||||
} = promo
|
||||
|
||||
const hasDiscount = computed(
|
||||
() => promoState.value.status === 'applied' && promoState.value.total < promoState.value.subtotal,
|
||||
)
|
||||
const promoDiscountAmount = computed(() =>
|
||||
promoState.value.status === 'applied' ? promoState.value.discount : 0,
|
||||
)
|
||||
/** What the buyer will actually be charged for the current quantity. */
|
||||
const effectiveTotal = computed(() =>
|
||||
promoState.value.status === 'applied' ? promoState.value.total : totalPrice.value,
|
||||
)
|
||||
const isZeroTotal = computed(() => effectiveTotal.value <= 0)
|
||||
// A 100 %-off code turns a paid ticket into a free claim, which stays
|
||||
// login-gated (events#29). Prompt inline — swapping the whole form would
|
||||
// hide the Remove button the guest needs to back out of the code.
|
||||
const guestNeedsLoginForDiscount = computed(
|
||||
() => !isFree.value && isZeroTotal.value && !isAuthenticated.value,
|
||||
)
|
||||
|
||||
function onPromoInput(event: Event) {
|
||||
const el = event.target as HTMLInputElement
|
||||
const value = normalizePromoCode(el.value)
|
||||
el.value = value
|
||||
promoInput.value = value
|
||||
}
|
||||
|
||||
function isPromoError(err: unknown): boolean {
|
||||
return err instanceof Error && /promo code/i.test(err.message)
|
||||
}
|
||||
|
||||
async function copyInvoice() {
|
||||
if (!paymentRequest.value) return
|
||||
try {
|
||||
|
|
@ -87,16 +157,69 @@ async function copyInvoice() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Contact (delivery email) form ---------------------------------
|
||||
// Every ticket carries an email so the extension can deliver it (and
|
||||
// so Stripe's checkout is prefilled + locked to the same address).
|
||||
// Guests additionally need a name — that pair is their identity on
|
||||
// the backend. Confirm-email guards against typos on the one field a
|
||||
// guest cannot fix afterwards.
|
||||
const contactSchema = computed(() =>
|
||||
toTypedSchema(
|
||||
z
|
||||
.object({
|
||||
name: isAuthenticated.value
|
||||
? z.string().optional().default('')
|
||||
: z.string().trim().min(1, t('events.purchase.contact.nameRequired')).max(100),
|
||||
email: z.string().trim().email(t('events.purchase.contact.emailInvalid')),
|
||||
confirmEmail: z.string().trim(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.email.toLowerCase() !== v.confirmEmail.toLowerCase()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['confirmEmail'],
|
||||
message: t('events.purchase.contact.emailMismatch'),
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: contactSchema,
|
||||
initialValues: { name: '', email: '', confirmEmail: '' },
|
||||
})
|
||||
|
||||
// The dialog stays mounted across opens — reseed on every open so a
|
||||
// login/logout between purchases is reflected.
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
form.resetForm({
|
||||
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
|
||||
})
|
||||
promo.clear()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const { providers, providerMeta } = useFiatProviders()
|
||||
const { convert } = usePriceConversion()
|
||||
|
||||
const selectedMethodId = ref<string>('lightning')
|
||||
const fiatRedirectUrl = ref<string | null>(null)
|
||||
const fiatProviderLabel = ref<string | null>(null)
|
||||
// Empty until the dialog opens: the watcher below picks the default per
|
||||
// rail availability + auth state, so a guest lands on Card, not Lightning.
|
||||
const selectedMethodId = ref<string>('')
|
||||
const isFiatPending = ref(false)
|
||||
const fiatError = ref<string | null>(null)
|
||||
|
||||
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
|
||||
const enabledRails = computed(() =>
|
||||
effectivePaymentMethods({
|
||||
allowFiat: props.event.allow_fiat,
|
||||
paymentMethods: props.event.payment_methods,
|
||||
}),
|
||||
)
|
||||
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
|
||||
const isPriceInSats = computed(
|
||||
() => props.event.currency === 'sat' || props.event.currency === 'sats',
|
||||
)
|
||||
|
|
@ -124,7 +247,9 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
|
|||
}
|
||||
|
||||
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
||||
const lightning: PaymentMethodEntry = {
|
||||
const entries: PaymentMethodEntry[] = []
|
||||
if (enabledRails.value.includes('lightning')) {
|
||||
entries.push({
|
||||
id: 'lightning',
|
||||
rail: 'lightning',
|
||||
label: 'Lightning',
|
||||
|
|
@ -134,116 +259,169 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
|||
!isPriceInSats.value && lightningSats.value
|
||||
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
if (!props.event.allow_fiat) return [lightning]
|
||||
|
||||
if (canChooseFiat.value) {
|
||||
if (providers.value.length > 0) {
|
||||
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
|
||||
for (const id of providers.value) {
|
||||
const meta = providerMeta(id)
|
||||
return {
|
||||
entries.push({
|
||||
id: `fiat:${id}`,
|
||||
rail: 'fiat',
|
||||
provider: id,
|
||||
label: meta.label,
|
||||
icon: iconFor(meta.icon),
|
||||
available: true,
|
||||
}
|
||||
})
|
||||
return [lightning, ...fiatRails]
|
||||
}
|
||||
|
||||
// Degenerate fallback — allow_fiat is on but the buyer's session
|
||||
// can't enumerate the organizer's providers. Show a generic Card
|
||||
// button and let the backend pick a default at request time.
|
||||
return [
|
||||
lightning,
|
||||
{
|
||||
} else {
|
||||
// Guests (and sessions without provider info) can't enumerate the
|
||||
// organizer's providers. Show a generic Card button and let the
|
||||
// backend pick the organizer's default at request time.
|
||||
entries.push({
|
||||
id: 'fiat',
|
||||
rail: 'fiat',
|
||||
label: 'Card',
|
||||
label: t('events.purchase.card'),
|
||||
icon: CreditCard,
|
||||
available: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
// Default rail: guests lean to card when it's on offer (no wallet to
|
||||
// pay a bolt11 with); everyone else takes the first enabled rail.
|
||||
watch(
|
||||
[paymentMethods, () => props.isOpen, isAuthenticated],
|
||||
([methods, open, authed]) => {
|
||||
if (!open || methods.length === 0) return
|
||||
const current = methods.find((m) => m.id === selectedMethodId.value)
|
||||
if (current) return
|
||||
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined
|
||||
selectedMethodId.value = (preferred ?? methods[0]).id
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selectedMethod = computed(() =>
|
||||
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
|
||||
)
|
||||
|
||||
async function handlePurchase() {
|
||||
if (!canPurchase.value) return
|
||||
fiatError.value = null
|
||||
const onSubmit = form.handleSubmit(async (contact) => {
|
||||
await handlePurchase(contact)
|
||||
})
|
||||
|
||||
async function handlePurchase(contact: { name?: string; email: string }) {
|
||||
fiatError.value = null
|
||||
const method = selectedMethod.value
|
||||
if (!method) return
|
||||
|
||||
// Lightning path: the composable just creates the invoice + starts
|
||||
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
|
||||
// external wallet" on the invoice screen (restaurant pattern), so
|
||||
// no auto-pay here.
|
||||
if (method.rail === 'lightning') {
|
||||
const isGuest = !isAuthenticated.value
|
||||
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
|
||||
const promoCode = promoCodeToSend.value
|
||||
|
||||
if (guestNeedsLoginForDiscount.value) return
|
||||
|
||||
// Nothing to charge (100 %-off code on a paid event): skip the rail
|
||||
// choice entirely — the backend issues the tickets already-paid and
|
||||
// the composable lands on the success screen from `invoice.paid`.
|
||||
if (isZeroTotal.value && !isFree.value) {
|
||||
try {
|
||||
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
|
||||
await purchaseTicketForEvent(props.event.id, {
|
||||
quantity: quantity.value,
|
||||
contact: guestContact,
|
||||
email: contact.email,
|
||||
promoCode,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error purchasing ticket:', err)
|
||||
console.error('Error claiming discounted ticket:', err)
|
||||
if (isPromoError(err)) void promo.revalidate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
|
||||
// API directly with the chosen provider, then redirect the buyer to
|
||||
// the provider's checkout URL. Payment confirmation happens via
|
||||
// webhook on the backend and shows up next time the buyer reloads
|
||||
// MyTickets.
|
||||
// Lightning path: the composable creates the invoice + starts polling.
|
||||
// The buyer pays from their LNbits wallet or an external one on the
|
||||
// invoice screen (restaurant pattern), so no auto-pay here.
|
||||
if (method.rail === 'lightning') {
|
||||
try {
|
||||
await purchaseTicketForEvent(props.event.id, {
|
||||
quantity: quantity.value,
|
||||
contact: guestContact,
|
||||
email: contact.email,
|
||||
promoCode,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error purchasing ticket:', err)
|
||||
if (isPromoError(err)) void promo.revalidate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fiat path: POST with the chosen provider, remember what we're
|
||||
// waiting for, then hand the tab to the provider's checkout. Stripe
|
||||
// brings the buyer back to the event page with ?checkout=success&
|
||||
// tickets=… (or ?checkout=cancelled) — see EventDetailPage.
|
||||
try {
|
||||
isFiatPending.value = true
|
||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||
const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined)
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const currentUser = (lnbitsAPI?.currentUser?.value) || null
|
||||
const userId = currentUser?.id
|
||||
if (!userId) {
|
||||
fiatError.value = 'Missing user id'
|
||||
return
|
||||
}
|
||||
const invoice = await ticketApi.requestTicket(
|
||||
props.event.id,
|
||||
userId,
|
||||
const invoice = await ticketApi.requestTicket(props.event.id, {
|
||||
userId: isGuest ? undefined : currentUser.value?.id,
|
||||
name: guestContact?.name,
|
||||
email: contact.email,
|
||||
accessToken,
|
||||
{
|
||||
frontendUrl: frontendRoot(),
|
||||
paymentMethod: 'fiat',
|
||||
fiatProvider: method.provider,
|
||||
quantity: quantity.value,
|
||||
},
|
||||
)
|
||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
||||
promoCode,
|
||||
})
|
||||
// The backend may still short-circuit to the free path (e.g. a code
|
||||
// that became 100 % between preview and pay): no checkout URL, the
|
||||
// tickets are already issued — show them instead of an error.
|
||||
if (invoice.paid) {
|
||||
paymentHash.value = invoice.paymentHash
|
||||
await finalizePurchasedTickets(invoice.ticketIds ?? [])
|
||||
isFiatPending.value = false
|
||||
return
|
||||
}
|
||||
fiatRedirectUrl.value = invoice.fiatPaymentRequest
|
||||
fiatProviderLabel.value = invoice.fiatProvider
|
||||
? providerMeta(invoice.fiatProvider).label
|
||||
: method.label
|
||||
} catch (err) {
|
||||
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
|
||||
} finally {
|
||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||
fiatError.value = t('events.purchase.noCheckoutUrl')
|
||||
isFiatPending.value = false
|
||||
return
|
||||
}
|
||||
const pending: PendingCheckout = {
|
||||
eventId: props.event.id,
|
||||
paymentHash: invoice.paymentHash,
|
||||
ticketIds: invoice.ticketIds ?? [],
|
||||
email: contact.email,
|
||||
ts: Date.now(),
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(pending))
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — the return URL still
|
||||
// carries the ticket ids, so the flow survives without it.
|
||||
}
|
||||
window.location.assign(invoice.fiatPaymentRequest)
|
||||
// Keep the spinner up until the navigation lands.
|
||||
} catch (err) {
|
||||
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
|
||||
isFiatPending.value = false
|
||||
if (isPromoError(err)) void promo.revalidate()
|
||||
}
|
||||
|
||||
function openFiatCheckout() {
|
||||
if (!fiatRedirectUrl.value) return
|
||||
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:isOpen', false)
|
||||
resetPaymentState()
|
||||
selectedMethodId.value = 'lightning'
|
||||
fiatRedirectUrl.value = null
|
||||
fiatProviderLabel.value = null
|
||||
promo.clear()
|
||||
selectedMethodId.value = ''
|
||||
fiatError.value = null
|
||||
isFiatPending.value = false
|
||||
quantity.value = 1
|
||||
copiedInvoice.value = false
|
||||
}
|
||||
|
|
@ -262,38 +440,43 @@ onUnmounted(() => {
|
|||
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span v-if="quantity > 1">
|
||||
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
|
||||
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
|
||||
</span>
|
||||
<span v-else-if="isFree">
|
||||
Get a free ticket for <strong>{{ event.name }}</strong>
|
||||
<span v-if="isFree">
|
||||
<template v-if="quantity > 1">{{ quantity }} free tickets for</template>
|
||||
<template v-else>Get a free ticket for</template>
|
||||
<strong>{{ event.name }}</strong>
|
||||
</span>
|
||||
<span v-else>
|
||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
||||
<template v-if="quantity > 1">{{ quantity }} tickets for</template>
|
||||
<template v-else>Purchase a ticket for</template>
|
||||
<strong>{{ event.name }}</strong> ·
|
||||
<s v-if="hasDiscount" class="text-muted-foreground">{{ formatEventPrice(totalPrice, event.currency) }}</s>
|
||||
<span :class="{ 'text-primary font-medium': hasDiscount }">
|
||||
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
|
||||
</span>
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Authentication Check -->
|
||||
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
|
||||
<!-- Free tickets need an account (anti-abuse). Paid tickets don't. -->
|
||||
<div v-if="guestNeedsLogin" class="py-4 text-center space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<User class="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold">Login Required</h3>
|
||||
<h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Please log in to your account to purchase tickets using your wallet.
|
||||
{{ t('events.purchase.loginRequiredFree') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="handleClose" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button>
|
||||
<Button @click="handleClose" variant="outline">Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info and Purchase -->
|
||||
<div v-else-if="!paymentHash" class="py-4 space-y-4">
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<!-- Contact + payment details, then submit -->
|
||||
<form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit">
|
||||
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Purchasing as:</span>
|
||||
|
|
@ -310,8 +493,108 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Information -->
|
||||
<!-- Contact: who the ticket is for + where to send it -->
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Mail class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t('events.purchase.contact.title') }}</span>
|
||||
</div>
|
||||
<FormField v-if="!isAuthenticated" v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('events.purchase.contact.name') }} *</FormLabel>
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" autocomplete="name" :placeholder="t('events.purchase.contact.namePlaceholder')" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('events.purchase.contact.email') }} *</FormLabel>
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
|
||||
</FormControl>
|
||||
<FormDescription class="text-xs">{{ t('events.purchase.contact.emailHint') }}</FormDescription>
|
||||
<p class="text-xs font-medium text-destructive">{{ t('events.purchase.contact.spamHint') }}</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="confirmEmail">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('events.purchase.contact.confirmEmail') }} *</FormLabel>
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Promo code (paid events only). Apply asks the backend for
|
||||
the discounted total; the badge + struck-through price
|
||||
reflect the server's numbers, never a client guess. -->
|
||||
<div v-if="!isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t('events.purchase.promo.label') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="promoState.status === 'applied' || promoState.status === 'unverified'" class="flex items-center justify-between gap-2">
|
||||
<Badge :variant="promoState.status === 'applied' ? 'default' : 'secondary'" class="font-mono">
|
||||
<template v-if="promoState.status === 'applied'">
|
||||
{{ t('events.purchase.promo.applied', { code: promoState.code, percent: promoState.discountPercent }) }}
|
||||
</template>
|
||||
<template v-else>{{ promoState.code }}</template>
|
||||
</Badge>
|
||||
<Button type="button" variant="ghost" size="sm" class="gap-1 h-7" @click="promo.clear()">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
{{ t('events.purchase.promo.remove') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="flex gap-2">
|
||||
<Input
|
||||
:model-value="promoInput"
|
||||
:placeholder="t('events.purchase.promo.placeholder')"
|
||||
class="font-mono uppercase"
|
||||
autocapitalize="characters"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="promoChecking"
|
||||
@input="onPromoInput"
|
||||
@keydown.enter.prevent="promo.apply()"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
:disabled="!promoInput || promoChecking"
|
||||
@click="promo.apply()"
|
||||
>
|
||||
<Loader2 v-if="promoChecking" class="w-4 h-4 mr-2 animate-spin" />
|
||||
{{ promoChecking ? t('events.purchase.promo.checking') : t('events.purchase.promo.apply') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="promoState.status === 'invalid'" class="text-xs text-destructive">
|
||||
{{ t('events.purchase.promo.invalid') }}
|
||||
</p>
|
||||
<p v-else-if="promoState.status === 'unverified'" class="text-xs text-muted-foreground">
|
||||
{{ t('events.purchase.promo.unverified') }}
|
||||
</p>
|
||||
|
||||
<Alert v-if="guestNeedsLoginForDiscount" class="mt-2">
|
||||
<User class="w-4 h-4" />
|
||||
<AlertDescription class="flex flex-col gap-2">
|
||||
<span>{{ t('events.purchase.promo.loginRequiredDiscount') }}</span>
|
||||
<Button type="button" size="sm" class="w-fit" @click="() => $router.push('/login')">
|
||||
{{ t('events.detail.logIn') }}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Information (logged-in Lightning buyers) -->
|
||||
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Wallet class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Wallet Status:</span>
|
||||
|
|
@ -330,7 +613,7 @@ onUnmounted(() => {
|
|||
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
|
||||
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Auto-payment available</span>
|
||||
</div>
|
||||
|
|
@ -348,7 +631,7 @@ onUnmounted(() => {
|
|||
</div>
|
||||
|
||||
<!-- Quantity selector — backend caps at 10. One invoice for
|
||||
the whole purchase, one ticket row representing N seats. -->
|
||||
the whole purchase, N ticket rows (one per attendee). -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Tickets:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -385,26 +668,34 @@ onUnmounted(() => {
|
|||
<span class="text-sm text-muted-foreground">
|
||||
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||
</span>
|
||||
<span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
|
||||
<span class="text-sm font-medium">
|
||||
<template v-if="isFree">Free</template>
|
||||
<template v-else-if="hasDiscount">
|
||||
<s class="text-muted-foreground font-normal mr-1">{{ formatEventPrice(totalPrice, event.currency) }}</s>
|
||||
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
|
||||
</template>
|
||||
<template v-else>{{ formatEventPrice(totalPrice, event.currency) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasDiscount && promoState.status === 'applied'" class="flex justify-between text-primary">
|
||||
<span class="text-sm">{{ t('events.purchase.promo.discountLine', { code: promoState.code }) }}</span>
|
||||
<span class="text-sm font-medium">−{{ formatEventPrice(promoDiscountAmount, event.currency) }}</span>
|
||||
</div>
|
||||
<PriceConversionPreview
|
||||
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||
:amount="totalPrice"
|
||||
:amount="effectiveTotal"
|
||||
from="sat"
|
||||
:to="event.fiat_currency"
|
||||
prefix="Equivalent ~"
|
||||
suffix=" if paid in fiat"
|
||||
suffix=" if paid by card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment method selector (only shown when fiat is enabled
|
||||
on the event). Buttons surface one per configured fiat
|
||||
provider so "Stripe" / "PayPal" / "Square" stand alongside
|
||||
Lightning rather than collapsing into a single "Fiat"
|
||||
catch-all. Hidden entirely for Lightning-only events to
|
||||
keep the dialog uncluttered. -->
|
||||
<div v-if="canChooseFiat && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<!-- Payment method selector — one button per enabled rail /
|
||||
configured fiat provider. Hidden when only one rail is on
|
||||
(nothing to choose) and for free tickets. -->
|
||||
<div v-if="paymentMethods.length > 1 && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="text-sm font-medium">Payment method</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Both methods charge the same amount via different rails.
|
||||
|
|
@ -422,43 +713,29 @@ onUnmounted(() => {
|
|||
{{ error || fiatError }}
|
||||
</div>
|
||||
|
||||
<!-- Fiat checkout panel — shown after a successful fiat
|
||||
POST when we have a provider URL to redirect to. -->
|
||||
<div v-if="fiatRedirectUrl" class="space-y-3">
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Opens the provider's checkout in a new tab. Your ticket
|
||||
appears in My Tickets once the payment settles.
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="openFiatCheckout" class="w-full">
|
||||
<ExternalLink class="w-4 h-4 mr-2" />
|
||||
Open {{ fiatProviderLabel }} checkout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
@click="handlePurchase"
|
||||
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
||||
type="submit"
|
||||
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)"
|
||||
class="w-full"
|
||||
>
|
||||
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
||||
<template v-else-if="isFree">
|
||||
<template v-else-if="isZeroTotal">
|
||||
<Ticket class="w-4 h-4 mr-2" />
|
||||
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
|
||||
</template>
|
||||
<template v-else-if="selectedMethod?.rail === 'fiat'">
|
||||
<CreditCard class="w-4 h-4 mr-2" />
|
||||
Continue to {{ selectedMethod.label }} checkout
|
||||
{{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<Zap class="w-4 h-4 mr-2" />
|
||||
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="selectedMethod?.rail === 'fiat' && !isZeroTotal" class="text-xs text-muted-foreground text-center">
|
||||
{{ t('events.purchase.fiatRedirectHint') }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Lightning invoice — restaurant-style. Shows QR + amount,
|
||||
with both pay paths visible at once: tap-to-pay from the
|
||||
|
|
@ -469,8 +746,13 @@ onUnmounted(() => {
|
|||
<div class="text-center space-y-1">
|
||||
<h3 class="text-lg font-semibold">Pay the invoice</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<template v-if="hasWalletWithBalance">
|
||||
Scan with any Lightning wallet, or tap the button below to
|
||||
pay from your LNbits wallet.
|
||||
</template>
|
||||
<template v-else>
|
||||
Scan with any Lightning wallet, or open it in your wallet app.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -489,7 +771,7 @@ onUnmounted(() => {
|
|||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-muted-foreground">Amount</span>
|
||||
<span class="font-mono text-sm font-semibold text-primary">
|
||||
{{ formatEventPrice(totalPrice, event.currency) }}
|
||||
{{ formatEventPrice(effectiveTotal, event.currency) }}
|
||||
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
|
||||
({{ quantity }} tickets)
|
||||
</span>
|
||||
|
|
@ -554,21 +836,20 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success state. QRs live in My Tickets — no need to
|
||||
pre-render them here; this view's job is to confirm the
|
||||
purchase landed and route the buyer to where they actually
|
||||
interact with their tickets. -->
|
||||
<!-- Success state. Logged-in buyers get routed to My Tickets
|
||||
(QRs live there); guests have no My Tickets, so their QRs
|
||||
render right here alongside the "emailed to" note. -->
|
||||
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
|
||||
<div class="flex justify-center">
|
||||
<Ticket class="w-12 h-12 text-green-600" />
|
||||
<Ticket class="w-12 h-12 text-primary" />
|
||||
</div>
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-lg font-semibold text-green-600">
|
||||
<h3 class="text-lg font-semibold text-primary">
|
||||
{{ purchasedTicketIds.length > 1
|
||||
? `${purchasedTicketIds.length} tickets purchased!`
|
||||
: 'Ticket purchased!' }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<p v-if="isAuthenticated" class="text-sm text-muted-foreground">
|
||||
<span v-if="purchasedTicketIds.length > 1">
|
||||
Each attendee gets their own scannable QR in My Tickets —
|
||||
hand them out independently for the door scan.
|
||||
|
|
@ -577,10 +858,31 @@ onUnmounted(() => {
|
|||
Your ticket is now in My Tickets.
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="form.values.email" class="text-sm text-muted-foreground">
|
||||
{{ t('events.checkout.emailedTo', { email: form.values.email }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAuthenticated" class="w-full space-y-3">
|
||||
<div
|
||||
v-for="(id, idx) in purchasedTicketIds"
|
||||
:key="id"
|
||||
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
|
||||
>
|
||||
<span v-if="purchasedTicketIds.length > 1" class="text-xs text-muted-foreground">
|
||||
{{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }}
|
||||
</span>
|
||||
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||
<img v-if="ticketQRCodes[id]" :src="ticketQRCodes[id]" :alt="`Ticket ${id}`" class="block h-40 w-40" />
|
||||
</div>
|
||||
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
|
||||
{{ t('events.checkout.viewTicket') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 w-full">
|
||||
<Button @click="() => $router.push('/my-tickets')" class="w-full">
|
||||
<Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full">
|
||||
View My Tickets
|
||||
</Button>
|
||||
<Button variant="outline" @click="handleClose" class="w-full">
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ export function useMyEvents() {
|
|||
// and merge them into the feed. Otherwise an event that drops to
|
||||
// `proposed` after a non-admin edit disappears from the user's view
|
||||
// entirely — they'd be unable to find it to make a follow-up edit or
|
||||
// monitor its approval status. Public approved events from other
|
||||
// users take precedence on dedup (server is the source of truth for
|
||||
// the public view).
|
||||
// monitor its approval status. For the user's OWN events the
|
||||
// authenticated record wins on dedup: it is the full `Event` (incl.
|
||||
// `extra.promo_codes`, which public responses omit), and it is what
|
||||
// the edit dialog must prefill from.
|
||||
const fetchAll = async (): Promise<TicketedEvent[]> => {
|
||||
const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[]
|
||||
|
||||
|
|
@ -26,9 +27,11 @@ export function useMyEvents() {
|
|||
|
||||
try {
|
||||
const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
||||
const seen = new Set(publicEvents.map((e) => e.id))
|
||||
const own = myEvents.filter((e) => !seen.has(e.id))
|
||||
return [...publicEvents, ...own]
|
||||
const own = new Map(myEvents.map((e) => [e.id, e]))
|
||||
const merged = publicEvents.map((e) => own.get(e.id) ?? e)
|
||||
const publicIds = new Set(publicEvents.map((e) => e.id))
|
||||
const ownOnly = myEvents.filter((e) => !publicIds.has(e.id))
|
||||
return [...merged, ...ownOnly]
|
||||
} catch (err) {
|
||||
// Falling back to just the public feed is acceptable — the user
|
||||
// can still browse, they just won't see their own pending events.
|
||||
|
|
|
|||
127
src/modules/events/composables/usePromoCode.ts
Normal file
127
src/modules/events/composables/usePromoCode.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes'
|
||||
|
||||
/**
|
||||
* Buyer-side promo state for the purchase dialog.
|
||||
*
|
||||
* - `idle` no code entered / cleared
|
||||
* - `checking` validate request in flight
|
||||
* - `applied` server confirmed the code; totals are authoritative
|
||||
* - `invalid` server answered but the code is not valid for this
|
||||
* event/quantity (unknown, inactive, exhausted, 0 %)
|
||||
* - `unverified` older extension without the validate route (404/405)
|
||||
* or a network failure: keep the code and let the
|
||||
* purchase endpoint decide — it still discounts on
|
||||
* every extension version.
|
||||
*/
|
||||
export type PromoState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'checking'; code: string }
|
||||
| {
|
||||
status: 'applied'
|
||||
code: string
|
||||
discountPercent: number
|
||||
subtotal: number
|
||||
discount: number
|
||||
total: number
|
||||
currency: string
|
||||
}
|
||||
| { status: 'invalid'; code: string }
|
||||
| { status: 'unverified'; code: string }
|
||||
|
||||
const REVALIDATE_DEBOUNCE_MS = 300
|
||||
|
||||
export function usePromoCode(eventId: MaybeRefOrGetter<string>, quantity: Ref<number>) {
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
|
||||
/** What the buyer typed (kept upper-case by the input handler). */
|
||||
const input = ref('')
|
||||
const state = ref<PromoState>({ status: 'idle' })
|
||||
|
||||
// Monotonic request counter so a slow response for a previous code or
|
||||
// quantity can never overwrite a newer result.
|
||||
let requestSeq = 0
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isChecking = computed(() => state.value.status === 'checking')
|
||||
|
||||
/** Code to put on the purchase request. `invalid` deliberately sends
|
||||
* nothing so the buyer is not surprised by a 400 at pay time. */
|
||||
const codeToSend = computed<string | undefined>(() => {
|
||||
const s = state.value
|
||||
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined
|
||||
})
|
||||
|
||||
const appliedTotal = computed<number | null>(() =>
|
||||
state.value.status === 'applied' ? state.value.total : null,
|
||||
)
|
||||
|
||||
async function validate(code: string): Promise<void> {
|
||||
const seq = ++requestSeq
|
||||
state.value = { status: 'checking', code }
|
||||
try {
|
||||
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value)
|
||||
if (seq !== requestSeq) return
|
||||
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0]
|
||||
if (!hit) {
|
||||
state.value = { status: 'invalid', code }
|
||||
return
|
||||
}
|
||||
state.value = {
|
||||
status: 'applied',
|
||||
code: hit.code,
|
||||
discountPercent: hit.discount_percent ?? 0,
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
total: totals.total,
|
||||
currency: totals.currency,
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq) return
|
||||
if (isUnsupportedEndpointError(err)) {
|
||||
state.value = { status: 'unverified', code }
|
||||
return
|
||||
}
|
||||
// Network / 5xx: don't block the purchase on an advisory call.
|
||||
console.warn('[usePromoCode] validate failed, sending code unverified:', err)
|
||||
state.value = { status: 'unverified', code }
|
||||
}
|
||||
}
|
||||
|
||||
async function apply(): Promise<void> {
|
||||
const code = normalizePromoCode(input.value)
|
||||
if (!code) return
|
||||
input.value = code
|
||||
await validate(code)
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
requestSeq++
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
input.value = ''
|
||||
state.value = { status: 'idle' }
|
||||
}
|
||||
|
||||
/** Re-check the current code (quantity changed, or the purchase
|
||||
* endpoint rejected it — e.g. the last use went to someone else). */
|
||||
async function revalidate(): Promise<void> {
|
||||
const s = state.value
|
||||
if (s.status === 'idle' || s.status === 'checking') return
|
||||
await validate(s.code)
|
||||
}
|
||||
|
||||
watch(quantity, () => {
|
||||
const s = state.value
|
||||
if (s.status === 'idle') return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void revalidate()
|
||||
}, REVALIDATE_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate }
|
||||
}
|
||||
|
|
@ -6,6 +6,24 @@ 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
|
||||
/** Promo code to redeem (already normalised by the dialog). */
|
||||
promoCode?: string
|
||||
}
|
||||
|
||||
export function useTicketPurchase() {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
|
|
@ -64,11 +82,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 +142,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 +168,15 @@ 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,
|
||||
promoCode: options.promoCode,
|
||||
})
|
||||
|
||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||
// issued + marked them paid, so there's no invoice to settle. Skip
|
||||
|
|
@ -304,6 +325,10 @@ export function useTicketPurchase() {
|
|||
handleOpenLightningWallet,
|
||||
resetPaymentState,
|
||||
cleanup,
|
||||
generateTicketQRCode
|
||||
generateTicketQRCode,
|
||||
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
|
||||
// (backend returns `paid: true`, no checkout URL) on the same
|
||||
// success screen as the Lightning / free paths.
|
||||
finalizePurchasedTickets
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export interface EventTicket {
|
|||
name?: string | null
|
||||
registered: boolean
|
||||
registeredAt: string | null
|
||||
/** Promo code redeemed on this ticket, when the extension reports it. */
|
||||
promoCode?: string | null
|
||||
}
|
||||
|
||||
/** Counts + roster snapshot for the event, sourced from the backend. */
|
||||
|
|
@ -110,6 +112,7 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
name: t.name ?? null,
|
||||
registered: t.registered,
|
||||
registeredAt: t.registered_at,
|
||||
promoCode: t.applied_promo_code ?? null,
|
||||
})),
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
16
src/modules/events/lib/frontendRoot.spec.ts
Normal file
16
src/modules/events/lib/frontendRoot.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
20
src/modules/events/lib/frontendRoot.ts
Normal file
20
src/modules/events/lib/frontendRoot.ts
Normal file
|
|
@ -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(/\/+$/, '')
|
||||
}
|
||||
21
src/modules/events/lib/paymentMethods.spec.ts
Normal file
21
src/modules/events/lib/paymentMethods.spec.ts
Normal file
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
22
src/modules/events/lib/paymentMethods.ts
Normal file
22
src/modules/events/lib/paymentMethods.ts
Normal file
|
|
@ -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']
|
||||
}
|
||||
38
src/modules/events/lib/pendingCheckout.ts
Normal file
38
src/modules/events/lib/pendingCheckout.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* What the buyer is waiting on while the tab is away at the fiat
|
||||
* provider's checkout. Written right before the redirect; read back by
|
||||
* EventDetailPage when Stripe returns with `?checkout=success` (the
|
||||
* URL carries the ticket ids too — this record is the belt-and-braces
|
||||
* copy that also remembers the delivery email for the confirmation
|
||||
* screen). Cleared once the return dialog sees the tickets paid, or on
|
||||
* `?checkout=cancelled`.
|
||||
*/
|
||||
export const PENDING_CHECKOUT_KEY = 'events:pending-checkout'
|
||||
|
||||
export interface PendingCheckout {
|
||||
eventId: string
|
||||
paymentHash: string
|
||||
ticketIds: string[]
|
||||
email?: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
export function readPendingCheckout(): PendingCheckout | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_CHECKOUT_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as PendingCheckout
|
||||
if (!parsed || typeof parsed.eventId !== 'string') return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingCheckout(): void {
|
||||
try {
|
||||
localStorage.removeItem(PENDING_CHECKOUT_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
93
src/modules/events/lib/promoCodes.spec.ts
Normal file
93
src/modules/events/lib/promoCodes.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyPercent,
|
||||
fromWirePromoCodes,
|
||||
hasPromoErrors,
|
||||
isUnsupportedEndpointError,
|
||||
normalizePromoCode,
|
||||
toWirePromoCodes,
|
||||
validatePromoRows,
|
||||
type PromoCodeRow,
|
||||
} from './promoCodes'
|
||||
|
||||
function row(overrides: Partial<PromoCodeRow> = {}): PromoCodeRow {
|
||||
return { code: 'HALF', discount_percent: 50, max_uses: null, active: true, ...overrides }
|
||||
}
|
||||
|
||||
describe('normalizePromoCode', () => {
|
||||
it('trims and upper-cases, and tolerates null', () => {
|
||||
expect(normalizePromoCode(' summer24 ')).toBe('SUMMER24')
|
||||
expect(normalizePromoCode(null)).toBe('')
|
||||
expect(normalizePromoCode(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePromoRows', () => {
|
||||
it('accepts a well-formed row', () => {
|
||||
const errors = validatePromoRows([row()])
|
||||
expect(errors).toEqual([{}])
|
||||
expect(hasPromoErrors(errors)).toBe(false)
|
||||
})
|
||||
|
||||
it('flags an empty code', () => {
|
||||
expect(validatePromoRows([row({ code: ' ' })])[0].code).toBe('events.create.promo.errors.required')
|
||||
})
|
||||
|
||||
it('flags duplicates case-insensitively on both rows', () => {
|
||||
const errors = validatePromoRows([row({ code: 'half' }), row({ code: ' HALF ' })])
|
||||
expect(errors[0].code).toBe('events.create.promo.errors.duplicate')
|
||||
expect(errors[1].code).toBe('events.create.promo.errors.duplicate')
|
||||
expect(hasPromoErrors(errors)).toBe(true)
|
||||
})
|
||||
|
||||
it('requires the percentage to be within 1..100', () => {
|
||||
for (const pct of [null, 0, 101, Number.NaN]) {
|
||||
expect(validatePromoRows([row({ discount_percent: pct })])[0].discount_percent)
|
||||
.toBe('events.create.promo.errors.percentRange')
|
||||
}
|
||||
expect(validatePromoRows([row({ discount_percent: 100 })])[0].discount_percent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows blank max uses and rejects zero / fractions', () => {
|
||||
expect(validatePromoRows([row({ max_uses: null })])[0].max_uses).toBeUndefined()
|
||||
expect(validatePromoRows([row({ max_uses: 3 })])[0].max_uses).toBeUndefined()
|
||||
expect(validatePromoRows([row({ max_uses: 0 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
|
||||
expect(validatePromoRows([row({ max_uses: 1.5 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toWirePromoCodes / fromWirePromoCodes', () => {
|
||||
it('normalises codes, drops used_count and maps blank max uses to null', () => {
|
||||
expect(toWirePromoCodes([row({ code: ' half ', used_count: 4 }), row({ code: 'CAP', max_uses: 2 })])).toEqual([
|
||||
{ code: 'HALF', discount_percent: 50, active: true, max_uses: null },
|
||||
{ code: 'CAP', discount_percent: 50, active: true, max_uses: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips the wire shape and tolerates the older three-field shape', () => {
|
||||
expect(fromWirePromoCodes([{ code: 'OLD', discount_percent: 20, active: false }])).toEqual([
|
||||
{ code: 'OLD', discount_percent: 20, max_uses: null, active: false, used_count: 0 },
|
||||
])
|
||||
expect(fromWirePromoCodes(undefined)).toEqual([])
|
||||
expect(fromWirePromoCodes([{ code: 'CAP', discount_percent: 10, active: true, max_uses: 0, used_count: 2 }]))
|
||||
.toEqual([{ code: 'CAP', discount_percent: 10, max_uses: null, active: true, used_count: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPercent', () => {
|
||||
it('rounds sats down to whole units and fiat to two decimals', () => {
|
||||
expect(applyPercent(333, 15, 'sat')).toBe(283)
|
||||
expect(applyPercent(59.97, 33, 'EUR')).toBe(40.18)
|
||||
expect(applyPercent(1000, 100, 'sats')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnsupportedEndpointError', () => {
|
||||
it('recognises 404/405 and nothing else', () => {
|
||||
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 404 }))).toBe(true)
|
||||
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 405 }))).toBe(true)
|
||||
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 400 }))).toBe(false)
|
||||
expect(isUnsupportedEndpointError(new Error('x'))).toBe(false)
|
||||
expect(isUnsupportedEndpointError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
125
src/modules/events/lib/promoCodes.ts
Normal file
125
src/modules/events/lib/promoCodes.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { PromoCode } from '../types/ticket'
|
||||
|
||||
/**
|
||||
* Promo-code helpers shared by the organizer editor and the buyer
|
||||
* dialog. Pure functions, no Vue — mirror `promo.py` in the events
|
||||
* extension by name so the two sides stay recognisably in step:
|
||||
*
|
||||
* normalizePromoCode ↔ promo.normalize_code
|
||||
* applyPercent ↔ promo.round_amount / basket_totals (display fallback only)
|
||||
*
|
||||
* The server's `POST /events/api/v1/promo/validate/{event_id}` is the
|
||||
* source of truth for the discounted total; `applyPercent` exists so
|
||||
* the UI can still show *something* against an older extension.
|
||||
*/
|
||||
|
||||
/** One editor row. `discount_percent` / `max_uses` are `null` while the
|
||||
* organizer is still typing (empty inputs), never `0`-as-empty. */
|
||||
export interface PromoCodeRow {
|
||||
code: string
|
||||
discount_percent: number | null
|
||||
/** `null` = unlimited (backend `max_uses: None`). */
|
||||
max_uses: number | null
|
||||
active: boolean
|
||||
/** Derived by the backend from paid tickets; read-only in the editor. */
|
||||
used_count?: number
|
||||
}
|
||||
|
||||
/** Per-row validation messages as i18n keys (the component runs them
|
||||
* through `t()`); a missing field means the field is fine. */
|
||||
export interface PromoRowErrors {
|
||||
code?: string
|
||||
discount_percent?: string
|
||||
max_uses?: string
|
||||
}
|
||||
|
||||
const ERROR_KEYS = {
|
||||
required: 'events.create.promo.errors.required',
|
||||
duplicate: 'events.create.promo.errors.duplicate',
|
||||
percentRange: 'events.create.promo.errors.percentRange',
|
||||
maxUsesMin: 'events.create.promo.errors.maxUsesMin',
|
||||
} as const
|
||||
|
||||
/** Trim + upper-case, the same normalisation the backend applies before
|
||||
* matching, so what the buyer sees in the input is what gets matched. */
|
||||
export function normalizePromoCode(raw: string | null | undefined): string {
|
||||
return (raw ?? '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor rules — stricter than the backend on purpose: the backend
|
||||
* accepts 0 % (a no-op the buyer would see as "code not valid"), the
|
||||
* editor requires 1–100 so an organizer cannot create a dud by accident.
|
||||
*/
|
||||
export function validatePromoRows(rows: PromoCodeRow[]): PromoRowErrors[] {
|
||||
const seen = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
const code = normalizePromoCode(row.code)
|
||||
if (code) seen.set(code, (seen.get(code) ?? 0) + 1)
|
||||
}
|
||||
return rows.map((row) => {
|
||||
const errors: PromoRowErrors = {}
|
||||
const code = normalizePromoCode(row.code)
|
||||
if (!code) {
|
||||
errors.code = ERROR_KEYS.required
|
||||
} else if ((seen.get(code) ?? 0) > 1) {
|
||||
errors.code = ERROR_KEYS.duplicate
|
||||
}
|
||||
const pct = row.discount_percent
|
||||
if (pct === null || !Number.isFinite(pct) || pct < 1 || pct > 100) {
|
||||
errors.discount_percent = ERROR_KEYS.percentRange
|
||||
}
|
||||
const max = row.max_uses
|
||||
if (max !== null && (!Number.isInteger(max) || max < 1)) {
|
||||
errors.max_uses = ERROR_KEYS.maxUsesMin
|
||||
}
|
||||
return errors
|
||||
})
|
||||
}
|
||||
|
||||
export function hasPromoErrors(errors: PromoRowErrors[]): boolean {
|
||||
return errors.some((e) => Object.keys(e).length > 0)
|
||||
}
|
||||
|
||||
/** Editor rows → wire `extra.promo_codes`. Drops the read-only
|
||||
* `used_count` (the backend recomputes it) and blank max-uses → `null`. */
|
||||
export function toWirePromoCodes(rows: PromoCodeRow[]): PromoCode[] {
|
||||
return rows.map((row) => ({
|
||||
code: normalizePromoCode(row.code),
|
||||
discount_percent: row.discount_percent ?? 0,
|
||||
active: row.active,
|
||||
max_uses: row.max_uses && row.max_uses > 0 ? Math.trunc(row.max_uses) : null,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Wire `extra.promo_codes` → editor rows. Tolerates the pre-`max_uses`
|
||||
* shape (older extension) and a missing list (public records). */
|
||||
export function fromWirePromoCodes(codes: PromoCode[] | null | undefined): PromoCodeRow[] {
|
||||
return (codes ?? []).map((c) => ({
|
||||
code: c.code,
|
||||
discount_percent: c.discount_percent ?? 0,
|
||||
max_uses: c.max_uses && c.max_uses > 0 ? c.max_uses : null,
|
||||
active: c.active ?? true,
|
||||
used_count: c.used_count ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only fallback for the discounted total when the validate
|
||||
* endpoint is unavailable. Rounds like the backend: whole sats, two
|
||||
* decimals for fiat.
|
||||
*/
|
||||
export function applyPercent(subtotal: number, percent: number, currency: string): number {
|
||||
const raw = subtotal * (1 - percent / 100)
|
||||
const unit = currency.toLowerCase()
|
||||
if (unit === 'sat' || unit === 'sats') return Math.max(0, Math.trunc(raw))
|
||||
return Math.max(0, Math.round(raw * 100) / 100)
|
||||
}
|
||||
|
||||
/** An older extension without the validate route answers 404 (or 405
|
||||
* when the path exists for another method). Anything else is a real
|
||||
* error. */
|
||||
export function isUnsupportedEndpointError(err: unknown): boolean {
|
||||
const status = (err as { status?: unknown } | null)?.status
|
||||
return status === 404 || status === 405
|
||||
}
|
||||
61
src/modules/events/lib/ticketQr.ts
Normal file
61
src/modules/events/lib/ticketQr.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import brandLogo from '@brand-app-logo?url'
|
||||
|
||||
/**
|
||||
* Render the scannable `ticket://<id>` 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<string> {
|
||||
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<HTMLImageElement> {
|
||||
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<string> {
|
||||
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')
|
||||
}
|
||||
146
src/modules/events/services/TicketApiService.spec.ts
Normal file
146
src/modules/events/services/TicketApiService.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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<string, string> }
|
||||
}
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TicketApiService promo codes', () => {
|
||||
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<string, string> }
|
||||
}
|
||||
|
||||
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => {
|
||||
const totals = {
|
||||
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
|
||||
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }],
|
||||
}
|
||||
fetchMock.mockResolvedValue(okJson(totals))
|
||||
const res = await api.validatePromoCode('evt', ' half ', 2)
|
||||
const { url, init, body } = lastCall()
|
||||
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`)
|
||||
expect(init.method).toBe('POST')
|
||||
expect(body).toEqual({ codes: ['HALF'], quantity: 2 })
|
||||
expect(res).toEqual(totals)
|
||||
})
|
||||
|
||||
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response)
|
||||
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 })
|
||||
})
|
||||
|
||||
it('requestTicket forwards promo_code verbatim', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
||||
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
|
||||
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
|
||||
})
|
||||
|
||||
it('createEvent sends extra.promo_codes verbatim', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
|
||||
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
|
||||
await api.createEvent(
|
||||
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
|
||||
'inkey',
|
||||
)
|
||||
const { body, headers } = lastCall()
|
||||
expect(body.extra.promo_codes).toEqual(promo_codes)
|
||||
expect(headers['X-API-KEY']).toBe('inkey')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,19 +1,45 @@
|
|||
import type {
|
||||
BasketTotals,
|
||||
EventTicket,
|
||||
EventTicketExtra,
|
||||
CreateTicketRequest,
|
||||
PaymentMethod,
|
||||
PublicTicket,
|
||||
TicketPurchaseInvoice,
|
||||
TicketPaymentStatus,
|
||||
TicketResendResult,
|
||||
TicketedEvent,
|
||||
CreateEventRequest,
|
||||
PromoValidateRequest,
|
||||
} from '../types/ticket'
|
||||
import { normalizePromoCode } from '../lib/promoCodes'
|
||||
|
||||
export interface TicketApiConfig {
|
||||
baseUrl: string
|
||||
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 +83,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<TicketPurchaseInvoice> {
|
||||
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 +111,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<string, string> = { '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 +136,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<PublicTicket> {
|
||||
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<TicketedEvent> {
|
||||
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a ticket payment has been confirmed.
|
||||
*/
|
||||
|
|
@ -226,22 +277,30 @@ 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<EventTicket> {
|
||||
const t = await this.request(
|
||||
): Promise<TicketResendResult> {
|
||||
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 {
|
||||
ticket: {
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
eventId: t.event,
|
||||
|
|
@ -253,6 +312,9 @@ export class TicketApiService {
|
|||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
},
|
||||
email: delivery(r.email),
|
||||
nostr: delivery(r.nostr),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +338,8 @@ export class TicketApiService {
|
|||
name?: string | null
|
||||
registered: boolean
|
||||
registered_at: string | null
|
||||
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
|
||||
applied_promo_code?: string | null
|
||||
}>
|
||||
}> {
|
||||
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
||||
|
|
@ -284,6 +348,22 @@ export class TicketApiService {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview what a promo code does to a purchase of `quantity` tickets
|
||||
* (`POST /events/api/v1/promo/validate/{event_id}`, anonymous). The
|
||||
* backend applies the first valid code; an empty `discounts_applied`
|
||||
* means "not valid". Older extensions 404 — the thrown error carries
|
||||
* `status` so callers can fall back to "applied at checkout".
|
||||
*/
|
||||
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
|
||||
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
|
||||
return this.request(`/events/api/v1/promo/validate/${eventId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a paid ticket as registered at the door. Organizer-only —
|
||||
* requires the event-owning wallet's admin_key. Backend rejects
|
||||
|
|
@ -382,7 +462,9 @@ export class TicketApiService {
|
|||
: Array.isArray(error.detail)
|
||||
? error.detail[0]?.msg ?? 'Request failed'
|
||||
: 'Request failed'
|
||||
throw new Error(errorMessage)
|
||||
// Carry the HTTP status so callers can tell "route missing on an
|
||||
// older extension" (404) apart from "bad input" (400).
|
||||
throw Object.assign(new Error(errorMessage), { status: response.status })
|
||||
}
|
||||
|
||||
return response.json()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
35
src/modules/events/types/nip52.spec.ts
Normal file
35
src/modules/events/types/nip52.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,38 @@ export interface PromoCode {
|
|||
code: string
|
||||
discount_percent: number
|
||||
active: boolean
|
||||
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
|
||||
* same name as upstream v2). */
|
||||
max_uses?: number | null
|
||||
/** Paid tickets that used this code — derived by the backend on read,
|
||||
* never sent back. */
|
||||
used_count?: number
|
||||
}
|
||||
|
||||
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
|
||||
* v2 shape; `quantity` stands in for v2's `items` since this fork has
|
||||
* no ticket types). */
|
||||
export interface PromoValidateRequest {
|
||||
codes: string[]
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface BasketDiscount {
|
||||
code: string
|
||||
discount_percent: number | null
|
||||
/** Always null on this fork (percent-only); kept for the v2 shape. */
|
||||
discount_fixed: number | null
|
||||
amount_saved: number
|
||||
}
|
||||
|
||||
/** Response of the promo validate endpoint. An empty `discounts_applied`
|
||||
* means none of the submitted codes is valid for this event/quantity. */
|
||||
export interface BasketTotals {
|
||||
subtotal: number
|
||||
discount: number
|
||||
total: number
|
||||
discounts_applied: BasketDiscount[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,13 +51,20 @@ export interface PromoCode {
|
|||
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
||||
*/
|
||||
export interface EventExtra {
|
||||
promo_codes: PromoCode[]
|
||||
/** Present on organizer-authenticated records only: public event
|
||||
* responses (`/events/public`, `/events/{id}`) no longer carry the
|
||||
* code list (events ext >= v1.6.1-aio.12). */
|
||||
promo_codes?: PromoCode[]
|
||||
conditional: boolean
|
||||
min_tickets: number
|
||||
email_notifications: boolean
|
||||
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 +204,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 +228,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { format } from 'date-fns'
|
||||
|
|
@ -14,6 +15,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 +119,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 +146,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 +161,75 @@ 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<string[]>([])
|
||||
const returnEmail = ref<string | undefined>(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)
|
||||
|
||||
// --- Sticky buy bar --------------------------------------------------
|
||||
// The ticket block sits right under the date/location so it is above the
|
||||
// fold on most screens, but long descriptions still scroll it away. When
|
||||
// it leaves the viewport a compact bar with the price + the same CTA
|
||||
// pins above the bottom nav so the primary action is never more than a
|
||||
// tap away. Hidden while a dialog is open (the dialog is the action).
|
||||
const ticketBlockRef = ref<HTMLElement | null>(null)
|
||||
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
|
||||
|
||||
const priceLabel = computed(() => {
|
||||
const info = event.value?.ticketInfo
|
||||
if (!info) return ''
|
||||
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
|
||||
})
|
||||
|
||||
const buyLabel = computed(() =>
|
||||
ownedPaidCount.value > 0
|
||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||
: t('events.detail.buyTicket', 'Buy ticket'),
|
||||
)
|
||||
|
||||
const showStickyCta = computed(
|
||||
() =>
|
||||
!!event.value?.ticketInfo &&
|
||||
!ownedLnbitsEvent.value &&
|
||||
!isPast.value &&
|
||||
(canBuyTicket.value || ownedPaidCount.value > 0) &&
|
||||
!ticketBlockVisible.value &&
|
||||
!showPurchaseDialog.value &&
|
||||
!showReturnDialog.value,
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
|
@ -167,7 +243,7 @@ function goToMyTickets() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }">
|
||||
<!-- Top bar — back-link only. Edit moves into the title row as a
|
||||
prominent icon button; Scan moves into the tickets section
|
||||
where it replaces the Buy-ticket CTA for the host. -->
|
||||
|
|
@ -276,13 +352,6 @@ function goToMyTickets() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
||||
OUTSIDE the ticketInfo gate so it appears even when the
|
||||
event was published without AIO ticket tags — a host always
|
||||
|
|
@ -302,8 +371,15 @@ function goToMyTickets() {
|
|||
by the calendar→Event converter from the AIO custom
|
||||
tickets_* tags on the published event). Skipped for the
|
||||
host entirely — they have the Scan CTA above and don't
|
||||
need a Buy CTA for their own event. -->
|
||||
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||
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). -->
|
||||
<div
|
||||
v-if="event.ticketInfo && !ownedLnbitsEvent"
|
||||
ref="ticketBlockRef"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
v-if="ownedPaidCount > 0"
|
||||
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||
|
|
@ -333,14 +409,8 @@ function goToMyTickets() {
|
|||
@click="openPurchaseDialog"
|
||||
>
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ ownedPaidCount > 0
|
||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||
: t('events.detail.buyTicket', 'Buy ticket') }}
|
||||
<span class="ml-2 opacity-80 font-normal">
|
||||
{{ event.ticketInfo.price === 0
|
||||
? t('events.detail.free')
|
||||
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
||||
</span>
|
||||
{{ buyLabel }}
|
||||
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span>
|
||||
</Button>
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
|
|
@ -359,6 +429,13 @@ function goToMyTickets() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<PurchaseTicketDialog
|
||||
v-if="purchaseEvent"
|
||||
:is-open="showPurchaseDialog"
|
||||
|
|
@ -366,6 +443,67 @@ function goToMyTickets() {
|
|||
@update:is-open="showPurchaseDialog = $event"
|
||||
/>
|
||||
|
||||
<CheckoutReturnDialog
|
||||
:is-open="showReturnDialog"
|
||||
:event-id="eventId"
|
||||
:ticket-ids="returnTicketIds"
|
||||
:email="returnEmail"
|
||||
@update:is-open="showReturnDialog = $event"
|
||||
/>
|
||||
|
||||
<!-- Sticky buy bar — see showStickyCta. Pinned flush above the
|
||||
bottom nav via the height it publishes (--bottom-nav-height,
|
||||
BottomNav.vue) and mirrors the inline CTA. -->
|
||||
<!-- Teleported to <body>: inside the page the bar would inherit the
|
||||
parent's space-y margin (margins offset fixed boxes too) and
|
||||
float 24px above the nav. -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-4 opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-to-class="translate-y-4 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showStickyCta && event"
|
||||
class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style="bottom: var(--bottom-nav-height)"
|
||||
role="region"
|
||||
:aria-label="t('events.detail.tickets')"
|
||||
>
|
||||
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
<span v-if="ownedPaidCount > 0">
|
||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||
</span>
|
||||
<span v-else-if="event.ticketInfo?.available === undefined">
|
||||
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canBuyTicket"
|
||||
class="gap-1.5 shrink-0"
|
||||
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
|
||||
@click="openPurchaseDialog"
|
||||
>
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ buyLabel }}
|
||||
</Button>
|
||||
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { renderTicketQr } from '../lib/ticketQr'
|
||||
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
|
||||
import { useUserTickets } from '../composables/useUserTickets'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
|
|
@ -134,13 +135,7 @@ async function generateQRCode(ticketId: string) {
|
|||
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
|
||||
|
||||
try {
|
||||
const qrcode = await import('qrcode')
|
||||
const ticketUrl = `ticket://${ticketId}`
|
||||
const dataUrl = await qrcode.toDataURL(ticketUrl, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#FFFFFF' }
|
||||
})
|
||||
const dataUrl = await renderTicketQr(ticketId, { width: 200 })
|
||||
qrCodes.value[ticketId] = dataUrl
|
||||
return dataUrl
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -104,8 +104,9 @@ const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
|
|||
{
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.7 },
|
||||
{ name: 'id', weight: 0.3 },
|
||||
{ name: 'name', weight: 0.6 },
|
||||
{ name: 'id', weight: 0.25 },
|
||||
{ name: 'promoCode', weight: 0.15 },
|
||||
],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
|
|
@ -296,6 +297,14 @@ function fmtTime(iso: string) {
|
|||
<span v-if="ticket.name" class="font-medium text-foreground">
|
||||
{{ ticket.name }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="ticket.promoCode"
|
||||
variant="outline"
|
||||
class="text-[10px] font-mono px-1.5"
|
||||
:title="ticket.promoCode"
|
||||
>
|
||||
{{ ticket.promoCode }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||
{{ ticket.id }}
|
||||
|
|
|
|||
155
src/modules/events/views/TicketPage.vue
Normal file
155
src/modules/events/views/TicketPage.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ArrowLeft, Ticket, CheckCircle2, Clock, CreditCard, Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { renderTicketQr } from '../lib/ticketQr'
|
||||
import type { PublicTicket } from '../types/ticket'
|
||||
|
||||
/**
|
||||
* Public ticket page — where the emailed link lands
|
||||
* (`<frontend_url>/events/ticket/{ticket_id}`). Anonymous: the ticket
|
||||
* id in the URL is the door credential, the same token the QR carries,
|
||||
* exactly like the LNbits UI's own /events/ticket/{id} page.
|
||||
*/
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const ticketId = route.params.ticketId as string
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const store = useEventsStore()
|
||||
|
||||
const ticket = ref<PublicTicket | null>(null)
|
||||
const eventName = ref<string | null>(null)
|
||||
const qr = ref<string | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const notFound = ref(false)
|
||||
|
||||
const status = computed(() => {
|
||||
if (!ticket.value) return null
|
||||
if (!ticket.value.paid) return { label: t('events.ticket.pending'), icon: Clock, variant: 'secondary' as const }
|
||||
if (ticket.value.registered) return { label: t('events.ticket.registered'), icon: CheckCircle2, variant: 'default' as const }
|
||||
return { label: t('events.ticket.paid'), icon: CreditCard, variant: 'default' as const }
|
||||
})
|
||||
|
||||
// Event name resolution, cheapest first: the Nostr store (d-tag ==
|
||||
// LNbits event id), a scoped relay query, then the public REST event
|
||||
// (which 410s for sold-out / closed events — non-fatal here).
|
||||
async function resolveEventName(eventId: string) {
|
||||
const cached = store.getEventById(eventId)
|
||||
if (cached) {
|
||||
eventName.value = cached.title
|
||||
return
|
||||
}
|
||||
const nostr = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||
if (nostr) {
|
||||
try {
|
||||
const results = await nostr.queryCalendarEvents({ dTags: [eventId] })
|
||||
store.upsertEvents(results)
|
||||
const hit = store.getEventById(eventId)
|
||||
if (hit) {
|
||||
eventName.value = hit.title
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
try {
|
||||
const pub = await ticketApi.getPublicEvent(eventId)
|
||||
eventName.value = pub.name
|
||||
} catch {
|
||||
eventName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
ticket.value = await ticketApi.getTicket(ticketId)
|
||||
} catch {
|
||||
notFound.value = true
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
isLoading.value = false
|
||||
void resolveEventName(ticket.value.eventId)
|
||||
try {
|
||||
qr.value = await renderTicketQr(ticketId, { width: 240 })
|
||||
} catch {
|
||||
qr.value = null
|
||||
}
|
||||
})
|
||||
|
||||
function goToEvent() {
|
||||
if (ticket.value) router.push(`/events/${ticket.value.eventId}`)
|
||||
else router.push('/events')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4 max-w-md">
|
||||
<div class="flex items-center mb-4">
|
||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goToEvent">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
{{ t('events.ticket.backToEvent') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 class="w-8 h-8 animate-spin" />
|
||||
<span class="text-sm">{{ t('events.ticket.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="notFound" class="py-16 flex flex-col items-center gap-3 text-center">
|
||||
<AlertCircle class="w-10 h-10 text-destructive" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('events.ticket.notFound') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="ticket" class="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Ticket class="w-5 h-5 shrink-0" />
|
||||
{{ t('events.ticket.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground truncate">
|
||||
{{ eventName ?? t('events.ticket.event') }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge v-if="status" :variant="status.variant" class="gap-1 shrink-0">
|
||||
<component :is="status.icon" class="w-3.5 h-3.5" />
|
||||
{{ status.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||
<img v-if="qr" :src="qr" :alt="`Ticket ${ticketId}`" class="block h-60 w-60" />
|
||||
<div v-else class="h-60 w-60 flex items-center justify-center">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
<div v-if="ticket.name" class="flex justify-between gap-3">
|
||||
<span class="text-muted-foreground">{{ t('events.ticket.holder') }}</span>
|
||||
<span class="font-medium truncate">{{ ticket.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted-foreground">ID</span>
|
||||
<span class="font-mono text-xs">{{ ticketId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
{{ ticket.paid ? t('events.ticket.showAtDoor') : t('events.ticket.pending') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -198,13 +198,12 @@ function back() {
|
|||
</section>
|
||||
|
||||
<!-- Sticky bottom bar with qty stepper + add-to-cart.
|
||||
Sits flush above BottomNav (h-14 + safe-area inset) on every
|
||||
breakpoint. Previously `bottom-16` left an 8px gap on mobile
|
||||
(BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made
|
||||
it overlap the nav on desktop. -->
|
||||
Sits flush above BottomNav on every breakpoint via the height
|
||||
the nav publishes (--bottom-nav-height, BottomNav.vue) — no
|
||||
hand-derived h-14 + safe-area arithmetic to drift. -->
|
||||
<div
|
||||
class="fixed inset-x-0 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
style="bottom: var(--bottom-nav-height)"
|
||||
>
|
||||
<div class="container mx-auto flex max-w-2xl items-center gap-3">
|
||||
<div class="inline-flex items-center rounded-full border border-border bg-card">
|
||||
|
|
|
|||
142
vite.chatelet.config.ts
Normal file
142
vite.chatelet.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue