Add landing page
This commit is contained in:
parent
3fea484585
commit
bce86ee840
47 changed files with 7119 additions and 439 deletions
|
|
@ -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>
|
||||
|
|
|
|||
264
app/components/EventSeriesTicketCard.vue
Normal file
264
app/components/EventSeriesTicketCard.vue
Normal 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>
|
||||
192
app/components/EventTicketCard.vue
Normal file
192
app/components/EventTicketCard.vue
Normal 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>
|
||||
412
app/components/EventTicketPurchase.vue
Normal file
412
app/components/EventTicketPurchase.vue
Normal 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>
|
||||
333
app/components/SeriesPassPurchase.vue
Normal file
333
app/components/SeriesPassPurchase.vue
Normal 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>
|
||||
|
|
@ -1,158 +1,232 @@
|
|||
// HelcimPay.js integration composable
|
||||
export const useHelcimPay = () => {
|
||||
let checkoutToken = null
|
||||
let secretToken = null
|
||||
|
||||
let checkoutToken = null;
|
||||
let secretToken = null;
|
||||
|
||||
// Initialize HelcimPay.js session
|
||||
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
||||
try {
|
||||
const response = await $fetch('/api/helcim/initialize-payment', {
|
||||
method: 'POST',
|
||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||
method: "POST",
|
||||
body: {
|
||||
customerId,
|
||||
customerCode,
|
||||
amount
|
||||
}
|
||||
})
|
||||
|
||||
amount,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
checkoutToken = response.checkoutToken
|
||||
secretToken = response.secretToken
|
||||
return true
|
||||
checkoutToken = response.checkoutToken;
|
||||
secretToken = response.secretToken;
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('Failed to initialize payment session')
|
||||
|
||||
throw new Error("Failed to initialize payment session");
|
||||
} catch (error) {
|
||||
console.error('Payment initialization error:', error)
|
||||
throw error
|
||||
console.error("Payment initialization error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Initialize payment for event ticket purchase
|
||||
const initializeTicketPayment = async (
|
||||
eventId,
|
||||
email,
|
||||
amount,
|
||||
eventTitle = null,
|
||||
) => {
|
||||
try {
|
||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||
method: "POST",
|
||||
body: {
|
||||
customerId: null,
|
||||
customerCode: email, // Use email as customer code for event tickets
|
||||
amount,
|
||||
metadata: {
|
||||
type: "event_ticket",
|
||||
eventId,
|
||||
email,
|
||||
eventTitle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
checkoutToken = response.checkoutToken;
|
||||
secretToken = response.secretToken;
|
||||
return {
|
||||
success: true,
|
||||
checkoutToken: response.checkoutToken,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Failed to initialize ticket payment session");
|
||||
} catch (error) {
|
||||
console.error("Ticket payment initialization error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Show payment modal
|
||||
const showPaymentModal = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!checkoutToken) {
|
||||
reject(new Error('Payment not initialized. Call initializeHelcimPay first.'))
|
||||
return
|
||||
reject(
|
||||
new Error("Payment not initialized. Call initializeHelcimPay first."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Add custom CSS to fix Helcim overlay styling
|
||||
if (!document.getElementById("helcim-overlay-fix")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "helcim-overlay-fix";
|
||||
style.textContent = `
|
||||
/* Fix Helcim iframe overlay - the second parameter to appendHelcimPayIframe controls this */
|
||||
/* Target all fixed position divs that might be the overlay */
|
||||
body > div[style*="position: fixed"][style*="inset: 0"],
|
||||
body > div[style*="position:fixed"][style*="inset:0"] {
|
||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Load HelcimPay.js modal script
|
||||
if (!window.appendHelcimPayIframe) {
|
||||
console.log('HelcimPay script not loaded, loading now...')
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://secure.helcim.app/helcim-pay/services/start.js'
|
||||
script.async = true
|
||||
console.log("HelcimPay script not loaded, loading now...");
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://secure.helcim.app/helcim-pay/services/start.js";
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
console.log('HelcimPay script loaded successfully!')
|
||||
console.log('Available functions:', Object.keys(window).filter(key => key.includes('Helcim') || key.includes('helcim')))
|
||||
console.log('appendHelcimPayIframe available:', typeof window.appendHelcimPayIframe)
|
||||
openModal(resolve, reject)
|
||||
}
|
||||
console.log("HelcimPay script loaded successfully!");
|
||||
console.log(
|
||||
"Available functions:",
|
||||
Object.keys(window).filter(
|
||||
(key) => key.includes("Helcim") || key.includes("helcim"),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
"appendHelcimPayIframe available:",
|
||||
typeof window.appendHelcimPayIframe,
|
||||
);
|
||||
openModal(resolve, reject);
|
||||
};
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load HelcimPay.js'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
reject(new Error("Failed to load HelcimPay.js"));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
console.log('HelcimPay script already loaded, calling openModal')
|
||||
openModal(resolve, reject)
|
||||
console.log("HelcimPay script already loaded, calling openModal");
|
||||
openModal(resolve, reject);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
// Open the payment modal
|
||||
const openModal = (resolve, reject) => {
|
||||
try {
|
||||
console.log('Trying to open modal with checkoutToken:', checkoutToken)
|
||||
|
||||
if (typeof window.appendHelcimPayIframe === 'function') {
|
||||
console.log("Trying to open modal with checkoutToken:", checkoutToken);
|
||||
|
||||
if (typeof window.appendHelcimPayIframe === "function") {
|
||||
// Set up event listener for HelcimPay.js responses
|
||||
const helcimPayJsIdentifierKey = 'helcim-pay-js-' + checkoutToken
|
||||
|
||||
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
||||
|
||||
const handleHelcimPayEvent = (event) => {
|
||||
console.log('Received window message:', event.data)
|
||||
|
||||
console.log("Received window message:", event.data);
|
||||
|
||||
if (event.data.eventName === helcimPayJsIdentifierKey) {
|
||||
console.log('HelcimPay event received:', event.data)
|
||||
|
||||
console.log("HelcimPay event received:", event.data);
|
||||
|
||||
// Remove event listener to prevent multiple responses
|
||||
window.removeEventListener('message', handleHelcimPayEvent)
|
||||
|
||||
if (event.data.eventStatus === 'SUCCESS') {
|
||||
console.log('Payment success:', event.data.eventMessage)
|
||||
|
||||
window.removeEventListener("message", handleHelcimPayEvent);
|
||||
|
||||
// Close the Helcim modal
|
||||
if (typeof window.removeHelcimPayIframe === "function") {
|
||||
window.removeHelcimPayIframe();
|
||||
}
|
||||
|
||||
if (event.data.eventStatus === "SUCCESS") {
|
||||
console.log("Payment success:", event.data.eventMessage);
|
||||
|
||||
// Parse the JSON string eventMessage
|
||||
let paymentData
|
||||
let paymentData;
|
||||
try {
|
||||
paymentData = JSON.parse(event.data.eventMessage)
|
||||
console.log('Parsed payment data:', paymentData)
|
||||
paymentData = JSON.parse(event.data.eventMessage);
|
||||
console.log("Parsed payment data:", paymentData);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse eventMessage:', parseError)
|
||||
reject(new Error('Invalid payment response format'))
|
||||
return
|
||||
console.error("Failed to parse eventMessage:", parseError);
|
||||
reject(new Error("Invalid payment response format"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Extract transaction details from nested data structure
|
||||
const transactionData = paymentData.data?.data || {}
|
||||
console.log('Transaction data:', transactionData)
|
||||
|
||||
const transactionData = paymentData.data?.data || {};
|
||||
console.log("Transaction data:", transactionData);
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
transactionId: transactionData.transactionId,
|
||||
cardToken: transactionData.cardToken,
|
||||
cardLast4: transactionData.cardNumber ? transactionData.cardNumber.slice(-4) : undefined,
|
||||
cardType: transactionData.cardType || 'unknown'
|
||||
})
|
||||
} else if (event.data.eventStatus === 'ABORTED') {
|
||||
console.log('Payment aborted:', event.data.eventMessage)
|
||||
reject(new Error(event.data.eventMessage || 'Payment failed'))
|
||||
} else if (event.data.eventStatus === 'HIDE') {
|
||||
console.log('Modal closed without completion')
|
||||
reject(new Error('Payment cancelled by user'))
|
||||
cardLast4: transactionData.cardNumber
|
||||
? transactionData.cardNumber.slice(-4)
|
||||
: undefined,
|
||||
cardType: transactionData.cardType || "unknown",
|
||||
});
|
||||
} else if (event.data.eventStatus === "ABORTED") {
|
||||
console.log("Payment aborted:", event.data.eventMessage);
|
||||
reject(new Error(event.data.eventMessage || "Payment failed"));
|
||||
} else if (event.data.eventStatus === "HIDE") {
|
||||
console.log("Modal closed without completion");
|
||||
reject(new Error("Payment cancelled by user"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('message', handleHelcimPayEvent)
|
||||
|
||||
window.addEventListener("message", handleHelcimPayEvent);
|
||||
|
||||
// Open the HelcimPay iframe modal
|
||||
console.log('Calling appendHelcimPayIframe with token:', checkoutToken)
|
||||
window.appendHelcimPayIframe(checkoutToken, true)
|
||||
console.log('appendHelcimPayIframe called, waiting for window messages...')
|
||||
|
||||
console.log("Calling appendHelcimPayIframe with token:", checkoutToken);
|
||||
window.appendHelcimPayIframe(checkoutToken, true);
|
||||
console.log(
|
||||
"appendHelcimPayIframe called, waiting for window messages...",
|
||||
);
|
||||
|
||||
// Add timeout to clean up if no response
|
||||
setTimeout(() => {
|
||||
console.log('60 seconds passed, cleaning up event listener...')
|
||||
window.removeEventListener('message', handleHelcimPayEvent)
|
||||
reject(new Error('Payment timeout - no response received'))
|
||||
}, 60000)
|
||||
console.log("60 seconds passed, cleaning up event listener...");
|
||||
window.removeEventListener("message", handleHelcimPayEvent);
|
||||
reject(new Error("Payment timeout - no response received"));
|
||||
}, 60000);
|
||||
} else {
|
||||
reject(new Error('appendHelcimPayIframe function not available'))
|
||||
reject(new Error("appendHelcimPayIframe function not available"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening modal:', error)
|
||||
reject(error)
|
||||
console.error("Error opening modal:", error);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Process payment verification
|
||||
const verifyPayment = async () => {
|
||||
try {
|
||||
return await showPaymentModal()
|
||||
return await showPaymentModal();
|
||||
} catch (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Cleanup tokens
|
||||
const cleanup = () => {
|
||||
checkoutToken = null
|
||||
secretToken = null
|
||||
}
|
||||
|
||||
checkoutToken = null;
|
||||
secretToken = null;
|
||||
};
|
||||
|
||||
return {
|
||||
initializeHelcimPay,
|
||||
initializeTicketPayment,
|
||||
verifyPayment,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
5
app/layouts/coming-soon.vue
Normal file
5
app/layouts/coming-soon.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-ghost-900">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
app/middleware/coming-soon.global.js
Normal file
18
app/middleware/coming-soon.global.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const config = useRuntimeConfig();
|
||||
const isComingSoonMode =
|
||||
config.public.comingSoon === "true" || config.public.comingSoon === true;
|
||||
|
||||
// Only enforce coming soon mode if enabled
|
||||
if (!isComingSoonMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow access to the coming-soon page itself
|
||||
if (to.path === "/coming-soon") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect all other routes to coming-soon
|
||||
return navigateTo("/coming-soon");
|
||||
});
|
||||
|
|
@ -103,6 +103,11 @@
|
|||
>
|
||||
Registration
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-4 text-left text-xs font-medium text-dimmed uppercase tracking-wider"
|
||||
>
|
||||
Tickets
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-right text-xs font-medium text-dimmed uppercase tracking-wider"
|
||||
>
|
||||
|
|
@ -258,6 +263,52 @@
|
|||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Tickets Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap">
|
||||
<div class="space-y-1">
|
||||
<div v-if="event.tickets?.enabled" class="space-y-1">
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<Icon
|
||||
name="heroicons:ticket"
|
||||
class="w-3.5 h-3.5 text-blue-600"
|
||||
/>
|
||||
<span class="font-medium text-default">Ticketing On</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="event.tickets?.requiresSeriesTicket"
|
||||
class="text-xs text-purple-600"
|
||||
>
|
||||
Series Pass Required
|
||||
</div>
|
||||
<div v-else class="space-y-0.5">
|
||||
<div
|
||||
v-if="event.tickets.member?.available"
|
||||
class="text-xs text-dimmed"
|
||||
>
|
||||
Member:
|
||||
{{
|
||||
event.tickets.member.isFree
|
||||
? "Free"
|
||||
: `$${event.tickets.member.price}`
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="event.tickets.public?.available"
|
||||
class="text-xs text-dimmed"
|
||||
>
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<span v-if="event.tickets.public.quantity" class="ml-1">
|
||||
({{ event.tickets.public.sold || 0 }}/{{
|
||||
event.tickets.public.quantity
|
||||
}})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-dimmed">No tickets</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<td class="px-6 py-6 whitespace-nowrap text-right">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
|
|
|
|||
|
|
@ -217,6 +217,42 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Ticketing Info -->
|
||||
<div
|
||||
v-if="series.tickets?.enabled"
|
||||
class="px-6 py-3 bg-blue-50 dark:bg-blue-950/20 border-t border-default"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<Icon name="heroicons:ticket" class="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-default">
|
||||
Series Pass Ticketing Enabled
|
||||
</span>
|
||||
<p class="text-xs text-dimmed">
|
||||
<span v-if="series.tickets.public?.available">
|
||||
Public: ${{ series.tickets.public.price || 0 }}
|
||||
</span>
|
||||
<span v-if="series.tickets.member?.available" class="ml-2">
|
||||
| Members:
|
||||
{{
|
||||
series.tickets.member.isFree
|
||||
? "Free"
|
||||
: `$${series.tickets.member.price || 0}`
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageSeriesTickets(series)"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Manage Tickets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Actions -->
|
||||
<div class="px-6 py-3 bg-muted border-t border-default">
|
||||
<div class="flex justify-between items-center">
|
||||
|
|
@ -224,6 +260,12 @@
|
|||
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="manageSeriesTickets(series)"
|
||||
class="text-sm text-primary hover:text-primary font-medium"
|
||||
>
|
||||
Ticketing
|
||||
</button>
|
||||
<button
|
||||
@click="editSeries(series)"
|
||||
class="text-sm text-primary hover:text-primary font-medium"
|
||||
|
|
@ -459,6 +501,384 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Ticketing Modal -->
|
||||
<div
|
||||
v-if="editingTicketsSeriesId"
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div
|
||||
class="bg-elevated rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div class="px-6 py-4 border-b border-default">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-highlighted">
|
||||
Series Pass Ticketing
|
||||
</h3>
|
||||
<p class="text-sm text-muted">{{ editingTicketsData.title }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="cancelTicketsEdit"
|
||||
class="text-muted hover:text-default"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Enable Ticketing Toggle -->
|
||||
<div class="p-4 bg-muted rounded-lg">
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input
|
||||
v-model="editingTicketsData.tickets.enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-default"
|
||||
>Enable Series Pass Ticketing</span
|
||||
>
|
||||
<p class="text-xs text-dimmed">
|
||||
Allow users to purchase a pass for all events in this series
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="editingTicketsData.tickets.enabled" class="space-y-6">
|
||||
<!-- Ticketing Behavior -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-highlighted">
|
||||
Ticketing Behavior
|
||||
</h4>
|
||||
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input
|
||||
v-model="editingTicketsData.tickets.requiresSeriesTicket"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-default"
|
||||
>Require Series Pass</span
|
||||
>
|
||||
<p class="text-xs text-dimmed">
|
||||
Users must buy the series pass; individual event tickets are
|
||||
not available
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input
|
||||
v-model="
|
||||
editingTicketsData.tickets.allowIndividualEventTickets
|
||||
"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
|
||||
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-default"
|
||||
>Allow Individual Event Tickets</span
|
||||
>
|
||||
<p class="text-xs text-dimmed">
|
||||
Users can attend single events without buying the full
|
||||
series pass
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Member Tickets -->
|
||||
<div class="border border-default rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-sm font-semibold text-highlighted">
|
||||
Member Series Pass
|
||||
</h4>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<span class="text-xs text-muted mr-2">Available</span>
|
||||
<input
|
||||
v-model="editingTicketsData.tickets.member.available"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editingTicketsData.tickets.member.available"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Pass Name</label
|
||||
>
|
||||
<UInput
|
||||
v-model="editingTicketsData.tickets.member.name"
|
||||
placeholder="e.g., Member Series Pass"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Price (CAD)</label
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model.number="editingTicketsData.tickets.member.price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="editingTicketsData.tickets.member.isFree"
|
||||
class="flex-1 w-full"
|
||||
/>
|
||||
<label
|
||||
class="flex items-center whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
<input
|
||||
v-model="editingTicketsData.tickets.member.isFree"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-1 text-xs text-muted">Free</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Description</label
|
||||
>
|
||||
<UTextarea
|
||||
v-model="editingTicketsData.tickets.member.description"
|
||||
placeholder="Describe what's included with the member series pass..."
|
||||
:rows="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Tickets -->
|
||||
<div class="border border-default rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-sm font-semibold text-highlighted">
|
||||
Public Series Pass
|
||||
</h4>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<span class="text-xs text-muted mr-2">Available</span>
|
||||
<input
|
||||
v-model="editingTicketsData.tickets.public.available"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editingTicketsData.tickets.public.available"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Pass Name</label
|
||||
>
|
||||
<UInput
|
||||
v-model="editingTicketsData.tickets.public.name"
|
||||
placeholder="e.g., Series Pass"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Price (CAD)</label
|
||||
>
|
||||
<UInput
|
||||
v-model.number="editingTicketsData.tickets.public.price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Description</label
|
||||
>
|
||||
<UTextarea
|
||||
v-model="editingTicketsData.tickets.public.description"
|
||||
placeholder="Describe what's included with the public series pass..."
|
||||
:rows="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Quantity Available</label
|
||||
>
|
||||
<UInput
|
||||
v-model.number="
|
||||
editingTicketsData.tickets.public.quantity
|
||||
"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for unlimited"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
{{ editingTicketsData.tickets.public.sold || 0 }} sold,
|
||||
{{ editingTicketsData.tickets.public.reserved || 0 }}
|
||||
reserved
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Early Bird Price (Optional)</label
|
||||
>
|
||||
<UInput
|
||||
v-model.number="
|
||||
editingTicketsData.tickets.public.earlyBirdPrice
|
||||
"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0"
|
||||
>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Early Bird Deadline</label
|
||||
>
|
||||
<UInput
|
||||
v-model="
|
||||
editingTicketsData.tickets.public.earlyBirdDeadline
|
||||
"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
Price increases to ${{
|
||||
editingTicketsData.tickets.public.price
|
||||
}}
|
||||
after this date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capacity Management -->
|
||||
<div class="border border-default rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-highlighted mb-4">
|
||||
Capacity Management
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Total Capacity</label
|
||||
>
|
||||
<UInput
|
||||
v-model.number="editingTicketsData.tickets.capacity.total"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for unlimited"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
Maximum series pass holders across all types
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Currently Reserved</label
|
||||
>
|
||||
<UInput
|
||||
v-model.number="
|
||||
editingTicketsData.tickets.capacity.reserved
|
||||
"
|
||||
type="number"
|
||||
min="0"
|
||||
disabled
|
||||
class="w-full bg-accented"
|
||||
/>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
Auto-calculated during checkout
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waitlist Configuration -->
|
||||
<div class="border border-default rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-sm font-semibold text-highlighted">Waitlist</h4>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<span class="text-xs text-muted mr-2">Enable Waitlist</span>
|
||||
<input
|
||||
v-model="editingTicketsData.tickets.waitlist.enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-default text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="editingTicketsData.tickets.waitlist.enabled">
|
||||
<label class="block text-sm font-medium text-default mb-2"
|
||||
>Max Waitlist Size</label
|
||||
>
|
||||
<UInput
|
||||
v-model.number="editingTicketsData.tickets.waitlist.maxSize"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for unlimited"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }}
|
||||
people currently on waitlist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-default flex justify-end gap-3">
|
||||
<button
|
||||
@click="cancelTicketsEdit"
|
||||
class="px-4 py-2 text-muted hover:text-default"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveTicketsEdit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Save Ticketing Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -477,6 +897,43 @@ const editingSeriesData = ref({
|
|||
type: "workshop_series",
|
||||
totalEvents: null,
|
||||
});
|
||||
const editingTicketsSeriesId = ref(null);
|
||||
const editingTicketsData = ref({
|
||||
title: "",
|
||||
tickets: {
|
||||
enabled: false,
|
||||
requiresSeriesTicket: false,
|
||||
allowIndividualEventTickets: true,
|
||||
currency: "CAD",
|
||||
member: {
|
||||
available: true,
|
||||
isFree: true,
|
||||
price: 0,
|
||||
name: "Member Series Pass",
|
||||
description: "",
|
||||
},
|
||||
public: {
|
||||
available: false,
|
||||
name: "Series Pass",
|
||||
description: "",
|
||||
price: 0,
|
||||
quantity: null,
|
||||
sold: 0,
|
||||
reserved: 0,
|
||||
earlyBirdPrice: null,
|
||||
earlyBirdDeadline: "",
|
||||
},
|
||||
capacity: {
|
||||
total: null,
|
||||
reserved: 0,
|
||||
},
|
||||
waitlist: {
|
||||
enabled: false,
|
||||
maxSize: null,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch series data
|
||||
const {
|
||||
|
|
@ -715,6 +1172,116 @@ const deleteSeries = async (series) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Ticketing management functions
|
||||
const manageSeriesTickets = (series) => {
|
||||
editingTicketsSeriesId.value = series.id;
|
||||
|
||||
// Deep clone the series data to avoid mutating the original
|
||||
editingTicketsData.value = {
|
||||
title: series.title,
|
||||
tickets: {
|
||||
enabled: series.tickets?.enabled || false,
|
||||
requiresSeriesTicket: series.tickets?.requiresSeriesTicket || false,
|
||||
allowIndividualEventTickets:
|
||||
series.tickets?.allowIndividualEventTickets !== false,
|
||||
currency: series.tickets?.currency || "CAD",
|
||||
member: {
|
||||
available: series.tickets?.member?.available !== false,
|
||||
isFree: series.tickets?.member?.isFree !== false,
|
||||
price: series.tickets?.member?.price || 0,
|
||||
name: series.tickets?.member?.name || "Member Series Pass",
|
||||
description: series.tickets?.member?.description || "",
|
||||
},
|
||||
public: {
|
||||
available: series.tickets?.public?.available || false,
|
||||
name: series.tickets?.public?.name || "Series Pass",
|
||||
description: series.tickets?.public?.description || "",
|
||||
price: series.tickets?.public?.price || 0,
|
||||
quantity: series.tickets?.public?.quantity || null,
|
||||
sold: series.tickets?.public?.sold || 0,
|
||||
reserved: series.tickets?.public?.reserved || 0,
|
||||
earlyBirdPrice: series.tickets?.public?.earlyBirdPrice || null,
|
||||
earlyBirdDeadline: series.tickets?.public?.earlyBirdDeadline
|
||||
? new Date(series.tickets.public.earlyBirdDeadline)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: "",
|
||||
},
|
||||
capacity: {
|
||||
total: series.tickets?.capacity?.total || null,
|
||||
reserved: series.tickets?.capacity?.reserved || 0,
|
||||
},
|
||||
waitlist: {
|
||||
enabled: series.tickets?.waitlist?.enabled || false,
|
||||
maxSize: series.tickets?.waitlist?.maxSize || null,
|
||||
entries: series.tickets?.waitlist?.entries || [],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const cancelTicketsEdit = () => {
|
||||
editingTicketsSeriesId.value = null;
|
||||
editingTicketsData.value = {
|
||||
title: "",
|
||||
tickets: {
|
||||
enabled: false,
|
||||
requiresSeriesTicket: false,
|
||||
allowIndividualEventTickets: true,
|
||||
currency: "CAD",
|
||||
member: {
|
||||
available: true,
|
||||
isFree: true,
|
||||
price: 0,
|
||||
name: "Member Series Pass",
|
||||
description: "",
|
||||
},
|
||||
public: {
|
||||
available: false,
|
||||
name: "Series Pass",
|
||||
description: "",
|
||||
price: 0,
|
||||
quantity: null,
|
||||
sold: 0,
|
||||
reserved: 0,
|
||||
earlyBirdPrice: null,
|
||||
earlyBirdDeadline: "",
|
||||
},
|
||||
capacity: {
|
||||
total: null,
|
||||
reserved: 0,
|
||||
},
|
||||
waitlist: {
|
||||
enabled: false,
|
||||
maxSize: null,
|
||||
entries: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const saveTicketsEdit = async () => {
|
||||
try {
|
||||
// Update the series with new ticketing configuration
|
||||
await $fetch("/api/admin/series/tickets", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
id: editingTicketsSeriesId.value,
|
||||
tickets: editingTicketsData.value.tickets,
|
||||
},
|
||||
});
|
||||
|
||||
await refresh();
|
||||
cancelTicketsEdit();
|
||||
alert("Ticketing settings updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to update ticketing settings:", error);
|
||||
alert(
|
||||
`Failed to update ticketing settings: ${error.data?.statusMessage || error.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk operations
|
||||
const reorderAllSeries = async () => {
|
||||
// TODO: Implement auto-reordering
|
||||
|
|
|
|||
14
app/pages/coming-soon.vue
Normal file
14
app/pages/coming-soon.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<div class="min-h-screen w-full flex items-center justify-center">
|
||||
<a href="https://babyghosts.fund/ghost-guild" class="text-center">
|
||||
<h1 class="text-5xl md:text-6xl font-bold mb-4">Ghost Guild</h1>
|
||||
<p class="text-xl md:text-2xl">Coming Soon</p>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: "coming-soon",
|
||||
});
|
||||
</script>
|
||||
|
|
@ -160,14 +160,14 @@
|
|||
<!-- Series Description -->
|
||||
<div
|
||||
v-if="event.series?.isSeriesEvent && event.series.description"
|
||||
class="mb-6 p-4 bg-purple-500/5 rounded-lg border border-purple-500/20"
|
||||
class="event-series-description mb-6 p-4 bg-ghost-800/30 dark:bg-ghost-700/20 rounded-lg border border-ghost-600 dark:border-ghost-600"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2"
|
||||
class="event-series-description__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
|
||||
>
|
||||
About the {{ event.series.title }} Series
|
||||
</h3>
|
||||
<p class="text-ghost-200">
|
||||
<p class="event-series-description__text text-ghost-200">
|
||||
{{ event.series.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -227,160 +227,180 @@
|
|||
|
||||
<!-- Registration Section -->
|
||||
<div v-if="!event.isCancelled">
|
||||
<!-- Already Registered Status -->
|
||||
<div v-if="registrationStatus === 'registered'">
|
||||
<!-- Use new ticket system if tickets are enabled -->
|
||||
<EventTicketPurchase
|
||||
v-if="event.tickets?.enabled"
|
||||
:event-id="event._id || event.id"
|
||||
:event-start-date="event.startDate"
|
||||
:event-title="event.title"
|
||||
:user-email="memberData?.email"
|
||||
@success="handleTicketSuccess"
|
||||
@error="handleTicketError"
|
||||
/>
|
||||
|
||||
<!-- Legacy registration system (for events without tickets enabled) -->
|
||||
<div v-else>
|
||||
<!-- Already Registered Status -->
|
||||
<div v-if="registrationStatus === 'registered'">
|
||||
<div
|
||||
class="p-4 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-400 dark:border-green-800 mb-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
class="font-semibold text-green-800 dark:text-green-300"
|
||||
>
|
||||
You're registered!
|
||||
</p>
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
We've sent a confirmation to your email
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
color="error"
|
||||
size="md"
|
||||
@click="handleCancelRegistration"
|
||||
:loading="isCancelling"
|
||||
>
|
||||
Cancel Registration
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logged In - Can Register -->
|
||||
<div
|
||||
class="p-4 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-400 dark:border-green-800 mb-6"
|
||||
v-else-if="memberData && (!event.membersOnly || isMember)"
|
||||
class="text-center"
|
||||
>
|
||||
<p class="text-lg text-ghost-200 mb-6">
|
||||
You are logged in, {{ memberData.name }}.
|
||||
</p>
|
||||
<UButton
|
||||
color="primary"
|
||||
size="xl"
|
||||
@click="handleRegistration"
|
||||
:loading="isRegistering"
|
||||
class="px-12 py-4"
|
||||
>
|
||||
{{ isRegistering ? "Registering..." : "Register Now" }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Member Gate Warning -->
|
||||
<div
|
||||
v-else-if="event.membersOnly && !isMember"
|
||||
class="text-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
|
||||
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
|
||||
>
|
||||
<p class="font-semibold text-amber-300 text-lg mb-2">
|
||||
Membership Required
|
||||
</p>
|
||||
<p class="text-amber-400">
|
||||
This event is exclusive to Ghost Guild members. Join any
|
||||
circle to gain access.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/join">
|
||||
<UButton color="primary" size="xl" class="px-12 py-4">
|
||||
Become a Member to Register
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Not Logged In - Show Registration Form -->
|
||||
<div v-else>
|
||||
<h3 class="text-xl font-bold text-ghost-100 mb-6">
|
||||
Register for This Event
|
||||
</h3>
|
||||
<form @submit.prevent="handleRegistration" class="space-y-4">
|
||||
<div>
|
||||
<p class="font-semibold text-green-800 dark:text-green-300">
|
||||
You're registered!
|
||||
</p>
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
We've sent a confirmation to your email
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
color="error"
|
||||
size="md"
|
||||
@click="handleCancelRegistration"
|
||||
:loading="isCancelling"
|
||||
>
|
||||
Cancel Registration
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logged In - Can Register -->
|
||||
<div
|
||||
v-else-if="memberData && (!event.membersOnly || isMember)"
|
||||
class="text-center"
|
||||
>
|
||||
<p class="text-lg text-ghost-200 mb-6">
|
||||
You are logged in, {{ memberData.name }}.
|
||||
</p>
|
||||
<UButton
|
||||
color="primary"
|
||||
size="xl"
|
||||
@click="handleRegistration"
|
||||
:loading="isRegistering"
|
||||
class="px-12 py-4"
|
||||
>
|
||||
{{ isRegistering ? "Registering..." : "Register Now" }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Member Gate Warning -->
|
||||
<div v-else-if="event.membersOnly && !isMember" class="text-center">
|
||||
<div
|
||||
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
|
||||
>
|
||||
<p class="font-semibold text-amber-300 text-lg mb-2">
|
||||
Membership Required
|
||||
</p>
|
||||
<p class="text-amber-400">
|
||||
This event is exclusive to Ghost Guild members. Join any
|
||||
circle to gain access.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/join">
|
||||
<UButton color="primary" size="xl" class="px-12 py-4">
|
||||
Become a Member to Register
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Not Logged In - Show Registration Form -->
|
||||
<div v-else>
|
||||
<h3 class="text-xl font-bold text-ghost-100 mb-6">
|
||||
Register for This Event
|
||||
</h3>
|
||||
<form @submit.prevent="handleRegistration" class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<UInput
|
||||
id="name"
|
||||
v-model="registrationForm.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
id="email"
|
||||
v-model="registrationForm.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="membershipLevel"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Membership Status
|
||||
</label>
|
||||
<USelect
|
||||
id="membershipLevel"
|
||||
v-model="registrationForm.membershipLevel"
|
||||
:options="membershipOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
:loading="isRegistering"
|
||||
>
|
||||
{{
|
||||
isRegistering ? "Registering..." : "Register for Event"
|
||||
}}
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Event Capacity -->
|
||||
<div
|
||||
v-if="event.maxAttendees"
|
||||
class="mt-6 pt-6 border-t border-ghost-700"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-ghost-300">Event Capacity</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-semibold text-ghost-100">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
<div
|
||||
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full"
|
||||
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<UInput
|
||||
id="name"
|
||||
v-model="registrationForm.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
id="email"
|
||||
v-model="registrationForm.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="membershipLevel"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Membership Status
|
||||
</label>
|
||||
<USelect
|
||||
id="membershipLevel"
|
||||
v-model="registrationForm.membershipLevel"
|
||||
:options="membershipOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
:loading="isRegistering"
|
||||
>
|
||||
{{
|
||||
isRegistering ? "Registering..." : "Register for Event"
|
||||
}}
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Event Capacity -->
|
||||
<div
|
||||
v-if="event.maxAttendees"
|
||||
class="mt-6 pt-6 border-t border-ghost-700"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-ghost-300">Event Capacity</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-semibold text-ghost-100">
|
||||
{{ event.registeredCount || 0 }} /
|
||||
{{ event.maxAttendees }}
|
||||
</span>
|
||||
<div
|
||||
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full"
|
||||
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -649,6 +669,20 @@ const handleCancelRegistration = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle ticket purchase success
|
||||
const handleTicketSuccess = (response) => {
|
||||
console.log("Ticket purchased successfully:", response);
|
||||
// Update registered count if needed
|
||||
if (event.value.registeredCount !== undefined) {
|
||||
event.value.registeredCount++;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle ticket purchase error
|
||||
const handleTicketError = (error) => {
|
||||
console.error("Ticket purchase failed:", error);
|
||||
};
|
||||
|
||||
// SEO Meta
|
||||
useHead(() => ({
|
||||
title: event.value
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@
|
|||
<div v-if="event.series?.isSeriesEvent" class="mt-2">
|
||||
<EventSeriesBadge
|
||||
:title="event.series.title"
|
||||
:description="event.series.description"
|
||||
:position="event.series.position"
|
||||
:total-events="event.series.totalEvents"
|
||||
:series-id="event.series.id"
|
||||
|
|
@ -126,7 +125,7 @@
|
|||
</section>
|
||||
|
||||
<!-- Event Series -->
|
||||
<div v-if="activeSeries.length > 0" class="text-center mb-12">
|
||||
<div v-if="activeSeries.length > 0" class="text-center my-12">
|
||||
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
|
||||
Current Event Series
|
||||
</h2>
|
||||
|
|
@ -140,24 +139,24 @@
|
|||
v-for="series in activeSeries.slice(0, 6)"
|
||||
:key="series.id"
|
||||
:to="`/series/${series.id}`"
|
||||
class="block bg-gradient-to-r from-purple-500/10 to-blue-500/10 rounded-xl p-6 border border-purple-500/30 hover:border-purple-500/50 hover:from-purple-500/15 hover:to-blue-500/15 transition-all duration-300"
|
||||
class="series-list-item block bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl p-6 border border-ghost-600 dark:border-ghost-600 hover:border-ghost-500 hover:bg-ghost-800/70 dark:hover:bg-ghost-700/50 transition-all duration-300"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-semibold text-purple-700 dark:text-purple-300"
|
||||
class="series-list-item__label text-sm font-semibold text-ghost-300 dark:text-ghost-300"
|
||||
>
|
||||
Event Series
|
||||
</span>
|
||||
<span
|
||||
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-list-item__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"
|
||||
>
|
||||
{{ series.eventCount }} events
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
|
||||
'series-list-item__status inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
|
||||
series.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: series.status === 'upcoming'
|
||||
|
|
@ -170,46 +169,51 @@
|
|||
</div>
|
||||
|
||||
<h3
|
||||
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2"
|
||||
class="series-list-item__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
|
||||
>
|
||||
{{ series.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
class="text-sm text-purple-600 dark:text-purple-400 mb-4 line-clamp-2"
|
||||
class="series-list-item__description text-sm text-ghost-300 dark:text-ghost-300 mb-4 line-clamp-2"
|
||||
>
|
||||
{{ series.description }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="series-list-item__events space-y-2 mb-4">
|
||||
<div
|
||||
v-for="(event, index) in series.events.slice(0, 3)"
|
||||
:key="event.id"
|
||||
class="flex items-center justify-between text-xs"
|
||||
class="series-list-item__event flex items-center justify-between text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-6 h-6 bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded-full flex items-center justify-center text-xs font-medium border border-purple-500/30"
|
||||
class="series-list-item__event-number w-6 h-6 bg-ghost-700/50 dark:bg-ghost-600/50 text-ghost-200 dark:text-ghost-200 rounded-full flex items-center justify-center text-xs font-medium border border-ghost-600 dark:border-ghost-500"
|
||||
>
|
||||
{{ event.series?.position || index + 1 }}
|
||||
</div>
|
||||
<span class="text-purple-700 dark:text-purple-300 truncate">{{
|
||||
event.title
|
||||
}}</span>
|
||||
<span
|
||||
class="series-list-item__event-title text-ghost-200 dark:text-ghost-200 truncate"
|
||||
>{{ event.title }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-purple-600 dark:text-purple-400">
|
||||
<span
|
||||
class="series-list-item__event-date text-ghost-300 dark:text-ghost-300"
|
||||
>
|
||||
{{ formatEventDate(event.startDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="series.events.length > 3"
|
||||
class="text-xs text-purple-600 dark:text-purple-400 text-center pt-1"
|
||||
class="series-list-item__more-events text-xs text-ghost-300 dark:text-ghost-300 text-center pt-1"
|
||||
>
|
||||
+{{ series.events.length - 3 }} more events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-purple-600 dark:text-purple-400">
|
||||
<div
|
||||
class="series-list-item__date-range text-sm text-ghost-300 dark:text-ghost-300"
|
||||
>
|
||||
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
<!-- Status Message -->
|
||||
<div
|
||||
v-if="series.statistics.isOngoing"
|
||||
v-if="series?.statistics?.isOngoing"
|
||||
class="p-4 bg-green-500/10 border border-green-500/30 rounded mb-8"
|
||||
>
|
||||
<p class="text-green-600 dark:text-green-400 font-semibold mb-1">
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="series.statistics.isUpcoming"
|
||||
v-else-if="series?.statistics?.isUpcoming"
|
||||
class="p-4 bg-blue-500/10 border border-blue-500/30 rounded mb-8"
|
||||
>
|
||||
<p class="text-blue-600 dark:text-blue-400 font-semibold mb-1">
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="series.statistics.isCompleted"
|
||||
v-else-if="series?.statistics?.isCompleted"
|
||||
class="p-4 bg-gray-500/10 border border-gray-500/30 rounded mb-8"
|
||||
>
|
||||
<p class="text-[--ui-text] font-semibold mb-1">
|
||||
|
|
@ -144,6 +144,30 @@
|
|||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Series Pass Purchase (if tickets enabled) -->
|
||||
<section v-if="series?.tickets?.enabled" class="py-20 bg-[--ui-bg]">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-[--ui-text] mb-8">
|
||||
Get Your Series Pass
|
||||
</h2>
|
||||
<SeriesPassPurchase
|
||||
:series-id="series.id || series._id"
|
||||
:series-info="{
|
||||
id: series.id,
|
||||
title: series.title,
|
||||
totalEvents: series?.statistics?.totalEvents || 0,
|
||||
type: series.type,
|
||||
}"
|
||||
:series-events="series.events || []"
|
||||
:user-email="user?.email"
|
||||
:user-name="user?.name"
|
||||
@purchase-success="handlePurchaseSuccess"
|
||||
/>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Events Timeline -->
|
||||
<section class="py-20 bg-[--ui-bg-elevated]">
|
||||
<UContainer>
|
||||
|
|
@ -154,7 +178,7 @@
|
|||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(event, index) in series.events"
|
||||
v-for="(event, index) in series?.events || []"
|
||||
:key="event.id"
|
||||
class="group"
|
||||
>
|
||||
|
|
@ -170,7 +194,7 @@
|
|||
{{ event.series?.position || index + 1 }}
|
||||
</div>
|
||||
<div
|
||||
v-if="index < series.events.length - 1"
|
||||
v-if="index < (series?.events?.length || 0) - 1"
|
||||
class="w-0.5 h-12 bg-[--ui-border]"
|
||||
></div>
|
||||
</div>
|
||||
|
|
@ -287,12 +311,18 @@
|
|||
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
const { data: session } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
// Get user info
|
||||
const user = computed(() => session?.value?.user || null);
|
||||
|
||||
// Fetch series data from API
|
||||
const {
|
||||
data: series,
|
||||
pending,
|
||||
error,
|
||||
refresh: refreshSeries,
|
||||
} = await useFetch(`/api/series/${route.params.id}`);
|
||||
|
||||
// Handle series not found
|
||||
|
|
@ -303,6 +333,15 @@ if (error.value?.statusCode === 404) {
|
|||
});
|
||||
}
|
||||
|
||||
// Handle successful series pass purchase
|
||||
const handlePurchaseSuccess = async (response) => {
|
||||
// Refresh series data to show updated registration status
|
||||
await refreshSeries();
|
||||
|
||||
// Scroll to top to show success message
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const formatSeriesType = (type) => {
|
||||
const types = {
|
||||
|
|
@ -335,6 +374,7 @@ const getSeriesTypeBadgeClass = (type) => {
|
|||
};
|
||||
|
||||
const getSeriesStatusText = () => {
|
||||
if (!series.value?.statistics) return "Active";
|
||||
if (series.value.statistics.isOngoing) return "Ongoing";
|
||||
if (series.value.statistics.isUpcoming) return "Starting Soon";
|
||||
if (series.value.statistics.isCompleted) return "Completed";
|
||||
|
|
@ -342,6 +382,8 @@ const getSeriesStatusText = () => {
|
|||
};
|
||||
|
||||
const getSeriesStatusClass = () => {
|
||||
if (!series.value?.statistics)
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/30";
|
||||
if (series.value.statistics.isOngoing)
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/30";
|
||||
if (series.value.statistics.isUpcoming)
|
||||
|
|
@ -423,19 +465,32 @@ const getEventTimelineColor = (event) => {
|
|||
};
|
||||
|
||||
// SEO Meta
|
||||
useHead(() => ({
|
||||
title: series.value
|
||||
? `${series.value.title} - Event Series - Ghost Guild`
|
||||
: "Event Series - Ghost Guild",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
series.value?.description ||
|
||||
"Explore our multi-event series designed for learning and growth",
|
||||
},
|
||||
],
|
||||
}));
|
||||
useHead(() => {
|
||||
if (!series || !series.value) {
|
||||
return {
|
||||
title: "Event Series - Ghost Guild",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Explore our multi-event series designed for learning and growth",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${series.value.title} - Event Series - Ghost Guild`,
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
series.value.description ||
|
||||
"Explore our multi-event series designed for learning and growth",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue