Compare commits
No commits in common. "99667add656a8867e98974af64eb163d546bb8f7" and "c556d28587cc8d83fd4030a5048c44977e5a090e" have entirely different histories.
99667add65
...
c556d28587
13 changed files with 67 additions and 984 deletions
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -714,90 +714,6 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
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
|
## Mobile Browser File Input & Form Refresh Issues
|
||||||
|
|
||||||
### **Problem Overview**
|
### **Problem Overview**
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,7 @@ interface User {
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
// pragma: allowlist secret
|
prvkey?: string // Nostr private key for user
|
||||||
prvkey?: string // Nostr signing key for user
|
|
||||||
external_id?: string
|
external_id?: string
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
wallets: Wallet[]
|
wallets: Wallet[]
|
||||||
|
|
@ -192,13 +191,6 @@ 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 {
|
isAuthenticated(): boolean {
|
||||||
return !!this.accessToken
|
return !!this.accessToken
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,12 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next'
|
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
|
||||||
import { toastService } from '@/core/services/ToastService'
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||||
import TimePicker from '@/modules/base/components/TimePicker.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 { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
|
@ -121,15 +120,13 @@ const formSchema = toTypedSchema(
|
||||||
event_end_time: z.string().optional().default(''),
|
event_end_time: z.string().optional().default(''),
|
||||||
location: z.string().max(500).optional().default(''),
|
location: z.string().max(500).optional().default(''),
|
||||||
currency: z.string().default("sat"),
|
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),
|
amount_tickets: z.number().min(0).max(100000).default(0),
|
||||||
price_per_ticket: z.number().min(0).default(0),
|
price_per_ticket: z.number().min(0).default(0),
|
||||||
})
|
})
|
||||||
.superRefine((v, ctx) => {
|
.superRefine((v, ctx) => {
|
||||||
// End must not precede start. Compare on the folded date+time
|
// End must not precede start. Compare on the folded date+time
|
||||||
// string so equal-date / later-time is enforced too.
|
// string so equal-date / later-time is enforced too.
|
||||||
if (v.event_end_date) {
|
if (!v.event_end_date) return
|
||||||
const start = foldDateTime(v.event_start_date, v.event_start_time)
|
const start = foldDateTime(v.event_start_date, v.event_start_time)
|
||||||
const end = foldDateTime(v.event_end_date, v.event_end_time)
|
const end = foldDateTime(v.event_end_date, v.event_end_time)
|
||||||
if (start && end && end < start) {
|
if (start && end && end < start) {
|
||||||
|
|
@ -139,17 +136,6 @@ const formSchema = toTypedSchema(
|
||||||
message: 'End must be on or after start',
|
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: ['fiat_currency'],
|
|
||||||
message: 'Pick a fiat currency for buyers paying by card',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -164,8 +150,6 @@ const form = useForm({
|
||||||
event_end_time: '',
|
event_end_time: '',
|
||||||
location: '',
|
location: '',
|
||||||
currency: 'sat',
|
currency: 'sat',
|
||||||
allow_fiat: false,
|
|
||||||
fiat_currency: 'USD',
|
|
||||||
amount_tickets: 0,
|
amount_tickets: 0,
|
||||||
price_per_ticket: 0,
|
price_per_ticket: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -229,8 +213,6 @@ async function populateFromEvent(event: TicketedEvent) {
|
||||||
event_end_time: end.time,
|
event_end_time: end.time,
|
||||||
location: event.location ?? '',
|
location: event.location ?? '',
|
||||||
currency: event.currency ?? 'sat',
|
currency: event.currency ?? 'sat',
|
||||||
allow_fiat: event.allow_fiat ?? false,
|
|
||||||
fiat_currency: event.fiat_currency ?? 'USD',
|
|
||||||
amount_tickets: event.amount_tickets ?? 0,
|
amount_tickets: event.amount_tickets ?? 0,
|
||||||
price_per_ticket: event.price_per_ticket ?? 0,
|
price_per_ticket: event.price_per_ticket ?? 0,
|
||||||
})
|
})
|
||||||
|
|
@ -336,13 +318,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
eventData.banner = null
|
eventData.banner = null
|
||||||
}
|
}
|
||||||
if (formValues.currency) eventData.currency = formValues.currency
|
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) {
|
|
||||||
eventData.fiat_currency = formValues.fiat_currency
|
|
||||||
}
|
|
||||||
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
||||||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||||
|
|
@ -572,15 +547,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Pricing ──────────────────────────────────────────── -->
|
<!-- Tickets (optional, visible) -->
|
||||||
<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">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<FormField v-slot="{ componentField }" name="amount_tickets">
|
<FormField v-slot="{ componentField }" name="amount_tickets">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
|
@ -606,7 +573,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="currency">
|
<FormField v-slot="{ componentField }" name="currency">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Price currency</FormLabel>
|
<FormLabel>Currency</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select v-bind="componentField">
|
<Select v-bind="componentField">
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -623,29 +590,6 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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')"
|
|
||||||
:disabled="isLoading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { onUnmounted } from 'vue'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
|
||||||
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 { 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 {
|
interface Props {
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -22,9 +14,6 @@ interface Props {
|
||||||
name: string
|
name: string
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
/** Whether the event accepts fiat payments. From v1.4.0+ */
|
|
||||||
allow_fiat?: boolean
|
|
||||||
fiat_currency?: string
|
|
||||||
}
|
}
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -56,153 +45,19 @@ const {
|
||||||
showTicketQR
|
showTicketQR
|
||||||
} = useTicketPurchase()
|
} = 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))
|
|
||||||
|
|
||||||
// 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 || cur === 'sat' || !amt) {
|
|
||||||
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:
|
|
||||||
props.event.currency !== 'sat' && 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() {
|
async function handlePurchase() {
|
||||||
if (!canPurchase.value) return
|
if (!canPurchase.value) return
|
||||||
fiatError.value = null
|
|
||||||
|
|
||||||
const method = selectedMethod.value
|
|
||||||
if (!method) return
|
|
||||||
|
|
||||||
// Lightning path: existing composable handles QR + wallet auto-pay.
|
|
||||||
if (method.rail === 'lightning') {
|
|
||||||
try {
|
try {
|
||||||
await purchaseTicketForEvent(props.event.id)
|
await purchaseTicketForEvent(props.event.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error purchasing ticket:', 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() {
|
function handleClose() {
|
||||||
emit('update:isOpen', false)
|
emit('update:isOpen', false)
|
||||||
resetPaymentState()
|
resetPaymentState()
|
||||||
selectedMethodId.value = 'lightning'
|
|
||||||
fiatRedirectUrl.value = null
|
|
||||||
fiatProviderLabel.value = null
|
|
||||||
fiatError.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -303,68 +158,19 @@ onUnmounted(() => {
|
||||||
<span class="text-sm text-muted-foreground">Price:</span>
|
<span class="text-sm text-muted-foreground">Price:</span>
|
||||||
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
|
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<PriceConversionPreview
|
|
||||||
v-if="canChooseFiat && event.currency === 'sat' && event.fiat_currency"
|
|
||||||
:amount="event.price_per_ticket"
|
|
||||||
from="sat"
|
|
||||||
:to="event.fiat_currency"
|
|
||||||
prefix="Equivalent ~"
|
|
||||||
suffix=" if paid in fiat"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment method selector (only shown when fiat is enabled
|
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||||
on the event). Buttons surface one per configured fiat
|
{{ error }}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
v-else
|
|
||||||
@click="handlePurchase"
|
@click="handlePurchase"
|
||||||
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
:disabled="isLoading || !canPurchase"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2">⚡</span>
|
<span v-if="isLoading" 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">
|
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
|
||||||
<Zap class="w-4 h-4" />
|
<Zap class="w-4 h-4" />
|
||||||
Pay with Wallet
|
Pay with Wallet
|
||||||
|
|
|
||||||
|
|
@ -98,31 +98,16 @@ export function useTicketPurchase() {
|
||||||
currentUser.value!.id,
|
currentUser.value!.id,
|
||||||
accessToken
|
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
|
paymentHash.value = invoice.paymentHash
|
||||||
paymentRequest.value = bolt11
|
paymentRequest.value = invoice.paymentRequest
|
||||||
|
|
||||||
// Generate QR code for payment
|
// Generate QR code for payment
|
||||||
await generateQRCode(bolt11)
|
await generateQRCode(invoice.paymentRequest)
|
||||||
|
|
||||||
// Try to pay with wallet if available
|
// Try to pay with wallet if available
|
||||||
if (hasWalletWithBalance.value) {
|
if (hasWalletWithBalance.value) {
|
||||||
try {
|
try {
|
||||||
await payWithWallet(bolt11)
|
await payWithWallet(invoice.paymentRequest)
|
||||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
||||||
} catch (walletError) {
|
} catch (walletError) {
|
||||||
console.log('Wallet payment failed, falling back to manual payment:', walletError)
|
console.log('Wallet payment failed, falling back to manual payment:', walletError)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import type {
|
import type {
|
||||||
ActivityTicket,
|
ActivityTicket,
|
||||||
ActivityTicketExtra,
|
|
||||||
CreateTicketRequest,
|
|
||||||
PaymentMethod,
|
|
||||||
TicketPurchaseInvoice,
|
TicketPurchaseInvoice,
|
||||||
TicketPaymentStatus,
|
TicketPaymentStatus,
|
||||||
TicketedEvent,
|
TicketedEvent,
|
||||||
|
|
@ -52,38 +49,14 @@ export class TicketApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a ticket purchase. Returns either a Lightning invoice
|
* Request a ticket purchase (creates a Lightning invoice).
|
||||||
* (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest`
|
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
|
||||||
* = 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(
|
async requestTicket(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string,
|
accessToken: string
|
||||||
options: {
|
|
||||||
paymentMethod?: PaymentMethod
|
|
||||||
fiatProvider?: string
|
|
||||||
promoCode?: string
|
|
||||||
refundAddress?: string
|
|
||||||
nostrIdentifier?: string
|
|
||||||
} = {},
|
|
||||||
): Promise<TicketPurchaseInvoice> {
|
): 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(
|
const data = await this.request(
|
||||||
`/events/api/v1/tickets/${eventId}`,
|
`/events/api/v1/tickets/${eventId}`,
|
||||||
{
|
{
|
||||||
|
|
@ -92,16 +65,13 @@ export class TicketApiService {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({ user_id: userId }),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentHash: data.payment_hash,
|
paymentHash: data.payment_hash,
|
||||||
paymentRequest: data.payment_request ?? undefined,
|
paymentRequest: data.payment_request,
|
||||||
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
|
|
||||||
fiatProvider: data.fiat_provider ?? undefined,
|
|
||||||
isFiat: Boolean(data.is_fiat),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +121,6 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as ActivityTicketExtra | undefined,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +144,6 @@ export class TicketApiService {
|
||||||
paid: t.paid,
|
paid: t.paid,
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as ActivityTicketExtra | undefined,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,39 +183,6 @@ 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
|
* Probe whether the current user has LNbits admin privileges. The
|
||||||
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* 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 {
|
export interface ActivityTicket {
|
||||||
id: string
|
id: string
|
||||||
wallet: string
|
wallet: string
|
||||||
|
|
@ -58,40 +21,19 @@ export interface ActivityTicket {
|
||||||
time: string
|
time: string
|
||||||
/** Registration/scan timestamp */
|
/** Registration/scan timestamp */
|
||||||
regTimestamp: string
|
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 TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
||||||
|
|
||||||
export type PaymentMethod = 'lightning' | 'fiat'
|
|
||||||
|
|
||||||
export interface TicketPurchaseRequest {
|
export interface TicketPurchaseRequest {
|
||||||
activityId: string
|
activityId: string
|
||||||
userId: string
|
userId: string
|
||||||
accessToken: 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 {
|
export interface TicketPurchaseInvoice {
|
||||||
paymentHash: string
|
paymentHash: string
|
||||||
paymentRequest?: string
|
paymentRequest: string
|
||||||
fiatPaymentRequest?: string
|
|
||||||
fiatProvider?: string
|
|
||||||
isFiat: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketPaymentStatus {
|
export interface TicketPaymentStatus {
|
||||||
|
|
@ -116,10 +58,6 @@ export interface TicketedEvent {
|
||||||
event_start_date: string
|
event_start_date: string
|
||||||
event_end_date: string | null
|
event_end_date: string | null
|
||||||
currency: string
|
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
|
amount_tickets: number
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
time: string
|
time: string
|
||||||
|
|
@ -127,7 +65,6 @@ export interface TicketedEvent {
|
||||||
banner: string | null
|
banner: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
categories: string[]
|
categories: string[]
|
||||||
extra: EventExtra
|
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,34 +76,9 @@ export interface CreateEventRequest {
|
||||||
event_start_date: string
|
event_start_date: string
|
||||||
event_end_date?: string
|
event_end_date?: string
|
||||||
currency?: string
|
currency?: string
|
||||||
allow_fiat?: boolean
|
|
||||||
fiat_currency?: string
|
|
||||||
amount_tickets?: number
|
amount_tickets?: number
|
||||||
price_per_ticket?: number
|
price_per_ticket?: number
|
||||||
banner?: string | null
|
banner?: string | null
|
||||||
location?: string | null
|
location?: string | null
|
||||||
categories?: string[]
|
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ const selectedEvent = ref<{
|
||||||
name: string
|
name: string
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
allow_fiat?: boolean
|
|
||||||
fiat_currency?: string
|
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const showEventDialog = ref(false)
|
const showEventDialog = ref(false)
|
||||||
|
|
@ -58,8 +56,6 @@ function handlePurchaseClick(event: {
|
||||||
name: string
|
name: string
|
||||||
price_per_ticket: number
|
price_per_ticket: number
|
||||||
currency: string
|
currency: string
|
||||||
allow_fiat?: boolean
|
|
||||||
fiat_currency?: string
|
|
||||||
}) {
|
}) {
|
||||||
if (!isAuthenticated.value) return
|
if (!isAuthenticated.value) return
|
||||||
selectedEvent.value = event
|
selectedEvent.value = event
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, watch } from 'vue'
|
|
||||||
import { useField } from 'vee-validate'
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
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 { value: allowFiat, handleChange: setAllowFiat } = useField<boolean>(
|
|
||||||
() => props.allowFiatField,
|
|
||||||
)
|
|
||||||
const { value: fiatCurrency, setValue: setFiatCurrency } = useField<string>(
|
|
||||||
() => props.fiatCurrencyField,
|
|
||||||
)
|
|
||||||
|
|
||||||
const { hasAnyProvider, refresh } = useFiatProviders()
|
|
||||||
|
|
||||||
// Refresh once on mount so the disabled-state reflects providers the
|
|
||||||
// user may have just configured in another tab.
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
const showCurrencyDropdown = computed(
|
|
||||||
() => allowFiat.value && props.denomination === 'sat',
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 (d && d !== 'sat' && fiatCurrency.value !== d) {
|
|
||||||
setFiatCurrency(d)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<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"
|
|
||||||
:disabled="disabled"
|
|
||||||
@update:model-value="setAllowFiat"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<FormItem v-show="showCurrencyDropdown">
|
|
||||||
<FormLabel>Fiat currency</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
:model-value="fiatCurrency"
|
|
||||||
:disabled="disabled"
|
|
||||||
@update:model-value="(v) => setFiatCurrency(v as string)"
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="USD" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="c in availableFiatCurrencies"
|
|
||||||
:key="c"
|
|
||||||
:value="c"
|
|
||||||
>
|
|
||||||
{{ c }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue