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>
This commit is contained in:
parent
985c10939d
commit
574c178d89
1 changed files with 121 additions and 36 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref, watch } 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'
|
||||||
|
|
@ -7,9 +7,14 @@ import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { PaymentMethod } from '../types/ticket'
|
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark } from 'lucide-vue-next'
|
||||||
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink } 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: {
|
||||||
|
|
@ -51,7 +56,10 @@ const {
|
||||||
showTicketQR
|
showTicketQR
|
||||||
} = useTicketPurchase()
|
} = useTicketPurchase()
|
||||||
|
|
||||||
const paymentMethod = ref<PaymentMethod>('lightning')
|
const { providers, providerMeta } = useFiatProviders()
|
||||||
|
const { convert } = usePriceConversion()
|
||||||
|
|
||||||
|
const selectedMethodId = ref<string>('lightning')
|
||||||
const fiatRedirectUrl = ref<string | null>(null)
|
const fiatRedirectUrl = ref<string | null>(null)
|
||||||
const fiatProviderLabel = ref<string | null>(null)
|
const fiatProviderLabel = ref<string | null>(null)
|
||||||
const isFiatPending = ref(false)
|
const isFiatPending = ref(false)
|
||||||
|
|
@ -59,12 +67,85 @@ const fiatError = ref<string | null>(null)
|
||||||
|
|
||||||
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
|
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
|
fiatError.value = null
|
||||||
|
|
||||||
|
const method = selectedMethod.value
|
||||||
|
if (!method) return
|
||||||
|
|
||||||
// Lightning path: existing composable handles QR + wallet auto-pay.
|
// Lightning path: existing composable handles QR + wallet auto-pay.
|
||||||
if (paymentMethod.value === 'lightning') {
|
if (method.rail === 'lightning') {
|
||||||
try {
|
try {
|
||||||
await purchaseTicketForEvent(props.event.id)
|
await purchaseTicketForEvent(props.event.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -74,9 +155,10 @@ async function handlePurchase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
|
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
|
||||||
// API directly, then redirect the buyer to the provider's checkout
|
// API directly with the chosen provider, then redirect the buyer to
|
||||||
// URL. Payment confirmation happens via webhook on the backend and
|
// the provider's checkout URL. Payment confirmation happens via
|
||||||
// shows up next time the buyer reloads MyTickets.
|
// webhook on the backend and shows up next time the buyer reloads
|
||||||
|
// MyTickets.
|
||||||
try {
|
try {
|
||||||
isFiatPending.value = true
|
isFiatPending.value = true
|
||||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||||
|
|
@ -92,14 +174,16 @@ async function handlePurchase() {
|
||||||
props.event.id,
|
props.event.id,
|
||||||
userId,
|
userId,
|
||||||
accessToken,
|
accessToken,
|
||||||
{ paymentMethod: 'fiat' },
|
{ paymentMethod: 'fiat', fiatProvider: method.provider },
|
||||||
)
|
)
|
||||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||||
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fiatRedirectUrl.value = invoice.fiatPaymentRequest
|
fiatRedirectUrl.value = invoice.fiatPaymentRequest
|
||||||
fiatProviderLabel.value = invoice.fiatProvider ?? 'provider'
|
fiatProviderLabel.value = invoice.fiatProvider
|
||||||
|
? providerMeta(invoice.fiatProvider).label
|
||||||
|
: method.label
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
|
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -115,7 +199,7 @@ function openFiatCheckout() {
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('update:isOpen', false)
|
emit('update:isOpen', false)
|
||||||
resetPaymentState()
|
resetPaymentState()
|
||||||
paymentMethod.value = 'lightning'
|
selectedMethodId.value = 'lightning'
|
||||||
fiatRedirectUrl.value = null
|
fiatRedirectUrl.value = null
|
||||||
fiatProviderLabel.value = null
|
fiatProviderLabel.value = null
|
||||||
fiatError.value = null
|
fiatError.value = null
|
||||||
|
|
@ -219,34 +303,35 @@ 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
|
<!-- Payment method selector (only shown when fiat is enabled
|
||||||
on the event). Hidden entirely for Lightning-only events
|
on the event). Buttons surface one per configured fiat
|
||||||
to keep the dialog uncluttered. -->
|
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 v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
<div class="text-sm font-medium">Payment method</div>
|
<div class="text-sm font-medium">Payment method</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<p class="text-xs text-muted-foreground">
|
||||||
<Button
|
Both methods charge the same amount via different rails.
|
||||||
type="button"
|
Live rates shown are estimates; the exact sat amount locks
|
||||||
:variant="paymentMethod === 'lightning' ? 'default' : 'outline'"
|
in when you start checkout.
|
||||||
size="sm"
|
</p>
|
||||||
@click="paymentMethod = 'lightning'"
|
<PaymentMethodSelector
|
||||||
>
|
:methods="paymentMethods"
|
||||||
<Zap class="w-4 h-4 mr-1.5" />
|
:model-value="selectedMethodId"
|
||||||
Lightning
|
@update:model-value="selectedMethodId = $event"
|
||||||
</Button>
|
/>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
:variant="paymentMethod === 'fiat' ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
@click="paymentMethod = 'fiat'"
|
|
||||||
>
|
|
||||||
<CreditCard class="w-4 h-4 mr-1.5" />
|
|
||||||
{{ event.fiat_currency ?? 'Fiat' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
|
||||||
|
|
@ -272,13 +357,13 @@ onUnmounted(() => {
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
@click="handlePurchase"
|
@click="handlePurchase"
|
||||||
:disabled="isLoading || isFiatPending || (paymentMethod === 'lightning' && !canPurchase)"
|
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2">⚡</span>
|
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2">⚡</span>
|
||||||
<span v-else-if="paymentMethod === 'fiat'" class="flex items-center gap-2">
|
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2">
|
||||||
<CreditCard class="w-4 h-4" />
|
<CreditCard class="w-4 h-4" />
|
||||||
Continue to {{ event.fiat_currency ?? 'fiat' }} checkout
|
Continue to {{ selectedMethod.label }} checkout
|
||||||
</span>
|
</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" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue