ghostguild-org/app/pages/events/[id].vue

931 lines
29 KiB
Vue

<template>
<div
v-if="pending"
class="min-h-screen bg-ghost-900 flex items-center justify-center"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-ghost-200">Loading event details...</p>
</div>
</div>
<div
v-else-if="error"
class="min-h-screen bg-ghost-900 flex items-center justify-center"
>
<div class="text-center">
<h2 class="text-2xl font-bold text-ghost-100 mb-2">Event Not Found</h2>
<p class="text-ghost-300 mb-6">
The event you're looking for doesn't exist.
</p>
<NuxtLink to="/events" class="text-blue-400 hover:underline">
Back to Events
</NuxtLink>
</div>
</div>
<div v-else>
<!-- Feature Image Header -->
<div
v-if="
event.featureImage &&
(event.featureImage.publicId || event.featureImage.url)
"
class="relative h-96 overflow-hidden"
>
<!-- Members Only Banner -->
<div
v-if="event.membersOnly"
class="absolute top-0 left-0 right-0 z-10 bg-purple-600/95 backdrop-blur-sm py-2"
>
<UContainer>
<div class="flex items-center justify-center">
<span class="text-sm font-medium text-white">
Members Only Event - Open to all circles and contribution levels
</span>
</div>
</UContainer>
</div>
<img
:src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<div
class="absolute inset-0"
style="background-color: rgba(0, 0, 0, 0.4)"
></div>
<div class="absolute inset-0 flex items-center">
<UContainer>
<div class="max-w-4xl">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
{{ event.title }}
</h1>
</div>
</UContainer>
</div>
</div>
<!-- Page Header (fallback when no image) -->
<PageHeader v-else :title="event.title" theme="blue" size="medium" />
<!-- Event Details Section -->
<section class="py-16 bg-ghost-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Event Meta Info -->
<div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div>
<p class="text-sm text-ghost-400">Date</p>
<p class="font-semibold text-ghost-100">
{{ formatDate(event.startDate) }}
</p>
</div>
<div>
<p class="text-sm text-ghost-400">Time</p>
<p class="font-semibold text-ghost-100">
{{ formatTime(event.startDate, event.endDate) }}
</p>
</div>
<div>
<p class="text-sm text-ghost-400">Location</p>
<p class="font-semibold text-ghost-100">
{{ event.location }}
</p>
</div>
<div>
<p class="text-sm text-ghost-400">Calendar</p>
<UButton
:href="`/api/events/${route.params.id}/calendar`"
download
variant="outline"
size="sm"
class="mt-1"
icon="i-heroicons-calendar-days"
>
Add to Calendar
</UButton>
</div>
</div>
</div>
<!-- Event Cancelled Notice -->
<div v-if="event.isCancelled" class="mb-8">
<div class="p-6 bg-red-900/20 rounded-xl border border-red-800">
<h3 class="text-lg font-semibold text-red-300 mb-2">
Event Cancelled
</h3>
<p class="text-red-400" v-if="event.cancellationMessage">
{{ event.cancellationMessage }}
</p>
<p class="text-red-400" v-else>
This event has been cancelled. We apologize for any
inconvenience.
</p>
</div>
</div>
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="mb-8">
<EventSeriesBadge
:title="event.series.title"
:position="event.series.position"
:total-events="event.series.totalEvents"
:series-id="event.series.id"
/>
</div>
<!-- Target Circles -->
<div
v-if="event.targetCircles && event.targetCircles.length > 0"
class="mb-8"
>
<div class="flex items-center space-x-2">
<span
class="text-sm font-medium text-gray-800 dark:text-ghost-200"
>Recommended for:</span
>
<div class="flex flex-wrap gap-2">
<span
v-for="circle in event.targetCircles"
:key="circle"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border border-blue-300 dark:border-blue-800/50"
>
{{ formatCircleName(circle) }}
</span>
</div>
</div>
</div>
<!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12">
<h2 class="text-2xl font-bold text-ghost-100 mb-4">
About This Event
</h2>
<!-- Series Description -->
<div
v-if="event.series?.isSeriesEvent && event.series.description"
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="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="event-series-description__text text-ghost-200">
{{ event.series.description }}
</p>
</div>
<p class="text-ghost-200">
{{ event.description }}
</p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-ghost-100 mb-4">
Event Agenda
</h3>
<ul class="space-y-3">
<li
v-for="(item, index) in event.agenda"
:key="index"
class="flex items-start"
>
<span
class="inline-block w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5"
>
{{ index + 1 }}
</span>
<span class="text-ghost-200">{{ item }}</span>
</li>
</ul>
</div>
<div
v-if="event.speakers && event.speakers.length > 0"
class="mt-8"
>
<h3 class="text-xl font-semibold text-ghost-100 mb-4">
Speakers
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="flex items-start space-x-4"
>
<div>
<p class="font-semibold text-ghost-100">
{{ speaker.name }}
</p>
<p class="text-sm text-ghost-300">
{{ speaker.role }}
</p>
<p class="text-sm text-ghost-400 mt-1">
{{ speaker.bio }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Registration Section -->
<div v-if="!event.isCancelled">
<!-- 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>
<!-- Member Status Check (pending payment, suspended, cancelled) -->
<div v-else-if="memberData && !canRSVP" class="text-center">
<div
:class="[
'p-6 rounded-lg border mb-6',
statusConfig.bgColor,
statusConfig.borderColor,
]"
>
<Icon
:name="statusConfig.icon"
:class="['w-8 h-8 mx-auto mb-3', statusConfig.textColor]"
/>
<p
:class="[
'font-semibold text-lg mb-2',
statusConfig.textColor,
]"
>
{{ statusConfig.label }}
</p>
<p :class="['mb-4', statusConfig.textColor]">
{{ getRSVPMessage }}
</p>
<UButton
v-if="isPendingPayment"
color="orange"
size="lg"
class="px-8"
:loading="isProcessingPayment"
@click="completePayment"
>
{{
isProcessingPayment ? "Processing..." : "Complete Payment"
}}
</UButton>
<NuxtLink
v-else-if="isCancelled"
to="/member/profile#account"
>
<UButton color="blue" size="lg" class="px-8">
Reactivate Membership
</UButton>
</NuxtLink>
<a
v-else-if="isSuspended"
href="mailto:support@ghostguild.org"
>
<UButton color="gray" size="lg" class="px-8">
Contact Support
</UButton>
</a>
</div>
</div>
<!-- Member Gate Warning (members-only locked event) -->
<div
v-else-if="event.membersOnly && memberData && !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>
<!-- 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>
<!-- 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}%`"
/>
</div>
</div>
</div>
</div>
<!-- Waitlist Section -->
<div
v-if="event.tickets?.waitlist?.enabled && isEventFull"
class="mt-6 pt-6 border-t border-ghost-700"
>
<!-- Already on Waitlist -->
<div v-if="isOnWaitlist" class="text-center">
<div class="p-4 bg-amber-900/20 rounded-lg border border-amber-800 mb-4">
<p class="font-semibold text-amber-300">
You're on the waitlist!
</p>
<p class="text-sm text-amber-400 mt-1">
Position #{{ waitlistPosition }} - We'll email you if a spot opens up
</p>
</div>
<UButton
color="gray"
variant="outline"
size="sm"
@click="handleLeaveWaitlist"
:loading="isJoiningWaitlist"
>
Leave Waitlist
</UButton>
</div>
<!-- Join Waitlist Form -->
<div v-else>
<div class="p-4 bg-amber-900/20 rounded-lg border border-amber-800 mb-4">
<p class="font-semibold text-amber-300">
This event is full
</p>
<p class="text-sm text-amber-400 mt-1">
Join the waitlist and we'll notify you if a spot opens up
</p>
</div>
<form @submit.prevent="handleJoinWaitlist" class="space-y-4">
<div v-if="!memberData">
<UInput
v-model="waitlistForm.email"
type="email"
placeholder="Your email address"
required
/>
</div>
<UButton
type="submit"
color="warning"
block
:loading="isJoiningWaitlist"
>
{{ isJoiningWaitlist ? "Joining..." : "Join Waitlist" }}
</UButton>
</form>
</div>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="mt-8 p-6 rounded-xl border border-ghost-700">
<h4 class="font-semibold text-ghost-100 mb-3">Questions?</h4>
<p class="text-sm text-ghost-200 mb-3">
If you have any questions about this event please drop us a line.
</p>
<a
href="mailto:events@ghostguild.org"
class="text-blue-400 hover:underline"
>
events@ghostguild.org
</a>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const route = useRoute();
const toast = useToast();
// Fetch event data from API
const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.id}`);
// Handle event not found
if (error.value?.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Authentication
const { isMember, memberData, checkMemberStatus } = useAuth();
const {
isPendingPayment,
isSuspended,
isCancelled,
canRSVP,
statusConfig,
getRSVPMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment, paymentError } =
useMemberPayment();
// Check member status on mount
onMounted(async () => {
await checkMemberStatus();
// Pre-fill form if member is logged in
if (memberData.value) {
registrationForm.value.name = memberData.value.name;
registrationForm.value.email = memberData.value.email;
registrationForm.value.membershipLevel =
memberData.value.membershipLevel || "non-member";
// Check if user is already registered
await checkRegistrationStatus();
// Check waitlist status
checkWaitlistStatus();
}
});
// Check if user is already registered for this event
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return;
try {
const response = await $fetch(
`/api/events/${route.params.id}/check-registration`,
{
method: "POST",
body: { email: memberData.value.email },
},
);
if (response.isRegistered) {
registrationStatus.value = "registered";
}
} catch (error) {
console.error("Failed to check registration status:", error);
}
};
// Registration form state
const registrationForm = ref({
name: "",
email: "",
membershipLevel: "non-member",
});
const membershipOptions = [
{ label: "Non-member", value: "non-member" },
{ label: "Circle of Community", value: "community" },
{ label: "Circle of Founders", value: "founder" },
{ label: "Circle of Practitioners", value: "practitioner" },
];
const isRegistering = ref(false);
const isCancelling = ref(false);
const registrationStatus = ref("not-registered"); // 'not-registered', 'registered'
// Waitlist state
const isJoiningWaitlist = ref(false);
const isOnWaitlist = ref(false);
const waitlistPosition = ref(0);
const waitlistForm = ref({
email: "",
});
// Computed: Check if event is full
const isEventFull = computed(() => {
if (!event.value?.maxAttendees) return false;
return (event.value.registeredCount || 0) >= event.value.maxAttendees;
});
// Check waitlist status
const checkWaitlistStatus = async () => {
const email = memberData.value?.email || waitlistForm.value.email;
if (!email || !event.value?.tickets?.waitlist?.enabled) return;
const entries = event.value.tickets.waitlist.entries || [];
const entryIndex = entries.findIndex(
(e) => e.email.toLowerCase() === email.toLowerCase()
);
if (entryIndex !== -1) {
isOnWaitlist.value = true;
waitlistPosition.value = entryIndex + 1;
} else {
isOnWaitlist.value = false;
waitlistPosition.value = 0;
}
};
// Join waitlist handler
const handleJoinWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
const name = memberData.value?.name || "Guest";
const response = await $fetch(`/api/events/${route.params.id}/waitlist`, {
method: "POST",
body: { email, name },
});
isOnWaitlist.value = true;
waitlistPosition.value = response.position;
toast.add({
title: "Added to Waitlist",
description: `You're #${response.position} on the waitlist. We'll email you if a spot opens up.`,
color: "orange",
});
} catch (error) {
const errorMessage =
error.data?.statusMessage || "Failed to join waitlist. Please try again.";
toast.add({
title: "Couldn't Join Waitlist",
description: errorMessage,
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
// Leave waitlist handler
const handleLeaveWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
await $fetch(`/api/events/${route.params.id}/waitlist`, {
method: "DELETE",
body: { email },
});
isOnWaitlist.value = false;
waitlistPosition.value = 0;
toast.add({
title: "Removed from Waitlist",
description: "You've been removed from the waitlist.",
color: "blue",
});
} catch (error) {
toast.add({
title: "Error",
description: "Failed to leave waitlist. Please try again.",
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
// Format date for display
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
// Format time range for display
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
// Format circle name for display
const formatCircleName = (circleValue) => {
const circleNames = {
community: "Community Circle",
founder: "Founder Circle",
practitioner: "Practitioner Circle",
};
return circleNames[circleValue] || circleValue;
};
// Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return "";
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
};
// Get image URL with fallback logic
const getImageUrl = (featureImage) => {
if (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return featureImage.url;
}
// Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, "w_1200,h_400,c_fill");
}
return "";
};
// Handle image loading errors
const handleImageError = (event) => {
console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder
};
// Handle registration submission
const handleRegistration = async () => {
isRegistering.value = true;
try {
// Submit registration to API using slug or ID
const response = await $fetch(`/api/events/${route.params.id}/register`, {
method: "POST",
body: registrationForm.value,
});
// Update registration status
registrationStatus.value = "registered";
// Show success toast
toast.add({
title: "Registration Successful!",
description: `You're registered for ${event.value.title}. Check your email for confirmation.`,
color: "green",
});
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount++;
}
} catch (error) {
console.error("Registration failed:", error);
// Handle specific error messages
const errorMessage =
error.data?.statusMessage || "Something went wrong. Please try again.";
toast.add({
title: "Registration Failed",
description: errorMessage,
color: "red",
});
} finally {
isRegistering.value = false;
}
};
// Handle registration cancellation
const handleCancelRegistration = async () => {
if (
!confirm(
"Are you sure you want to cancel your registration for this event?",
)
) {
return;
}
isCancelling.value = true;
try {
const response = await $fetch(
`/api/events/${route.params.id}/cancel-registration`,
{
method: "POST",
body: {
email: registrationForm.value.email || memberData.value?.email,
},
},
);
// Update registration status
registrationStatus.value = "not-registered";
// Show success toast
toast.add({
title: "Registration Cancelled",
description: "Your registration has been cancelled successfully.",
color: "blue",
});
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount--;
}
} catch (error) {
console.error("Cancel registration failed:", error);
const errorMessage =
error.data?.statusMessage ||
"Failed to cancel registration. Please try again.";
toast.add({
title: "Cancellation Failed",
description: errorMessage,
color: "red",
});
} finally {
isCancelling.value = false;
}
};
// Handle ticket purchase success
const handleTicketSuccess = (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
? `${event.value.title} - Ghost Guild Events`
: "Event - Ghost Guild",
meta: [
{
name: "description",
content: event.value?.description || "View event details and register",
},
],
}));
</script>