feat(events): buy from a chosen ticket wave

Checkout was priced and requested off the event, which since events ext
v1.6.8 means the PRIMARY wave. Two things were wrong at once: once early
bird closed the dialog displayed the closed tier's price while the
backend charged the open one, and an event with two waves open could not
be bought at all — the purchase endpoint refuses to guess ("Please
select a ticket wave") and nothing sent `ticket_wave_id`.

The dialog now resolves a wave the way the backend does: a single open
wave is implied, several means the buyer picks, none means nothing is on
sale. Price, currency and fiat availability all read from that wave —
`allow_fiat` too, which is a per-wave opt-in and could otherwise offer a
rail the purchase would refuse. The picker only renders when there is an
actual choice, so the common single-wave case gains no extra click, and
the CTA is disabled rather than firing a request the backend will reject.

`ticket_wave_id` now rides on all three purchase paths (discounted-free,
Lightning, fiat) and on the promo preview, so the quote and the charge
are priced against the same tier.

Plumbing worth noting: the tags a card renders from describe the active
wave but carry no wave id, by the flat-tag decision on aiolabs/events#61.
So the detail page fetches the LNbits record for the pickable tiers —
possible only because `ticket_waves` is public from v1.6.8-aio.2
(aiolabs/events#66). Non-fatal on failure: the backend answers 410 for
sold-out or closed events, where there is no tier to pick anyway.

3 service tests pin the wire format, including that no empty wave id is
sent when the buyer had no choice. 62 pass; vue-tsc and prettier clean.
This commit is contained in:
Padreug 2026-09-29 08:23:52 +02:00
commit afa34c90c2
6 changed files with 1619 additions and 937 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,17 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue' import {
import { injectService, SERVICE_TOKENS } from '@/core/di-container' computed,
import type { TicketApiService } from '../services/TicketApiService' ref,
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes' toValue,
watch,
type MaybeRefOrGetter,
type Ref,
} from "vue";
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { TicketApiService } from "../services/TicketApiService";
import {
isUnsupportedEndpointError,
normalizePromoCode,
} from "../lib/promoCodes";
/** /**
* Buyer-side promo state for the purchase dialog. * Buyer-side promo state for the purchase dialog.
@ -17,111 +27,138 @@ import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCode
* every extension version. * every extension version.
*/ */
export type PromoState = export type PromoState =
| { status: 'idle' } | { status: "idle" }
| { status: 'checking'; code: string } | { status: "checking"; code: string }
| { | {
status: 'applied' status: "applied";
code: string code: string;
discountPercent: number discountPercent: number;
subtotal: number subtotal: number;
discount: number discount: number;
total: number total: number;
currency: string currency: string;
} }
| { status: 'invalid'; code: string } | { status: "invalid"; code: string }
| { status: 'unverified'; code: string } | { status: "unverified"; code: string };
const REVALIDATE_DEBOUNCE_MS = 300 const REVALIDATE_DEBOUNCE_MS = 300;
export function usePromoCode(eventId: MaybeRefOrGetter<string>, quantity: Ref<number>) { export function usePromoCode(
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService eventId: MaybeRefOrGetter<string>,
quantity: Ref<number>,
ticketWaveId?: MaybeRefOrGetter<string | undefined>,
) {
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
/** What the buyer typed (kept upper-case by the input handler). */ /** What the buyer typed (kept upper-case by the input handler). */
const input = ref('') const input = ref("");
const state = ref<PromoState>({ status: 'idle' }) const state = ref<PromoState>({ status: "idle" });
// Monotonic request counter so a slow response for a previous code or // Monotonic request counter so a slow response for a previous code or
// quantity can never overwrite a newer result. // quantity can never overwrite a newer result.
let requestSeq = 0 let requestSeq = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const isChecking = computed(() => state.value.status === 'checking') const isChecking = computed(() => state.value.status === "checking");
/** Code to put on the purchase request. `invalid` deliberately sends /** Code to put on the purchase request. `invalid` deliberately sends
* nothing so the buyer is not surprised by a 400 at pay time. */ * nothing so the buyer is not surprised by a 400 at pay time. */
const codeToSend = computed<string | undefined>(() => { const codeToSend = computed<string | undefined>(() => {
const s = state.value const s = state.value;
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined return s.status === "applied" || s.status === "unverified"
}) ? s.code
: undefined;
});
const appliedTotal = computed<number | null>(() => const appliedTotal = computed<number | null>(() =>
state.value.status === 'applied' ? state.value.total : null, state.value.status === "applied" ? state.value.total : null,
) );
async function validate(code: string): Promise<void> { async function validate(code: string): Promise<void> {
const seq = ++requestSeq const seq = ++requestSeq;
state.value = { status: 'checking', code } state.value = { status: "checking", code };
try { try {
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value) const totals = await ticketApi.validatePromoCode(
if (seq !== requestSeq) return toValue(eventId),
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0] code,
quantity.value,
toValue(ticketWaveId),
);
if (seq !== requestSeq) return;
const hit =
totals.discounts_applied.find((d) => d.code === code) ??
totals.discounts_applied[0];
if (!hit) { if (!hit) {
state.value = { status: 'invalid', code } state.value = { status: "invalid", code };
return return;
} }
state.value = { state.value = {
status: 'applied', status: "applied",
code: hit.code, code: hit.code,
discountPercent: hit.discount_percent ?? 0, discountPercent: hit.discount_percent ?? 0,
subtotal: totals.subtotal, subtotal: totals.subtotal,
discount: totals.discount, discount: totals.discount,
total: totals.total, total: totals.total,
currency: totals.currency, currency: totals.currency,
} };
} catch (err) { } catch (err) {
if (seq !== requestSeq) return if (seq !== requestSeq) return;
if (isUnsupportedEndpointError(err)) { if (isUnsupportedEndpointError(err)) {
state.value = { status: 'unverified', code } state.value = { status: "unverified", code };
return return;
} }
// Network / 5xx: don't block the purchase on an advisory call. // Network / 5xx: don't block the purchase on an advisory call.
console.warn('[usePromoCode] validate failed, sending code unverified:', err) console.warn(
state.value = { status: 'unverified', code } "[usePromoCode] validate failed, sending code unverified:",
err,
);
state.value = { status: "unverified", code };
} }
} }
async function apply(): Promise<void> { async function apply(): Promise<void> {
const code = normalizePromoCode(input.value) const code = normalizePromoCode(input.value);
if (!code) return if (!code) return;
input.value = code input.value = code;
await validate(code) await validate(code);
} }
function clear(): void { function clear(): void {
requestSeq++ requestSeq++;
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null debounceTimer = null;
input.value = '' input.value = "";
state.value = { status: 'idle' } state.value = { status: "idle" };
} }
/** Re-check the current code (quantity changed, or the purchase /** Re-check the current code (quantity changed, or the purchase
* endpoint rejected it — e.g. the last use went to someone else). */ * endpoint rejected it — e.g. the last use went to someone else). */
async function revalidate(): Promise<void> { async function revalidate(): Promise<void> {
const s = state.value const s = state.value;
if (s.status === 'idle' || s.status === 'checking') return if (s.status === "idle" || s.status === "checking") return;
await validate(s.code) await validate(s.code);
} }
watch(quantity, () => { watch(quantity, () => {
const s = state.value const s = state.value;
if (s.status === 'idle') return if (s.status === "idle") return;
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
debounceTimer = null debounceTimer = null;
void revalidate() void revalidate();
}, REVALIDATE_DEBOUNCE_MS) }, REVALIDATE_DEBOUNCE_MS);
}) });
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate } return {
input,
state,
isChecking,
codeToSend,
appliedTotal,
apply,
clear,
revalidate,
};
} }

View file

@ -1,49 +1,56 @@
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from "vue";
import { useAuth } from '@/composables/useAuthService' import { useAuth } from "@/composables/useAuthService";
import { toast } from 'vue-sonner' import { toast } from "vue-sonner";
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from "@/core/composables/useAsyncOperation";
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from "@/core/services/PaymentService";
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from "../services/TicketApiService";
import { useOwnedTickets } from './useOwnedTickets' import { useOwnedTickets } from "./useOwnedTickets";
import { frontendRoot } from '../lib/frontendRoot' import { frontendRoot } from "../lib/frontendRoot";
import { renderTicketQr } from '../lib/ticketQr' import { renderTicketQr } from "../lib/ticketQr";
/** Guest identity for a purchase without an LNbits account. */ /** Guest identity for a purchase without an LNbits account. */
export interface PurchaseContact { export interface PurchaseContact {
name: string name: string;
email: string email: string;
} }
export interface PurchaseTicketOptions { export interface PurchaseTicketOptions {
quantity?: number quantity?: number;
/** Guest buyer (no account). Required when not authenticated. */ /** Guest buyer (no account). Required when not authenticated. */
contact?: PurchaseContact contact?: PurchaseContact;
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */ /** Delivery email for a logged-in buyer (guests carry it in `contact`). */
email?: string email?: string;
/** Promo code to redeem (already normalised by the dialog). */ /** Promo code to redeem (already normalised by the dialog). */
promoCode?: string promoCode?: string;
/** Pricing tier the buyer chose. Required when several waves are open;
* the backend refuses to guess. */
ticketWaveId?: string;
} }
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth();
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const paymentService = injectService(
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService SERVICE_TOKENS.PAYMENT_SERVICE,
) as PaymentService;
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
// Refresh the shared owned-tickets singleton after a purchase so the // Refresh the shared owned-tickets singleton after a purchase so the
// feed/calendar "My tickets" filter and EventCard owned badges update // feed/calendar "My tickets" filter and EventCard owned badges update
// without a reload — purchase is exactly the "consumer that mutates // without a reload — purchase is exactly the "consumer that mutates
// the ticket set" useOwnedTickets's docs anticipate. // the ticket set" useOwnedTickets's docs anticipate.
const { refresh: refreshOwnedTickets } = useOwnedTickets() const { refresh: refreshOwnedTickets } = useOwnedTickets();
// Async operations // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation();
// State // State
const paymentHash = ref<string | null>(null) const paymentHash = ref<string | null>(null);
const paymentRequest = ref<string | null>(null) const paymentRequest = ref<string | null>(null);
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null);
const isPaymentPending = ref(false) const isPaymentPending = ref(false);
// Ticket QR code state. After payment lands, `purchasedTicketIds` // Ticket QR code state. After payment lands, `purchasedTicketIds`
// is populated with every row id created on the invoice (one for // is populated with every row id created on the invoice (one for
@ -51,62 +58,68 @@ export function useTicketPurchase() {
// parallel map id → QR data URL so the UI can render one QR per // parallel map id → QR data URL so the UI can render one QR per
// attendee. `purchasedTicketId` stays for back-compat with the // attendee. `purchasedTicketId` stays for back-compat with the
// single-id success path. // single-id success path.
const ticketQRCode = ref<string | null>(null) const ticketQRCode = ref<string | null>(null);
const ticketQRCodes = ref<Record<string, string>>({}) const ticketQRCodes = ref<Record<string, string>>({});
const purchasedTicketId = ref<string | null>(null) const purchasedTicketId = ref<string | null>(null);
const purchasedTicketIds = ref<string[]>([]) const purchasedTicketIds = ref<string[]>([]);
const showTicketQR = ref(false) const showTicketQR = ref(false);
// Computed properties // Computed properties
const canPurchase = computed(() => isAuthenticated.value && currentUser.value) const canPurchase = computed(
() => isAuthenticated.value && currentUser.value,
);
const userDisplay = computed(() => { const userDisplay = computed(() => {
if (!currentUser.value) return null if (!currentUser.value) return null;
return { return {
name: currentUser.value.username || currentUser.value.id, name: currentUser.value.username || currentUser.value.id,
shortId: currentUser.value.id.slice(0, 8) shortId: currentUser.value.id.slice(0, 8),
} };
}) });
// Delegate to PaymentService // Delegate to PaymentService
const userWallets = computed(() => paymentService.userWallets) const userWallets = computed(() => paymentService.userWallets);
const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance) const hasWalletWithBalance = computed(
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value) () => paymentService.hasWalletWithBalance,
);
const isPayingWithWallet = computed(
() => paymentService.isProcessingPayment.value,
);
async function generateQRCode(bolt11: string) { async function generateQRCode(bolt11: string) {
try { try {
qrCode.value = await paymentService.generateQRCode(bolt11) qrCode.value = await paymentService.generateQRCode(bolt11);
} catch (err) { } catch (err) {
console.error('Error generating QR code:', err) console.error("Error generating QR code:", err);
} }
} }
async function generateTicketQRCode(ticketId: string) { async function generateTicketQRCode(ticketId: string) {
try { try {
const dataUrl = await renderTicketQr(ticketId, { width: 160 }) const dataUrl = await renderTicketQr(ticketId, { width: 160 });
ticketQRCode.value = dataUrl ticketQRCode.value = dataUrl;
return dataUrl return dataUrl;
} catch (error) { } catch (error) {
console.error('Error generating ticket QR code:', error) console.error("Error generating ticket QR code:", error);
return null return null;
} }
} }
async function payWithWallet(pr: string) { async function payWithWallet(pr: string) {
try { try {
await paymentService.payWithWallet(pr, undefined, { await paymentService.payWithWallet(pr, undefined, {
showToast: false showToast: false,
}) });
return true return true;
} catch (error) { } catch (error) {
console.error('Wallet payment failed:', error) console.error("Wallet payment failed:", error);
throw error throw error;
} }
} }
/** The event id this composable is currently driving — kept so /** The event id this composable is currently driving — kept so
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't * `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* have to take it as an argument from the UI. */ * have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null) const currentEventId = ref<string | null>(null);
/** /**
* Advance to the ticket-QR success state for the given row ids. Shared * Advance to the ticket-QR success state for the given row ids. Shared
@ -118,26 +131,26 @@ export function useTicketPurchase() {
// Ticket row(s) now exist — refresh the shared owned-tickets state so // Ticket row(s) now exist — refresh the shared owned-tickets state so
// the feed/calendar My-tickets filter and owned badges reflect the // the feed/calendar My-tickets filter and owned badges reflect the
// purchase immediately (no reload). Runs in parallel with QR gen. // purchase immediately (no reload). Runs in parallel with QR gen.
void refreshOwnedTickets() void refreshOwnedTickets();
if (ids.length > 0) { if (ids.length > 0) {
purchasedTicketIds.value = ids purchasedTicketIds.value = ids;
purchasedTicketId.value = ids[0] purchasedTicketId.value = ids[0];
const qrMap: Record<string, string> = {} const qrMap: Record<string, string> = {};
for (const id of ids) { for (const id of ids) {
const dataUrl = await generateTicketQRCode(id) const dataUrl = await generateTicketQRCode(id);
if (dataUrl) qrMap[id] = dataUrl if (dataUrl) qrMap[id] = dataUrl;
} }
ticketQRCodes.value = qrMap ticketQRCodes.value = qrMap;
ticketQRCode.value = qrMap[ids[0]] ?? null ticketQRCode.value = qrMap[ids[0]] ?? null;
showTicketQR.value = true showTicketQR.value = true;
} }
toast.success( toast.success(
ids.length > 1 ids.length > 1
? `${ids.length} tickets purchased!` ? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!', : "Ticket purchased successfully!",
) );
} }
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
@ -147,78 +160,82 @@ export function useTicketPurchase() {
// Guests are identified by name + email; logged-in buyers by user id. // Guests are identified by name + email; logged-in buyers by user id.
// Either is enough for a paid ticket — free tickets stay login-gated // Either is enough for a paid ticket — free tickets stay login-gated
// in the dialog (events#29: no anonymous free issuance). // in the dialog (events#29: no anonymous free issuance).
const isGuest = !canPurchase.value || !currentUser.value const isGuest = !canPurchase.value || !currentUser.value;
if (isGuest && !options.contact) { if (isGuest && !options.contact) {
throw new Error('User must be authenticated to purchase tickets') throw new Error("User must be authenticated to purchase tickets");
} }
return await purchaseOperation.execute(async () => { return await purchaseOperation.execute(
// Clear previous state async () => {
paymentHash.value = null // Clear previous state
paymentRequest.value = null paymentHash.value = null;
qrCode.value = null paymentRequest.value = null;
ticketQRCode.value = null qrCode.value = null;
ticketQRCodes.value = {} ticketQRCode.value = null;
purchasedTicketId.value = null ticketQRCodes.value = {};
purchasedTicketIds.value = [] purchasedTicketId.value = null;
showTicketQR.value = false purchasedTicketIds.value = [];
currentEventId.value = eventId showTicketQR.value = false;
currentEventId.value = eventId;
// Get the invoice via TicketApiService // Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any;
const accessToken = lnbitsAPI?.getAccessToken?.() || '' const accessToken = lnbitsAPI?.getAccessToken?.() || "";
const invoice = await ticketApi.requestTicket(eventId, { const invoice = await ticketApi.requestTicket(eventId, {
userId: isGuest ? undefined : currentUser.value!.id, userId: isGuest ? undefined : currentUser.value!.id,
name: isGuest ? options.contact?.name : undefined, name: isGuest ? options.contact?.name : undefined,
email: isGuest ? options.contact?.email : options.email, email: isGuest ? options.contact?.email : options.email,
accessToken: isGuest ? undefined : accessToken, accessToken: isGuest ? undefined : accessToken,
frontendUrl: frontendRoot(), frontendUrl: frontendRoot(),
quantity: options.quantity, quantity: options.quantity,
promoCode: options.promoCode, promoCode: options.promoCode,
}) ticketWaveId: options.ticketWaveId,
});
// Free tickets (price 0 / 100%-off promo): the backend already // Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip // issued + marked them paid, so there's no invoice to settle. Skip
// the QR / payment-poll and jump straight to the ticket-QR success // the QR / payment-poll and jump straight to the ticket-QR success
// state with the ids returned inline. // state with the ids returned inline.
if (invoice.paid) { if (invoice.paid) {
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash;
await finalizePurchasedTickets(invoice.ticketIds ?? []) await finalizePurchasedTickets(invoice.ticketIds ?? []);
return invoice return invoice;
} }
// Otherwise the backend returns either a Lightning invoice or a fiat // Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how // checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate // to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it // redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat // has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent // response here so callers get a clear error instead of a silent
// broken QR. // broken QR.
if (invoice.isFiat || !invoice.paymentRequest) { if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error( throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' + "This event uses fiat checkout. Use the purchase dialog " +
'to follow the provider link.', "to follow the provider link.",
) );
} }
const bolt11: string = invoice.paymentRequest const bolt11: string = invoice.paymentRequest;
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash;
paymentRequest.value = bolt11 paymentRequest.value = bolt11;
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(bolt11) await generateQRCode(bolt11);
// Restaurant-style: don't auto-pay. Surface the QR + amount and // Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in // let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just // external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI // starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state. // advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash) await startPaymentStatusCheck(eventId, invoice.paymentHash);
return invoice return invoice;
}, { },
errorMessage: 'Failed to purchase ticket' {
}) errorMessage: "Failed to purchase ticket",
},
);
} }
/** /**
@ -227,76 +244,77 @@ export function useTicketPurchase() {
* my LNbits wallet" button on the invoice screen. * my LNbits wallet" button on the invoice screen.
*/ */
async function payCurrentInvoiceWithWallet(): Promise<void> { async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return if (!paymentRequest.value) return;
await payWithWallet(paymentRequest.value) await payWithWallet(paymentRequest.value);
// Polling is already running from purchaseTicketForEvent — when // Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to // the payment lands, it advances to showTicketQR. No need to
// restart it here. // restart it here.
} }
async function startPaymentStatusCheck(eventId: string, hash: string) { async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true isPaymentPending.value = true;
let checkInterval: number | null = null let checkInterval: number | null = null;
const checkPayment = async () => { const checkPayment = async () => {
try { try {
const result = await ticketApi.checkPaymentStatus(eventId, hash) const result = await ticketApi.checkPaymentStatus(eventId, hash);
if (result.paid) { if (result.paid) {
isPaymentPending.value = false isPaymentPending.value = false;
if (checkInterval) { if (checkInterval) {
clearInterval(checkInterval) clearInterval(checkInterval);
} }
// Multi-ticket purchases come back with `ticketIds` (N rows // Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include // sharing one invoice). Single-ticket purchases include
// `ticketId` only. // `ticketId` only.
const ids = result.ticketIds && result.ticketIds.length > 0 const ids =
? result.ticketIds result.ticketIds && result.ticketIds.length > 0
: result.ticketId ? result.ticketIds
? [result.ticketId] : result.ticketId
: [] ? [result.ticketId]
await finalizePurchasedTickets(ids) : [];
await finalizePurchasedTickets(ids);
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error("Error checking payment status:", err);
} }
} };
await checkPayment() await checkPayment();
checkInterval = setInterval(checkPayment, 2000) as unknown as number checkInterval = setInterval(checkPayment, 2000) as unknown as number;
} }
function stopPaymentStatusCheck() { function stopPaymentStatusCheck() {
isPaymentPending.value = false isPaymentPending.value = false;
} }
function resetPaymentState() { function resetPaymentState() {
purchaseOperation.clear() purchaseOperation.clear();
paymentHash.value = null paymentHash.value = null;
paymentRequest.value = null paymentRequest.value = null;
qrCode.value = null qrCode.value = null;
isPaymentPending.value = false isPaymentPending.value = false;
ticketQRCode.value = null ticketQRCode.value = null;
ticketQRCodes.value = {} ticketQRCodes.value = {};
purchasedTicketId.value = null purchasedTicketId.value = null;
purchasedTicketIds.value = [] purchasedTicketIds.value = [];
showTicketQR.value = false showTicketQR.value = false;
} }
function handleOpenLightningWallet() { function handleOpenLightningWallet() {
if (paymentRequest.value) { if (paymentRequest.value) {
paymentService.openExternalWallet(paymentRequest.value) paymentService.openExternalWallet(paymentRequest.value);
} }
} }
function cleanup() { function cleanup() {
stopPaymentStatusCheck() stopPaymentStatusCheck();
} }
onUnmounted(() => { onUnmounted(() => {
cleanup() cleanup();
}) });
return { return {
// State // State
@ -329,6 +347,6 @@ export function useTicketPurchase() {
// Exposed so the dialog's fiat branch can land a 100 %-off purchase // Exposed so the dialog's fiat branch can land a 100 %-off purchase
// (backend returns `paid: true`, no checkout URL) on the same // (backend returns `paid: true`, no checkout URL) on the same
// success screen as the Lightning / free paths. // success screen as the Lightning / free paths.
finalizePurchasedTickets finalizePurchasedTickets,
} };
} }

View file

@ -1,146 +1,300 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TicketApiService } from './TicketApiService' import { TicketApiService } from "./TicketApiService";
const BASE = 'https://lnbits.test' const BASE = "https://lnbits.test";
function okJson(body: unknown) { function okJson(body: unknown) {
return { ok: true, json: async () => body } as Response return { ok: true, json: async () => body } as Response;
} }
describe('TicketApiService.requestTicket', () => { describe("TicketApiService.requestTicket", () => {
const fetchMock = vi.fn() const fetchMock = vi.fn();
let api: TicketApiService let api: TicketApiService;
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset() fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
}) });
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals());
function lastCall() { function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> } return {
url,
init,
body: JSON.parse(init.body as string),
headers: init.headers as Record<string, string>,
};
} }
it('sends name + email and no user_id / Authorization for guests', async () => { it("sends name + email and no user_id / Authorization for guests", async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] })) fetchMock.mockResolvedValue(
const res = await api.requestTicket('evt', { okJson({
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events', payment_hash: "h",
paymentMethod: 'fiat', quantity: 2, fiat_payment_request: "https://checkout",
}) is_fiat: true,
const { url, body, headers } = lastCall() ticket_ids: ["a", "b"],
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`) }),
);
const res = await api.requestTicket("evt", {
name: "Guest",
email: "g@example.com",
frontendUrl: "https://demo.aiolabs.dev/events",
paymentMethod: "fiat",
quantity: 2,
});
const { url, body, headers } = lastCall();
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`);
expect(body).toEqual({ expect(body).toEqual({
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events', name: "Guest",
payment_method: 'fiat', quantity: 2, email: "g@example.com",
}) frontend_url: "https://demo.aiolabs.dev/events",
expect(body.user_id).toBeUndefined() payment_method: "fiat",
expect(headers.Authorization).toBeUndefined() quantity: 2,
expect(headers['X-API-KEY']).toBe('inst-key') });
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] }) expect(body.user_id).toBeUndefined();
}) expect(headers.Authorization).toBeUndefined();
expect(headers["X-API-KEY"]).toBe("inst-key");
expect(res).toMatchObject({
isFiat: true,
fiatPaymentRequest: "https://checkout",
ticketIds: ["a", "b"],
});
});
it('sends user_id + email with a Bearer token for logged-in buyers', async () => { it("sends user_id + email with a Bearer token for logged-in buyers", async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) fetchMock.mockResolvedValue(
await api.requestTicket('evt', { okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173', );
}) await api.requestTicket("evt", {
const { body, headers } = lastCall() userId: "u1",
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' }) email: "me@example.com",
expect(headers.Authorization).toBe('Bearer tok') accessToken: "tok",
}) frontendUrl: "http://localhost:5173",
});
const { body, headers } = lastCall();
expect(body).toEqual({
user_id: "u1",
email: "me@example.com",
frontend_url: "http://localhost:5173",
});
expect(headers.Authorization).toBe("Bearer tok");
});
it('surfaces the backend detail string as the error message', async () => { it("surfaces the backend detail string as the error message", async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response) fetchMock.mockResolvedValue({
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' })) ok: false,
.rejects.toThrow('Payment method not enabled for this event.') json: async () => ({
}) detail: "Payment method not enabled for this event.",
}) }),
} as Response);
await expect(
api.requestTicket("evt", {
name: "G",
email: "g@x.io",
paymentMethod: "fiat",
}),
).rejects.toThrow("Payment method not enabled for this event.");
});
});
describe('TicketApiService.getTicket / resendTicketEmail', () => { describe("TicketApiService.getTicket / resendTicketEmail", () => {
const fetchMock = vi.fn() const fetchMock = vi.fn();
let api: TicketApiService let api: TicketApiService;
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset() fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' }) api = new TicketApiService({ baseUrl: BASE, apiKey: "k" });
}) });
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals());
it('maps the anonymous PublicTicket shape', async () => { it("maps the anonymous PublicTicket shape", async () => {
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' })) fetchMock.mockResolvedValue(
const t = await api.getTicket('tid') okJson({
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`) event: "evt",
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' }) name: "G",
}) registered: false,
paid: true,
time: "t",
reg_timestamp: "r",
}),
);
const t = await api.getTicket("tid");
expect(fetchMock.mock.calls[0][0]).toBe(
`${BASE}/events/api/v1/tickets/tid`,
);
expect(t).toEqual({
eventId: "evt",
name: "G",
registered: false,
paid: true,
time: "t",
regTimestamp: "r",
});
});
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => { it("maps the structured resend result and tolerates the legacy bare-ticket shape", async () => {
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} } const ticket = {
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } })) id: "tid",
const r = await api.resendTicketEmail('tid', 'admin') wallet: "w",
expect(r.ticket.id).toBe('tid') event: "evt",
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' }) name: "G",
expect(r.nostr.attempted).toBe(false) email: "g@x.io",
user_id: null,
registered: false,
paid: true,
time: "t",
reg_timestamp: "r",
extra: {},
};
fetchMock.mockResolvedValueOnce(
okJson({
ticket,
email: { attempted: true, sent: false, error: "smtp down" },
nostr: { attempted: false, sent: false, error: null },
}),
);
const r = await api.resendTicketEmail("tid", "admin");
expect(r.ticket.id).toBe("tid");
expect(r.email).toEqual({
attempted: true,
sent: false,
error: "smtp down",
});
expect(r.nostr.attempted).toBe(false);
fetchMock.mockResolvedValueOnce(okJson(ticket)) fetchMock.mockResolvedValueOnce(okJson(ticket));
const legacy = await api.resendTicketEmail('tid', 'admin') const legacy = await api.resendTicketEmail("tid", "admin");
expect(legacy.ticket.id).toBe('tid') expect(legacy.ticket.id).toBe("tid");
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null }) expect(legacy.email).toEqual({
}) attempted: false,
}) sent: false,
error: null,
});
});
});
describe('TicketApiService promo codes', () => { describe("TicketApiService promo codes", () => {
const fetchMock = vi.fn() const fetchMock = vi.fn();
let api: TicketApiService let api: TicketApiService;
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset() fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
}) });
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals());
function lastCall() { function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> } return {
url,
init,
body: JSON.parse(init.body as string),
headers: init.headers as Record<string, string>,
};
} }
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => { it("validatePromoCode posts the v2 shape with an upper-cased code", async () => {
const totals = { const totals = {
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat', subtotal: 2000,
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }], discount: 1000,
} total: 1000,
fetchMock.mockResolvedValue(okJson(totals)) currency: "sat",
const res = await api.validatePromoCode('evt', ' half ', 2) discounts_applied: [
const { url, init, body } = lastCall() {
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`) code: "HALF",
expect(init.method).toBe('POST') discount_percent: 50,
expect(body).toEqual({ codes: ['HALF'], quantity: 2 }) discount_fixed: null,
expect(res).toEqual(totals) amount_saved: 1000,
}) },
],
};
fetchMock.mockResolvedValue(okJson(totals));
const res = await api.validatePromoCode("evt", " half ", 2);
const { url, init, body } = lastCall();
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`);
expect(init.method).toBe("POST");
expect(body).toEqual({ codes: ["HALF"], quantity: 2 });
expect(res).toEqual(totals);
});
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => { it("carries the HTTP status on errors so a missing route (older extension) is recognisable", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response) fetchMock.mockResolvedValue({
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 }) ok: false,
}) status: 404,
json: async () => ({ detail: "Not Found" }),
} as Response);
await expect(api.validatePromoCode("evt", "HALF")).rejects.toMatchObject({
message: "Not Found",
status: 404,
});
});
it('requestTicket forwards promo_code verbatim', async () => { it("requestTicket forwards promo_code verbatim", async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) fetchMock.mockResolvedValue(
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 }) okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 }) );
}) await api.requestTicket("evt", {
userId: "u1",
promoCode: "HALF",
quantity: 2,
});
expect(lastCall().body).toEqual({
user_id: "u1",
promo_code: "HALF",
quantity: 2,
});
});
it('createEvent sends extra.promo_codes verbatim', async () => { it("requestTicket names the chosen ticket wave", async () => {
fetchMock.mockResolvedValue(okJson({ id: 'evt' })) // Without it the backend refuses ("Please select a ticket wave")
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }] // whenever more than one wave is open.
fetchMock.mockResolvedValue(
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
);
await api.requestTicket("evt", { userId: "u1", ticketWaveId: "regular" });
expect(lastCall().body).toEqual({
user_id: "u1",
ticket_wave_id: "regular",
});
});
it("requestTicket omits the wave when none was chosen", async () => {
// A single open wave is implied server-side; sending an empty value
// would be rejected as naming a wave that does not exist.
fetchMock.mockResolvedValue(
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
);
await api.requestTicket("evt", { userId: "u1" });
expect(lastCall().body).not.toHaveProperty("ticket_wave_id");
});
it("validatePromoCode prices against the same wave the purchase will", async () => {
fetchMock.mockResolvedValue(
okJson({ subtotal: 50, discount: 0, total: 50, currency: "sat" }),
);
await api.validatePromoCode("evt", "HALF", 2, "regular");
expect(lastCall().body).toEqual({
codes: ["HALF"],
quantity: 2,
ticket_wave_id: "regular",
});
});
it("createEvent sends extra.promo_codes verbatim", async () => {
fetchMock.mockResolvedValue(okJson({ id: "evt" }));
const promo_codes = [
{ code: "HALF", discount_percent: 50, active: true, max_uses: 2 },
];
await api.createEvent( await api.createEvent(
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } }, { name: "E", event_start_date: "2030-01-01", extra: { promo_codes } },
'inkey', "inkey",
) );
const { body, headers } = lastCall() const { body, headers } = lastCall();
expect(body.extra.promo_codes).toEqual(promo_codes) expect(body.extra.promo_codes).toEqual(promo_codes);
expect(headers['X-API-KEY']).toBe('inkey') expect(headers["X-API-KEY"]).toBe("inkey");
}) });
}) });

View file

@ -11,33 +11,36 @@ import type {
TicketedEvent, TicketedEvent,
CreateEventRequest, CreateEventRequest,
PromoValidateRequest, PromoValidateRequest,
} from '../types/ticket' } from "../types/ticket";
import { normalizePromoCode } from '../lib/promoCodes' import { normalizePromoCode } from "../lib/promoCodes";
export interface TicketApiConfig { export interface TicketApiConfig {
baseUrl: string baseUrl: string;
apiKey: string apiKey: string;
} }
/** Options for `TicketApiService.requestTicket`. */ /** Options for `TicketApiService.requestTicket`. */
export interface RequestTicketOptions { export interface RequestTicketOptions {
/** LNbits user id — logged-in buyers. */ /** LNbits user id — logged-in buyers. */
userId?: string userId?: string;
/** Guest buyer name (required together with `email` when no `userId`). */ /** Guest buyer name (required together with `email` when no `userId`). */
name?: string name?: string;
/** Delivery email — required for guests, optional for logged-in buyers. */ /** Delivery email — required for guests, optional for logged-in buyers. */
email?: string email?: string;
/** Bearer token; forwarded only when present. */ /** Bearer token; forwarded only when present. */
accessToken?: string accessToken?: string;
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */ /** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
frontendUrl?: string frontendUrl?: string;
paymentMethod?: PaymentMethod paymentMethod?: PaymentMethod;
fiatProvider?: string fiatProvider?: string;
promoCode?: string promoCode?: string;
refundAddress?: string refundAddress?: string;
nostrIdentifier?: string nostrIdentifier?: string;
/** Number of tickets to buy on this invoice. Backend caps at 10. */ /** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number quantity?: number;
/** Which pricing tier to buy from. Required when several waves are
* open — the backend refuses to guess. */
ticketWaveId?: string;
} }
/** /**
@ -56,11 +59,10 @@ export class TicketApiService {
* Used to correlate Nostr events with ticketed events. * Used to correlate Nostr events with ticketed events.
*/ */
async fetchTicketedEvents(): Promise<any[]> { async fetchTicketedEvents(): Promise<any[]> {
const response = await this.request( const response = await this.request("/events/api/v1/events/public", {
'/events/api/v1/events/public', method: "GET",
{ method: 'GET' } });
) return response;
return response
} }
/** /**
@ -71,10 +73,10 @@ export class TicketApiService {
* own event the moment it drops to `proposed`. * own event the moment it drops to `proposed`.
*/ */
async fetchMyEvents(invoiceKey: string): Promise<any[]> { async fetchMyEvents(invoiceKey: string): Promise<any[]> {
return this.request('/events/api/v1/events?all_wallets=true', { return this.request("/events/api/v1/events?all_wallets=true", {
method: 'GET', method: "GET",
headers: { 'X-API-KEY': invoiceKey }, headers: { "X-API-KEY": invoiceKey },
}) });
} }
/** /**
@ -99,29 +101,32 @@ export class TicketApiService {
eventId: string, eventId: string,
options: RequestTicketOptions = {}, options: RequestTicketOptions = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = {} const body: CreateTicketRequest = {};
if (options.userId) body.user_id = options.userId if (options.userId) body.user_id = options.userId;
if (options.name) body.name = options.name if (options.name) body.name = options.name;
if (options.email) body.email = options.email if (options.email) body.email = options.email;
if (options.frontendUrl) body.frontend_url = options.frontendUrl if (options.frontendUrl) body.frontend_url = options.frontendUrl;
if (options.paymentMethod) body.payment_method = options.paymentMethod if (options.paymentMethod) body.payment_method = options.paymentMethod;
if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.fiatProvider) body.fiat_provider = options.fiatProvider;
if (options.promoCode) body.promo_code = options.promoCode if (options.promoCode) body.promo_code = options.promoCode;
if (options.refundAddress) body.refund_address = options.refundAddress if (options.refundAddress) body.refund_address = options.refundAddress;
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.nostrIdentifier)
if (options.quantity && options.quantity > 1) body.quantity = options.quantity body.nostr_identifier = options.nostrIdentifier;
if (options.quantity && options.quantity > 1)
body.quantity = options.quantity;
if (options.ticketWaveId) body.ticket_wave_id = options.ticketWaveId;
const headers: Record<string, string> = { 'Content-Type': 'application/json' } const headers: Record<string, string> = {
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}` "Content-Type": "application/json",
};
if (options.accessToken)
headers["Authorization"] = `Bearer ${options.accessToken}`;
const data = await this.request( const data = await this.request(`/events/api/v1/tickets/${eventId}`, {
`/events/api/v1/tickets/${eventId}`, method: "POST",
{ headers,
method: 'POST', body: JSON.stringify(body),
headers, });
body: JSON.stringify(body),
}
)
return { return {
paymentHash: data.payment_hash, paymentHash: data.payment_hash,
@ -133,7 +138,7 @@ export class TicketApiService {
// and no invoice to settle. // and no invoice to settle.
paid: Boolean(data.paid), paid: Boolean(data.paid),
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined, ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} };
} }
/** /**
@ -142,7 +147,9 @@ export class TicketApiService {
* the public ticket page the emailed link opens. * the public ticket page the emailed link opens.
*/ */
async getTicket(ticketId: string): Promise<PublicTicket> { async getTicket(ticketId: string): Promise<PublicTicket> {
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' }) const t = await this.request(`/events/api/v1/tickets/${ticketId}`, {
method: "GET",
});
return { return {
eventId: t.event, eventId: t.event,
name: t.name ?? null, name: t.name ?? null,
@ -150,7 +157,7 @@ export class TicketApiService {
paid: Boolean(t.paid), paid: Boolean(t.paid),
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
} };
} }
/** /**
@ -160,7 +167,7 @@ export class TicketApiService {
* failure here as non-fatal. * failure here as non-fatal.
*/ */
async getPublicEvent(eventId: string): Promise<TicketedEvent> { async getPublicEvent(eventId: string): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' }) return this.request(`/events/api/v1/events/${eventId}`, { method: "GET" });
} }
/** /**
@ -168,18 +175,18 @@ export class TicketApiService {
*/ */
async checkPaymentStatus( async checkPaymentStatus(
eventId: string, eventId: string,
paymentHash: string paymentHash: string,
): Promise<TicketPaymentStatus> { ): Promise<TicketPaymentStatus> {
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}/${paymentHash}`, `/events/api/v1/tickets/${eventId}/${paymentHash}`,
{ method: 'POST' } { method: "POST" },
) );
return { return {
paid: data.paid === true, paid: data.paid === true,
ticketId: data.ticket_id, ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined, ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} };
} }
/** /**
@ -187,19 +194,16 @@ export class TicketApiService {
*/ */
async fetchUserTickets( async fetchUserTickets(
userId: string, userId: string,
accessToken: string accessToken: string,
): Promise<EventTicket[]> { ): Promise<EventTicket[]> {
const data = await this.request( const data = await this.request(`/events/api/v1/tickets/user/${userId}`, {
`/events/api/v1/tickets/user/${userId}`, method: "GET",
{ headers: {
method: 'GET', Authorization: `Bearer ${accessToken}`,
headers: { },
'Authorization': `Bearer ${accessToken}`, });
},
}
)
return (data as any[]).map(t => ({ return (data as any[]).map((t) => ({
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -211,7 +215,7 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
})) }));
} }
/** /**
@ -220,10 +224,10 @@ export class TicketApiService {
async validateTicket(ticketId: string): Promise<EventTicket[]> { async validateTicket(ticketId: string): Promise<EventTicket[]> {
const data = await this.request( const data = await this.request(
`/events/api/v1/register/ticket/${ticketId}`, `/events/api/v1/register/ticket/${ticketId}`,
{ method: 'GET' } { method: "GET" },
) );
return (data as any[]).map(t => ({ return (data as any[]).map((t) => ({
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -235,22 +239,25 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
})) }));
} }
/** /**
* Create a new event. Uses invoice key (any user). * Create a new event. Uses invoice key (any user).
* Backend determines approval status based on user role and settings. * Backend determines approval status based on user role and settings.
*/ */
async createEvent(eventData: CreateEventRequest, invoiceKey: string): Promise<TicketedEvent> { async createEvent(
return this.request('/events/api/v1/events', { eventData: CreateEventRequest,
method: 'POST', invoiceKey: string,
): Promise<TicketedEvent> {
return this.request("/events/api/v1/events", {
method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-API-KEY': invoiceKey, "X-API-KEY": invoiceKey,
}, },
body: JSON.stringify(eventData), body: JSON.stringify(eventData),
}) });
} }
/** /**
@ -265,13 +272,13 @@ export class TicketApiService {
adminKey: string, adminKey: string,
): Promise<TicketedEvent> { ): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { return this.request(`/events/api/v1/events/${eventId}`, {
method: 'PUT', method: "PUT",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-API-KEY': adminKey, "X-API-KEY": adminKey,
}, },
body: JSON.stringify(eventData), body: JSON.stringify(eventData),
}) });
} }
/** /**
@ -289,16 +296,16 @@ export class TicketApiService {
const r = await this.request( const r = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`, `/events/api/v1/tickets/${ticketId}/resend-email`,
{ {
method: 'POST', method: "POST",
headers: { 'X-API-KEY': adminKey }, headers: { "X-API-KEY": adminKey },
} },
) );
const t = r.ticket ?? r const t = r.ticket ?? r;
const delivery = (d: any) => ({ const delivery = (d: any) => ({
attempted: Boolean(d?.attempted), attempted: Boolean(d?.attempted),
sent: Boolean(d?.sent), sent: Boolean(d?.sent),
error: d?.error ?? null, error: d?.error ?? null,
}) });
return { return {
ticket: { ticket: {
id: t.id, id: t.id,
@ -315,7 +322,7 @@ export class TicketApiService {
}, },
email: delivery(r.email), email: delivery(r.email),
nostr: delivery(r.nostr), nostr: delivery(r.nostr),
} };
} }
/** /**
@ -329,23 +336,23 @@ export class TicketApiService {
eventId: string, eventId: string,
adminKey: string, adminKey: string,
): Promise<{ ): Promise<{
event_id: string event_id: string;
sold: number sold: number;
registered: number registered: number;
remaining: number remaining: number;
tickets: Array<{ tickets: Array<{
id: string id: string;
name?: string | null name?: string | null;
registered: boolean registered: boolean;
registered_at: string | null registered_at: string | null;
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */ /** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
applied_promo_code?: string | null applied_promo_code?: string | null;
}> }>;
}> { }> {
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, { return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
method: 'GET', method: "GET",
headers: { 'X-API-KEY': adminKey }, headers: { "X-API-KEY": adminKey },
}) });
} }
/** /**
@ -355,13 +362,24 @@ export class TicketApiService {
* means "not valid". Older extensions 404 — the thrown error carries * means "not valid". Older extensions 404 — the thrown error carries
* `status` so callers can fall back to "applied at checkout". * `status` so callers can fall back to "applied at checkout".
*/ */
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> { async validatePromoCode(
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity } eventId: string,
code: string,
quantity = 1,
ticketWaveId?: string,
): Promise<BasketTotals> {
// The preview must name the same wave the purchase will, or the quote
// and the charge are priced against different tiers.
const body: PromoValidateRequest = {
codes: [normalizePromoCode(code)],
quantity,
};
if (ticketWaveId) body.ticket_wave_id = ticketWaveId;
return this.request(`/events/api/v1/promo/validate/${eventId}`, { return this.request(`/events/api/v1/promo/validate/${eventId}`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}) });
} }
/** /**
@ -370,11 +388,17 @@ export class TicketApiService {
* unpaid / already-registered / not-owned cases with HTTP errors * unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message. * whose `detail` becomes the thrown Error message.
*/ */
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> { async registerTicket(
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, { ticketId: string,
method: 'PUT', adminKey: string,
headers: { 'X-API-KEY': adminKey }, ): Promise<EventTicket> {
}) const t = await this.request(
`/events/api/v1/tickets/register/${ticketId}`,
{
method: "PUT",
headers: { "X-API-KEY": adminKey },
},
);
return { return {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
@ -387,7 +411,7 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
} };
} }
/** /**
@ -397,13 +421,13 @@ export class TicketApiService {
*/ */
async isAdmin(adminKey: string): Promise<boolean> { async isAdmin(adminKey: string): Promise<boolean> {
try { try {
await this.request('/events/api/v1/events/all', { await this.request("/events/api/v1/events/all", {
method: 'GET', method: "GET",
headers: { 'X-API-KEY': adminKey }, headers: { "X-API-KEY": adminKey },
}) });
return true return true;
} catch { } catch {
return false return false;
} }
} }
@ -415,13 +439,16 @@ export class TicketApiService {
*/ */
async getAutoApprove(invoiceKey: string): Promise<boolean> { async getAutoApprove(invoiceKey: string): Promise<boolean> {
try { try {
const settings = await this.request('/events/api/v1/events/settings/public', { const settings = await this.request(
method: 'GET', "/events/api/v1/events/settings/public",
headers: { 'X-API-KEY': invoiceKey }, {
}) method: "GET",
return Boolean(settings?.auto_approve) headers: { "X-API-KEY": invoiceKey },
},
);
return Boolean(settings?.auto_approve);
} catch { } catch {
return false return false;
} }
} }
@ -430,13 +457,15 @@ export class TicketApiService {
*/ */
async getCurrencies(): Promise<string[]> { async getCurrencies(): Promise<string[]> {
try { try {
const currencies = await this.request('/api/v1/currencies', { method: 'GET' }) const currencies = await this.request("/api/v1/currencies", {
method: "GET",
});
if (Array.isArray(currencies)) { if (Array.isArray(currencies)) {
return ['sats', ...currencies.filter((c: string) => c !== 'sats')] return ["sats", ...currencies.filter((c: string) => c !== "sats")];
} }
return ['sats'] return ["sats"];
} catch { } catch {
return ['sats'] return ["sats"];
} }
} }
@ -445,28 +474,31 @@ export class TicketApiService {
*/ */
private async request(path: string, init: RequestInit = {}): Promise<any> { private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'accept': 'application/json', accept: "application/json",
'X-API-KEY': this.config.apiKey, "X-API-KEY": this.config.apiKey,
...(init.headers as Record<string, string> ?? {}), ...((init.headers as Record<string, string>) ?? {}),
} };
const response = await fetch(`${this.config.baseUrl}${path}`, { const response = await fetch(`${this.config.baseUrl}${path}`, {
...init, ...init,
headers, headers,
}) });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` })) const error = await response
const errorMessage = typeof error.detail === 'string' .json()
? error.detail .catch(() => ({ detail: `Request failed: ${path}` }));
: Array.isArray(error.detail) const errorMessage =
? error.detail[0]?.msg ?? 'Request failed' typeof error.detail === "string"
: 'Request failed' ? error.detail
: Array.isArray(error.detail)
? (error.detail[0]?.msg ?? "Request failed")
: "Request failed";
// Carry the HTTP status so callers can tell "route missing on an // Carry the HTTP status so callers can tell "route missing on an
// older extension" (404) apart from "bad input" (400). // older extension" (404) apart from "bad input" (400).
throw Object.assign(new Error(errorMessage), { status: response.status }) throw Object.assign(new Error(errorMessage), { status: response.status });
} }
return response.json() return response.json();
} }
} }

View file

@ -1,117 +1,157 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from "vue";
import { useElementVisibility } from '@vueuse/core' import { useElementVisibility } from "@vueuse/core";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router";
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
import { format } from 'date-fns' import { format } from "date-fns";
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from "../composables/useDateLocale";
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 { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator";
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History, Calendar,
} from 'lucide-vue-next' MapPin,
import { useEventDetail } from '../composables/useEventDetail' ArrowLeft,
import BookmarkButton from '../components/BookmarkButton.vue' Pencil,
import OrganizerCard from '../components/OrganizerCard.vue' Ticket,
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' CheckCircle2,
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue' ScanLine,
import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout' History,
import { useAuth } from '@/composables/useAuthService' } from "lucide-vue-next";
import { useEventsStore } from '../stores/events' import { useEventDetail } from "../composables/useEventDetail";
import { useOwnedTickets } from '../composables/useOwnedTickets' import BookmarkButton from "../components/BookmarkButton.vue";
import { toastService } from '@/core/services/ToastService' import OrganizerCard from "../components/OrganizerCard.vue";
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import PurchaseTicketDialog from "../components/PurchaseTicketDialog.vue";
import type { TicketApiService } from '../services/TicketApiService' import CheckoutReturnDialog from "../components/CheckoutReturnDialog.vue";
import type { TicketedEvent } from '../types/ticket' import {
clearPendingCheckout,
readPendingCheckout,
} from "../lib/pendingCheckout";
import { useAuth } from "@/composables/useAuthService";
import { useEventsStore } from "../stores/events";
import { useOwnedTickets } from "../composables/useOwnedTickets";
import { toastService } from "@/core/services/ToastService";
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { TicketApiService } from "../services/TicketApiService";
import type { TicketedEvent } from "../types/ticket";
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const { t } = useI18n() const { t } = useI18n();
const eventId = route.params.id as string const eventId = route.params.id as string;
const { event, isLoading, error, reload } = useEventDetail(eventId) const { event, isLoading, error, reload } = useEventDetail(eventId);
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale();
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is // Owner-edit affordance: the NIP-52 d-tag we use for the event id is
// the same as the LNbits event id (set at publish time in // the same as the LNbits event id (set at publish time in
// nostr_publisher.build_nip52_event). Look the user's own events up // nostr_publisher.build_nip52_event). Look the user's own events up
// once and offer an Edit button on a match. // once and offer an Edit button on a match.
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth();
const eventsStore = useEventsStore() const eventsStore = useEventsStore();
const ownedLnbitsEvent = ref<TicketedEvent | null>(null) const ownedLnbitsEvent = ref<TicketedEvent | null>(null);
async function loadOwnedEvent() { async function loadOwnedEvent() {
ownedLnbitsEvent.value = null ownedLnbitsEvent.value = null;
if (!isAuthenticated.value) return if (!isAuthenticated.value) return;
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey const invoiceKey = currentUser.value?.wallets?.[0]?.inkey;
if (!invoiceKey) return if (!invoiceKey) return;
try { try {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(
const mine = await ticketApi.fetchMyEvents(invoiceKey) SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
const mine = await ticketApi.fetchMyEvents(invoiceKey);
ownedLnbitsEvent.value = ownedLnbitsEvent.value =
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null (mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null;
} catch { } catch {
ownedLnbitsEvent.value = null ownedLnbitsEvent.value = null;
} }
} }
onMounted(loadOwnedEvent) // Ticket waves live on the LNbits record, not in the NIP-52 tags: the
watch(isAuthenticated, () => loadOwnedEvent()) // published tags describe the ACTIVE wave but carry no wave id, and a
// buyer needs one to choose a tier. Fetched anonymously and treated as
// non-fatal — the backend answers 410 for sold-out or closed events,
// where there is nothing to pick anyway.
const publicLnbitsEvent = ref<TicketedEvent | null>(null);
async function loadPublicEvent() {
try {
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
publicLnbitsEvent.value = await ticketApi.getPublicEvent(eventId);
} catch {
publicLnbitsEvent.value = null;
}
}
/** Whichever LNbits record we have; the organizer's carries the same waves. */
const lnbitsEvent = computed<TicketedEvent | null>(
() => ownedLnbitsEvent.value ?? publicLnbitsEvent.value,
);
onMounted(loadOwnedEvent);
onMounted(loadPublicEvent);
watch(isAuthenticated, () => loadOwnedEvent());
function openEditDialog() { function openEditDialog() {
if (!ownedLnbitsEvent.value) return if (!ownedLnbitsEvent.value) return;
eventsStore.editingEvent = ownedLnbitsEvent.value eventsStore.editingEvent = ownedLnbitsEvent.value;
eventsStore.showCreateDialog = true eventsStore.showCreateDialog = true;
} }
function openScannerPage() { function openScannerPage() {
router.push({ name: 'scan-tickets', params: { eventId } }) router.push({ name: "scan-tickets", params: { eventId } });
} }
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
if (!event.value) return '' if (!event.value) return "";
const a = event.value const a = event.value;
const opts = { locale: dateLocale.value } const opts = { locale: dateLocale.value };
if (a.type === 'date') { if (a.type === "date") {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts) const start = format(a.startDate, "EEEE, MMMM d, yyyy", opts);
if (a.endDate) { if (a.endDate) {
return `${start} — ${format(a.endDate, 'EEEE, MMMM d, yyyy', opts)}` return `${start} — ${format(a.endDate, "EEEE, MMMM d, yyyy", opts)}`;
} }
return start return start;
} }
// Time-based event. If start and end share the same calendar day, // Time-based event. If start and end share the same calendar day,
// show end as time-only ("19:00 — 21:45"). Otherwise repeat the full // show end as time-only ("19:00 — 21:45"). Otherwise repeat the full
// date on the end side so a multi-day event reads unambiguously // date on the end side so a multi-day event reads unambiguously
// ("May 29 • 19:00 — May 30 • 21:45"). // ("May 29 • 19:00 — May 30 • 21:45").
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm' const FULL = "EEEE, MMMM d, yyyy \u2022 HH:mm";
const start = format(a.startDate, FULL, opts) const start = format(a.startDate, FULL, opts);
if (!a.endDate) return start if (!a.endDate) return start;
const sameDay = const sameDay =
a.startDate.getFullYear() === a.endDate.getFullYear() && a.startDate.getFullYear() === a.endDate.getFullYear() &&
a.startDate.getMonth() === a.endDate.getMonth() && a.startDate.getMonth() === a.endDate.getMonth() &&
a.startDate.getDate() === a.endDate.getDate() a.startDate.getDate() === a.endDate.getDate();
return `${start} — ${format(a.endDate, sameDay ? 'HH:mm' : FULL, opts)}` return `${start} — ${format(a.endDate, sameDay ? "HH:mm" : FULL, opts)}`;
}) });
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
if (!event.value?.category) return null if (!event.value?.category) return null;
return t(`events.categories.${event.value.category}`, event.value.category) return t(`events.categories.${event.value.category}`, event.value.category);
}) });
function goBack() { function goBack() {
router.push({ name: 'events' }) router.push({ name: "events" });
} }
// --- Ticket purchase + owned-tickets surface ---------------------- // --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets() const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets();
const ownedPaidCount = computed(() => paidCount(eventId)) const ownedPaidCount = computed(() => paidCount(eventId));
const purchaseEvent = computed(() => { const purchaseEvent = computed(() => {
const a = event.value const a = event.value;
if (!a || !a.ticketInfo) return null if (!a || !a.ticketInfo) return null;
// Nostr tags describe the active wave but name no wave id, so the
// pickable tiers come from the LNbits record when we have one. The tag
// values stay the fallback for events with no waves at all.
const lnbits = lnbitsEvent.value;
return { return {
id: a.id, id: a.id,
name: a.title, name: a.title,
@ -120,45 +160,52 @@ const purchaseEvent = computed(() => {
allow_fiat: a.ticketInfo.allowFiat, allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency, fiat_currency: a.ticketInfo.fiatCurrency,
payment_methods: a.ticketInfo.paymentMethods, payment_methods: a.ticketInfo.paymentMethods,
} // Fields the dialog synthesizes an implied wave from when an event
}) // predates waves entirely.
closing_date: lnbits?.closing_date ?? null,
event_start_date: lnbits?.event_start_date,
event_end_date: lnbits?.event_end_date ?? null,
amount_tickets: lnbits?.amount_tickets ?? a.ticketInfo.available,
extra: lnbits?.extra ? { ticket_waves: lnbits.extra.ticket_waves } : null,
};
});
// available === undefined → unlimited capacity, button always shown // available === undefined → unlimited capacity, button always shown
// available === 0 → sold out, button hidden // available === 0 → sold out, button hidden
// available > 0 → button shown // available > 0 → button shown
const canBuyTicket = computed(() => { const canBuyTicket = computed(() => {
const info = event.value?.ticketInfo const info = event.value?.ticketInfo;
if (!info) return false if (!info) return false;
return info.available === undefined || info.available > 0 return info.available === undefined || info.available > 0;
}) });
// Past events can't be bought into. The notice below replaces the // Past events can't be bought into. The notice below replaces the
// buy CTA so the flow is unambiguous — date alone is easy to miss // buy CTA so the flow is unambiguous — date alone is easy to miss
// on a long detail page. // on a long detail page.
const isPast = computed(() => { const isPast = computed(() => {
const a = event.value const a = event.value;
if (!a) return false if (!a) return false;
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate;
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false;
return end.getTime() < Date.now() return end.getTime() < Date.now();
}) });
const showPurchaseDialog = ref(false) const showPurchaseDialog = ref(false);
function openPurchaseDialog() { function openPurchaseDialog() {
// Paid tickets are open to guests (name + email identity). Free // Paid tickets are open to guests (name + email identity). Free
// claims still need an account so seats can't be drained anonymously. // claims still need an account so seats can't be drained anonymously.
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0 const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0;
if (!isAuthenticated.value && isFreeEvent) { if (!isAuthenticated.value && isFreeEvent) {
toastService.info(t('events.detail.loginToBuyTickets'), { toastService.info(t("events.detail.loginToBuyTickets"), {
action: { action: {
label: t('events.detail.logIn'), label: t("events.detail.logIn"),
onClick: () => router.push('/login'), onClick: () => router.push("/login"),
}, },
}) });
return return;
} }
showPurchaseDialog.value = true showPurchaseDialog.value = true;
} }
// --- Return from the fiat provider --------------------------------- // --- Return from the fiat provider ---------------------------------
@ -167,36 +214,37 @@ function openPurchaseDialog() {
// or ?checkout=cancelled. The query is stripped immediately so the // or ?checkout=cancelled. The query is stripped immediately so the
// ticket ids don't linger in history; the localStorage record written // ticket ids don't linger in history; the localStorage record written
// before the redirect is the fallback source and carries the email. // before the redirect is the fallback source and carries the email.
const showReturnDialog = ref(false) const showReturnDialog = ref(false);
const returnTicketIds = ref<string[]>([]) const returnTicketIds = ref<string[]>([]);
const returnEmail = ref<string | undefined>(undefined) const returnEmail = ref<string | undefined>(undefined);
function handleCheckoutReturn() { function handleCheckoutReturn() {
const status = route.query.checkout const status = route.query.checkout;
if (status !== 'success' && status !== 'cancelled') return if (status !== "success" && status !== "cancelled") return;
const pending = readPendingCheckout() const pending = readPendingCheckout();
const samePurchase = pending?.eventId === eventId const samePurchase = pending?.eventId === eventId;
void router.replace({ query: {} }) void router.replace({ query: {} });
if (status === 'cancelled') { if (status === "cancelled") {
clearPendingCheckout() clearPendingCheckout();
toastService.info(t('events.checkout.cancelled')) toastService.info(t("events.checkout.cancelled"));
return return;
} }
const fromQuery = String(route.query.tickets ?? '') const fromQuery = String(route.query.tickets ?? "")
.split(',') .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean);
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [] const ids =
if (ids.length === 0) return fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [];
returnTicketIds.value = ids if (ids.length === 0) return;
returnEmail.value = samePurchase ? pending?.email : undefined returnTicketIds.value = ids;
showReturnDialog.value = true returnEmail.value = samePurchase ? pending?.email : undefined;
showReturnDialog.value = true;
} }
onMounted(handleCheckoutReturn) onMounted(handleCheckoutReturn);
// --- Sticky buy bar -------------------------------------------------- // --- Sticky buy bar --------------------------------------------------
// The ticket block sits right under the date/location so it is above the // The ticket block sits right under the date/location so it is above the
@ -204,20 +252,22 @@ onMounted(handleCheckoutReturn)
// it leaves the viewport a compact bar with the price + the same CTA // it leaves the viewport a compact bar with the price + the same CTA
// pins above the bottom nav so the primary action is never more than a // pins above the bottom nav so the primary action is never more than a
// tap away. Hidden while a dialog is open (the dialog is the action). // tap away. Hidden while a dialog is open (the dialog is the action).
const ticketBlockRef = ref<HTMLElement | null>(null) const ticketBlockRef = ref<HTMLElement | null>(null);
const ticketBlockVisible = useElementVisibility(ticketBlockRef) const ticketBlockVisible = useElementVisibility(ticketBlockRef);
const priceLabel = computed(() => { const priceLabel = computed(() => {
const info = event.value?.ticketInfo const info = event.value?.ticketInfo;
if (!info) return '' if (!info) return "";
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}` return info.price === 0
}) ? t("events.detail.free")
: `${info.price} ${info.currency}`;
});
const buyLabel = computed(() => const buyLabel = computed(() =>
ownedPaidCount.value > 0 ownedPaidCount.value > 0
? t('events.detail.buyAnotherTicket', 'Buy another ticket') ? t("events.detail.buyAnotherTicket", "Buy another ticket")
: t('events.detail.buyTicket', 'Buy ticket'), : t("events.detail.buyTicket", "Buy ticket"),
) );
const showStickyCta = computed( const showStickyCta = computed(
() => () =>
@ -228,22 +278,25 @@ const showStickyCta = computed(
!ticketBlockVisible.value && !ticketBlockVisible.value &&
!showPurchaseDialog.value && !showPurchaseDialog.value &&
!showReturnDialog.value, !showReturnDialog.value,
) );
// Re-fetch the user's tickets when the purchase dialog closes (the // Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically // buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension. // via the relay republish from the events extension.
watch(showPurchaseDialog, (open) => { watch(showPurchaseDialog, (open) => {
if (!open) refreshOwnedTickets() if (!open) refreshOwnedTickets();
}) });
function goToMyTickets() { function goToMyTickets() {
router.push('/my-tickets') router.push("/my-tickets");
} }
</script> </script>
<template> <template>
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }"> <div
class="container mx-auto py-6 px-4 max-w-3xl"
:class="{ 'pb-24': showStickyCta }"
>
<!-- Top bar — back-link only. Edit moves into the title row as a <!-- Top bar — back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. --> where it replaces the Buy-ticket CTA for the host. -->
@ -264,7 +317,9 @@ function goToMyTickets() {
<!-- Error --> <!-- Error -->
<div v-else-if="error" class="text-center py-16"> <div v-else-if="error" class="text-center py-16">
<h2 class="text-xl font-semibold text-foreground mb-2">Event not found</h2> <h2 class="text-xl font-semibold text-foreground mb-2">
Event not found
</h2>
<p class="text-muted-foreground mb-4">{{ error }}</p> <p class="text-muted-foreground mb-4">{{ error }}</p>
<Button variant="outline" @click="reload">Retry</Button> <Button variant="outline" @click="reload">Retry</Button>
</div> </div>
@ -286,11 +341,7 @@ function goToMyTickets() {
<!-- "Yours" leads the row in the highlighted variant so the <!-- "Yours" leads the row in the highlighted variant so the
ownership signal stands out against the neutral ownership signal stands out against the neutral
category/tag chips that follow. --> category/tag chips that follow. -->
<Badge <Badge v-if="event.isMine" variant="secondary" class="shrink-0">
v-if="event.isMine"
variant="secondary"
class="shrink-0"
>
Yours Yours
</Badge> </Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0"> <Badge v-if="categoryLabel" variant="outline" class="shrink-0">
@ -298,10 +349,14 @@ function goToMyTickets() {
</Badge> </Badge>
<Badge <Badge
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'" v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'" :variant="
event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'
"
class="shrink-0 capitalize" class="shrink-0 capitalize"
> >
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{
event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review"
}}
</Badge> </Badge>
<div v-for="tag in event.tags.slice(1)" :key="tag"> <div v-for="tag in event.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge> <Badge variant="outline" class="text-xs">{{ tag }}</Badge>
@ -342,7 +397,9 @@ function goToMyTickets() {
<Calendar class="w-4 h-4 shrink-0 mt-0.5" /> <Calendar class="w-4 h-4 shrink-0 mt-0.5" />
<span> <span>
{{ dateDisplay }} {{ dateDisplay }}
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span> <span v-if="event.timezone" class="opacity-70"
>({{ event.timezone }})</span
>
</span> </span>
</div> </div>
<div v-if="event.location" class="flex items-start gap-1.5"> <div v-if="event.location" class="flex items-start gap-1.5">
@ -364,7 +421,7 @@ function goToMyTickets() {
@click="openScannerPage" @click="openScannerPage"
> >
<ScanLine class="w-4 h-4" /> <ScanLine class="w-4 h-4" />
{{ t('events.detail.scanTickets', 'Scan tickets') }} {{ t("events.detail.scanTickets", "Scan tickets") }}
</Button> </Button>
<!-- Tickets — gated on the event carrying ticketInfo (set <!-- Tickets — gated on the event carrying ticketInfo (set
@ -384,13 +441,21 @@ function goToMyTickets() {
v-if="ownedPaidCount > 0" v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3" class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
> >
<div class="flex items-center gap-2 text-sm font-medium text-foreground"> <div
class="flex items-center gap-2 text-sm font-medium text-foreground"
>
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" /> <CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }} {{
t(
"events.detail.ticketsOwned",
{ count: ownedPaidCount },
ownedPaidCount,
)
}}
</div> </div>
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets"> <Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }} {{ t("events.detail.viewMyTickets", "View in My Tickets") }}
</Button> </Button>
</div> </div>
@ -399,7 +464,7 @@ function goToMyTickets() {
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3" class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
> >
<History class="w-4 h-4 shrink-0" /> <History class="w-4 h-4 shrink-0" />
{{ t('events.detail.pastEvent', 'This event has already happened') }} {{ t("events.detail.pastEvent", "This event has already happened") }}
</div> </div>
<div v-else-if="canBuyTicket" class="space-y-1"> <div v-else-if="canBuyTicket" class="space-y-1">
<Button <Button
@ -414,10 +479,15 @@ function goToMyTickets() {
</Button> </Button>
<p class="text-xs text-muted-foreground text-center"> <p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
</span> </span>
<span v-else> <span v-else>
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }} {{
t("events.detail.ticketsRemainingOfTotal", {
count: event.ticketInfo.available,
total: event.ticketInfo.total,
})
}}
</span> </span>
</p> </p>
</div> </div>
@ -425,7 +495,7 @@ function goToMyTickets() {
v-else-if="ownedPaidCount === 0" v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center" class="text-sm text-destructive text-center"
> >
{{ t('events.detail.soldOut') }} {{ t("events.detail.soldOut") }}
</p> </p>
</div> </div>
@ -458,50 +528,69 @@ function goToMyTickets() {
parent's space-y margin (margins offset fixed boxes too) and parent's space-y margin (margins offset fixed boxes too) and
float 24px above the nav. --> float 24px above the nav. -->
<Teleport to="body"> <Teleport to="body">
<Transition <Transition
enter-active-class="transition duration-200 ease-out" enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0" enter-from-class="translate-y-4 opacity-0"
leave-active-class="transition duration-150 ease-in" leave-active-class="transition duration-150 ease-in"
leave-to-class="translate-y-4 opacity-0" leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="showStickyCta && event"
class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style="bottom: var(--bottom-nav-height)"
role="region"
:aria-label="t('events.detail.tickets')"
> >
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3"> <div
<div class="min-w-0 flex-1"> v-if="showStickyCta && event"
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p> class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
<p class="text-xs text-muted-foreground truncate"> style="bottom: var(--bottom-nav-height)"
<span v-if="ownedPaidCount > 0"> role="region"
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }} :aria-label="t('events.detail.tickets')"
</span> >
<span v-else-if="event.ticketInfo?.available === undefined"> <div
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3"
</span>
<span v-else>
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span>
</p>
</div>
<Button
v-if="canBuyTicket"
class="gap-1.5 shrink-0"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
> >
<Ticket class="w-4 h-4" /> <div class="min-w-0 flex-1">
{{ buyLabel }} <p class="text-sm font-medium text-foreground truncate">
</Button> {{ event.title }}
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets"> </p>
<Ticket class="w-4 h-4" /> <p class="text-xs text-muted-foreground truncate">
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }} <span v-if="ownedPaidCount > 0">
</Button> {{
t(
"events.detail.ticketsOwned",
{ count: ownedPaidCount },
ownedPaidCount,
)
}}
</span>
<span v-else-if="event.ticketInfo?.available === undefined">
{{ priceLabel }} ·
{{
t("events.detail.unlimitedTickets", "Unlimited tickets")
}}
</span>
<span v-else>
{{ priceLabel }} ·
{{
t("events.detail.ticketsRemainingOfTotal", {
count: event.ticketInfo.available,
total: event.ticketInfo.total,
})
}}
</span>
</p>
</div>
<Button
v-if="canBuyTicket"
class="gap-1.5 shrink-0"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ buyLabel }}
</Button>
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t("events.detail.viewMyTickets", "View in My Tickets") }}
</Button>
</div>
</div> </div>
</div> </Transition>
</Transition>
</Teleport> </Teleport>
<!-- External references --> <!-- External references -->
@ -513,8 +602,10 @@ function goToMyTickets() {
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p
{{ t('events.detail.organizer') }} class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"
>
{{ t("events.detail.organizer") }}
</p> </p>
<OrganizerCard :pubkey="event.organizer.pubkey" /> <OrganizerCard :pubkey="event.organizer.pubkey" />
</div> </div>