649 lines
20 KiB
Vue
649 lines
20 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-3 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>
|
|
</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"
|
|
:description="event.series.description"
|
|
: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>
|
|
<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">
|
|
<!-- 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
|
|
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}%`"
|
|
/>
|
|
</div>
|
|
</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();
|
|
|
|
// Check member status on mount
|
|
onMounted(async () => {
|
|
await checkMemberStatus();
|
|
|
|
// Debug: Log series data
|
|
if (event.value?.series) {
|
|
console.log("Series data:", event.value.series);
|
|
}
|
|
|
|
// 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 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'
|
|
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
// 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>
|