+
+
+
+ {{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
+
+
+ {{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
+
+
+ {{ t('activities.detail.soldOut') }}
+
+
+
+
+
+
+ {{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
+
+
+
+ {{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
+
+
+
+
+
+
+ {{ ownedPaidCount > 0
+ ? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
+ : t('activities.detail.buyTicket', 'Buy ticket') }}
+
+ {{ activity.ticketInfo.price === 0
+ ? t('activities.detail.free')
+ : `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
+
+
+
+
+ {{ t('activities.detail.soldOut') }}
+
+
+
+
diff --git a/src/modules/activities/views/EventsPage.vue b/src/modules/activities/views/EventsPage.vue
index c5adac0..6c74394 100644
--- a/src/modules/activities/views/EventsPage.vue
+++ b/src/modules/activities/views/EventsPage.vue
@@ -27,6 +27,8 @@ const selectedEvent = ref<{
name: string
price_per_ticket: number
currency: string
+ allow_fiat?: boolean
+ fiat_currency?: string
} | null>(null)
const showEventDialog = ref(false)
@@ -56,6 +58,8 @@ function handlePurchaseClick(event: {
name: string
price_per_ticket: number
currency: string
+ allow_fiat?: boolean
+ fiat_currency?: string
}) {
if (!isAuthenticated.value) return
selectedEvent.value = event
diff --git a/src/modules/base/components/payments/FiatToggleField.vue b/src/modules/base/components/payments/FiatToggleField.vue
new file mode 100644
index 0000000..2832c9e
--- /dev/null
+++ b/src/modules/base/components/payments/FiatToggleField.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+ Also accept fiat
+
+ Buyers can pay with card or bank through your configured provider.
+
+
+
+
+
+
+
+
+
+
+
+ Your LNbits user has no fiat provider configured. Open
+ LNbits → Account → Fiat providers and add Stripe, PayPal,
+ or Square to enable this.
+
+
+
+
+
+
+
+
+
+ Fiat currency
+
+
+
+
+
+
+
+ {{ c }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ method.label }}
+
+
+ {{ method.badge }}
+
+
+
+ {{ method.unavailableReason }}
+
+
+
+
+
+ {{ method.label }}
+
+
+ {{ method.badge }}
+
+
+
+
+
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 @@
+
+
+
+
+ Loading rate…
+ {{ prefix }} {{ formatted }}{{ suffix }}
+ (rate unavailable)
+
+
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 }
+}
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)
*/