477 lines
12 KiB
Vue
477 lines
12 KiB
Vue
<template>
|
|
<div class="event-ticket-purchase">
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="ticket-panel">
|
|
<div class="box-title">Tickets</div>
|
|
<p class="ticket-status">Loading ticket information...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="ticket-panel">
|
|
<div class="box-title">Tickets</div>
|
|
<p class="ticket-status" style="color: var(--ember)">
|
|
Unable to Load Tickets
|
|
</p>
|
|
<p class="ticket-detail">{{ error }}</p>
|
|
</div>
|
|
|
|
<!-- Series Pass Required -->
|
|
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
|
|
<div class="box-title">Tickets</div>
|
|
<p class="ticket-status" style="color: var(--candle)">
|
|
Series Pass Required
|
|
</p>
|
|
<p class="ticket-detail">
|
|
This event is part of
|
|
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
|
|
pass to attend.
|
|
</p>
|
|
<p class="ticket-hint">
|
|
Purchase a series pass to get access to all events in this series.
|
|
</p>
|
|
<NuxtLink
|
|
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
|
|
>
|
|
<button class="btn btn-primary">View Series & Purchase Pass</button>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Already Registered -->
|
|
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
|
<div class="box-title">Registration</div>
|
|
<p class="ticket-status" style="color: var(--green)">
|
|
You're Registered!
|
|
</p>
|
|
<p class="ticket-detail">
|
|
<template v-if="ticketInfo.viaSeriesPass">
|
|
You have access to this event via your series pass for
|
|
<strong>{{ ticketInfo.series?.title }}</strong>.
|
|
</template>
|
|
<template v-else>
|
|
You're all set for this event. Check your email for confirmation
|
|
details.
|
|
</template>
|
|
</p>
|
|
<p class="ticket-hint">
|
|
See you on {{ formatEventDate(eventStartDate) }}!
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Ticket Selection -->
|
|
<div v-else-if="ticketInfo">
|
|
<!-- Ticket Card -->
|
|
<EventTicketCard
|
|
:ticket-info="ticketInfo"
|
|
:is-selected="true"
|
|
:is-available="ticketInfo.available"
|
|
:already-registered="ticketInfo.alreadyRegistered"
|
|
@join-waitlist="handleJoinWaitlist"
|
|
/>
|
|
|
|
<!-- Registration (logged-in member) -->
|
|
<div
|
|
v-if="ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn"
|
|
class="ticket-panel"
|
|
>
|
|
<div class="box-title">
|
|
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
|
</div>
|
|
|
|
<p
|
|
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
|
class="ticket-notice"
|
|
style="color: var(--candle)"
|
|
>
|
|
This event is free for Ghost Guild members
|
|
</p>
|
|
|
|
<p
|
|
v-if="!ticketInfo.isFree"
|
|
class="ticket-notice"
|
|
style="color: var(--candle)"
|
|
>
|
|
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
|
securely
|
|
</p>
|
|
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="processing"
|
|
@click="handleSubmit"
|
|
>
|
|
{{
|
|
processing
|
|
? "Processing..."
|
|
: ticketInfo.isFree
|
|
? "Register for this event"
|
|
: `Pay ${ticketInfo.formattedPrice}`
|
|
}}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Registration Form (guest) -->
|
|
<div
|
|
v-else-if="ticketInfo.available && !ticketInfo.alreadyRegistered"
|
|
class="ticket-panel"
|
|
>
|
|
<div class="box-title">
|
|
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
|
</div>
|
|
|
|
<form @submit.prevent="handleSubmit">
|
|
<div class="field">
|
|
<label for="ticket-name">Full Name</label>
|
|
<input
|
|
id="ticket-name"
|
|
v-model="form.name"
|
|
name="name"
|
|
type="text"
|
|
autocomplete="name"
|
|
required
|
|
:disabled="processing"
|
|
>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="ticket-email">Email Address</label>
|
|
<input
|
|
id="ticket-email"
|
|
v-model="form.email"
|
|
name="email"
|
|
type="email"
|
|
autocomplete="email"
|
|
required
|
|
:disabled="processing"
|
|
>
|
|
</div>
|
|
|
|
<p
|
|
v-if="!ticketInfo.isFree"
|
|
class="ticket-notice"
|
|
style="color: var(--candle)"
|
|
>
|
|
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
|
securely
|
|
</p>
|
|
|
|
<div class="consent-block">
|
|
<label class="consent-field">
|
|
<input
|
|
v-model="form.createAccount"
|
|
type="checkbox"
|
|
:disabled="processing"
|
|
>
|
|
<span>Create a free guest account so I can manage my registration</span>
|
|
</label>
|
|
<p class="field-hint consent-hint">
|
|
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
class="btn btn-primary"
|
|
:disabled="processing || !form.name || !form.email"
|
|
>
|
|
{{
|
|
processing
|
|
? "Processing..."
|
|
: ticketInfo.isFree
|
|
? "Complete Registration"
|
|
: `Pay ${ticketInfo.formattedPrice}`
|
|
}}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Sold Out with Waitlist -->
|
|
<div
|
|
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
|
|
class="ticket-panel"
|
|
>
|
|
<div class="box-title">Waitlist</div>
|
|
<p class="ticket-status" style="color: var(--ember)">
|
|
Event Sold Out
|
|
</p>
|
|
<p class="ticket-detail">
|
|
This event is currently at capacity. Join the waitlist to be notified
|
|
if spots become available.
|
|
</p>
|
|
<button class="btn" @click="handleJoinWaitlist">
|
|
Join Waitlist
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Sold Out (No Waitlist) -->
|
|
<div v-else-if="!ticketInfo.available" class="ticket-panel">
|
|
<div class="box-title">Tickets</div>
|
|
<p class="ticket-status" style="color: var(--ember)">
|
|
Event Sold Out
|
|
</p>
|
|
<p class="ticket-detail">
|
|
Unfortunately, this event is at capacity and no longer accepting
|
|
registrations.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
const props = defineProps({
|
|
eventId: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
eventStartDate: {
|
|
type: Date,
|
|
required: true,
|
|
},
|
|
eventTitle: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
userEmail: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
userName: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(["success", "error"]);
|
|
|
|
const toast = useToast();
|
|
const { initializeTicketPayment, verifyPayment, cleanup } = useHelcimPay();
|
|
|
|
// State
|
|
const loading = ref(true);
|
|
const processing = ref(false);
|
|
const error = ref(null);
|
|
const ticketInfo = ref(null);
|
|
|
|
const form = ref({
|
|
name: props.userName || "",
|
|
email: props.userEmail || "",
|
|
createAccount: true,
|
|
});
|
|
|
|
const isLoggedIn = computed(() => !!props.userEmail);
|
|
|
|
// Fetch ticket availability on mount
|
|
onMounted(async () => {
|
|
await fetchTicketInfo();
|
|
});
|
|
|
|
const fetchTicketInfo = async (emailOverride = null) => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
try {
|
|
const effectiveEmail = emailOverride || props.userEmail;
|
|
|
|
// First check if this event requires a series pass
|
|
if (props.userEmail) {
|
|
try {
|
|
const seriesAccess = await $fetch(
|
|
`/api/events/${props.eventId}/check-series-access`,
|
|
);
|
|
|
|
if (seriesAccess.requiresSeriesPass) {
|
|
if (seriesAccess.hasSeriesPass) {
|
|
ticketInfo.value = {
|
|
available: true,
|
|
alreadyRegistered: true,
|
|
viaSeriesPass: true,
|
|
series: seriesAccess.series,
|
|
message: seriesAccess.message,
|
|
};
|
|
loading.value = false;
|
|
return;
|
|
} else {
|
|
ticketInfo.value = {
|
|
available: false,
|
|
requiresSeriesPass: true,
|
|
series: seriesAccess.series,
|
|
message: seriesAccess.message,
|
|
};
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
}
|
|
} catch (seriesErr) {
|
|
console.warn("Series access check failed:", seriesErr);
|
|
}
|
|
}
|
|
|
|
// Regular ticket availability check
|
|
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : "";
|
|
const response = await $fetch(
|
|
`/api/events/${props.eventId}/tickets/available${params}`,
|
|
);
|
|
|
|
ticketInfo.value = response;
|
|
} catch (err) {
|
|
console.error("Error fetching ticket info:", err);
|
|
error.value =
|
|
err.data?.statusMessage || "Failed to load ticket information";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
processing.value = true;
|
|
|
|
try {
|
|
let transactionId = null;
|
|
|
|
if (!ticketInfo.value.isFree) {
|
|
await initializeTicketPayment(
|
|
props.eventId,
|
|
form.value.email,
|
|
props.eventTitle,
|
|
);
|
|
|
|
const paymentResult = await verifyPayment();
|
|
|
|
if (!paymentResult.success) {
|
|
throw new Error("Payment was not completed");
|
|
}
|
|
|
|
transactionId = paymentResult.transactionId;
|
|
|
|
if (!transactionId) {
|
|
throw new Error("No transaction ID received from payment");
|
|
}
|
|
}
|
|
|
|
const body = {
|
|
name: form.value.name,
|
|
email: form.value.email,
|
|
createAccount: form.value.createAccount,
|
|
};
|
|
if (transactionId) body.transactionId = transactionId;
|
|
|
|
const response = await $fetch(
|
|
`/api/events/${props.eventId}/tickets/purchase`,
|
|
{
|
|
method: "POST",
|
|
body,
|
|
},
|
|
);
|
|
|
|
toast.add({
|
|
title: "Success!",
|
|
description: ticketInfo.value.isFree
|
|
? "You're registered for this event"
|
|
: "Ticket purchased successfully!",
|
|
color: "success",
|
|
});
|
|
|
|
emit("success", response);
|
|
|
|
if (response?.signedIn) {
|
|
// New guest account or returning guest — refresh client auth state so the
|
|
// rest of the app sees them as logged in.
|
|
await useAuth().checkMemberStatus();
|
|
}
|
|
|
|
await fetchTicketInfo(form.value.email);
|
|
} catch (err) {
|
|
console.error("Error purchasing ticket:", err);
|
|
|
|
const errorMessage =
|
|
err.data?.statusMessage ||
|
|
err.message ||
|
|
"Failed to process registration";
|
|
|
|
toast.add({
|
|
title: "Registration Failed",
|
|
description: errorMessage,
|
|
color: "error",
|
|
});
|
|
|
|
emit("error", err);
|
|
} finally {
|
|
processing.value = false;
|
|
cleanup();
|
|
}
|
|
};
|
|
|
|
const handleJoinWaitlist = () => {
|
|
toast.add({
|
|
title: "Waitlist",
|
|
description: "Waitlist functionality coming soon!",
|
|
color: "info",
|
|
});
|
|
};
|
|
|
|
const formatEventDate = (date) => {
|
|
return new Date(date).toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ticket-panel {
|
|
padding: 20px 24px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.ticket-status {
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
margin-bottom: 4px;
|
|
}
|
|
.ticket-detail {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-bottom: 10px;
|
|
line-height: 1.6;
|
|
}
|
|
.ticket-hint {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 10px;
|
|
}
|
|
.ticket-notice {
|
|
font-size: 11px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.field-hint {
|
|
font-size: 10px;
|
|
color: var(--text-faint);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.consent-block {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
align-items: flex-start;
|
|
column-gap: 8px;
|
|
row-gap: 4px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.consent-field {
|
|
display: contents;
|
|
font-size: 12px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
.consent-field input[type="checkbox"] {
|
|
margin-top: 3px;
|
|
accent-color: var(--candle);
|
|
}
|
|
.consent-hint {
|
|
grid-column: 2;
|
|
margin: 0;
|
|
}
|
|
</style>
|