feat: reskin public pages to zine direction

This commit is contained in:
Jennie Robinson Faber 2026-04-02 21:29:52 +01:00
parent 8b3daadadd
commit 88caca94c7
8 changed files with 2663 additions and 3577 deletions

View file

@ -1,503 +1,164 @@
<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-if="pending" class="loading">Loading series details...</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 v-else-if="error" class="loading">
<h2>Series Not Found</h2>
<p>The event series you're looking for doesn't exist.</p>
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else>
<!-- Page Header -->
<PageHeader :title="series.title" size="large" />
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<!-- 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>
<!-- SERIES HEADER -->
<div class="series-header">
<h1>{{ series.title }}</h1>
<div class="series-meta-row">
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span>
<span class="meta-text">{{ series.events?.length || 0 }} sessions</span>
<span v-if="series.startDate" class="meta-text">
{{ formatDate(series.startDate) }} &ndash; {{ formatDate(series.endDate) }}
</span>
</div>
</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>
<!-- DESCRIPTION -->
<div v-if="series.description" class="section">
<p>{{ series.description }}</p>
</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-candlelight-900/20 border border-candlelight-700/30 rounded mb-8"
>
<p class="text-candlelight-500 dark:text-candlelight-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-guild-500/10 border border-guild-500/30 rounded mb-8"
>
<p class="text-guild-300 dark:text-guild-300 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-guild-500/10 border border-guild-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-display-sm 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-display-sm 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
<!-- EVENT LIST -->
<div class="section">
<div class="section-label">Sessions</div>
<div v-if="series.events?.length">
<div v-for="(event, index) in series.events" :key="event._id || index" class="event-row">
<span class="event-num">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<div class="event-info">
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
{{ event.title }}
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
</div>
</UContainer>
</section>
</div>
<p v-else class="empty">No sessions scheduled yet.</p>
</div>
<!-- PASS PURCHASE -->
<div v-if="series.passPrice" class="section">
<DashedBox>
<div class="section-label">Series Pass</div>
<p>Get access to all sessions in this series with a single pass.</p>
<div class="pass-price">${{ series.passPrice }}</div>
</DashedBox>
</div>
<!-- QUESTIONS -->
<div class="section">
<div class="section-label">Questions?</div>
<p>If you have questions about this series, reach out to us.</p>
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute();
const { data: session } = useAuth();
const toast = useToast();
const route = useRoute()
// Get user info
const user = computed(() => session?.value?.user || null);
const { data: series, pending, error } = await useFetch(`/api/series/${route.params.id}`)
// 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",
});
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 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-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
recurring_meetup:
"bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30",
multi_day:
"bg-ember-900/20 text-ember-500 dark:text-ember-400 border border-ember-700/30",
course:
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
tournament:
"bg-ember-900/20 text-ember-500 dark:text-ember-400 border border-ember-700/30",
};
return (
classes[type] ||
"bg-earth-900/20 text-earth-400 dark:text-earth-400 border border-earth-700/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-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30";
if (series.value.statistics.isOngoing)
return "bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30";
if (series.value.statistics.isUpcoming)
return "bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30";
if (series.value.statistics.isCompleted)
return "bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30";
return "bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/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 formatDate = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const getEventStatus = (event) => {
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
const now = new Date()
const start = new Date(event.startDate)
const end = new Date(event.endDate)
if (now < start) return 'Upcoming'
if (now >= start && now <= end) return 'Ongoing'
return 'Completed'
}
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-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30",
Ongoing:
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
Completed:
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30",
};
return (
classes[status] ||
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30"
);
};
const getEventTimelineColor = (event) => {
const status = getEventStatus(event);
const classes = {
Upcoming:
"bg-guild-500/10 text-guild-300 dark:text-guild-300 border-guild-500/30",
Ongoing:
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border-candlelight-700/30",
Completed:
"bg-earth-900/20 text-earth-400 dark:text-earth-400 border-earth-700/30",
};
return (
classes[status] ||
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border-guild-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",
},
],
};
});
useHead(() => ({
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
}))
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.loading { padding: 48px 32px; color: var(--text-dim); }
.loading h2 { font-family: 'Brygada 1918', serif; font-size: 22px; color: var(--text-bright); margin-bottom: 8px; }
.back-link { padding: 12px 32px; border-bottom: 1px dashed var(--border); font-size: 12px; }
.back-link a { color: var(--candle); text-decoration: none; }
.series-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.series-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 12px;
}
.series-meta-row {
display: flex;
gap: 12px;
align-items: center;
font-size: 12px;
}
.meta-text { color: var(--text-faint); }
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
.section a { font-size: 12px; color: var(--candle); }
.event-row {
display: grid;
grid-template-columns: 32px 80px 1fr;
gap: 12px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.event-row:last-child { border-bottom: none; }
.event-num { color: var(--text-faint); font-size: 11px; }
.event-date { color: var(--text-faint); }
.event-title-link { color: var(--text); text-decoration: none; font-size: 13px; }
.event-title-link:hover { color: var(--candle); }
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; }
.pass-price {
font-family: 'Brygada 1918', serif;
font-size: 22px;
font-weight: 600;
color: var(--candle);
margin-top: 8px;
}
.empty { font-size: 12px; color: var(--text-faint); }
</style>