Compare commits

...

7 commits

Author SHA1 Message Date
663e32e7a4 fix(activities): normalize 'sat' vs 'sats' across fiat conditionals
TicketApiService.getCurrencies() returns 'sats' (plural) while the
schema, initialValues, and existing comparisons used 'sat' (singular)
— a pre-existing inconsistency in the events extension surface. The
new payment-rails conditionals tripped on it: as soon as the user
picked the populated 'sats' option from the price-currency dropdown,
form.values.currency became 'sats', the `=== 'sat'` check failed, and
the Fiat currency dropdown stayed hidden even with the toggle on.

Normalize all the new comparisons to accept both spellings:

- FiatToggleField: isSatDenomination(d) helper drives both the
  v-show and the auto-mirror watch.
- CreateEventDialog Zod superRefine: same accept-both rule on the
  require-fiat_currency branch.
- PurchaseTicketDialog: isPriceInSats computed drives the
  Lightning-sats badge AND the PriceConversionPreview render
  condition AND the inverse conversion watcher's bail-out.

Also flip FiatToggleField to drive dropdown visibility from the
outer FormField's slot value rather than useFormContext — slot
bindings are guaranteed reactive, sidesteps the public-form-context
indirection that earlier left allowFiat stale in the child's
template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:44:43 +02:00
b7b5a08594 fix(base): FiatToggleField reads form state via useFormContext
The previous version called useField directly with a getter for the
field name. That created a child-local field rather than connecting
to the parent form's allow_fiat / fiat_currency state — so the
Switch's on/off visually toggled but the form never knew, and the
conditional Fiat currency dropdown never appeared.

Rewrite around the proven pattern used elsewhere in the dialog: bind
the inputs through FormField (the shadcn-vue / vee-validate Field
component) and reach for cross-field state via useFormContext.
showCurrencyDropdown now reads form.values[allowFiatField] directly,
which mirrors the parent's actual state, and the denomination-mirror
watch goes through form.setFieldValue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:30:03 +02:00
99667add65 feat(activities): provider-aware checkout labels and conversion preview
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) <noreply@anthropic.com>
2026-05-23 18:40:50 +02:00
61665790b3 refactor(activities): adopt shared payment-rails pattern in CreateEventDialog
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)
    <FiatToggleField/>                  ← 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) <noreply@anthropic.com>
2026-05-23 18:37:11 +02:00
a0087b6bf3 feat(base): payment-rails composables + components shared across modules
Activities is the first module to mix Lightning + fiat rails; restaurant
and marketplace will follow. Extract the cross-cutting bits to the base
module so the next adoption is a wiring exercise:

- useFiatProviders: reactive `User.fiat_providers` (today the same list
  for organizer + buyer because LNbits configures providers globally),
  plus `providerMeta()` for label/icon hints.
- usePriceConversion: `convert()` + reactive `useLivePreview()` over
  the existing `/api/v1/conversion` endpoint, 60s cache, null on
  transient failure.
- PaymentMethodSelector: buyer-side rail picker. `PaymentMethod.id`
  enumerates rails (`lightning | fiat | cash | internal | …`) with
  `provider` for the fiat case so a multi-provider instance shows one
  button per provider instead of a bare "Fiat" catch-all.
- FiatToggleField: organizer-side switch + conditional fiat-currency
  dropdown. Auto-disables with a setup-instructions tooltip when the
  user has no providers; silently mirrors fiat_currency to a non-sat
  price denomination to keep the backend payload consistent.
- PriceConversionPreview: muted "≈ X.XX USD" line for surfaces where
  the price denomination differs from the chosen rail's currency.

LnbitsAPI.getConversion wraps the conversion endpoint so the composable
goes through the existing API service rather than raw fetch. CLAUDE.md
gains a "Payment rails pattern" section documenting the canonical
vocabulary ("Price currency" / "Fiat currency" / "Payment method" /
"Also accept fiat" — bare "Currency" and "Pay in fiat" are banned in
payment-context UI labels) and the fiat-providers-are-global note.

The pre-existing `prvkey` comment on User picks up an inline allowlist
marker so the secret scanner stops flagging this file on every commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:34:19 +02:00
f94ad30ac7 feat(activities): expose fiat checkout on event create + purchase
Both sides of the fiat-payment surface introduced by events v1.4.0:

CreateEventDialog — organizer-side opt-in:
- New "Accept fiat payments" switch (allow_fiat) + fiat currency
  picker (USD/EUR/GBP/CHF). Toggle is always shipped on
  create/edit so a true→false flip propagates correctly.
- Hint copy notes that the host's LNbits admin needs a configured
  fiat provider (Stripe etc.) for the toggle to actually work at
  purchase time.

PurchaseTicketDialog — buyer-side method selector:
- Two-button selector (Lightning / Fiat) shown only when the event
  has `allow_fiat=true`. Hidden entirely for Lightning-only events.
- Lightning path: unchanged (uses useTicketPurchase composable).
- Fiat path: posts to the API with `payment_method=fiat`, then
  surfaces a "Open <provider> checkout" button that opens the
  returned fiat_payment_request URL in a new tab. Payment
  confirmation happens via webhook on the backend; ticket appears
  in My Tickets on next reload.

EventsPage threads the new fiat fields through `selectedEvent` so
the dialog sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:47:39 +02:00
620919da58 feat(activities): align types + API service with events v1.6.1
The backend rebase brought in PR #50 (fiat checkout + email/Nostr
ticket notifications), v1.6.0 (custom notification subject/body), and
PR #51 (resend-email endpoint). The webapp types lagged.

Aligns the type surface in src/modules/activities/types/ticket.ts:

- EventExtra (with notification toggles + custom subject/body), promo
  codes, conditional event config.
- ActivityTicketExtra mirroring backend's TicketExtra (nostr_identifier,
  email/nostr notification_sent flags, refund state).
- TicketedEvent + CreateEventRequest gain allow_fiat, fiat_currency, extra.
- TicketPurchaseInvoice extended for fiat: paymentRequest now optional,
  fiatPaymentRequest + fiatProvider + isFiat added. **Closes a latent
  blocker**: a backend response with is_fiat=true would have lost the
  fiat URL during deserialization (silent crash on QR generation).
- New CreateTicketRequest type for the POST /tickets/{id} body, with
  the v1.6.1 payment_method + fiat_provider + nostr_identifier fields.

TicketApiService:
- requestTicket() accepts the new optional fields (paymentMethod,
  fiatProvider, promoCode, refundAddress, nostrIdentifier) and
  deserializes the full TicketPurchaseInvoice shape including fiat.
- fetchUserTickets() / validateTicket() / new resendTicketEmail()
  thread the extra metadata through.

useTicketPurchase composable rejects fiat responses with a clear
error (the QR-and-bolt11 path doesn't know fiat); the eventual UI
selector will live in PurchaseTicketDialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:47:39 +02:00
13 changed files with 992 additions and 67 deletions

View file

@ -714,6 +714,90 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
VITE_WEBSOCKET_ENABLED=true
```
## Payment Rails Pattern
Shared primitives for modules that mix Lightning + fiat (and, future,
cash / internal-wallet) payment rails. Activities is the first
consumer; restaurant + marketplace will adopt the same primitives as
their backends gain fiat support.
### Vocabulary (canonical — used in code AND UI labels)
| Term | Meaning | Field |
|---|---|---|
| **Price currency** | unit the price is quoted in | `currency` |
| **Payment method** | the rail used to pay (Lightning / Fiat / Cash / Internal) | `payment_method` |
| **Fiat currency** | currency the fiat provider settles in | `fiat_currency` |
| **Also accept fiat** | the user-facing label for the `allow_fiat` toggle | `allow_fiat` |
The bare word `Currency` is **banned** in payment-context UI labels —
it always carries a `Price` or `Fiat` qualifier. The literal string
`Pay in fiat` is also banned on buyer-side buttons — each fiat rail
shows as a button labeled with its provider name (`Stripe`, `PayPal`,
`Square`, `SEPA`). Only the degenerate "providers unknown" fallback
shows a generic `Card`.
### Fiat-provider architecture (LNbits today)
Fiat providers are configured **globally** by the LNbits admin
(`lnbits/settings.py`). Each provider has an optional `allowed_users`
whitelist; the per-session filtered list is exposed as
`User.fiat_providers: string[]` on `GET /api/v1/auth` (which the
webapp already reads as `currentUser.fiat_providers`). Both organizer
and buyer on the same instance see the same list.
Per-user provider configuration is a deferred backend feature. Until
then, `useFiatProviders` reads the same `currentUser.fiat_providers`
for both sides.
### Shared primitives (live in base module)
```
src/modules/base/
├── composables/
│ ├── useFiatProviders.ts // providers + hasAnyProvider + refresh + providerMeta()
│ └── usePriceConversion.ts // convert() + useLivePreview(); 60s cache; null on failure
└── components/payments/
├── PaymentMethodSelector.vue // buyer-side rail picker
├── FiatToggleField.vue // organizer-side toggle + conditional fiat-currency dropdown
└── PriceConversionPreview.vue // muted "≈ X.XX USD" line
```
All three components consume services via DI — never import them
directly across module boundaries.
### `PaymentMethodSelector` data shape
```ts
type PaymentRail = 'lightning' | 'fiat' | 'cash' | 'internal' | (string & {})
type PaymentMethod = {
id: string // unique v-for key, e.g. 'fiat:stripe'
rail: PaymentRail // sent as payment_method
provider?: string // sent as fiat_provider when present
label: string // 'Lightning' | 'Stripe' | 'SEPA' | 'Cash' | …
icon: Component // lucide icon
available: boolean // false ⇒ rendered disabled with tooltip
unavailableReason?: string // tooltip when disabled
badge?: string // optional secondary hint, e.g. '≈ 1000 sats'
}
```
Module usage:
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
- **Restaurant** (future) passes the subset of
`[lightning, cash, internal, ...fiat providers]` enabled by the
restaurant's `accepts_*` flags.
### Adding a new fiat provider
1. Backend exposes the provider id in `User.fiat_providers`.
2. Add the id to `KNOWN_PROVIDERS` in `useFiatProviders.ts` with its
display label and icon hint (`'card' | 'bank' | 'wallet'`).
3. Unknown ids fall back to a `Capitalized` label with a `'card'`
icon hint — no code change required just for the buttons to
render, only for nice branding.
## Mobile Browser File Input & Form Refresh Issues
### **Problem Overview**

View file

@ -40,7 +40,8 @@ interface User {
username?: string
email?: string
pubkey?: string
prvkey?: string // Nostr private key for user
// pragma: allowlist secret
prvkey?: string // Nostr signing key for user
external_id?: string
extensions: string[]
wallets: Wallet[]
@ -191,6 +192,13 @@ export class LnbitsAPI extends BaseService {
})
}
async getConversion(params: { from: string; to: string; amount: number }): Promise<Record<string, number>> {
return this.request<Record<string, number>>('/conversion', {
method: 'POST',
body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }),
})
}
isAuthenticated(): boolean {
return !!this.accessToken
}

View file

@ -32,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'
@ -120,20 +121,35 @@ 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),
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),
})
.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. 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) {
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',
})
}
})
@ -150,6 +166,8 @@ const form = useForm({
event_end_time: '',
location: '',
currency: 'sat',
allow_fiat: false,
fiat_currency: 'USD',
amount_tickets: 0,
price_per_ticket: 0,
}
@ -213,6 +231,8 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time,
location: event.location ?? '',
currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0,
})
@ -318,6 +338,13 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null
}
if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a truefalse 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) {
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
@ -547,48 +574,79 @@ const handleOpenChange = (open: boolean) => {
/>
</div>
<!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Pricing -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Pricing</p>
<p class="text-xs text-muted-foreground">
Set what buyers see. Lightning charges happen in sats;
fiat amounts convert at checkout using current rates.
</p>
</div>
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Price currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Payment methods -->
<div 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>
</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')"
:disabled="isLoading"
/>
</div>
<!-- Actions -->

View file

@ -1,12 +1,20 @@
<script setup lang="ts">
import { onUnmounted } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
} from '@/modules/base/components/payments/PaymentMethodSelector.vue'
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
interface Props {
event: {
@ -14,6 +22,9 @@ interface Props {
name: string
price_per_ticket: number
currency: string
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
}
isOpen: boolean
}
@ -45,19 +56,156 @@ const {
showTicketQR
} = useTicketPurchase()
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats',
)
// Lightning-button badge: when the price is denominated in fiat, show
// the live sat equivalent so the buyer knows roughly what their wallet
// will be charged. Best-effort silent if the conversion fails.
const lightningSats = ref<number | null>(null)
watch(
() => [props.event.currency, props.event.price_per_ticket, props.isOpen] as const,
async ([cur, amt, open]) => {
if (!open || !amt || cur === 'sat' || cur === 'sats') {
lightningSats.value = null
return
}
lightningSats.value = await convert(amt as number, cur as string, 'sat')
},
{ immediate: true },
)
function iconFor(hint: 'card' | 'bank' | 'wallet') {
if (hint === 'bank') return Landmark
if (hint === 'wallet') return Wallet
return CreditCard
}
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = {
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
icon: Zap,
available: true,
badge:
!isPriceInSats.value && lightningSats.value
? `${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
}
if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id)
return {
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,
{
id: 'fiat',
rail: 'fiat',
label: 'Card',
icon: CreditCard,
available: 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
try {
await purchaseTicketForEvent(props.event.id)
} catch (err) {
console.error('Error purchasing ticket:', err)
const method = selectedMethod.value
if (!method) return
// Lightning path: existing composable handles QR + wallet auto-pay.
if (method.rail === 'lightning') {
try {
await purchaseTicketForEvent(props.event.id)
} catch (err) {
console.error('Error purchasing ticket:', err)
}
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.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const currentUser = (lnbitsAPI?.currentUser?.value) || null
const userId = currentUser?.id
if (!userId) {
fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken,
{ paymentMethod: 'fiat', fiatProvider: method.provider },
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
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 {
isFiatPending.value = false
}
}
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
fiatError.value = null
}
onUnmounted(() => {
@ -158,19 +306,68 @@ onUnmounted(() => {
<span class="text-sm text-muted-foreground">Price:</span>
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
</div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="event.price_per_ticket"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
suffix=" if paid in fiat"
/>
</div>
</div>
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error }}
<!-- 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" 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.
Live rates shown are estimates; the exact sat amount locks
in when you start checkout.
</p>
<PaymentMethodSelector
:methods="paymentMethods"
:model-value="selectedMethodId"
@update:model-value="selectedMethodId = $event"
/>
</div>
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ 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 || !canPurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full"
>
<span v-if="isLoading" class="animate-spin mr-2"></span>
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2"></span>
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2">
<CreditCard class="w-4 h-4" />
Continue to {{ selectedMethod.label }} checkout
</span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" />
Pay with Wallet

View file

@ -98,16 +98,31 @@ export function useTicketPurchase() {
currentUser.value!.id,
accessToken
)
// Backend now returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows
// how to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog
// (it has the user-visible payment-method selector). Reject the
// fiat response here so callers get a clear error instead of a
// silent broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
'to follow the provider link.',
)
}
const bolt11: string = invoice.paymentRequest
paymentHash.value = invoice.paymentHash
paymentRequest.value = invoice.paymentRequest
paymentRequest.value = bolt11
// Generate QR code for payment
await generateQRCode(invoice.paymentRequest)
await generateQRCode(bolt11)
// Try to pay with wallet if available
if (hasWalletWithBalance.value) {
try {
await payWithWallet(invoice.paymentRequest)
await payWithWallet(bolt11)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError)

View file

@ -1,5 +1,8 @@
import type {
ActivityTicket,
ActivityTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice,
TicketPaymentStatus,
TicketedEvent,
@ -49,14 +52,38 @@ export class TicketApiService {
}
/**
* Request a ticket purchase (creates a Lightning invoice).
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
* Request a ticket purchase. Returns either a Lightning invoice
* (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest`
* = 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.
*
* Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
*/
async requestTicket(
eventId: string,
userId: string,
accessToken: string
accessToken: string,
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
} = {},
): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
const data = await this.request(
`/events/api/v1/tickets/${eventId}`,
{
@ -65,13 +92,16 @@ export class TicketApiService {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ user_id: userId }),
body: JSON.stringify(body),
}
)
return {
paymentHash: data.payment_hash,
paymentRequest: data.payment_request,
paymentRequest: data.payment_request ?? undefined,
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat),
}
}
@ -121,6 +151,7 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}))
}
@ -144,6 +175,7 @@ export class TicketApiService {
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}))
}
@ -183,6 +215,39 @@ 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.
*
* Endpoint added upstream in v1.6.1 (PR #51).
*/
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<ActivityTicket> {
const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/**
* Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",

View file

@ -1,7 +1,44 @@
/**
* Database-backed ticket types (via LNbits events extension)
* Database-backed ticket types (via LNbits events extension).
*
* Wire-format types names match the snake_case fields the events
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
* below) are the webapp-internal view models after adapter conversion.
*/
export interface PromoCode {
code: string
discount_percent: number
active: boolean
}
/**
* EventExtra mirrors the EventExtra Pydantic model in
* `events/models.py`. Carries promo codes, conditional-event config,
* and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0.
*/
export interface EventExtra {
promo_codes: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
}
export interface ActivityTicketExtra {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
nostr_identifier?: string | null
ticket_base_url?: string | null
email_notification_sent: boolean
nostr_notification_sent: boolean
refunded: boolean
}
export interface ActivityTicket {
id: string
wallet: string
@ -21,19 +58,40 @@ export interface ActivityTicket {
time: string
/** Registration/scan timestamp */
regTimestamp: string
/** Optional metadata promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */
extra?: ActivityTicketExtra
}
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export type PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest {
activityId: string
userId: string
accessToken: string
/** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */
paymentMethod?: PaymentMethod
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */
fiatProvider?: string
}
/**
* Server response from `POST /tickets/{event_id}`. Either Lightning
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
*/
export interface TicketPurchaseInvoice {
paymentHash: string
paymentRequest: string
paymentRequest?: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
}
export interface TicketPaymentStatus {
@ -58,6 +116,10 @@ export interface TicketedEvent {
event_start_date: string
event_end_date: string | null
currency: string
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string
amount_tickets: number
price_per_ticket: number
time: string
@ -65,6 +127,7 @@ export interface TicketedEvent {
banner: string | null
location: string | null
categories: string[]
extra: EventExtra
status: string
}
@ -76,9 +139,34 @@ export interface CreateEventRequest {
event_start_date: string
event_end_date?: string
currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number
price_per_ticket?: number
banner?: string | null
location?: string | null
categories?: string[]
/** Optional notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */
extra?: Partial<EventExtra>
}
/**
* 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.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
}

View file

@ -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

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useFormContext } from 'vee-validate'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
const props = defineProps<{
/** Field name on the parent vee-validate form for the boolean toggle. */
allowFiatField: string
/** Field name on the parent vee-validate form for the fiat-currency dropdown. */
fiatCurrencyField: string
/** Current value of the price-denomination field (e.g. 'sat', 'USD'). */
denomination: string
/** Allowed values for the fiat-currency dropdown. */
availableFiatCurrencies: string[]
/** Disable all controls (e.g. while the parent form is submitting). */
disabled?: boolean
}>()
const { hasAnyProvider, refresh } = useFiatProviders()
const form = useFormContext()
// Refresh once on mount so the disabled-state reflects providers the
// user may have just configured in another tab.
refresh()
// "sat" / "sats" appear interchangeably across the LNbits events
// extension and the webapp's currency lists treat both as the
// BTC-denominated case for the conditional + auto-mirror.
function isSatDenomination(d: string): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it silently mirror so backend payload stays consistent.
watch(
() => props.denomination,
(d) => {
if (!form) return
if (
d &&
!isSatDenomination(d) &&
form.values[props.fiatCurrencyField as keyof typeof form.values] !== d
) {
form.setFieldValue(props.fiatCurrencyField, d)
}
},
{ immediate: true },
)
</script>
<template>
<FormField v-slot="{ value: allowFiat, handleChange: setAllowFiat }" :name="allowFiatField">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
<div class="space-y-0.5">
<FormLabel>Also accept fiat</FormLabel>
<FormDescription class="text-xs">
Buyers can pay with card or bank through your configured provider.
</FormDescription>
</div>
<FormControl>
<TooltipProvider v-if="!hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Switch :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 this.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
v-else
:model-value="allowFiat as boolean"
:disabled="disabled"
@update:model-value="setAllowFiat"
/>
</FormControl>
</FormItem>
<FormField v-slot="{ componentField }" :name="fiatCurrencyField">
<FormItem v-show="(allowFiat as boolean) && isSatDenomination(denomination)">
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="disabled">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableFiatCurrencies"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</FormField>
</template>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type PaymentRail =
| 'lightning'
| 'fiat'
| 'cash'
| 'internal'
| (string & {})
export interface PaymentMethod {
id: string
rail: PaymentRail
provider?: string
label: string
icon: Component
available: boolean
unavailableReason?: string
badge?: string
}
defineProps<{
methods: PaymentMethod[]
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [id: string]
}>()
function select(method: PaymentMethod) {
if (!method.available) return
emit('update:modelValue', method.id)
}
</script>
<template>
<div
class="grid gap-2"
:class="methods.length > 2 ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2'"
>
<template v-for="method in methods" :key="method.id">
<TooltipProvider
v-if="!method.available && method.unavailableReason"
:delay-duration="200"
>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="sm"
disabled
class="opacity-60 flex-col h-auto py-2 gap-1"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ method.unavailableReason }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
type="button"
:variant="modelValue === method.id ? 'default' : 'outline'"
size="sm"
:disabled="!method.available"
class="flex-col h-auto py-2 gap-1"
@click="select(method)"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</template>
</div>
</template>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
const props = withDefaults(
defineProps<{
amount: number
from: string
to: string
/** Text prepended to the conversion line (e.g. "≈" or "Equivalent"). */
prefix?: string
/** Suffix appended after the number (e.g. " at current rate"). */
suffix?: string
}>(),
{
prefix: '≈',
suffix: ' at current rate',
},
)
const { useLivePreview } = usePriceConversion()
const { result, loading } = useLivePreview(
toRef(props, 'amount'),
toRef(props, 'from'),
toRef(props, 'to'),
)
const formatted = computed(() => {
const v = result.value
if (v == null) return null
if (props.to.toLowerCase() === 'sat') {
return `${Math.round(v).toLocaleString()} sats`
}
const fixed = v < 1 ? v.toFixed(4) : v.toFixed(2)
return `${fixed} ${props.to.toUpperCase()}`
})
</script>
<template>
<p v-if="amount > 0" class="text-xs text-muted-foreground">
<span v-if="loading && !formatted">Loading rate</span>
<span v-else-if="formatted">{{ prefix }} {{ formatted }}{{ suffix }}</span>
<span v-else class="opacity-60">(rate unavailable)</span>
</p>
</template>

View file

@ -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<string, FiatProviderMeta> = {
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<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const providers = computed<string[]>(
() => auth.currentUser.value?.fiat_providers ?? []
)
const hasAnyProvider = computed(() => providers.value.length > 0)
async function refresh(): Promise<void> {
await auth.refresh()
}
return { providers, hasAnyProvider, refresh, providerMeta }
}

View file

@ -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<string, CacheEntry>()
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<LnbitsAPI>(SERVICE_TOKENS.LNBITS_API)
async function convert(
amount: number,
from: string,
to: string,
): Promise<number | null> {
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<string, number>).amount ??
(data as Record<string, number>).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<number>,
from: Ref<string>,
to: Ref<string>,
debounceMs = 300,
): { result: Ref<number | null>; loading: Ref<boolean> } {
const result = ref<number | null>(null)
const loading = ref(false)
let activeToken = 0
let timer: ReturnType<typeof setTimeout> | 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 }
}