From cf1740d025f060c8fbd474df2b85e1e67393b3a1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 14:38:38 +0200 Subject: [PATCH 01/50] chore(deps): bump nostr-tools to ^2.23.3 to match lnbits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only breaking surface in webapp code is SimplePool.subscribeMany — 2.23 dropped the Filter[] form: a single subscription now takes one Filter, and multi-filter REQs go through subscribeMap. RelayHub gets an internal poolSubscribe() adapter that routes single-filter to pool.subscribe() and multi-filter to pool.subscribeMap(), preserving the external RelayHub.subscribe() API so no downstream modules change. Peer-dep bump (@noble/* and @scure/* → 2.x) is contained: nostr-tools is the only consumer in the lockfile, so the major version shift doesn't conflict with anything else. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- pnpm-lock.yaml | 88 ++++++++++++++--------------- src/modules/base/nostr/relay-hub.ts | 23 +++++++- 3 files changed, 63 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index 106d1c5..1770e19 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "light-bolt11-decoder": "^3.2.0", "lucide-vue-next": "^0.474.0", "ngeohash": "^0.6.3", - "nostr-tools": "2.15.0", + "nostr-tools": "^2.23.3", "pinia": "^2.3.1", "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8891845..d1a8b7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: ^0.6.3 version: 0.6.3 nostr-tools: - specifier: 2.15.0 - version: 2.15.0(typescript@5.6.3) + specifier: ^2.23.3 + version: 2.23.5(typescript@5.6.3) pinia: specifier: ^2.3.1 version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3)) @@ -1247,22 +1247,17 @@ packages: resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} - '@noble/ciphers@0.5.3': - resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} - '@noble/curves@1.1.0': - resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} - '@noble/curves@1.2.0': - resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} - - '@noble/hashes@1.3.1': - resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} - engines: {node: '>= 16'} - - '@noble/hashes@1.3.2': - resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} - engines: {node: '>= 16'} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1478,11 +1473,14 @@ packages: '@scure/base@1.1.1': resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} - '@scure/bip32@1.3.1': - resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} - '@scure/bip39@1.2.1': - resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} @@ -3535,8 +3533,8 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} - nostr-tools@2.15.0: - resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==} + nostr-tools@2.23.5: + resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==} peerDependencies: typescript: '>=5.0.0' peerDependenciesMeta: @@ -6204,19 +6202,13 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@noble/ciphers@0.5.3': {} + '@noble/ciphers@2.1.1': {} - '@noble/curves@1.1.0': + '@noble/curves@2.0.1': dependencies: - '@noble/hashes': 1.3.1 + '@noble/hashes': 2.0.1 - '@noble/curves@1.2.0': - dependencies: - '@noble/hashes': 1.3.2 - - '@noble/hashes@1.3.1': {} - - '@noble/hashes@1.3.2': {} + '@noble/hashes@2.0.1': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -6362,16 +6354,18 @@ snapshots: '@scure/base@1.1.1': {} - '@scure/bip32@1.3.1': - dependencies: - '@noble/curves': 1.1.0 - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 + '@scure/base@2.0.0': {} - '@scure/bip39@1.2.1': + '@scure/bip32@2.0.1': dependencies: - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 '@sindresorhus/is@4.6.0': {} @@ -8539,14 +8533,14 @@ snapshots: normalize-url@6.1.0: {} - nostr-tools@2.15.0(typescript@5.6.3): + nostr-tools@2.23.5(typescript@5.6.3): dependencies: - '@noble/ciphers': 0.5.3 - '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 - '@scure/bip32': 1.3.1 - '@scure/bip39': 1.2.1 + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 nostr-wasm: 0.1.0 optionalDependencies: typescript: 5.6.3 diff --git a/src/modules/base/nostr/relay-hub.ts b/src/modules/base/nostr/relay-hub.ts index 7cdd00f..add0037 100644 --- a/src/modules/base/nostr/relay-hub.ts +++ b/src/modules/base/nostr/relay-hub.ts @@ -1,4 +1,5 @@ import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools' +import type { SubscribeManyParams, SubCloser } from 'nostr-tools/abstract-pool' import { BaseService } from '@/core/base/BaseService' import { ref } from 'vue' @@ -438,7 +439,7 @@ export class RelayHub extends BaseService { } // Recreate the subscription - const subscription = this.pool.subscribeMany(availableRelays, config.filters, { + const subscription = this.poolSubscribe(availableRelays, config.filters, { onevent: (event: Event) => { config.onEvent?.(event) this.emit('event', { subscriptionId: id, event, relay: 'unknown' }) @@ -482,7 +483,7 @@ export class RelayHub extends BaseService { // Create subscription using the pool - const subscription = this.pool.subscribeMany(availableRelays, config.filters, { + const subscription = this.poolSubscribe(availableRelays, config.filters, { onevent: (event: Event) => { config.onEvent?.(event) this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' }) @@ -550,6 +551,24 @@ export class RelayHub extends BaseService { return { success: successful, total } } + // nostr-tools 2.23+ deprecated the Filter[] form on pool.subscribeMany; route + // single-filter through pool.subscribe and multi-filter through subscribeMap + // so a single REQ-per-relay still carries every filter. + private poolSubscribe( + relays: string[], + filters: Filter[], + params: SubscribeManyParams + ): SubCloser { + if (filters.length === 0) { + throw new Error('Cannot subscribe with empty filters') + } + if (filters.length === 1) { + return this.pool.subscribe(relays, filters[0], params) + } + const requests = relays.flatMap(url => filters.map(filter => ({ url, filter }))) + return this.pool.subscribeMap(requests, params) + } + /** * Query events from relays (one-time fetch) */ From 8c09fbdc18dd875b58fdeb2d28964f33ea640cbb Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 14:50:09 +0200 Subject: [PATCH 02/50] fix(activities): toast on logged-out Create tap instead of opening dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BottomNav fires onClick regardless of tab.disabled — the opacity gate was visual only. Mirror BookmarkButton/RSVPButton: show a toast.info with a Log in action and bail before opening the create dialog. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/activities-app/App.vue | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/activities-app/App.vue b/src/activities-app/App.vue index 640608b..41e9ad1 100644 --- a/src/activities-app/App.vue +++ b/src/activities-app/App.vue @@ -1,7 +1,8 @@ + + diff --git a/src/modules/base/components/payments/PaymentMethodSelector.vue b/src/modules/base/components/payments/PaymentMethodSelector.vue new file mode 100644 index 0000000..a5fb3dd --- /dev/null +++ b/src/modules/base/components/payments/PaymentMethodSelector.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/modules/base/components/payments/PriceConversionPreview.vue b/src/modules/base/components/payments/PriceConversionPreview.vue new file mode 100644 index 0000000..17cdba3 --- /dev/null +++ b/src/modules/base/components/payments/PriceConversionPreview.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/modules/base/composables/useFiatProviders.ts b/src/modules/base/composables/useFiatProviders.ts new file mode 100644 index 0000000..ac28691 --- /dev/null +++ b/src/modules/base/composables/useFiatProviders.ts @@ -0,0 +1,53 @@ +import { computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { AuthService } from '@/modules/base/auth/auth-service' + +export type FiatProviderIcon = 'card' | 'bank' | 'wallet' + +export interface FiatProviderMeta { + label: string + icon: FiatProviderIcon +} + +const KNOWN_PROVIDERS: Record = { + stripe: { label: 'Stripe', icon: 'card' }, + paypal: { label: 'PayPal', icon: 'wallet' }, + square: { label: 'Square', icon: 'card' }, + sepa: { label: 'SEPA', icon: 'bank' }, +} + +export function providerMeta(id: string): FiatProviderMeta { + const known = KNOWN_PROVIDERS[id.toLowerCase()] + if (known) return known + return { + label: id.charAt(0).toUpperCase() + id.slice(1), + icon: 'card', + } +} + +/** + * Shared accessor for the current user's available fiat providers. + * + * Fiat providers (Stripe, PayPal, Square, SEPA, …) are configured + * globally by the LNbits admin. Per-provider `allowed_users` + * whitelists narrow that to a session-specific list, exposed as + * `User.fiat_providers` on `GET /api/v1/auth`. Both organizers and + * buyers on the same instance see the same list today. + * + * Call `refresh()` from owner-side dialogs that may open right after + * the user configured a new provider in another tab. + */ +export function useFiatProviders() { + const auth = injectService(SERVICE_TOKENS.AUTH_SERVICE) + + const providers = computed( + () => auth.currentUser.value?.fiat_providers ?? [] + ) + const hasAnyProvider = computed(() => providers.value.length > 0) + + async function refresh(): Promise { + await auth.refresh() + } + + return { providers, hasAnyProvider, refresh, providerMeta } +} diff --git a/src/modules/base/composables/usePriceConversion.ts b/src/modules/base/composables/usePriceConversion.ts new file mode 100644 index 0000000..5dfc083 --- /dev/null +++ b/src/modules/base/composables/usePriceConversion.ts @@ -0,0 +1,88 @@ +import { ref, watch, type Ref } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { LnbitsAPI } from '@/lib/api/lnbits' + +interface CacheEntry { + value: number + expiresAt: number +} + +const cache = new Map() +const TTL_MS = 60_000 + +function cacheKey(amount: number, from: string, to: string): string { + return `${from.toLowerCase()}|${to.toLowerCase()}|${amount}` +} + +/** + * Live + cached BTC ⇄ fiat rate previews via LNbits `/api/v1/conversion`. + * + * Both helpers tolerate a transient failure (returning `null`) — surface + * conversion preview as best-effort UX, never as a blocker. 60s in-memory + * cache de-duplicates dialog re-renders. + */ +export function usePriceConversion() { + const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) + + async function convert( + amount: number, + from: string, + to: string, + ): Promise { + if (!amount || !from || !to) return null + if (from.toLowerCase() === to.toLowerCase()) return amount + + const key = cacheKey(amount, from, to) + const cached = cache.get(key) + if (cached && cached.expiresAt > Date.now()) return cached.value + + try { + const data = await lnbitsAPI.getConversion({ from, to, amount }) + const result = + data[to] ?? + data[to.toUpperCase()] ?? + data[to.toLowerCase()] ?? + (data as Record).amount ?? + (data as Record).result + if (typeof result !== 'number') return null + cache.set(key, { value: result, expiresAt: Date.now() + TTL_MS }) + return result + } catch (err) { + console.warn('[usePriceConversion] convert failed:', err) + return null + } + } + + function useLivePreview( + amount: Ref, + from: Ref, + to: Ref, + debounceMs = 300, + ): { result: Ref; loading: Ref } { + const result = ref(null) + const loading = ref(false) + let activeToken = 0 + let timer: ReturnType | null = null + + watch( + [amount, from, to], + () => { + if (timer) clearTimeout(timer) + const myToken = ++activeToken + loading.value = true + timer = setTimeout(async () => { + const v = await convert(amount.value, from.value, to.value) + if (myToken === activeToken) { + result.value = v + loading.value = false + } + }, debounceMs) + }, + { immediate: true }, + ) + + return { result, loading } + } + + return { convert, useLivePreview } +} From 985c10939d5a4a5d4e20292e3076fcfefcc2aa06 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 18:37:11 +0200 Subject: [PATCH 07/50] refactor(activities): adopt shared payment-rails pattern in CreateEventDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the bottom of the create/edit form into two semantic sections that read in the canonical vocabulary: Pricing Tickets · Price · Price currency ← renamed from bare "Currency" Payment methods Lightning — always on (informational chip) ← replaces the inline switch + raw fiat_currency dropdown The toggle field handles the conditional dropdown (hide + auto-mirror when the price denomination IS the fiat currency) and the disabled- with-tooltip state when the user has no configured provider, so the parent form just supplies field names + the denomination value. Zod superRefine grows a check that requires `fiat_currency` only in the surface where the toggle exposes the dropdown — `allow_fiat && currency === 'sat'`. Submit-time payload drops `fiat_currency` when `allow_fiat` is off so we don't persist a rail-currency the backend won't use. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 183 +++++++++--------- 1 file changed, 94 insertions(+), 89 deletions(-) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index c2c1f45..5f9d45b 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Switch } from '@/components/ui/switch' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, @@ -33,12 +32,13 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next' +import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next' import { toastService } from '@/core/services/ToastService' 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 { Alert, AlertDescription } from '@/components/ui/alert' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' @@ -129,14 +129,25 @@ const formSchema = toTypedSchema( .superRefine((v, ctx) => { // End must not precede start. Compare on the folded date+time // string so equal-date / later-time is enforced too. - if (!v.event_end_date) return - const start = foldDateTime(v.event_start_date, v.event_start_time) - const end = foldDateTime(v.event_end_date, v.event_end_time) - if (start && end && end < start) { + if (v.event_end_date) { + const start = foldDateTime(v.event_start_date, v.event_start_time) + const end = foldDateTime(v.event_end_date, v.event_end_time) + if (start && end && end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['event_end_date'], + message: 'End must be on or after start', + }) + } + } + // When the price is in sats and the organizer also accepts fiat, + // they MUST choose a settle currency. Other price denominations + // mirror themselves into fiat_currency automatically. + if (v.allow_fiat && v.currency === 'sat' && !v.fiat_currency) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ['event_end_date'], - message: 'End must be on or after start', + path: ['fiat_currency'], + message: 'Pick a fiat currency for buyers paying by card', }) } }) @@ -325,10 +336,13 @@ const onSubmit = form.handleSubmit(async (formValues) => { eventData.banner = null } if (formValues.currency) eventData.currency = formValues.currency - // allow_fiat / fiat_currency: always send so the toggle reads - // both directions on edit (a true→false flip must propagate). + // 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.fiat_currency) eventData.fiat_currency = formValues.fiat_currency + if (formValues.allow_fiat && 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 @@ -558,88 +572,79 @@ const handleOpenChange = (open: boolean) => { /> - -
- - - Tickets - - - - 0 = unlimited - - - + +
+
+

Pricing

+

+ Set what buyers see. Lightning charges happen in sats; + fiat amounts convert at checkout using current rates. +

+
+
+ + + Tickets + + + + 0 = unlimited + + + - - - Price - - - - 0 = free - - - + + + Price + + + + 0 = free + + + - - - Currency - - - - - - + + + Price currency + + + + + + +
- -
- - -
- Accept fiat payments - - Buyers can pay with the LNbits instance's configured fiat provider (e.g. Stripe). - -
- - - -
-
- - - - Fiat currency - - - - - - + +
+
+

Payment methods

+

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

+
+
+ + Lightning — always on +
+
From 574c178d89b050a4ea6c184a375e2333f3bb3f29 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 18:40:50 +0200 Subject: [PATCH 08/50] feat(activities): provider-aware checkout labels and conversion preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buyer-side payment-method block now surfaces one button per configured fiat provider — Stripe, PayPal, Square, SEPA — rather than a single bare "Fiat" catch-all. Buttons read in provider names so the buyer never has to guess what rail backs each choice; the dispatch on click forwards both `rail` and `provider` to the existing `ticketApi.requestTicket` signature. PaymentMethodSelector + useFiatProviders from the base module drive the list. The Lightning button picks up a "≈ N sats" badge whenever the event price is denominated in fiat, so the buyer sees the live sat charge alongside the headline price. A new conversion-preview line under the headline shows the sat→fiat estimate in the inverse case (sat-denominated event with fiat enabled), giving the rail-vs- unit asymmetry an explicit place in the UI. Explanatory copy makes the equivalence explicit: both methods charge the same amount, rates are estimates, exact amount locks in at checkout. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/PurchaseTicketDialog.vue | 157 ++++++++++++++---- 1 file changed, 121 insertions(+), 36 deletions(-) diff --git a/src/modules/activities/components/PurchaseTicketDialog.vue b/src/modules/activities/components/PurchaseTicketDialog.vue index 790e2ab..db126cc 100644 --- a/src/modules/activities/components/PurchaseTicketDialog.vue +++ b/src/modules/activities/components/PurchaseTicketDialog.vue @@ -1,5 +1,5 @@ From 9f38611f4fa4d237d6ffd19cefbe15d5bbc88843 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 22 May 2026 12:32:00 +0200 Subject: [PATCH 11/50] feat(activities): notification config on event create + edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateEventDialog gains a collapsible "Buyer notifications" section exposing the EventExtra fields added upstream in v1.4.0 / v1.6.0: - email_notifications + nostr_notifications switches — opt buyers into email and NIP-04 Nostr DM ticket confirmations. - notification_subject + notification_body inputs — let organizers customize the message. Empty falls back to extension defaults. Submit handler builds `extra` by overlaying onto the existing event.extra so unrelated fields the LNbits admin UI sets (promo_codes, conditional, min_tickets) survive the round-trip through the webapp. Populate-from-event mirrors the same. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index 739e300..5773200 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -24,6 +24,9 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' 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 { ScrollArea } from '@/components/ui/scroll-area' import { Select, @@ -125,6 +128,10 @@ const formSchema = toTypedSchema( 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), + email_notifications: z.boolean().default(false), + nostr_notifications: z.boolean().default(false), + notification_subject: z.string().max(200).default(''), + notification_body: z.string().max(2000).default(''), }) .superRefine((v, ctx) => { // End must not precede start. Compare on the folded date+time @@ -170,6 +177,10 @@ const form = useForm({ fiat_currency: 'USD', amount_tickets: 0, price_per_ticket: 0, + email_notifications: false, + nostr_notifications: false, + notification_subject: '', + notification_body: '', } }) @@ -192,6 +203,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time: // When `true`, suppress the auto-mirror watcher so we don't clobber an // edit-mode population with start-date side effects mid-setValues. const isPopulating = ref(false) +const notificationsOpen = 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 @@ -235,6 +247,10 @@ async function populateFromEvent(event: TicketedEvent) { fiat_currency: event.fiat_currency ?? 'USD', amount_tickets: event.amount_tickets ?? 0, price_per_ticket: event.price_per_ticket ?? 0, + email_notifications: event.extra?.email_notifications ?? false, + nostr_notifications: event.extra?.nostr_notifications ?? false, + notification_subject: event.extra?.notification_subject ?? '', + notification_body: event.extra?.notification_body ?? '', }) selectedCategories.value = [...(event.categories ?? [])] if (event.banner) { @@ -349,6 +365,18 @@ const onSubmit = form.handleSubmit(async (formValues) => { 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. + eventData.extra = { + ...(props.event?.extra ?? {}), + email_notifications: formValues.email_notifications, + nostr_notifications: formValues.nostr_notifications, + notification_subject: formValues.notification_subject, + notification_body: formValues.notification_body, + } + if (isEditMode.value) { if (!props.onUpdateEvent || !props.event?.id) { toastService.error('Update handler missing') @@ -649,6 +677,68 @@ const handleOpenChange = (open: boolean) => { />
+ + + + + + +
+ + + Email confirmation + + + + + + + + + Nostr DM confirmation + + + + + +
+ + + + Subject + + + + Leave blank to use the default. + + + + + + + Body + +