Add landing page

This commit is contained in:
Jennie Robinson Faber 2025-11-03 11:17:51 +00:00
parent 3fea484585
commit bce86ee840
47 changed files with 7119 additions and 439 deletions

View file

@ -1,35 +1,33 @@
<template>
<div
class="p-4 bg-gradient-to-r from-purple-500/10 to-blue-500/10 rounded-xl border border-purple-500/30"
class="series-badge p-4 bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl border border-ghost-600 dark:border-ghost-600"
>
<div class="flex items-start justify-between gap-6">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="text-sm font-semibold text-purple-700 dark:text-purple-300"
class="series-badge__label text-sm font-semibold text-ghost-300 dark:text-ghost-300"
>
Part of a Series
</span>
<span
v-if="totalEvents"
class="inline-flex items-center px-2 py-0.5 rounded-md bg-purple-500/20 text-sm font-medium text-purple-700 dark:text-purple-300"
class="series-badge__count inline-flex items-center px-2 py-0.5 rounded-md bg-ghost-700/50 dark:bg-ghost-600/50 text-sm font-medium text-ghost-200 dark:text-ghost-200"
>
<template v-if="position">
Event {{ position }} of {{ totalEvents }}
</template>
<template v-else>
{{ totalEvents }} events in series
</template>
<template v-else> {{ totalEvents }} events in series </template>
</span>
</div>
<h3
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2"
class="series-badge__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
>
{{ title }}
</h3>
<p
v-if="description"
class="text-sm text-purple-600 dark:text-purple-400"
class="series-badge__description text-sm text-ghost-300 dark:text-ghost-300"
>
{{ description }}
</p>

View file

@ -0,0 +1,264 @@
<template>
<div
class="series-ticket-card border border-ghost-600 dark:border-ghost-600 rounded-xl overflow-hidden"
>
<!-- Header -->
<div
class="bg-gradient-to-br from-purple-600 to-purple-800 dark:from-purple-700 dark:to-purple-900 p-6"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Icon
name="heroicons:ticket"
class="w-5 h-5 text-purple-200 dark:text-purple-300"
/>
<span class="text-sm font-semibold text-purple-200 dark:text-purple-300">
Series Pass
</span>
</div>
<h3 class="text-xl font-bold text-white mb-1">
{{ ticket.name }}
</h3>
<p v-if="ticket.description" class="text-sm text-purple-200 dark:text-purple-300">
{{ ticket.description }}
</p>
</div>
<div class="text-right flex-shrink-0">
<div class="text-3xl font-bold text-white">
{{ formatPrice(ticket.price, ticket.currency) }}
</div>
<div
v-if="ticket.isEarlyBird"
class="text-xs text-purple-200 dark:text-purple-300 mt-1"
>
Early Bird Price
</div>
</div>
</div>
</div>
<!-- Body -->
<div class="p-6 bg-ghost-800/50 dark:bg-ghost-700/30">
<!-- What's Included -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-ghost-200 dark:text-ghost-200 mb-3 uppercase tracking-wide">
What's Included
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2 text-ghost-300 dark:text-ghost-300">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-400" />
<span>Access to all {{ totalEvents }} events in the series</span>
</div>
<div
v-if="ticket.isFree && !isMember"
class="flex items-center gap-2 text-ghost-300 dark:text-ghost-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-400" />
<span>Automatic registration for all sessions</span>
</div>
<div
v-if="memberSavings > 0"
class="flex items-center gap-2 text-ghost-300 dark:text-ghost-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-400" />
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
</div>
</div>
</div>
<!-- Events List Preview -->
<div v-if="events && events.length > 0" class="mb-6">
<h4 class="text-sm font-semibold text-ghost-200 dark:text-ghost-200 mb-3 uppercase tracking-wide">
Series Schedule
</h4>
<div class="space-y-2">
<div
v-for="(event, index) in events.slice(0, 3)"
:key="event.id"
class="flex items-start gap-3 p-3 bg-ghost-700/50 dark:bg-ghost-600/30 rounded-lg"
>
<div
class="w-8 h-8 rounded-full bg-purple-600/20 border border-purple-500/30 flex items-center justify-center flex-shrink-0"
>
<span class="text-sm font-bold text-purple-300">{{ index + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-ghost-100 dark:text-ghost-100 text-sm">
{{ event.title }}
</div>
<div class="text-xs text-ghost-400 dark:text-ghost-400 mt-1">
{{ formatEventDate(event.startDate) }}
</div>
</div>
</div>
<div
v-if="events.length > 3"
class="text-center text-sm text-ghost-400 dark:text-ghost-400 pt-2"
>
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
</div>
</div>
</div>
<!-- Member Benefit Callout -->
<div
v-if="ticket.isFree && isMember"
class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg mb-6"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:sparkles" class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<div>
<div class="font-semibold text-green-300 mb-1">Member Benefit</div>
<div class="text-sm text-green-400">
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
</div>
</div>
</div>
</div>
<!-- Public vs Member Pricing -->
<div
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
class="p-4 bg-blue-900/20 border border-blue-700/30 rounded-lg mb-6"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:tag" class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div class="flex-1">
<div class="font-semibold text-blue-300 mb-1">Member Savings</div>
<div class="text-sm text-blue-400">
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
</div>
</div>
</div>
</div>
<!-- Availability -->
<div v-if="availability" class="mb-6">
<div
v-if="!availability.unlimited && availability.remaining !== null"
class="flex items-center gap-2"
>
<Icon
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
:class="[
'w-5 h-5',
availability.remaining > 5 ? 'text-green-400' : 'text-yellow-400'
]"
/>
<span
:class="[
'text-sm font-medium',
availability.remaining > 5 ? 'text-green-300' : 'text-yellow-300'
]"
>
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
</span>
</div>
</div>
<!-- Sold Out / Waitlist -->
<div v-if="!available" class="space-y-3">
<div class="p-4 bg-red-900/20 border border-red-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<div class="font-semibold text-red-300 mb-1">Series Pass Sold Out</div>
<div class="text-sm text-red-400">
All series passes have been claimed.
</div>
</div>
</div>
</div>
<UButton
v-if="availability?.waitlistAvailable"
block
color="gray"
size="lg"
@click="$emit('join-waitlist')"
>
Join Waitlist
</UButton>
</div>
<!-- Already Registered -->
<div v-else-if="alreadyRegistered" class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 text-green-400 flex-shrink-0" />
<div>
<div class="font-semibold text-green-300 mb-1">You're Registered!</div>
<div class="text-sm text-green-400">
You have a series pass and are registered for all {{ totalEvents }} events.
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
ticket: {
type: Object,
required: true,
// Expected: { name, description, price, currency, type, isFree, isEarlyBird }
},
availability: {
type: Object,
default: null,
// Expected: { remaining, unlimited, waitlistAvailable }
},
available: {
type: Boolean,
default: true,
},
alreadyRegistered: {
type: Boolean,
default: false,
},
isMember: {
type: Boolean,
default: false,
},
totalEvents: {
type: Number,
required: true,
},
events: {
type: Array,
default: () => [],
// Expected: Array of { id, title, startDate }
},
publicPrice: {
type: Number,
default: null,
},
});
defineEmits(['join-waitlist']);
const memberSavings = computed(() => {
if (props.publicPrice && props.ticket.price < props.publicPrice) {
return props.publicPrice - props.ticket.price;
}
return 0;
});
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
</script>

View file

@ -0,0 +1,192 @@
<template>
<div
class="ticket-card rounded-xl border p-6 transition-all duration-200"
:class="[
isSelected
? 'border-primary bg-primary/5'
: 'border-ghost-600 bg-ghost-800/50',
isAvailable && !alreadyRegistered
? 'hover:border-primary/50 cursor-pointer'
: 'opacity-60 cursor-not-allowed',
]"
@click="handleClick"
>
<!-- Ticket Header -->
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-ghost-100">
{{ ticketInfo.name }}
</h3>
<p v-if="ticketInfo.description" class="text-sm text-ghost-300 mt-1">
{{ ticketInfo.description }}
</p>
</div>
<!-- Badge -->
<div v-if="ticketInfo.isMember" class="flex-shrink-0 ml-4">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
Members Only
</span>
</div>
</div>
<!-- Price Display -->
<div class="mb-4">
<div class="flex items-baseline gap-2">
<span
class="text-3xl font-bold"
:class="ticketInfo.isFree ? 'text-green-400' : 'text-ghost-100'"
>
{{ ticketInfo.formattedPrice }}
</span>
<!-- Early Bird Badge -->
<span
v-if="ticketInfo.isEarlyBird"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
Early Bird
</span>
</div>
<!-- Regular Price (if early bird) -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
class="mt-1"
>
<span class="text-sm text-ghost-400 line-through">
Regular: {{ ticketInfo.formattedRegularPrice }}
</span>
</div>
<!-- Early Bird Countdown -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
class="mt-2 text-xs text-amber-400"
>
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
</div>
</div>
<!-- Member Savings -->
<div
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
class="mb-4 p-3 bg-green-900/20 rounded-lg border border-green-800"
>
<p class="text-sm text-green-400">
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
</p>
<p class="text-xs text-ghost-400 mt-1">
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
</p>
</div>
<!-- Availability -->
<div class="flex items-center justify-between text-sm">
<div>
<span
v-if="alreadyRegistered"
class="text-green-400 flex items-center gap-1"
>
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
You're registered
</span>
<span
v-else-if="!isAvailable"
class="text-red-400 flex items-center gap-1"
>
<Icon name="heroicons:x-circle-solid" class="w-4 h-4" />
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="text-ghost-300">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="text-ghost-300"> Unlimited availability </span>
</div>
<!-- Selection Indicator -->
<div v-if="isSelected && isAvailable && !alreadyRegistered">
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
</div>
</div>
<!-- Waitlist Option -->
<div
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
class="mt-4 pt-4 border-t border-ghost-600"
>
<UButton
color="gray"
size="sm"
block
@click.stop="$emit('join-waitlist')"
>
Join Waitlist
</UButton>
</div>
</div>
</template>
<script setup>
const props = defineProps({
ticketInfo: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
isAvailable: {
type: Boolean,
default: true,
},
alreadyRegistered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select", "join-waitlist"]);
const handleClick = () => {
if (props.isAvailable && !props.alreadyRegistered) {
emit("select");
}
};
const formatDeadline = (deadline) => {
const date = new Date(deadline);
const now = new Date();
const diff = date - now;
// If less than 24 hours, show hours
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
}
// Otherwise show date
return `on ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}`;
};
const formatPrice = (amount) => {
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
};
</script>
<style scoped>
.ticket-card {
position: relative;
}
</style>

View file

@ -0,0 +1,412 @@
<template>
<div class="event-ticket-purchase">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-ghost-300">Loading ticket information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-red-900/20 rounded-xl border border-red-800"
>
<h3 class="text-lg font-semibold text-red-300 mb-2">
Unable to Load Tickets
</h3>
<p class="text-red-400">{{ error }}</p>
</div>
<!-- Series Pass Required -->
<div
v-else-if="ticketInfo?.requiresSeriesPass"
class="p-6 bg-purple-900/20 rounded-xl border border-purple-800"
>
<h3
class="text-lg font-semibold text-purple-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:ticket" class="w-6 h-6" />
Series Pass Required
</h3>
<p class="text-purple-400 mb-4">
This event is part of
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
pass to attend.
</p>
<p class="text-sm text-ghost-300 mb-6">
Purchase a series pass to get access to all events in this series.
</p>
<UButton
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
color="primary"
size="lg"
block
>
View Series & Purchase Pass
</UButton>
</div>
<!-- Already Registered -->
<div
v-else-if="ticketInfo?.alreadyRegistered"
class="p-6 bg-green-900/20 rounded-xl border border-green-800"
>
<h3
class="text-lg font-semibold text-green-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
You're Registered!
</h3>
<p class="text-green-400 mb-4">
<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="text-sm text-ghost-300">
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"
class="mb-6"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration Form -->
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
<h3 class="text-xl font-bold text-ghost-100 mb-4">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</h3>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="form.name"
type="text"
required
placeholder="Enter your full name"
:disabled="processing"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="form.email"
type="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
/>
<p v-if="isLoggedIn" class="text-xs text-ghost-400 mt-1">
Using your member email
</p>
</div>
<!-- Member Benefits Notice -->
<div
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="p-4 bg-purple-900/20 rounded-lg border border-purple-800"
>
<p class="text-sm text-purple-300 flex items-center gap-2">
<Icon name="heroicons:sparkles" class="w-4 h-4" />
This event is free for Ghost Guild members
</p>
</div>
<!-- Payment Required Notice -->
<div
v-if="!ticketInfo.isFree"
class="p-4 bg-blue-900/20 rounded-lg border border-blue-800"
>
<p class="text-sm text-blue-300 flex items-center gap-2">
<Icon name="heroicons:credit-card" class="w-4 h-4" />
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
</div>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="processing"
:disabled="!form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</UButton>
</div>
</form>
</div>
<!-- Sold Out with Waitlist -->
<div
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
class="text-center py-8"
>
<Icon
name="heroicons:ticket"
class="w-16 h-16 text-ghost-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-ghost-100 mb-2">Event Sold Out</h3>
<p class="text-ghost-300 mb-6">
This event is currently at capacity. Join the waitlist to be notified
if spots become available.
</p>
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
Join Waitlist
</UButton>
</div>
<!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="text-center py-8">
<Icon
name="heroicons:x-circle"
class="w-16 h-16 text-red-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-ghost-100 mb-2">Event Sold Out</h3>
<p class="text-ghost-300">
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,
},
});
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: "",
email: props.userEmail || "",
});
const isLoggedIn = computed(() => !!props.userEmail);
// Fetch ticket availability on mount
onMounted(async () => {
await fetchTicketInfo();
});
const fetchTicketInfo = async () => {
loading.value = true;
error.value = null;
try {
// 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) {
// User has series pass - show as already registered
ticketInfo.value = {
available: true,
alreadyRegistered: true,
viaSeriesPass: true,
series: seriesAccess.series,
message: seriesAccess.message,
};
loading.value = false;
return;
} else {
// User needs to buy series pass
ticketInfo.value = {
available: false,
requiresSeriesPass: true,
series: seriesAccess.series,
message: seriesAccess.message,
};
loading.value = false;
return;
}
}
} catch (seriesErr) {
// If series check fails, continue with regular ticket check
console.warn("Series access check failed:", seriesErr);
}
}
// Regular ticket availability check
const params = props.userEmail ? `?email=${props.userEmail}` : "";
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 payment is required, initialize Helcim and process payment
if (!ticketInfo.value.isFree) {
// Initialize Helcim payment
await initializeTicketPayment(
props.eventId,
form.value.email,
ticketInfo.value.price,
props.eventTitle,
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
// For purchase transactions, we get a transactionId
transactionId = paymentResult.transactionId;
if (!transactionId) {
throw new Error("No transaction ID received from payment");
}
}
// Purchase ticket
const response = await $fetch(
`/api/events/${props.eventId}/tickets/purchase`,
{
method: "POST",
body: {
name: form.value.name,
email: form.value.email,
transactionId,
},
},
);
// Success!
toast.add({
title: "Success!",
description: ticketInfo.value.isFree
? "You're registered for this event"
: "Ticket purchased successfully!",
color: "green",
});
emit("success", response);
// Refresh ticket info to show registered state
await fetchTicketInfo();
} 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: "red",
});
emit("error", err);
} finally {
processing.value = false;
cleanup();
}
};
const handleJoinWaitlist = () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist",
description: "Waitlist functionality coming soon!",
color: "blue",
});
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
</script>

View file

@ -0,0 +1,333 @@
<template>
<div class="series-pass-purchase">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-red-900/20 rounded-xl border border-red-800"
>
<h3 class="text-lg font-semibold text-red-300 mb-2">
Unable to Load Series Pass
</h3>
<p class="text-red-400">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="passInfo">
<!-- Series Pass Card -->
<EventSeriesTicketCard
:ticket="passInfo.ticket"
:availability="passInfo.availability"
:available="passInfo.available"
:already-registered="passInfo.alreadyRegistered"
:is-member="passInfo.memberInfo?.isMember"
:total-events="seriesInfo.totalEvents"
:events="seriesEvents"
:public-price="passInfo.publicPrice"
class="mb-8"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration Form -->
<div
v-if="passInfo.available && !passInfo.alreadyRegistered"
class="bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl border border-ghost-600 dark:border-ghost-600 p-6"
>
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{
passInfo.ticket.isFree
? "Register for Series"
: "Purchase Series Pass"
}}
</h3>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-[--ui-text] mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="form.name"
type="text"
required
placeholder="Enter your full name"
:disabled="processing"
size="lg"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-[--ui-text] mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="form.email"
type="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
size="lg"
/>
<p v-if="isLoggedIn" class="text-xs text-[--ui-text-muted] mt-2">
Using your member email
</p>
</div>
<!-- Member Benefits Notice -->
<div
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg"
>
<div class="flex items-start gap-3">
<Icon
name="heroicons:sparkles"
class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5"
/>
<div>
<div class="font-semibold text-green-300 mb-1">
Member Benefit
</div>
<div class="text-sm text-green-400">
This series pass is free for Ghost Guild members!
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<UButton
type="submit"
block
size="xl"
:disabled="processing || !form.name || !form.email"
:loading="processing"
>
<template v-if="processing">
{{ paymentProcessing ? "Processing Payment..." : "Registering..." }}
</template>
<template v-else>
{{
passInfo.ticket.isFree
? "Complete Registration"
: `Pay ${formatPrice(passInfo.ticket.price, passInfo.ticket.currency)}`
}}
</template>
</UButton>
<p class="text-xs text-[--ui-text-muted] text-center">
By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series.
</p>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { useHelcimPay } from "~/composables/useHelcimPay";
const props = defineProps({
seriesId: {
type: String,
required: true,
},
seriesInfo: {
type: Object,
required: true,
// Expected: { id, title, totalEvents, type }
},
seriesEvents: {
type: Array,
default: () => [],
// Expected: Array of event objects
},
userEmail: {
type: String,
default: null,
},
userName: {
type: String,
default: null,
},
});
const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast();
const { initializePayment, verifyPayment } = useHelcimPay();
// State
const loading = ref(true);
const processing = ref(false);
const paymentProcessing = ref(false);
const error = ref(null);
const passInfo = ref(null);
const form = ref({
name: props.userName || "",
email: props.userEmail || "",
});
const isLoggedIn = computed(() => !!props.userEmail);
// Fetch series pass info on mount
onMounted(async () => {
await fetchPassInfo();
});
const fetchPassInfo = async () => {
loading.value = true;
error.value = null;
try {
const response = await $fetch(
`/api/series/${props.seriesId}/tickets/available`
);
passInfo.value = response;
// Pre-fill form if member info available
if (response.memberInfo?.isMember) {
form.value.name = response.memberInfo.name || form.value.name;
form.value.email = response.memberInfo.email || form.value.email;
}
// Also fetch public price for comparison
if (response.memberInfo?.isMember && response.ticket?.type === "member") {
// Make another request to get public pricing
try {
const publicResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/available?forcePublic=true`
);
if (publicResponse.ticket?.price) {
passInfo.value.publicPrice = publicResponse.ticket.price;
}
} catch (err) {
console.warn("Could not fetch public price for comparison");
}
}
} catch (err) {
console.error("Error fetching series pass info:", err);
error.value =
err.data?.statusMessage || "Failed to load series pass information";
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
processing.value = true;
try {
let transactionId = null;
// If payment is required, initialize Helcim and process payment
if (!passInfo.value.ticket.isFree) {
paymentProcessing.value = true;
// Initialize Helcim payment for series pass
await initializePayment(
form.value.email,
passInfo.value.ticket.price,
passInfo.value.ticket.currency || "CAD",
{
type: "series_pass",
seriesId: props.seriesId,
seriesTitle: props.seriesInfo.title,
}
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
transactionId = paymentResult.transactionId;
paymentProcessing.value = false;
}
// Complete series pass purchase
const purchaseResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/purchase`,
{
method: "POST",
body: {
name: form.value.name,
email: form.value.email,
paymentId: transactionId,
},
}
);
// Show success message
toast.add({
title: "Series Pass Purchased!",
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
color: "green",
timeout: 5000,
});
// Emit success event
emit("purchase-success", purchaseResponse);
// Refresh pass info to show registered state
await fetchPassInfo();
} catch (err) {
console.error("Error purchasing series pass:", err);
const errorMessage =
err.data?.statusMessage ||
err.message ||
"Failed to complete series pass purchase";
toast.add({
title: "Purchase Failed",
description: errorMessage,
color: "red",
timeout: 5000,
});
emit("purchase-error", errorMessage);
} finally {
processing.value = false;
paymentProcessing.value = false;
}
};
const handleJoinWaitlist = async () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist Coming Soon",
description: "The waitlist feature is coming soon!",
color: "blue",
});
};
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
</script>