Adding features

This commit is contained in:
Jennie Robinson Faber 2025-10-05 16:15:09 +01:00
parent 600fef2b7c
commit 2b55ca4104
75 changed files with 9796 additions and 2759 deletions

View file

@ -1,83 +1,105 @@
<template>
<div v-if="pending" class="min-h-screen flex items-center justify-center">
<div
v-if="pending"
class="min-h-screen bg-stone-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-gray-600 dark:text-gray-400">Loading event details...</p>
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-stone-200">Loading event details...</p>
</div>
</div>
<div v-else-if="error" class="min-h-screen flex items-center justify-center">
<div
v-else-if="error"
class="min-h-screen bg-stone-900 flex items-center justify-center"
>
<div class="text-center">
<Icon name="heroicons:exclamation-triangle" class="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Event Not Found</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">The event you're looking for doesn't exist.</p>
<NuxtLink to="/events" class="text-blue-600 dark:text-blue-400 hover:underline">
<Icon
name="heroicons:exclamation-triangle"
class="w-16 h-16 text-red-500 mx-auto mb-4"
/>
<h2 class="text-2xl font-bold text-stone-100 mb-2">Event Not Found</h2>
<p class="text-stone-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">
<img
<div
v-if="
event.featureImage &&
(event.featureImage.publicId || event.featureImage.url)
"
class="relative h-96 overflow-hidden"
>
<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"
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>
<p v-if="event.tagline" class="text-xl text-gray-200">
{{ event.tagline }}
</p>
</div>
</UContainer>
</div>
</div>
<!-- Page Header (fallback when no image) -->
<PageHeader
v-else
:title="event.title"
:subtitle="event.tagline"
theme="blue"
size="medium"
/>
<PageHeader v-else :title="event.title" theme="blue" size="medium" />
<!-- Event Details Section -->
<section class="py-16 bg-white dark:bg-gray-900">
<section class="py-16 bg-stone-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Event Meta Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 mb-8 border border-blue-200 dark:border-blue-800">
<div class="bg-stone-800 rounded-xl p-6 mb-8 border border-stone-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center space-x-3">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-400"
/>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Date</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatDate(event.startDate) }}</p>
<p class="text-sm text-stone-400">Date</p>
<p class="font-semibold text-stone-100">
{{ formatDate(event.startDate) }}
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Time</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatTime(event.startDate, event.endDate) }}</p>
<p class="text-sm text-stone-400">Time</p>
<p class="font-semibold text-stone-100">
{{ formatTime(event.startDate, event.endDate) }}
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Location</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ event.location }}</p>
<p class="text-sm text-stone-400">Location</p>
<p class="font-semibold text-stone-100">
{{ event.location }}
</p>
</div>
</div>
</div>
@ -85,16 +107,22 @@
<!-- Event Cancelled Notice -->
<div v-if="event.isCancelled" class="mb-8">
<div class="p-6 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800">
<div class="p-6 bg-red-900/20 rounded-xl border border-red-800">
<div class="flex items-start">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-red-600 dark:text-red-400 mr-3 mt-0.5" />
<Icon
name="heroicons:exclamation-triangle"
class="w-6 h-6 text-red-400 mr-3 mt-0.5"
/>
<div>
<h3 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Event Cancelled</h3>
<p class="text-red-600 dark:text-red-400" v-if="event.cancellationMessage">
<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-600 dark:text-red-400" v-else>
This event has been cancelled. We apologize for any inconvenience.
<p class="text-red-400" v-else>
This event has been cancelled. We apologize for any
inconvenience.
</p>
</div>
</div>
@ -103,24 +131,36 @@
<!-- Member-Only Badge -->
<div v-if="event.membersOnly" class="mb-8">
<div class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<Icon name="heroicons:lock-closed" class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2" />
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
<div
class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full"
>
<Icon
name="heroicons:lock-closed"
class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2"
/>
<span
class="text-sm font-medium text-purple-700 dark:text-purple-300"
>
Members Only Event - Open to all circles and contribution levels
</span>
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles && event.targetCircles.length > 0" class="mb-8">
<div
v-if="event.targetCircles && event.targetCircles.length > 0"
class="mb-8"
>
<div class="flex items-center space-x-2">
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Recommended for:</span>
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" />
<span class="text-sm font-medium text-stone-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 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
<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-900/30 text-blue-400"
>
{{ formatCircleName(circle) }}
</span>
@ -130,32 +170,64 @@
<!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">About This Event</h2>
<p class="text-gray-700 dark:text-gray-300">{{ event.description }}</p>
<h2 class="text-2xl font-bold text-stone-100 mb-4">
About This Event
</h2>
<p class="text-stone-200">
{{ event.description }}
</p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Event Agenda</h3>
<h3 class="text-xl font-semibold text-stone-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">
<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-gray-700 dark:text-gray-300">{{ item }}</span>
<span class="text-stone-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-gray-900 dark:text-white mb-4">Speakers</h3>
<div
v-if="event.speakers && event.speakers.length > 0"
class="mt-8"
>
<h3 class="text-xl font-semibold text-stone-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 class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon name="heroicons:user" class="w-8 h-8 text-gray-400 dark:text-gray-500" />
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="flex items-start space-x-4"
>
<div
class="w-16 h-16 bg-stone-700 rounded-full flex items-center justify-center"
>
<Icon
name="heroicons:user"
class="w-8 h-8 text-stone-500"
/>
</div>
<div>
<p class="font-semibold text-gray-900 dark:text-white">{{ speaker.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ speaker.role }}</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">{{ speaker.bio }}</p>
<p class="font-semibold text-stone-100">
{{ speaker.name }}
</p>
<p class="text-sm text-stone-300">
{{ speaker.role }}
</p>
<p class="text-sm text-stone-400 mt-1">
{{ speaker.bio }}
</p>
</div>
</div>
</div>
@ -163,30 +235,75 @@
</div>
<!-- Registration Section -->
<div v-if="!event.isCancelled" class="bg-gray-50 dark:bg-gray-800 rounded-xl p-8 border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6">Register for This Event</h3>
<div
v-if="!event.isCancelled"
class="bg-stone-800 rounded-xl p-8 border border-stone-700"
>
<h3 class="text-xl font-bold text-stone-100 mb-6">
Register for This Event
</h3>
<!-- Registration Status -->
<div v-if="registrationStatus === 'registered'" class="mb-6">
<div class="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Icon name="heroicons:check-circle" class="w-6 h-6 text-green-600 dark:text-green-400 mr-3" />
<div>
<p class="font-semibold text-green-700 dark:text-green-300">You're registered!</p>
<p class="text-sm text-green-600 dark:text-green-400">We've sent a confirmation to your email</p>
<div
class="p-4 bg-green-900/20 rounded-lg border border-green-800"
>
<div class="flex items-start justify-between">
<div class="flex items-center">
<Icon
name="heroicons:check-circle"
class="w-6 h-6 text-green-400 mr-3"
/>
<div>
<p class="font-semibold text-green-300">
You're registered!
</p>
<p class="text-sm text-green-400">
We've sent a confirmation to your email
</p>
</div>
</div>
<UButton
color="red"
variant="ghost"
size="sm"
@click="handleCancelRegistration"
:loading="isCancelling"
>
Cancel Registration
</UButton>
</div>
</div>
</div>
<!-- Member Gate Warning -->
<div v-if="event.membersOnly && !isMember && registrationStatus !== 'registered'" class="mb-6">
<div class="flex items-start p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-amber-600 dark:text-amber-400 mr-3 mt-0.5" />
<div
v-if="
event.membersOnly &&
!isMember &&
registrationStatus !== 'registered'
"
class="mb-6"
>
<div
class="flex items-start p-4 bg-amber-900/20 rounded-lg border border-amber-800"
>
<Icon
name="heroicons:exclamation-triangle"
class="w-6 h-6 text-amber-400 mr-3 mt-0.5"
/>
<div>
<p class="font-semibold text-amber-700 dark:text-amber-300">Membership Required</p>
<p class="text-sm text-amber-600 dark:text-amber-400 mt-1">
This event is exclusive to Ghost Guild members. Join any circle to gain access.
<p class="font-semibold text-amber-300">
Membership Required
</p>
<NuxtLink to="/join" class="inline-flex items-center text-sm font-medium text-amber-700 dark:text-amber-300 hover:underline mt-2">
<p class="text-sm text-amber-400 mt-1">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
<NuxtLink
to="/join"
class="inline-flex items-center text-sm font-medium text-amber-300 hover:underline mt-2"
>
Become a member
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</NuxtLink>
@ -195,40 +312,53 @@
</div>
<!-- Registration Form -->
<form v-if="registrationStatus !== 'registered'" @submit.prevent="handleRegistration" class="space-y-4">
<form
v-if="registrationStatus !== 'registered'"
@submit.prevent="handleRegistration"
class="space-y-4"
>
<!-- Show form fields only for public events OR for logged-in members -->
<template v-if="!event.membersOnly || isMember">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label
for="name"
class="block text-sm font-medium text-stone-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
<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-gray-700 dark:text-gray-300 mb-2">
<label
for="email"
class="block text-sm font-medium text-stone-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
<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-gray-700 dark:text-gray-300 mb-2">
<label
for="membershipLevel"
class="block text-sm font-medium text-stone-200 mb-2"
>
Membership Status
</label>
<USelect
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
@ -236,28 +366,19 @@
</div>
</template>
<div class="pt-4">
<UButton
<UButton
v-if="!event.membersOnly || isMember"
type="submit"
color="primary"
size="lg"
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
{{ isRegistering ? "Registering..." : "Register for Event" }}
</UButton>
<NuxtLink
v-else
to="/join"
class="block"
>
<UButton
color="primary"
size="lg"
block
>
<NuxtLink v-else to="/join" class="block">
<UButton color="primary" size="lg" block>
Become a Member to Register
</UButton>
</NuxtLink>
@ -265,15 +386,20 @@
</form>
<!-- Event Capacity -->
<div v-if="event.maxAttendees" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-stone-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Event Capacity</span>
<span class="text-sm text-stone-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
<span class="text-sm font-semibold text-stone-100">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
<div
class="w-24 h-2 bg-stone-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/>
@ -284,12 +410,15 @@
</div>
<!-- Additional Information -->
<div class="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<h4 class="font-semibold text-gray-900 dark:text-white mb-3">Questions?</h4>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
If you have any questions about this event, please reach out to our events team.
<div class="mt-8 p-6 bg-stone-800 rounded-xl border border-stone-700">
<h4 class="font-semibold text-stone-100 mb-3">Questions?</h4>
<p class="text-sm text-stone-200 mb-3">
If you have any questions about this event please drop us a line.
</p>
<a href="mailto:events@ghostguild.org" class="inline-flex items-center text-blue-600 dark:text-blue-400 hover:underline">
<a
href="mailto:events@ghostguild.org"
class="inline-flex items-center text-blue-400 hover:underline"
>
<Icon name="heroicons:envelope" class="w-4 h-4 mr-2" />
events@ghostguild.org
</a>
@ -301,164 +430,254 @@
</template>
<script setup>
const route = useRoute()
const toast = useToast()
const route = useRoute();
const toast = useToast();
// Fetch event data from API
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`)
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'
})
statusMessage: "Event not found",
});
}
// Authentication
const { isMember, memberData, checkMemberStatus } = useAuth()
const { isMember, memberData, checkMemberStatus } = useAuth();
// Check member status on mount
onMounted(async () => {
await checkMemberStatus()
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'
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'
})
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' }
]
{ 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 registrationStatus = ref('not-registered') // 'not-registered', 'registered'
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)
}
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)}`
}
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
}
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}`
}
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 (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return 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 getOptimizedImageUrl(featureImage.publicId, "w_1200,h_400,c_fill");
}
return ''
}
return "";
};
// Handle image loading errors
const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src)
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
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
})
method: "POST",
body: registrationForm.value,
});
// Update registration status
registrationStatus.value = 'registered'
registrationStatus.value = "registered";
// Show success toast
toast.add({
title: 'Registration Successful!',
title: "Registration Successful!",
description: `You're registered for ${event.value.title}. Check your email for confirmation.`,
color: 'green'
})
color: "green",
});
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount++
event.value.registeredCount++;
}
} catch (error) {
console.error('Registration failed:', error)
console.error("Registration failed:", error);
// Handle specific error messages
const errorMessage = error.data?.statusMessage || 'Something went wrong. Please try again.'
const errorMessage =
error.data?.statusMessage || "Something went wrong. Please try again.";
toast.add({
title: 'Registration Failed',
title: "Registration Failed",
description: errorMessage,
color: 'red'
})
color: "red",
});
} finally {
isRegistering.value = false
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',
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>
{
name: "description",
content: event.value?.description || "View event details and register",
},
],
}));
</script>

View file

@ -1,141 +1,219 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
<PageHeader
title="Events"
subtitle="Join our community events, workshops, and gatherings designed to connect developers and share knowledge about cooperative game development."
theme="blue"
subtitle="Join our community events, workshops, and gatherings"
size="large"
/>
<!-- Event Calendar -->
<section class="py-20 bg-white dark:bg-gray-900">
<!-- Events Section with Tabs -->
<section class="py-20 bg-stone-900 dark:bg-stone-950">
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Event Calendar
</h2>
<div class="flex items-center justify-center gap-2 mb-8">
<div class="w-6 h-6 bg-blue-500 rounded-full" />
<div class="w-6 h-6 bg-blue-400 rounded-full" />
<div class="w-8 h-1 bg-blue-300 rounded-full" />
<div class="w-8 h-1 bg-blue-200 rounded-full" />
<div class="w-8 h-1 bg-blue-100 rounded-full" />
</div>
</div>
<div class="max-w-5xl mx-auto">
<div class="bg-gray-50 dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<ClientOnly>
<div v-if="pending" class="min-h-[400px] bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading events...</p>
</div>
</div>
<VueCal
v-else
:events="events"
:time="false"
active-view="month"
class="custom-calendar"
:disable-views="['years', 'year']"
:hide-weekends="false"
today-button
events-on-month-view="short"
:editable-events="{
title: false,
drag: false,
resize: false,
delete: false,
create: false
}"
@event-click="onEventClick"
/>
<template #fallback>
<div class="min-h-[400px] bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading calendar...</p>
<UTabs
v-model="activeTab"
:items="[
{ label: 'Upcoming Events', value: 'upcoming', slot: 'upcoming' },
{ label: 'Calendar', value: 'calendar', slot: 'calendar' },
]"
class="max-w-6xl mx-auto"
>
<template #upcoming>
<div class="max-w-4xl mx-auto space-y-6 pt-8">
<NuxtLink
v-for="event in upcomingEvents"
:key="event.id"
:to="`/events/${event.slug || event.id}`"
class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
>
<div class="flex-shrink-0 text-center">
<div class="text-2xl font-bold text-stone-100">
{{ event.start.getDate() }}
</div>
<div class="text-xs text-stone-400 uppercase">
{{
event.start.toLocaleDateString("en-US", {
month: "short",
})
}}
</div>
</div>
</template>
</ClientOnly>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 mb-1">
<h3
class="text-lg font-semibold text-stone-100 group-hover:text-blue-400 transition-colors"
>
{{ event.title }}
</h3>
<Icon
v-if="event.membersOnly"
name="heroicons:lock-closed"
class="w-4 h-4 text-purple-500 flex-shrink-0 mt-1"
/>
</div>
<p class="text-sm text-stone-300 mb-2 line-clamp-2">
{{ event.content }}
</p>
<div
v-if="event.series?.isSeriesEvent"
class="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400"
>
<div
class="w-4 h-4 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center text-xs font-bold"
>
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<Icon
name="heroicons:arrow-right"
class="w-5 h-5 text-stone-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
/>
</NuxtLink>
</div>
</template>
<template #calendar>
<div class="pt-8">
<ClientOnly>
<div
v-if="pending"
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<p class="text-stone-200">Loading events...</p>
</div>
</div>
<VueCal
v-else
:events="events"
:time="false"
active-view="month"
class="custom-calendar"
:disable-views="['years', 'year']"
:hide-weekends="false"
today-button
events-on-month-view="short"
:editable-events="{
title: false,
drag: false,
resize: false,
delete: false,
create: false,
}"
@event-click="onEventClick"
/>
<template #fallback>
<div
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-stone-200">Loading calendar...</p>
</div>
</div>
</template>
</ClientOnly>
</div>
</template>
</UTabs>
</UContainer>
</section>
<!-- Event Series -->
<section v-if="activeSeries.length > 0" class="py-20 bg-purple-50 dark:bg-purple-900/20">
<section
v-if="activeSeries.length > 0"
class="py-20 bg-stone-800 dark:bg-stone-900"
>
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-8">
<h2 class="text-3xl font-bold text-stone-100 mb-8">
Active Event Series
</h2>
<p class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your knowledge and build community connections.
<p class="text-stone-300 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your
knowledge and build community connections.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"
>
<div
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
class="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700"
class="bg-stone-900 rounded-xl p-6 shadow-lg border border-stone-700"
>
<div class="flex items-start justify-between mb-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
<div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</div>
<div class="flex items-center gap-1 text-xs text-gray-500">
<div class="flex items-center gap-1 text-xs text-stone-400">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
<span>{{ series.eventCount }} events</span>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<h3 class="text-lg font-semibold text-stone-100 mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
<p class="text-sm text-stone-300 mb-4 line-clamp-2">
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div
v-for="event in series.events.slice(0, 3)"
<div
v-for="event in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium">
{{ event.series?.position || '?' }}
<div
class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium"
>
{{ event.series?.position || "?" }}
</div>
<span class="text-gray-600 dark:text-gray-400 truncate">{{ event.title }}</span>
<span class="text-stone-300 truncate">{{ event.title }}</span>
</div>
<span class="text-gray-500 dark:text-gray-500">
<span class="text-stone-400">
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div v-if="series.events.length > 3" class="text-xs text-gray-500 dark:text-gray-500 text-center pt-1">
<div
v-if="series.events.length > 3"
class="text-xs text-stone-400 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="text-gray-500 dark:text-gray-500">
<div class="text-stone-400">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<span :class="[
'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' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
]">
<span
:class="[
'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'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
]"
>
{{ series.status }}
</span>
</div>
@ -144,237 +222,101 @@
</UContainer>
</section>
<!-- Upcoming Events -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Upcoming Events
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<NuxtLink
v-for="event in upcomingEvents"
:key="event.id"
:to="`/events/${event.slug || event.id}`"
class="group bg-white dark:bg-gray-900 rounded-xl overflow-hidden shadow-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 transition-all hover:shadow-xl"
>
<!-- Feature Image -->
<div v-if="event.featureImage?.url" class="aspect-video w-full overflow-hidden">
<img
:src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
@error="handleImageError"
/>
</div>
<div class="p-6">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="mb-3">
<div class="inline-flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400">
<div class="w-4 h-4 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-bold">
{{ event.series.position }}
</div>
<Icon name="heroicons:squares-2x2" class="w-3 h-3" />
{{ event.series.title }}
</div>
</div>
<div class="flex items-start justify-between mb-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
event.class === 'event-community' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
event.class === 'event-workshop' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' :
event.class === 'event-social' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
]">
{{ event.class === 'event-community' ? 'Community' :
event.class === 'event-workshop' ? 'Workshop' :
event.class === 'event-social' ? 'Social' : 'Showcase' }}
</div>
<Icon v-if="event.membersOnly" name="heroicons:lock-closed" class="w-4 h-4 text-purple-500" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{{ event.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ event.content }}
</p>
<div class="flex items-center text-sm text-gray-500 dark:text-gray-500">
<Icon name="heroicons:calendar" class="w-4 h-4 mr-1" />
{{ formatEventDate(event.start) }}
</div>
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<span class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 group-hover:translate-x-1 transition-transform">
View Details
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</span>
</div>
</div>
</NuxtLink>
</div>
</UContainer>
</section>
<!-- Attend Our Events -->
<section class="py-20 bg-white dark:bg-gray-900">
<section class="py-20 bg-stone-800 dark:bg-stone-900">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
<h2 class="text-3xl font-bold text-stone-100 mb-8">
Attend Our Events
</h2>
</div>
<div class="max-w-4xl mx-auto">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800 mb-12">
<div class="space-y-6 mb-8">
<div class="h-2 bg-blue-500 rounded-full" />
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-2/3" />
</div>
<div
class="bg-stone-900 rounded-2xl p-8 border border-stone-700 mb-12"
>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Our events are designed to build community, share knowledge, and support developers exploring cooperative models. From informal networking sessions to structured workshops, there's something for everyone.
<p class="text-lg leading-relaxed text-stone-200 mb-6">
Our events are ,Lorem ipsum, dolor sit amet consectetur
adipisicing elit. Quibusdam exercitationem delectus ab
voluptates aspernatur, quia deleniti aut maxime, veniam
accusantium non dolores saepe error, ipsam laudantium asperiores
dolorum alias nulla!
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Regular events include monthly community meetups, quarterly workshops on cooperative business structures, and seasonal social gatherings. We also host special events featuring guest speakers and collaborative project showcases.
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">
All events are welcoming to developers at any stage of their cooperative journey, from those just curious about alternative models to experienced co-op members sharing their insights.
<p class="text-lg leading-relaxed text-stone-200 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-blue-500 rounded" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Monthly Meetups</h3>
<div class="space-y-1 mb-3">
<div class="h-1 bg-blue-500 rounded-full" />
<div class="h-1 bg-blue-300 rounded-full w-3/4 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Casual networking and knowledge sharing sessions
<h3 class="text-lg font-semibold text-stone-100 mb-2">
Monthly Meetups
</h3>
<p class="text-sm text-stone-300">
Casual knowledge sharing sessions
</p>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Workshops</h3>
<div class="space-y-1 mb-3">
<div class="h-1 bg-emerald-500 rounded-full" />
<div class="h-1 bg-emerald-300 rounded-full w-5/6 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Hands-on learning about cooperative business models
<h3 class="text-lg font-semibold text-stone-100 mb-2">
Workshops
</h3>
<p class="text-sm text-stone-300">
Hands-on learning about cooperative and worker-centric business
models
</p>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-purple-500 rounded-full" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Social Events</h3>
<div class="space-y-1 mb-3">
<div class="h-1 bg-purple-500 rounded-full" />
<div class="h-1 bg-purple-300 rounded-full w-2/3 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Community building and celebration gatherings
<h3 class="text-lg font-semibold text-stone-100 mb-2">
Social Events
</h3>
<p class="text-sm text-stone-300">
Game nights, socials, and more
</p>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Event Highlights -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Event Highlights
</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
<div class="space-y-6">
<div class="space-y-4">
<div class="h-2 bg-blue-500 rounded-full" />
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-3/4" />
<div class="h-2 bg-blue-200 rounded-full w-1/2" />
</div>
<div class="space-y-6">
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Recent Highlights
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed mb-4">
Our latest workshop on "Building Sustainable Game Co-ops" brought together 50+ developers to explore practical strategies for transitioning to cooperative models.
</p>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
The quarterly showcase featured three member studios presenting their games and sharing insights about democratic decision-making in creative projects.
</p>
</div>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Upcoming Features
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Next month's event will include a panel discussion on funding cooperative studios, featuring successful co-op founders and supporting investors.
</p>
</div>
</div>
</div>
<div class="flex items-center justify-center">
<div class="w-full max-w-md h-64 bg-blue-100 dark:bg-blue-900/30 rounded-2xl border-2 border-dashed border-blue-300 dark:border-blue-700 flex items-center justify-center">
<div class="text-center">
<div class="w-16 h-16 bg-blue-200 dark:bg-blue-800 rounded-xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-blue-500 rounded" />
</div>
<p class="text-blue-600 dark:text-blue-400 font-medium">Event Photos</p>
<p class="text-sm text-blue-500 dark:text-blue-500 mt-2">Coming Soon</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
import { VueCal } from 'vue-cal'
import 'vue-cal/style.css'
import { VueCal } from "vue-cal";
import "vue-cal/style.css";
// Active tab state
const activeTab = ref("upcoming");
// Fetch events from API
const { data: eventsData, pending, error } = await useFetch('/api/events')
const { data: eventsData, pending, error } = await useFetch("/api/events");
// Fetch series from API
const { data: seriesData } = await useFetch('/api/series')
const { data: seriesData } = await useFetch("/api/series");
// Transform events for calendar display
const events = computed(() => {
if (!eventsData.value) return []
return eventsData.value.map(event => ({
if (!eventsData.value) return [];
return eventsData.value.map((event) => ({
id: event.id || event._id,
slug: event.slug,
start: new Date(event.startDate),
@ -388,117 +330,128 @@ const events = computed(() => {
registeredCount: event.registeredCount,
maxAttendees: event.maxAttendees,
featureImage: event.featureImage,
series: event.series
}))
})
series: event.series,
}));
});
// Get active event series
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value.filter(series =>
series.status === 'active' || series.isOngoing || series.isUpcoming
)
})
if (!seriesData.value) return [];
return seriesData.value.filter(
(series) =>
series.status === "active" || series.isOngoing || series.isUpcoming,
);
});
// Get upcoming events (future events)
const upcomingEvents = computed(() => {
const now = new Date()
const now = new Date();
return events.value
.filter(event => event.start > now)
.filter((event) => event.start > now)
.sort((a, b) => a.start - b.start)
.slice(0, 6) // Show max 6 upcoming events
})
.slice(0, 6); // Show max 6 upcoming events
});
// Format event date for display
const formatEventDate = (date) => {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date)
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
};
// 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}`
}
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 (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return featureImage.url
return featureImage.url;
}
// Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, 'w_400,h_200,c_fill')
return getOptimizedImageUrl(featureImage.publicId, "w_400,h_200,c_fill");
}
return ''
}
return "";
};
// Handle image loading errors
const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src)
console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder
}
};
// Handle calendar event click
const onEventClick = (event) => {
if (event.id) {
navigateTo(`/events/${event.slug || event.id}`)
navigateTo(`/events/${event.slug || event.id}`);
}
}
};
// Series helper functions
const formatSeriesType = (type) => {
const types = {
'workshop_series': 'Workshop Series',
'recurring_meetup': 'Recurring Meetup',
'multi_day': 'Multi-Day Event',
'course': 'Course',
'tournament': 'Tournament'
}
return types[type] || type
}
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
tournament: "Tournament",
};
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
'recurring_meetup': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'multi_day': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
'course': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
'tournament': 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
return classes[type] || 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
workshop_series:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
recurring_meetup:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
multi_day:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
course:
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
tournament: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
};
return (
classes[type] ||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
);
};
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const startMonth = start.toLocaleDateString('en-US', { month: 'short' })
const endMonth = end.toLocaleDateString('en-US', { month: 'short' })
const startDay = start.getDate()
const endDay = end.getDate()
const year = end.getFullYear()
if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay}-${endDay}, ${year}`
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`;
}
}
};
</script>
<style scoped>
@ -511,19 +464,127 @@ const formatDateRange = (startDate, endDate) => {
/* Custom calendar styling to match the site theme */
.custom-calendar {
--vuecal-primary-color: #3b82f6;
--vuecal-text-color: #374151;
--vuecal-border-color: #e5e7eb;
--vuecal-header-color: #f9fafb;
--vuecal-today-color: #dbeafe;
--vuecal-primary-color: #fff;
--vuecal-text-color: #e7e5e4;
--vuecal-border-color: #57534e;
--vuecal-header-color: #1c1917;
--vuecal-today-color: #292524;
background-color: #292524;
}
.dark .custom-calendar {
--vuecal-primary-color: #60a5fa;
--vuecal-text-color: #d1d5db;
--vuecal-border-color: #4b5563;
--vuecal-header-color: #374151;
--vuecal-today-color: #1e3a8a;
.custom-calendar :deep(.vuecal__bg) {
background-color: #292524;
}
.custom-calendar :deep(.vuecal__header) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.custom-calendar :deep(.vuecal__title-bar) {
background-color: #1c1917;
}
.custom-calendar :deep(.vuecal__title) {
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__weekdays-headings) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.custom-calendar :deep(.vuecal__heading) {
color: #a8a29e;
}
.custom-calendar :deep(.vuecal__cell) {
background-color: #292524;
border-color: #57534e;
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__cell:hover) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__cell-content) {
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__cell--today) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__cell--out-of-scope) {
background-color: #1c1917;
color: #78716c;
}
.custom-calendar :deep(.vuecal__arrow) {
color: #a8a29e;
}
.custom-calendar :deep(.vuecal__arrow:hover) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__today-btn) {
background-color: #44403c;
color: white;
border: 1px solid #78716c;
}
.custom-calendar :deep(.vuecal__today-btn:hover) {
background-color: #57534e;
border-color: #a8a29e;
}
.custom-calendar :deep(.vuecal__view-btn),
.custom-calendar :deep(button[class*="view"]) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.custom-calendar :deep(.vuecal__view-btn:hover),
.custom-calendar :deep(button[class*="view"]:hover) {
background-color: #57534e !important;
border-color: #a8a29e !important;
color: #ffffff !important;
}
.custom-calendar :deep(.vuecal__view-btn--active),
.custom-calendar :deep(button[class*="view"][class*="active"]) {
background-color: #0c0a09 !important;
color: #ffffff !important;
border-color: #a8a29e !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.custom-calendar :deep(.vuecal__view-btn--active:hover),
.custom-calendar :deep(button[class*="view"][class*="active"]:hover) {
background-color: #1c1917 !important;
border-color: #d6d3d1 !important;
color: #ffffff !important;
}
.custom-calendar :deep(.vuecal__title-bar button) {
color: #ffffff !important;
font-weight: 600 !important;
}
.custom-calendar :deep(.vuecal__title-bar .default-view-btn) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
}
.custom-calendar :deep(.vuecal__title-bar .default-view-btn.active) {
background-color: #0c0a09 !important;
border-color: #a8a29e !important;
}
/* Event type styling */
@ -562,4 +623,4 @@ const formatDateRange = (startDate, endDate) => {
color: var(--vuecal-primary-color);
font-weight: 600;
}
</style>
</style>