503 lines
17 KiB
Vue
503 lines
17 KiB
Vue
<template>
|
|
<div>
|
|
<div v-if="pending" class="min-h-screen flex items-center justify-center">
|
|
<div class="text-center">
|
|
<div
|
|
class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"
|
|
></div>
|
|
<p class="text-[--ui-text-muted]">Loading series details...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="error"
|
|
class="min-h-screen flex items-center justify-center"
|
|
>
|
|
<div class="text-center">
|
|
<h2 class="text-2xl font-bold text-[--ui-text] mb-2">
|
|
Series Not Found
|
|
</h2>
|
|
<p class="text-[--ui-text-muted] mb-6">
|
|
The event series you're looking for doesn't exist.
|
|
</p>
|
|
<NuxtLink to="/series" class="text-primary hover:underline">
|
|
← Back to Event Series
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- Page Header -->
|
|
<PageHeader :title="series.title" theme="purple" size="large" />
|
|
|
|
<!-- Series Meta -->
|
|
<section class="py-20 bg-[--ui-bg]">
|
|
<UContainer>
|
|
<div class="max-w-4xl mx-auto">
|
|
<!-- Series Description -->
|
|
<div v-if="series.description" class="mb-8">
|
|
<p class="text-lg text-[--ui-text-muted] leading-relaxed">
|
|
{{ series.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4 mb-8 flex-wrap">
|
|
<span
|
|
:class="[
|
|
'inline-flex items-center px-3 py-1 rounded text-sm font-medium',
|
|
getSeriesTypeBadgeClass(series.type),
|
|
]"
|
|
>
|
|
{{ formatSeriesType(series.type) }}
|
|
</span>
|
|
<span
|
|
:class="[
|
|
'inline-flex items-center px-3 py-1 rounded text-sm font-medium',
|
|
getSeriesStatusClass(),
|
|
]"
|
|
>
|
|
{{ getSeriesStatusText() }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Series Stats -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
|
|
<div>
|
|
<div class="text-3xl font-bold text-[--ui-text] mb-1">
|
|
{{ series.statistics.totalEvents }}
|
|
</div>
|
|
<div class="text-sm text-[--ui-text-muted]">Total Events</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="text-3xl font-bold text-[--ui-text] mb-1">
|
|
{{ series.statistics.completedEvents }}
|
|
</div>
|
|
<div class="text-sm text-[--ui-text-muted]">Completed</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="text-3xl font-bold text-[--ui-text] mb-1">
|
|
{{ series.statistics.upcomingEvents }}
|
|
</div>
|
|
<div class="text-sm text-[--ui-text-muted]">Upcoming</div>
|
|
</div>
|
|
|
|
<div v-if="series.statistics.totalRegistrations">
|
|
<div class="text-3xl font-bold text-[--ui-text] mb-1">
|
|
{{ series.statistics.totalRegistrations }}
|
|
</div>
|
|
<div class="text-sm text-[--ui-text-muted]">Registrations</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Date Range -->
|
|
<div
|
|
v-if="series.startDate && series.endDate"
|
|
class="flex items-center gap-2 text-[--ui-text-muted] mb-8"
|
|
>
|
|
<Icon name="heroicons:calendar-days" class="w-5 h-5" />
|
|
<span>
|
|
Series runs from
|
|
{{ formatDateRange(series.startDate, series.endDate) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Status Message -->
|
|
<div
|
|
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">
|
|
This series is currently ongoing!
|
|
</p>
|
|
<p class="text-sm text-[--ui-text-muted]">
|
|
Register for upcoming events to join the learning journey.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
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">
|
|
This series is starting soon!
|
|
</p>
|
|
<p class="text-sm text-[--ui-text-muted]">
|
|
Mark your calendar and register for the events.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
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">
|
|
This series has concluded.
|
|
</p>
|
|
<p class="text-sm text-[--ui-text-muted]">
|
|
Check out our other event series for more opportunities to learn
|
|
and connect.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
<div class="max-w-4xl mx-auto">
|
|
<h2 class="text-2xl font-bold text-[--ui-text] mb-8">
|
|
Event Schedule
|
|
</h2>
|
|
|
|
<div class="space-y-4">
|
|
<div
|
|
v-for="(event, index) in series?.events || []"
|
|
:key="event.id"
|
|
class="group"
|
|
>
|
|
<div class="flex items-start gap-4">
|
|
<!-- Position indicator -->
|
|
<div class="flex flex-col items-center flex-shrink-0">
|
|
<div
|
|
:class="[
|
|
'w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold border',
|
|
getEventTimelineColor(event),
|
|
]"
|
|
>
|
|
{{ event.series?.position || index + 1 }}
|
|
</div>
|
|
<div
|
|
v-if="index < (series?.events?.length || 0) - 1"
|
|
class="w-0.5 h-12 bg-[--ui-border]"
|
|
></div>
|
|
</div>
|
|
|
|
<!-- Event Card -->
|
|
<NuxtLink
|
|
:to="`/events/${event.slug || event.id}`"
|
|
class="flex-1 border border-[--ui-border] rounded p-4 hover:border-primary transition-colors"
|
|
>
|
|
<div
|
|
class="flex flex-col md:flex-row md:items-start md:justify-between gap-3"
|
|
>
|
|
<!-- Event Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-start gap-2 mb-2 flex-wrap">
|
|
<h3
|
|
class="text-lg font-semibold text-[--ui-text] group-hover:text-primary transition-colors"
|
|
>
|
|
{{ event.title }}
|
|
</h3>
|
|
<span
|
|
:class="[
|
|
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0',
|
|
getEventStatusClass(event),
|
|
]"
|
|
>
|
|
{{ getEventStatus(event) }}
|
|
</span>
|
|
</div>
|
|
|
|
<p
|
|
v-if="event.description"
|
|
class="text-[--ui-text-muted] mb-3 line-clamp-2"
|
|
>
|
|
{{ event.description }}
|
|
</p>
|
|
|
|
<div
|
|
class="flex flex-wrap items-center gap-3 text-sm text-[--ui-text-muted]"
|
|
>
|
|
<div class="flex items-center gap-1">
|
|
<Icon
|
|
name="heroicons:calendar-days"
|
|
class="w-4 h-4"
|
|
/>
|
|
{{ formatEventDate(event.startDate) }}
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<Icon name="heroicons:clock" class="w-4 h-4" />
|
|
{{ formatEventTime(event.startDate) }}
|
|
</div>
|
|
<div
|
|
v-if="event.location"
|
|
class="flex items-center gap-1"
|
|
>
|
|
<Icon name="heroicons:map-pin" class="w-4 h-4" />
|
|
{{ event.location }}
|
|
</div>
|
|
<div
|
|
v-if="event.registeredCount"
|
|
class="flex items-center gap-1"
|
|
>
|
|
<Icon name="heroicons:users" class="w-4 h-4" />
|
|
{{ event.registeredCount }} registered
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Arrow -->
|
|
<div class="flex items-center md:pt-1">
|
|
<Icon
|
|
name="heroicons:arrow-right"
|
|
class="w-5 h-5 text-[--ui-text-muted] group-hover:text-primary group-hover:translate-x-1 transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UContainer>
|
|
</section>
|
|
|
|
<!-- Questions -->
|
|
<section class="py-20 bg-[--ui-bg]">
|
|
<UContainer>
|
|
<div class="max-w-4xl mx-auto">
|
|
<h3 class="text-xl font-bold text-[--ui-text] mb-3">
|
|
Questions About This Series?
|
|
</h3>
|
|
<p class="text-[--ui-text-muted] mb-4">
|
|
If you have any questions about this event series, please reach
|
|
out to us.
|
|
</p>
|
|
<a
|
|
href="mailto:events@ghostguild.org"
|
|
class="text-primary hover:underline"
|
|
>
|
|
events@ghostguild.org
|
|
</a>
|
|
|
|
<div class="mt-8">
|
|
<NuxtLink to="/series" class="text-primary hover:underline">
|
|
← Back to all event series
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</UContainer>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<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
|
|
if (error.value?.statusCode === 404) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: "Event series not found",
|
|
});
|
|
}
|
|
|
|
// 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 = {
|
|
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-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30",
|
|
recurring_meetup:
|
|
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30",
|
|
multi_day:
|
|
"bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/30",
|
|
course:
|
|
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/30",
|
|
tournament:
|
|
"bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/30",
|
|
};
|
|
return (
|
|
classes[type] ||
|
|
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/30"
|
|
);
|
|
};
|
|
|
|
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";
|
|
return "Active";
|
|
};
|
|
|
|
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)
|
|
return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30";
|
|
if (series.value.statistics.isCompleted)
|
|
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/30";
|
|
return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/30";
|
|
};
|
|
|
|
const formatEventDate = (date) => {
|
|
return new Date(date).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const formatEventTime = (date) => {
|
|
return new Date(date).toLocaleTimeString("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
});
|
|
};
|
|
|
|
const formatDateRange = (startDate, endDate) => {
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
|
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
|
|
return `${formatter.format(start)} to ${formatter.format(end)}`;
|
|
};
|
|
|
|
const getEventStatus = (event) => {
|
|
const now = new Date();
|
|
const startDate = new Date(event.startDate);
|
|
const endDate = new Date(event.endDate);
|
|
|
|
if (now < startDate) return "Upcoming";
|
|
if (now >= startDate && now <= endDate) return "Ongoing";
|
|
return "Completed";
|
|
};
|
|
|
|
const getEventStatusClass = (event) => {
|
|
const status = getEventStatus(event);
|
|
const classes = {
|
|
Upcoming:
|
|
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30",
|
|
Ongoing:
|
|
"bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/30",
|
|
Completed:
|
|
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/30",
|
|
};
|
|
return (
|
|
classes[status] ||
|
|
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/30"
|
|
);
|
|
};
|
|
|
|
const getEventTimelineColor = (event) => {
|
|
const status = getEventStatus(event);
|
|
const classes = {
|
|
Upcoming:
|
|
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30",
|
|
Ongoing:
|
|
"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30",
|
|
Completed:
|
|
"bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30",
|
|
};
|
|
return (
|
|
classes[status] ||
|
|
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30"
|
|
);
|
|
};
|
|
|
|
// SEO Meta
|
|
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>
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|