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:
parent
8490cdf84c
commit
afa34c90c2
6 changed files with 1619 additions and 937 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,17 @@
|
|||
import { computed, ref, 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'
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
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.
|
||||
|
|
@ -17,111 +27,138 @@ import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCode
|
|||
* every extension version.
|
||||
*/
|
||||
export type PromoState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'checking'; code: string }
|
||||
| { status: "idle" }
|
||||
| { status: "checking"; code: string }
|
||||
| {
|
||||
status: 'applied'
|
||||
code: string
|
||||
discountPercent: number
|
||||
subtotal: number
|
||||
discount: number
|
||||
total: number
|
||||
currency: string
|
||||
status: "applied";
|
||||
code: string;
|
||||
discountPercent: number;
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
}
|
||||
| { status: 'invalid'; code: string }
|
||||
| { status: 'unverified'; code: string }
|
||||
| { status: "invalid"; 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>) {
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
export function usePromoCode(
|
||||
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). */
|
||||
const input = ref('')
|
||||
const state = ref<PromoState>({ status: 'idle' })
|
||||
const input = ref("");
|
||||
const state = ref<PromoState>({ status: "idle" });
|
||||
|
||||
// Monotonic request counter so a slow response for a previous code or
|
||||
// quantity can never overwrite a newer result.
|
||||
let requestSeq = 0
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestSeq = 0;
|
||||
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
|
||||
* nothing so the buyer is not surprised by a 400 at pay time. */
|
||||
const codeToSend = computed<string | undefined>(() => {
|
||||
const s = state.value
|
||||
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined
|
||||
})
|
||||
const s = state.value;
|
||||
return s.status === "applied" || s.status === "unverified"
|
||||
? s.code
|
||||
: undefined;
|
||||
});
|
||||
|
||||
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> {
|
||||
const seq = ++requestSeq
|
||||
state.value = { status: 'checking', code }
|
||||
const seq = ++requestSeq;
|
||||
state.value = { status: "checking", code };
|
||||
try {
|
||||
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value)
|
||||
if (seq !== requestSeq) return
|
||||
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0]
|
||||
const totals = await ticketApi.validatePromoCode(
|
||||
toValue(eventId),
|
||||
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) {
|
||||
state.value = { status: 'invalid', code }
|
||||
return
|
||||
state.value = { status: "invalid", code };
|
||||
return;
|
||||
}
|
||||
state.value = {
|
||||
status: 'applied',
|
||||
status: "applied",
|
||||
code: hit.code,
|
||||
discountPercent: hit.discount_percent ?? 0,
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
total: totals.total,
|
||||
currency: totals.currency,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq) return
|
||||
if (seq !== requestSeq) return;
|
||||
if (isUnsupportedEndpointError(err)) {
|
||||
state.value = { status: 'unverified', code }
|
||||
return
|
||||
state.value = { status: "unverified", code };
|
||||
return;
|
||||
}
|
||||
// Network / 5xx: don't block the purchase on an advisory call.
|
||||
console.warn('[usePromoCode] validate failed, sending code unverified:', err)
|
||||
state.value = { status: 'unverified', code }
|
||||
console.warn(
|
||||
"[usePromoCode] validate failed, sending code unverified:",
|
||||
err,
|
||||
);
|
||||
state.value = { status: "unverified", code };
|
||||
}
|
||||
}
|
||||
|
||||
async function apply(): Promise<void> {
|
||||
const code = normalizePromoCode(input.value)
|
||||
if (!code) return
|
||||
input.value = code
|
||||
await validate(code)
|
||||
const code = normalizePromoCode(input.value);
|
||||
if (!code) return;
|
||||
input.value = code;
|
||||
await validate(code);
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
requestSeq++
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
input.value = ''
|
||||
state.value = { status: 'idle' }
|
||||
requestSeq++;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
input.value = "";
|
||||
state.value = { status: "idle" };
|
||||
}
|
||||
|
||||
/** Re-check the current code (quantity changed, or the purchase
|
||||
* endpoint rejected it — e.g. the last use went to someone else). */
|
||||
async function revalidate(): Promise<void> {
|
||||
const s = state.value
|
||||
if (s.status === 'idle' || s.status === 'checking') return
|
||||
await validate(s.code)
|
||||
const s = state.value;
|
||||
if (s.status === "idle" || s.status === "checking") return;
|
||||
await validate(s.code);
|
||||
}
|
||||
|
||||
watch(quantity, () => {
|
||||
const s = state.value
|
||||
if (s.status === 'idle') return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
const s = state.value;
|
||||
if (s.status === "idle") return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void revalidate()
|
||||
}, REVALIDATE_DEBOUNCE_MS)
|
||||
})
|
||||
debounceTimer = null;
|
||||
void revalidate();
|
||||
}, REVALIDATE_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate }
|
||||
return {
|
||||
input,
|
||||
state,
|
||||
isChecking,
|
||||
codeToSend,
|
||||
appliedTotal,
|
||||
apply,
|
||||
clear,
|
||||
revalidate,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,56 @@
|
|||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { PaymentService } from '@/core/services/PaymentService'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import { useOwnedTickets } from './useOwnedTickets'
|
||||
import { frontendRoot } from '../lib/frontendRoot'
|
||||
import { renderTicketQr } from '../lib/ticketQr'
|
||||
import { ref, computed, onUnmounted } from "vue";
|
||||
import { useAuth } from "@/composables/useAuthService";
|
||||
import { toast } from "vue-sonner";
|
||||
import { useAsyncOperation } from "@/core/composables/useAsyncOperation";
|
||||
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
|
||||
import type { PaymentService } from "@/core/services/PaymentService";
|
||||
import type { TicketApiService } from "../services/TicketApiService";
|
||||
import { useOwnedTickets } from "./useOwnedTickets";
|
||||
import { frontendRoot } from "../lib/frontendRoot";
|
||||
import { renderTicketQr } from "../lib/ticketQr";
|
||||
|
||||
/** Guest identity for a purchase without an LNbits account. */
|
||||
export interface PurchaseContact {
|
||||
name: string
|
||||
email: string
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PurchaseTicketOptions {
|
||||
quantity?: number
|
||||
quantity?: number;
|
||||
/** Guest buyer (no account). Required when not authenticated. */
|
||||
contact?: PurchaseContact
|
||||
contact?: PurchaseContact;
|
||||
/** 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). */
|
||||
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() {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const { isAuthenticated, currentUser } = useAuth();
|
||||
const paymentService = injectService(
|
||||
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
|
||||
// feed/calendar "My tickets" filter and EventCard owned badges update
|
||||
// without a reload — purchase is exactly the "consumer that mutates
|
||||
// the ticket set" useOwnedTickets's docs anticipate.
|
||||
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||
const { refresh: refreshOwnedTickets } = useOwnedTickets();
|
||||
|
||||
// Async operations
|
||||
const purchaseOperation = useAsyncOperation()
|
||||
const purchaseOperation = useAsyncOperation();
|
||||
|
||||
// State
|
||||
const paymentHash = ref<string | null>(null)
|
||||
const paymentRequest = ref<string | null>(null)
|
||||
const qrCode = ref<string | null>(null)
|
||||
const isPaymentPending = ref(false)
|
||||
const paymentHash = ref<string | null>(null);
|
||||
const paymentRequest = ref<string | null>(null);
|
||||
const qrCode = ref<string | null>(null);
|
||||
const isPaymentPending = ref(false);
|
||||
|
||||
// Ticket QR code state. After payment lands, `purchasedTicketIds`
|
||||
// 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
|
||||
// attendee. `purchasedTicketId` stays for back-compat with the
|
||||
// single-id success path.
|
||||
const ticketQRCode = ref<string | null>(null)
|
||||
const ticketQRCodes = ref<Record<string, string>>({})
|
||||
const purchasedTicketId = ref<string | null>(null)
|
||||
const purchasedTicketIds = ref<string[]>([])
|
||||
const showTicketQR = ref(false)
|
||||
const ticketQRCode = ref<string | null>(null);
|
||||
const ticketQRCodes = ref<Record<string, string>>({});
|
||||
const purchasedTicketId = ref<string | null>(null);
|
||||
const purchasedTicketIds = ref<string[]>([]);
|
||||
const showTicketQR = ref(false);
|
||||
|
||||
// Computed properties
|
||||
const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
|
||||
const canPurchase = computed(
|
||||
() => isAuthenticated.value && currentUser.value,
|
||||
);
|
||||
const userDisplay = computed(() => {
|
||||
if (!currentUser.value) return null
|
||||
if (!currentUser.value) return null;
|
||||
return {
|
||||
name: currentUser.value.username || currentUser.value.id,
|
||||
shortId: currentUser.value.id.slice(0, 8)
|
||||
}
|
||||
})
|
||||
shortId: currentUser.value.id.slice(0, 8),
|
||||
};
|
||||
});
|
||||
|
||||
// Delegate to PaymentService
|
||||
const userWallets = computed(() => paymentService.userWallets)
|
||||
const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance)
|
||||
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value)
|
||||
const userWallets = computed(() => paymentService.userWallets);
|
||||
const hasWalletWithBalance = computed(
|
||||
() => paymentService.hasWalletWithBalance,
|
||||
);
|
||||
const isPayingWithWallet = computed(
|
||||
() => paymentService.isProcessingPayment.value,
|
||||
);
|
||||
|
||||
async function generateQRCode(bolt11: string) {
|
||||
try {
|
||||
qrCode.value = await paymentService.generateQRCode(bolt11)
|
||||
qrCode.value = await paymentService.generateQRCode(bolt11);
|
||||
} catch (err) {
|
||||
console.error('Error generating QR code:', err)
|
||||
console.error("Error generating QR code:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTicketQRCode(ticketId: string) {
|
||||
try {
|
||||
const dataUrl = await renderTicketQr(ticketId, { width: 160 })
|
||||
ticketQRCode.value = dataUrl
|
||||
return dataUrl
|
||||
const dataUrl = await renderTicketQr(ticketId, { width: 160 });
|
||||
ticketQRCode.value = dataUrl;
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('Error generating ticket QR code:', error)
|
||||
return null
|
||||
console.error("Error generating ticket QR code:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function payWithWallet(pr: string) {
|
||||
try {
|
||||
await paymentService.payWithWallet(pr, undefined, {
|
||||
showToast: false
|
||||
})
|
||||
return true
|
||||
showToast: false,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Wallet payment failed:', error)
|
||||
throw error
|
||||
console.error("Wallet payment failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The event id this composable is currently driving — kept so
|
||||
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
|
||||
* 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
|
||||
|
|
@ -118,26 +131,26 @@ export function useTicketPurchase() {
|
|||
// Ticket row(s) now exist — refresh the shared owned-tickets state so
|
||||
// the feed/calendar My-tickets filter and owned badges reflect the
|
||||
// purchase immediately (no reload). Runs in parallel with QR gen.
|
||||
void refreshOwnedTickets()
|
||||
void refreshOwnedTickets();
|
||||
|
||||
if (ids.length > 0) {
|
||||
purchasedTicketIds.value = ids
|
||||
purchasedTicketId.value = ids[0]
|
||||
const qrMap: Record<string, string> = {}
|
||||
purchasedTicketIds.value = ids;
|
||||
purchasedTicketId.value = ids[0];
|
||||
const qrMap: Record<string, string> = {};
|
||||
for (const id of ids) {
|
||||
const dataUrl = await generateTicketQRCode(id)
|
||||
if (dataUrl) qrMap[id] = dataUrl
|
||||
const dataUrl = await generateTicketQRCode(id);
|
||||
if (dataUrl) qrMap[id] = dataUrl;
|
||||
}
|
||||
ticketQRCodes.value = qrMap
|
||||
ticketQRCode.value = qrMap[ids[0]] ?? null
|
||||
showTicketQR.value = true
|
||||
ticketQRCodes.value = qrMap;
|
||||
ticketQRCode.value = qrMap[ids[0]] ?? null;
|
||||
showTicketQR.value = true;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
ids.length > 1
|
||||
? `${ids.length} tickets purchased!`
|
||||
: 'Ticket purchased successfully!',
|
||||
)
|
||||
: "Ticket purchased successfully!",
|
||||
);
|
||||
}
|
||||
|
||||
async function purchaseTicketForEvent(
|
||||
|
|
@ -147,78 +160,82 @@ export function useTicketPurchase() {
|
|||
// Guests are identified by name + email; logged-in buyers by user id.
|
||||
// Either is enough for a paid ticket — free tickets stay login-gated
|
||||
// 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) {
|
||||
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 () => {
|
||||
// Clear previous state
|
||||
paymentHash.value = null
|
||||
paymentRequest.value = null
|
||||
qrCode.value = null
|
||||
ticketQRCode.value = null
|
||||
ticketQRCodes.value = {}
|
||||
purchasedTicketId.value = null
|
||||
purchasedTicketIds.value = []
|
||||
showTicketQR.value = false
|
||||
currentEventId.value = eventId
|
||||
return await purchaseOperation.execute(
|
||||
async () => {
|
||||
// Clear previous state
|
||||
paymentHash.value = null;
|
||||
paymentRequest.value = null;
|
||||
qrCode.value = null;
|
||||
ticketQRCode.value = null;
|
||||
ticketQRCodes.value = {};
|
||||
purchasedTicketId.value = null;
|
||||
purchasedTicketIds.value = [];
|
||||
showTicketQR.value = false;
|
||||
currentEventId.value = eventId;
|
||||
|
||||
// Get the invoice via TicketApiService
|
||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
||||
// Get the invoice via TicketApiService
|
||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any;
|
||||
const accessToken = lnbitsAPI?.getAccessToken?.() || "";
|
||||
|
||||
const invoice = await ticketApi.requestTicket(eventId, {
|
||||
userId: isGuest ? undefined : currentUser.value!.id,
|
||||
name: isGuest ? options.contact?.name : undefined,
|
||||
email: isGuest ? options.contact?.email : options.email,
|
||||
accessToken: isGuest ? undefined : accessToken,
|
||||
frontendUrl: frontendRoot(),
|
||||
quantity: options.quantity,
|
||||
promoCode: options.promoCode,
|
||||
})
|
||||
const invoice = await ticketApi.requestTicket(eventId, {
|
||||
userId: isGuest ? undefined : currentUser.value!.id,
|
||||
name: isGuest ? options.contact?.name : undefined,
|
||||
email: isGuest ? options.contact?.email : options.email,
|
||||
accessToken: isGuest ? undefined : accessToken,
|
||||
frontendUrl: frontendRoot(),
|
||||
quantity: options.quantity,
|
||||
promoCode: options.promoCode,
|
||||
ticketWaveId: options.ticketWaveId,
|
||||
});
|
||||
|
||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||
// issued + marked them paid, so there's no invoice to settle. Skip
|
||||
// the QR / payment-poll and jump straight to the ticket-QR success
|
||||
// state with the ids returned inline.
|
||||
if (invoice.paid) {
|
||||
paymentHash.value = invoice.paymentHash
|
||||
await finalizePurchasedTickets(invoice.ticketIds ?? [])
|
||||
return invoice
|
||||
}
|
||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||
// issued + marked them paid, so there's no invoice to settle. Skip
|
||||
// the QR / payment-poll and jump straight to the ticket-QR success
|
||||
// state with the ids returned inline.
|
||||
if (invoice.paid) {
|
||||
paymentHash.value = invoice.paymentHash;
|
||||
await finalizePurchasedTickets(invoice.ticketIds ?? []);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
// Otherwise the backend returns either a Lightning invoice or a fiat
|
||||
// checkout URL (post-events-v1.4.0). This composable only knows how
|
||||
// to drive the Lightning path; fiat would need a separate
|
||||
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
|
||||
// has the user-visible payment-method selector). Reject the fiat
|
||||
// response here so callers get a clear error instead of a silent
|
||||
// broken QR.
|
||||
if (invoice.isFiat || !invoice.paymentRequest) {
|
||||
throw new Error(
|
||||
'This event uses fiat checkout. Use the purchase dialog ' +
|
||||
'to follow the provider link.',
|
||||
)
|
||||
}
|
||||
const bolt11: string = invoice.paymentRequest
|
||||
paymentHash.value = invoice.paymentHash
|
||||
paymentRequest.value = bolt11
|
||||
// Otherwise the backend returns either a Lightning invoice or a fiat
|
||||
// checkout URL (post-events-v1.4.0). This composable only knows how
|
||||
// to drive the Lightning path; fiat would need a separate
|
||||
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
|
||||
// has the user-visible payment-method selector). Reject the fiat
|
||||
// response here so callers get a clear error instead of a silent
|
||||
// broken QR.
|
||||
if (invoice.isFiat || !invoice.paymentRequest) {
|
||||
throw new Error(
|
||||
"This event uses fiat checkout. Use the purchase dialog " +
|
||||
"to follow the provider link.",
|
||||
);
|
||||
}
|
||||
const bolt11: string = invoice.paymentRequest;
|
||||
paymentHash.value = invoice.paymentHash;
|
||||
paymentRequest.value = bolt11;
|
||||
|
||||
// Generate QR code for payment
|
||||
await generateQRCode(bolt11)
|
||||
// Generate QR code for payment
|
||||
await generateQRCode(bolt11);
|
||||
|
||||
// Restaurant-style: don't auto-pay. Surface the QR + amount and
|
||||
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
|
||||
// external wallet" on the same screen. The composable just
|
||||
// starts polling so when payment lands (from any path) the UI
|
||||
// advances to the ticket-QR success state.
|
||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
||||
// Restaurant-style: don't auto-pay. Surface the QR + amount and
|
||||
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
|
||||
// external wallet" on the same screen. The composable just
|
||||
// starts polling so when payment lands (from any path) the UI
|
||||
// advances to the ticket-QR success state.
|
||||
await startPaymentStatusCheck(eventId, invoice.paymentHash);
|
||||
|
||||
return invoice
|
||||
}, {
|
||||
errorMessage: 'Failed to purchase ticket'
|
||||
})
|
||||
return invoice;
|
||||
},
|
||||
{
|
||||
errorMessage: "Failed to purchase ticket",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -227,76 +244,77 @@ export function useTicketPurchase() {
|
|||
* my LNbits wallet" button on the invoice screen.
|
||||
*/
|
||||
async function payCurrentInvoiceWithWallet(): Promise<void> {
|
||||
if (!paymentRequest.value) return
|
||||
await payWithWallet(paymentRequest.value)
|
||||
if (!paymentRequest.value) return;
|
||||
await payWithWallet(paymentRequest.value);
|
||||
// Polling is already running from purchaseTicketForEvent — when
|
||||
// the payment lands, it advances to showTicketQR. No need to
|
||||
// restart it here.
|
||||
}
|
||||
|
||||
async function startPaymentStatusCheck(eventId: string, hash: string) {
|
||||
isPaymentPending.value = true
|
||||
let checkInterval: number | null = null
|
||||
isPaymentPending.value = true;
|
||||
let checkInterval: number | null = null;
|
||||
|
||||
const checkPayment = async () => {
|
||||
try {
|
||||
const result = await ticketApi.checkPaymentStatus(eventId, hash)
|
||||
const result = await ticketApi.checkPaymentStatus(eventId, hash);
|
||||
|
||||
if (result.paid) {
|
||||
isPaymentPending.value = false
|
||||
isPaymentPending.value = false;
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval)
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
|
||||
// Multi-ticket purchases come back with `ticketIds` (N rows
|
||||
// sharing one invoice). Single-ticket purchases include
|
||||
// `ticketId` only.
|
||||
const ids = result.ticketIds && result.ticketIds.length > 0
|
||||
? result.ticketIds
|
||||
: result.ticketId
|
||||
? [result.ticketId]
|
||||
: []
|
||||
await finalizePurchasedTickets(ids)
|
||||
const ids =
|
||||
result.ticketIds && result.ticketIds.length > 0
|
||||
? result.ticketIds
|
||||
: result.ticketId
|
||||
? [result.ticketId]
|
||||
: [];
|
||||
await finalizePurchasedTickets(ids);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking payment status:', err)
|
||||
console.error("Error checking payment status:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await checkPayment()
|
||||
checkInterval = setInterval(checkPayment, 2000) as unknown as number
|
||||
await checkPayment();
|
||||
checkInterval = setInterval(checkPayment, 2000) as unknown as number;
|
||||
}
|
||||
|
||||
function stopPaymentStatusCheck() {
|
||||
isPaymentPending.value = false
|
||||
isPaymentPending.value = false;
|
||||
}
|
||||
|
||||
function resetPaymentState() {
|
||||
purchaseOperation.clear()
|
||||
paymentHash.value = null
|
||||
paymentRequest.value = null
|
||||
qrCode.value = null
|
||||
isPaymentPending.value = false
|
||||
ticketQRCode.value = null
|
||||
ticketQRCodes.value = {}
|
||||
purchasedTicketId.value = null
|
||||
purchasedTicketIds.value = []
|
||||
showTicketQR.value = false
|
||||
purchaseOperation.clear();
|
||||
paymentHash.value = null;
|
||||
paymentRequest.value = null;
|
||||
qrCode.value = null;
|
||||
isPaymentPending.value = false;
|
||||
ticketQRCode.value = null;
|
||||
ticketQRCodes.value = {};
|
||||
purchasedTicketId.value = null;
|
||||
purchasedTicketIds.value = [];
|
||||
showTicketQR.value = false;
|
||||
}
|
||||
|
||||
function handleOpenLightningWallet() {
|
||||
if (paymentRequest.value) {
|
||||
paymentService.openExternalWallet(paymentRequest.value)
|
||||
paymentService.openExternalWallet(paymentRequest.value);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
stopPaymentStatusCheck()
|
||||
stopPaymentStatusCheck();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
|
|
@ -329,6 +347,6 @@ export function useTicketPurchase() {
|
|||
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
|
||||
// (backend returns `paid: true`, no checkout URL) on the same
|
||||
// success screen as the Lightning / free paths.
|
||||
finalizePurchasedTickets
|
||||
}
|
||||
finalizePurchasedTickets,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,146 +1,300 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TicketApiService } from './TicketApiService'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TicketApiService } from "./TicketApiService";
|
||||
|
||||
const BASE = 'https://lnbits.test'
|
||||
const BASE = "https://lnbits.test";
|
||||
|
||||
function okJson(body: unknown) {
|
||||
return { ok: true, json: async () => body } as Response
|
||||
return { ok: true, json: async () => body } as Response;
|
||||
}
|
||||
|
||||
describe('TicketApiService.requestTicket', () => {
|
||||
const fetchMock = vi.fn()
|
||||
let api: TicketApiService
|
||||
describe("TicketApiService.requestTicket", () => {
|
||||
const fetchMock = vi.fn();
|
||||
let api: TicketApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function lastCall() {
|
||||
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> }
|
||||
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>,
|
||||
};
|
||||
}
|
||||
|
||||
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'] }))
|
||||
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`)
|
||||
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"],
|
||||
}),
|
||||
);
|
||||
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({
|
||||
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
|
||||
payment_method: 'fiat', quantity: 2,
|
||||
})
|
||||
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'] })
|
||||
})
|
||||
name: "Guest",
|
||||
email: "g@example.com",
|
||||
frontend_url: "https://demo.aiolabs.dev/events",
|
||||
payment_method: "fiat",
|
||||
quantity: 2,
|
||||
});
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
||||
await api.requestTicket('evt', {
|
||||
userId: 'u1', email: 'me@example.com', 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("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 }),
|
||||
);
|
||||
await api.requestTicket("evt", {
|
||||
userId: "u1",
|
||||
email: "me@example.com",
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, 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.')
|
||||
})
|
||||
})
|
||||
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);
|
||||
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', () => {
|
||||
const fetchMock = vi.fn()
|
||||
let api: TicketApiService
|
||||
describe("TicketApiService.getTicket / resendTicketEmail", () => {
|
||||
const fetchMock = vi.fn();
|
||||
let api: TicketApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: "k" });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('maps the anonymous PublicTicket shape', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ event: 'evt', 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 anonymous PublicTicket shape", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({
|
||||
event: "evt",
|
||||
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 () => {
|
||||
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: {} }
|
||||
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)
|
||||
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: {},
|
||||
};
|
||||
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))
|
||||
const legacy = await api.resendTicketEmail('tid', 'admin')
|
||||
expect(legacy.ticket.id).toBe('tid')
|
||||
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
|
||||
})
|
||||
})
|
||||
fetchMock.mockResolvedValueOnce(okJson(ticket));
|
||||
const legacy = await api.resendTicketEmail("tid", "admin");
|
||||
expect(legacy.ticket.id).toBe("tid");
|
||||
expect(legacy.email).toEqual({
|
||||
attempted: false,
|
||||
sent: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TicketApiService promo codes', () => {
|
||||
const fetchMock = vi.fn()
|
||||
let api: TicketApiService
|
||||
describe("TicketApiService promo codes", () => {
|
||||
const fetchMock = vi.fn();
|
||||
let api: TicketApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function lastCall() {
|
||||
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> }
|
||||
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>,
|
||||
};
|
||||
}
|
||||
|
||||
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 = {
|
||||
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
|
||||
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, 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)
|
||||
})
|
||||
subtotal: 2000,
|
||||
discount: 1000,
|
||||
total: 1000,
|
||||
currency: "sat",
|
||||
discounts_applied: [
|
||||
{
|
||||
code: "HALF",
|
||||
discount_percent: 50,
|
||||
discount_fixed: null,
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue({ 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("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);
|
||||
await expect(api.validatePromoCode("evt", "HALF")).rejects.toMatchObject({
|
||||
message: "Not Found",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('requestTicket forwards promo_code verbatim', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
||||
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
|
||||
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
|
||||
})
|
||||
it("requestTicket forwards promo_code verbatim", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
|
||||
);
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
|
||||
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
|
||||
it("requestTicket names the chosen ticket wave", async () => {
|
||||
// Without it the backend refuses ("Please select a ticket wave")
|
||||
// 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(
|
||||
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
|
||||
'inkey',
|
||||
)
|
||||
const { body, headers } = lastCall()
|
||||
expect(body.extra.promo_codes).toEqual(promo_codes)
|
||||
expect(headers['X-API-KEY']).toBe('inkey')
|
||||
})
|
||||
})
|
||||
{ name: "E", event_start_date: "2030-01-01", extra: { promo_codes } },
|
||||
"inkey",
|
||||
);
|
||||
const { body, headers } = lastCall();
|
||||
expect(body.extra.promo_codes).toEqual(promo_codes);
|
||||
expect(headers["X-API-KEY"]).toBe("inkey");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,33 +11,36 @@ import type {
|
|||
TicketedEvent,
|
||||
CreateEventRequest,
|
||||
PromoValidateRequest,
|
||||
} from '../types/ticket'
|
||||
import { normalizePromoCode } from '../lib/promoCodes'
|
||||
} from "../types/ticket";
|
||||
import { normalizePromoCode } from "../lib/promoCodes";
|
||||
|
||||
export interface TicketApiConfig {
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/** Options for `TicketApiService.requestTicket`. */
|
||||
export interface RequestTicketOptions {
|
||||
/** LNbits user id — logged-in buyers. */
|
||||
userId?: string
|
||||
userId?: string;
|
||||
/** Guest buyer name (required together with `email` when no `userId`). */
|
||||
name?: string
|
||||
name?: string;
|
||||
/** Delivery email — required for guests, optional for logged-in buyers. */
|
||||
email?: string
|
||||
email?: string;
|
||||
/** Bearer token; forwarded only when present. */
|
||||
accessToken?: string
|
||||
accessToken?: string;
|
||||
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
|
||||
frontendUrl?: string
|
||||
paymentMethod?: PaymentMethod
|
||||
fiatProvider?: string
|
||||
promoCode?: string
|
||||
refundAddress?: string
|
||||
nostrIdentifier?: string
|
||||
frontendUrl?: string;
|
||||
paymentMethod?: PaymentMethod;
|
||||
fiatProvider?: string;
|
||||
promoCode?: string;
|
||||
refundAddress?: string;
|
||||
nostrIdentifier?: string;
|
||||
/** 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.
|
||||
*/
|
||||
async fetchTicketedEvents(): Promise<any[]> {
|
||||
const response = await this.request(
|
||||
'/events/api/v1/events/public',
|
||||
{ method: 'GET' }
|
||||
)
|
||||
return response
|
||||
const response = await this.request("/events/api/v1/events/public", {
|
||||
method: "GET",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,10 +73,10 @@ export class TicketApiService {
|
|||
* own event the moment it drops to `proposed`.
|
||||
*/
|
||||
async fetchMyEvents(invoiceKey: string): Promise<any[]> {
|
||||
return this.request('/events/api/v1/events?all_wallets=true', {
|
||||
method: 'GET',
|
||||
headers: { 'X-API-KEY': invoiceKey },
|
||||
})
|
||||
return this.request("/events/api/v1/events?all_wallets=true", {
|
||||
method: "GET",
|
||||
headers: { "X-API-KEY": invoiceKey },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,29 +101,32 @@ export class TicketApiService {
|
|||
eventId: string,
|
||||
options: RequestTicketOptions = {},
|
||||
): Promise<TicketPurchaseInvoice> {
|
||||
const body: CreateTicketRequest = {}
|
||||
if (options.userId) body.user_id = options.userId
|
||||
if (options.name) body.name = options.name
|
||||
if (options.email) body.email = options.email
|
||||
if (options.frontendUrl) body.frontend_url = options.frontendUrl
|
||||
if (options.paymentMethod) body.payment_method = options.paymentMethod
|
||||
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
|
||||
if (options.promoCode) body.promo_code = options.promoCode
|
||||
if (options.refundAddress) body.refund_address = options.refundAddress
|
||||
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
|
||||
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
|
||||
const body: CreateTicketRequest = {};
|
||||
if (options.userId) body.user_id = options.userId;
|
||||
if (options.name) body.name = options.name;
|
||||
if (options.email) body.email = options.email;
|
||||
if (options.frontendUrl) body.frontend_url = options.frontendUrl;
|
||||
if (options.paymentMethod) body.payment_method = options.paymentMethod;
|
||||
if (options.fiatProvider) body.fiat_provider = options.fiatProvider;
|
||||
if (options.promoCode) body.promo_code = options.promoCode;
|
||||
if (options.refundAddress) body.refund_address = options.refundAddress;
|
||||
if (options.nostrIdentifier)
|
||||
body.nostr_identifier = options.nostrIdentifier;
|
||||
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' }
|
||||
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (options.accessToken)
|
||||
headers["Authorization"] = `Bearer ${options.accessToken}`;
|
||||
|
||||
const data = await this.request(
|
||||
`/events/api/v1/tickets/${eventId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
const data = await this.request(`/events/api/v1/tickets/${eventId}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return {
|
||||
paymentHash: data.payment_hash,
|
||||
|
|
@ -133,7 +138,7 @@ export class TicketApiService {
|
|||
// and no invoice to settle.
|
||||
paid: Boolean(data.paid),
|
||||
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.
|
||||
*/
|
||||
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 {
|
||||
eventId: t.event,
|
||||
name: t.name ?? null,
|
||||
|
|
@ -150,7 +157,7 @@ export class TicketApiService {
|
|||
paid: Boolean(t.paid),
|
||||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -160,7 +167,7 @@ export class TicketApiService {
|
|||
* failure here as non-fatal.
|
||||
*/
|
||||
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(
|
||||
eventId: string,
|
||||
paymentHash: string
|
||||
paymentHash: string,
|
||||
): Promise<TicketPaymentStatus> {
|
||||
const data = await this.request(
|
||||
`/events/api/v1/tickets/${eventId}/${paymentHash}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
return {
|
||||
paid: data.paid === true,
|
||||
ticketId: data.ticket_id,
|
||||
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -187,19 +194,16 @@ export class TicketApiService {
|
|||
*/
|
||||
async fetchUserTickets(
|
||||
userId: string,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
): Promise<EventTicket[]> {
|
||||
const data = await this.request(
|
||||
`/events/api/v1/tickets/user/${userId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const data = await this.request(`/events/api/v1/tickets/user/${userId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return (data as any[]).map(t => ({
|
||||
return (data as any[]).map((t) => ({
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
eventId: t.event,
|
||||
|
|
@ -211,7 +215,7 @@ export class TicketApiService {
|
|||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -220,10 +224,10 @@ export class TicketApiService {
|
|||
async validateTicket(ticketId: string): Promise<EventTicket[]> {
|
||||
const data = await this.request(
|
||||
`/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,
|
||||
wallet: t.wallet,
|
||||
eventId: t.event,
|
||||
|
|
@ -235,22 +239,25 @@ export class TicketApiService {
|
|||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event. Uses invoice key (any user).
|
||||
* Backend determines approval status based on user role and settings.
|
||||
*/
|
||||
async createEvent(eventData: CreateEventRequest, invoiceKey: string): Promise<TicketedEvent> {
|
||||
return this.request('/events/api/v1/events', {
|
||||
method: 'POST',
|
||||
async createEvent(
|
||||
eventData: CreateEventRequest,
|
||||
invoiceKey: string,
|
||||
): Promise<TicketedEvent> {
|
||||
return this.request("/events/api/v1/events", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': invoiceKey,
|
||||
"Content-Type": "application/json",
|
||||
"X-API-KEY": invoiceKey,
|
||||
},
|
||||
body: JSON.stringify(eventData),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -265,13 +272,13 @@ export class TicketApiService {
|
|||
adminKey: string,
|
||||
): Promise<TicketedEvent> {
|
||||
return this.request(`/events/api/v1/events/${eventId}`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': adminKey,
|
||||
"Content-Type": "application/json",
|
||||
"X-API-KEY": adminKey,
|
||||
},
|
||||
body: JSON.stringify(eventData),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -289,16 +296,16 @@ export class TicketApiService {
|
|||
const r = await this.request(
|
||||
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-API-KEY': adminKey },
|
||||
}
|
||||
)
|
||||
const t = r.ticket ?? r
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": adminKey },
|
||||
},
|
||||
);
|
||||
const t = r.ticket ?? r;
|
||||
const delivery = (d: any) => ({
|
||||
attempted: Boolean(d?.attempted),
|
||||
sent: Boolean(d?.sent),
|
||||
error: d?.error ?? null,
|
||||
})
|
||||
});
|
||||
return {
|
||||
ticket: {
|
||||
id: t.id,
|
||||
|
|
@ -315,7 +322,7 @@ export class TicketApiService {
|
|||
},
|
||||
email: delivery(r.email),
|
||||
nostr: delivery(r.nostr),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -329,23 +336,23 @@ export class TicketApiService {
|
|||
eventId: string,
|
||||
adminKey: string,
|
||||
): Promise<{
|
||||
event_id: string
|
||||
sold: number
|
||||
registered: number
|
||||
remaining: number
|
||||
event_id: string;
|
||||
sold: number;
|
||||
registered: number;
|
||||
remaining: number;
|
||||
tickets: Array<{
|
||||
id: string
|
||||
name?: string | null
|
||||
registered: boolean
|
||||
registered_at: string | null
|
||||
id: string;
|
||||
name?: string | null;
|
||||
registered: boolean;
|
||||
registered_at: string | null;
|
||||
/** 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`, {
|
||||
method: 'GET',
|
||||
headers: { 'X-API-KEY': adminKey },
|
||||
})
|
||||
method: "GET",
|
||||
headers: { "X-API-KEY": adminKey },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -355,13 +362,24 @@ export class TicketApiService {
|
|||
* means "not valid". Older extensions 404 — the thrown error carries
|
||||
* `status` so callers can fall back to "applied at checkout".
|
||||
*/
|
||||
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
|
||||
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
|
||||
async validatePromoCode(
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -370,11 +388,17 @@ export class TicketApiService {
|
|||
* unpaid / already-registered / not-owned cases with HTTP errors
|
||||
* whose `detail` becomes the thrown Error message.
|
||||
*/
|
||||
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> {
|
||||
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-API-KEY': adminKey },
|
||||
})
|
||||
async registerTicket(
|
||||
ticketId: string,
|
||||
adminKey: string,
|
||||
): Promise<EventTicket> {
|
||||
const t = await this.request(
|
||||
`/events/api/v1/tickets/register/${ticketId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "X-API-KEY": adminKey },
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: t.id,
|
||||
wallet: t.wallet,
|
||||
|
|
@ -387,7 +411,7 @@ export class TicketApiService {
|
|||
time: t.time,
|
||||
regTimestamp: t.reg_timestamp,
|
||||
extra: t.extra as EventTicketExtra | undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -397,13 +421,13 @@ export class TicketApiService {
|
|||
*/
|
||||
async isAdmin(adminKey: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/events/api/v1/events/all', {
|
||||
method: 'GET',
|
||||
headers: { 'X-API-KEY': adminKey },
|
||||
})
|
||||
return true
|
||||
await this.request("/events/api/v1/events/all", {
|
||||
method: "GET",
|
||||
headers: { "X-API-KEY": adminKey },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -415,13 +439,16 @@ export class TicketApiService {
|
|||
*/
|
||||
async getAutoApprove(invoiceKey: string): Promise<boolean> {
|
||||
try {
|
||||
const settings = await this.request('/events/api/v1/events/settings/public', {
|
||||
method: 'GET',
|
||||
headers: { 'X-API-KEY': invoiceKey },
|
||||
})
|
||||
return Boolean(settings?.auto_approve)
|
||||
const settings = await this.request(
|
||||
"/events/api/v1/events/settings/public",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "X-API-KEY": invoiceKey },
|
||||
},
|
||||
);
|
||||
return Boolean(settings?.auto_approve);
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -430,13 +457,15 @@ export class TicketApiService {
|
|||
*/
|
||||
async getCurrencies(): Promise<string[]> {
|
||||
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)) {
|
||||
return ['sats', ...currencies.filter((c: string) => c !== 'sats')]
|
||||
return ["sats", ...currencies.filter((c: string) => c !== "sats")];
|
||||
}
|
||||
return ['sats']
|
||||
return ["sats"];
|
||||
} catch {
|
||||
return ['sats']
|
||||
return ["sats"];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,28 +474,31 @@ export class TicketApiService {
|
|||
*/
|
||||
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||
const headers: Record<string, string> = {
|
||||
'accept': 'application/json',
|
||||
'X-API-KEY': this.config.apiKey,
|
||||
...(init.headers as Record<string, string> ?? {}),
|
||||
}
|
||||
accept: "application/json",
|
||||
"X-API-KEY": this.config.apiKey,
|
||||
...((init.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` }))
|
||||
const errorMessage = typeof error.detail === 'string'
|
||||
? error.detail
|
||||
: Array.isArray(error.detail)
|
||||
? error.detail[0]?.msg ?? 'Request failed'
|
||||
: 'Request failed'
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: `Request failed: ${path}` }));
|
||||
const errorMessage =
|
||||
typeof error.detail === "string"
|
||||
? 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
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,117 +1,157 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { format } from 'date-fns'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useElementVisibility } from "@vueuse/core";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { format } from "date-fns";
|
||||
import { useDateLocale } from "../composables/useDateLocale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
||||
} from 'lucide-vue-next'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue'
|
||||
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'
|
||||
Calendar,
|
||||
MapPin,
|
||||
ArrowLeft,
|
||||
Pencil,
|
||||
Ticket,
|
||||
CheckCircle2,
|
||||
ScanLine,
|
||||
History,
|
||||
} from "lucide-vue-next";
|
||||
import { useEventDetail } from "../composables/useEventDetail";
|
||||
import BookmarkButton from "../components/BookmarkButton.vue";
|
||||
import OrganizerCard from "../components/OrganizerCard.vue";
|
||||
import PurchaseTicketDialog from "../components/PurchaseTicketDialog.vue";
|
||||
import CheckoutReturnDialog from "../components/CheckoutReturnDialog.vue";
|
||||
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 router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const eventId = route.params.id as string
|
||||
const { event, isLoading, error, reload } = useEventDetail(eventId)
|
||||
const { dateLocale } = useDateLocale()
|
||||
const eventId = route.params.id as string;
|
||||
const { event, isLoading, error, reload } = useEventDetail(eventId);
|
||||
const { dateLocale } = useDateLocale();
|
||||
|
||||
// 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
|
||||
// nostr_publisher.build_nip52_event). Look the user's own events up
|
||||
// once and offer an Edit button on a match.
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const eventsStore = useEventsStore()
|
||||
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
|
||||
const { isAuthenticated, currentUser } = useAuth();
|
||||
const eventsStore = useEventsStore();
|
||||
const ownedLnbitsEvent = ref<TicketedEvent | null>(null);
|
||||
|
||||
async function loadOwnedEvent() {
|
||||
ownedLnbitsEvent.value = null
|
||||
if (!isAuthenticated.value) return
|
||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||
if (!invoiceKey) return
|
||||
ownedLnbitsEvent.value = null;
|
||||
if (!isAuthenticated.value) return;
|
||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey;
|
||||
if (!invoiceKey) return;
|
||||
try {
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
const mine = await ticketApi.fetchMyEvents(invoiceKey)
|
||||
const ticketApi = injectService(
|
||||
SERVICE_TOKENS.TICKET_API,
|
||||
) as TicketApiService;
|
||||
const mine = await ticketApi.fetchMyEvents(invoiceKey);
|
||||
ownedLnbitsEvent.value =
|
||||
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
|
||||
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null;
|
||||
} catch {
|
||||
ownedLnbitsEvent.value = null
|
||||
ownedLnbitsEvent.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOwnedEvent)
|
||||
watch(isAuthenticated, () => loadOwnedEvent())
|
||||
// Ticket waves live on the LNbits record, not in the NIP-52 tags: the
|
||||
// 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() {
|
||||
if (!ownedLnbitsEvent.value) return
|
||||
eventsStore.editingEvent = ownedLnbitsEvent.value
|
||||
eventsStore.showCreateDialog = true
|
||||
if (!ownedLnbitsEvent.value) return;
|
||||
eventsStore.editingEvent = ownedLnbitsEvent.value;
|
||||
eventsStore.showCreateDialog = true;
|
||||
}
|
||||
|
||||
function openScannerPage() {
|
||||
router.push({ name: 'scan-tickets', params: { eventId } })
|
||||
router.push({ name: "scan-tickets", params: { eventId } });
|
||||
}
|
||||
|
||||
const dateDisplay = computed(() => {
|
||||
if (!event.value) return ''
|
||||
const a = event.value
|
||||
const opts = { locale: dateLocale.value }
|
||||
if (a.type === 'date') {
|
||||
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
|
||||
if (!event.value) return "";
|
||||
const a = event.value;
|
||||
const opts = { locale: dateLocale.value };
|
||||
if (a.type === "date") {
|
||||
const start = format(a.startDate, "EEEE, MMMM d, yyyy", opts);
|
||||
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,
|
||||
// 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
|
||||
// ("May 29 • 19:00 — May 30 • 21:45").
|
||||
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm'
|
||||
const start = format(a.startDate, FULL, opts)
|
||||
if (!a.endDate) return start
|
||||
const FULL = "EEEE, MMMM d, yyyy \u2022 HH:mm";
|
||||
const start = format(a.startDate, FULL, opts);
|
||||
if (!a.endDate) return start;
|
||||
const sameDay =
|
||||
a.startDate.getFullYear() === a.endDate.getFullYear() &&
|
||||
a.startDate.getMonth() === a.endDate.getMonth() &&
|
||||
a.startDate.getDate() === a.endDate.getDate()
|
||||
return `${start} — ${format(a.endDate, sameDay ? 'HH:mm' : FULL, opts)}`
|
||||
})
|
||||
a.startDate.getDate() === a.endDate.getDate();
|
||||
return `${start} — ${format(a.endDate, sameDay ? "HH:mm" : FULL, opts)}`;
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
if (!event.value?.category) return null
|
||||
return t(`events.categories.${event.value.category}`, event.value.category)
|
||||
})
|
||||
if (!event.value?.category) return null;
|
||||
return t(`events.categories.${event.value.category}`, event.value.category);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'events' })
|
||||
router.push({ name: "events" });
|
||||
}
|
||||
|
||||
// --- 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 a = event.value
|
||||
if (!a || !a.ticketInfo) return null
|
||||
const a = event.value;
|
||||
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 {
|
||||
id: a.id,
|
||||
name: a.title,
|
||||
|
|
@ -120,45 +160,52 @@ const purchaseEvent = computed(() => {
|
|||
allow_fiat: a.ticketInfo.allowFiat,
|
||||
fiat_currency: a.ticketInfo.fiatCurrency,
|
||||
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 === 0 → sold out, button hidden
|
||||
// available > 0 → button shown
|
||||
const canBuyTicket = computed(() => {
|
||||
const info = event.value?.ticketInfo
|
||||
if (!info) return false
|
||||
return info.available === undefined || info.available > 0
|
||||
})
|
||||
const info = event.value?.ticketInfo;
|
||||
if (!info) return false;
|
||||
return info.available === undefined || info.available > 0;
|
||||
});
|
||||
|
||||
// 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
|
||||
// on a long detail page.
|
||||
const isPast = computed(() => {
|
||||
const a = event.value
|
||||
if (!a) return false
|
||||
const end = a.endDate ?? a.startDate
|
||||
if (!end || isNaN(end.getTime())) return false
|
||||
return end.getTime() < Date.now()
|
||||
})
|
||||
const a = event.value;
|
||||
if (!a) return false;
|
||||
const end = a.endDate ?? a.startDate;
|
||||
if (!end || isNaN(end.getTime())) return false;
|
||||
return end.getTime() < Date.now();
|
||||
});
|
||||
|
||||
const showPurchaseDialog = ref(false)
|
||||
const showPurchaseDialog = ref(false);
|
||||
|
||||
function openPurchaseDialog() {
|
||||
// Paid tickets are open to guests (name + email identity). Free
|
||||
// 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) {
|
||||
toastService.info(t('events.detail.loginToBuyTickets'), {
|
||||
toastService.info(t("events.detail.loginToBuyTickets"), {
|
||||
action: {
|
||||
label: t('events.detail.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
label: t("events.detail.logIn"),
|
||||
onClick: () => router.push("/login"),
|
||||
},
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
showPurchaseDialog.value = true
|
||||
showPurchaseDialog.value = true;
|
||||
}
|
||||
|
||||
// --- Return from the fiat provider ---------------------------------
|
||||
|
|
@ -167,36 +214,37 @@ function openPurchaseDialog() {
|
|||
// or ?checkout=cancelled. The query is stripped immediately so the
|
||||
// ticket ids don't linger in history; the localStorage record written
|
||||
// before the redirect is the fallback source and carries the email.
|
||||
const showReturnDialog = ref(false)
|
||||
const returnTicketIds = ref<string[]>([])
|
||||
const returnEmail = ref<string | undefined>(undefined)
|
||||
const showReturnDialog = ref(false);
|
||||
const returnTicketIds = ref<string[]>([]);
|
||||
const returnEmail = ref<string | undefined>(undefined);
|
||||
|
||||
function handleCheckoutReturn() {
|
||||
const status = route.query.checkout
|
||||
if (status !== 'success' && status !== 'cancelled') return
|
||||
const status = route.query.checkout;
|
||||
if (status !== "success" && status !== "cancelled") return;
|
||||
|
||||
const pending = readPendingCheckout()
|
||||
const samePurchase = pending?.eventId === eventId
|
||||
void router.replace({ query: {} })
|
||||
const pending = readPendingCheckout();
|
||||
const samePurchase = pending?.eventId === eventId;
|
||||
void router.replace({ query: {} });
|
||||
|
||||
if (status === 'cancelled') {
|
||||
clearPendingCheckout()
|
||||
toastService.info(t('events.checkout.cancelled'))
|
||||
return
|
||||
if (status === "cancelled") {
|
||||
clearPendingCheckout();
|
||||
toastService.info(t("events.checkout.cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const fromQuery = String(route.query.tickets ?? '')
|
||||
.split(',')
|
||||
const fromQuery = String(route.query.tickets ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : []
|
||||
if (ids.length === 0) return
|
||||
returnTicketIds.value = ids
|
||||
returnEmail.value = samePurchase ? pending?.email : undefined
|
||||
showReturnDialog.value = true
|
||||
.filter(Boolean);
|
||||
const ids =
|
||||
fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [];
|
||||
if (ids.length === 0) return;
|
||||
returnTicketIds.value = ids;
|
||||
returnEmail.value = samePurchase ? pending?.email : undefined;
|
||||
showReturnDialog.value = true;
|
||||
}
|
||||
|
||||
onMounted(handleCheckoutReturn)
|
||||
onMounted(handleCheckoutReturn);
|
||||
|
||||
// --- Sticky buy bar --------------------------------------------------
|
||||
// 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
|
||||
// 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).
|
||||
const ticketBlockRef = ref<HTMLElement | null>(null)
|
||||
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
|
||||
const ticketBlockRef = ref<HTMLElement | null>(null);
|
||||
const ticketBlockVisible = useElementVisibility(ticketBlockRef);
|
||||
|
||||
const priceLabel = computed(() => {
|
||||
const info = event.value?.ticketInfo
|
||||
if (!info) return ''
|
||||
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
|
||||
})
|
||||
const info = event.value?.ticketInfo;
|
||||
if (!info) return "";
|
||||
return info.price === 0
|
||||
? t("events.detail.free")
|
||||
: `${info.price} ${info.currency}`;
|
||||
});
|
||||
|
||||
const buyLabel = computed(() =>
|
||||
ownedPaidCount.value > 0
|
||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
||||
: t('events.detail.buyTicket', 'Buy ticket'),
|
||||
)
|
||||
? t("events.detail.buyAnotherTicket", "Buy another ticket")
|
||||
: t("events.detail.buyTicket", "Buy ticket"),
|
||||
);
|
||||
|
||||
const showStickyCta = computed(
|
||||
() =>
|
||||
|
|
@ -228,22 +278,25 @@ const showStickyCta = computed(
|
|||
!ticketBlockVisible.value &&
|
||||
!showPurchaseDialog.value &&
|
||||
!showReturnDialog.value,
|
||||
)
|
||||
);
|
||||
|
||||
// Re-fetch the user's tickets when the purchase dialog closes (the
|
||||
// buyer may have just paid). The inventory side updates automatically
|
||||
// via the relay republish from the events extension.
|
||||
watch(showPurchaseDialog, (open) => {
|
||||
if (!open) refreshOwnedTickets()
|
||||
})
|
||||
if (!open) refreshOwnedTickets();
|
||||
});
|
||||
|
||||
function goToMyTickets() {
|
||||
router.push('/my-tickets')
|
||||
router.push("/my-tickets");
|
||||
}
|
||||
</script>
|
||||
|
||||
<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
|
||||
prominent icon button; Scan moves into the tickets section
|
||||
where it replaces the Buy-ticket CTA for the host. -->
|
||||
|
|
@ -264,7 +317,9 @@ function goToMyTickets() {
|
|||
|
||||
<!-- Error -->
|
||||
<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>
|
||||
<Button variant="outline" @click="reload">Retry</Button>
|
||||
</div>
|
||||
|
|
@ -286,11 +341,7 @@ function goToMyTickets() {
|
|||
<!-- "Yours" leads the row in the highlighted variant so the
|
||||
ownership signal stands out against the neutral
|
||||
category/tag chips that follow. -->
|
||||
<Badge
|
||||
v-if="event.isMine"
|
||||
variant="secondary"
|
||||
class="shrink-0"
|
||||
>
|
||||
<Badge v-if="event.isMine" variant="secondary" class="shrink-0">
|
||||
Yours
|
||||
</Badge>
|
||||
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
|
||||
|
|
@ -298,10 +349,14 @@ function goToMyTickets() {
|
|||
</Badge>
|
||||
<Badge
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
:variant="
|
||||
event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'
|
||||
"
|
||||
class="shrink-0 capitalize"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
{{
|
||||
event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review"
|
||||
}}
|
||||
</Badge>
|
||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||
<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" />
|
||||
<span>
|
||||
{{ dateDisplay }}
|
||||
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
|
||||
<span v-if="event.timezone" class="opacity-70"
|
||||
>({{ event.timezone }})</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="event.location" class="flex items-start gap-1.5">
|
||||
|
|
@ -364,7 +421,7 @@ function goToMyTickets() {
|
|||
@click="openScannerPage"
|
||||
>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
{{ t('events.detail.scanTickets', 'Scan tickets') }}
|
||||
{{ t("events.detail.scanTickets", "Scan tickets") }}
|
||||
</Button>
|
||||
|
||||
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||
|
|
@ -384,13 +441,21 @@ function goToMyTickets() {
|
|||
v-if="ownedPaidCount > 0"
|
||||
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" />
|
||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||
{{
|
||||
t(
|
||||
"events.detail.ticketsOwned",
|
||||
{ count: ownedPaidCount },
|
||||
ownedPaidCount,
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||
{{ t("events.detail.viewMyTickets", "View in My Tickets") }}
|
||||
</Button>
|
||||
</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"
|
||||
>
|
||||
<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 v-else-if="canBuyTicket" class="space-y-1">
|
||||
<Button
|
||||
|
|
@ -414,10 +479,15 @@ function goToMyTickets() {
|
|||
</Button>
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
{{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
|
||||
</span>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -425,7 +495,7 @@ function goToMyTickets() {
|
|||
v-else-if="ownedPaidCount === 0"
|
||||
class="text-sm text-destructive text-center"
|
||||
>
|
||||
{{ t('events.detail.soldOut') }}
|
||||
{{ t("events.detail.soldOut") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -458,50 +528,69 @@ function goToMyTickets() {
|
|||
parent's space-y margin (margins offset fixed boxes too) and
|
||||
float 24px above the nav. -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-4 opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
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')"
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-4 opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-to-class="translate-y-4 opacity-0"
|
||||
>
|
||||
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
<span v-if="ownedPaidCount > 0">
|
||||
{{ 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"
|
||||
<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"
|
||||
>
|
||||
<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 class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ event.title }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
<span v-if="ownedPaidCount > 0">
|
||||
{{
|
||||
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>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- External references -->
|
||||
|
|
@ -513,8 +602,10 @@ function goToMyTickets() {
|
|||
|
||||
<!-- Organizer -->
|
||||
<div class="bg-muted/50 rounded-lg p-4">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{{ t('events.detail.organizer') }}
|
||||
<p
|
||||
class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"
|
||||
>
|
||||
{{ t("events.detail.organizer") }}
|
||||
</p>
|
||||
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue