chore(visual): Phase 4 audit polish on event/series surface

Migrates event/series UI from Tailwind/Nuxt UI form components to the
zine pattern (dashed borders, CSS-var palette, native inputs). Restructures
single-event and series detail/index pages to the two-column grid pattern
matching about.vue and member/dashboard.vue.

Touches:
- app/components/EventSeriesTicketCard.vue — phantom-palette → CSS-var
  migration (--candle, --ember, --surface), color="gray" → "neutral"
- app/components/EventTicketCard.vue — --candle-faint border var
- app/components/EventTicketPurchase.vue — accent-color: var(--candle)
- app/pages/events/[slug].vue — page-fill flex chain, .event-body grid
- app/pages/events/index.vue — series link uses series.id (was _id)
- app/pages/series/[id].vue — two-column layout (1fr/280px) + sidebar
- app/pages/series/index.vue — full rewrite to dashed-border zine pattern

Per docs/specs/events-visual-audit-findings.md Phase 4. Behavior unchanged.
This commit is contained in:
Jennie Robinson Faber 2026-04-25 18:41:04 +01:00
parent 8f0648de57
commit 0f2f1d1cbf
7 changed files with 376 additions and 337 deletions

View file

@ -1,37 +1,27 @@
<template>
<div
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
>
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
<!-- Header -->
<div
class="bg-gradient-to-br from-candlelight-500 to-candlelight-700 dark:from-candlelight-600 dark:to-candlelight-800 p-6"
>
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Icon
name="heroicons:ticket"
class="w-5 h-5 text-candlelight-900 dark:text-candlelight-200"
/>
<span class="text-sm font-semibold text-candlelight-900 dark:text-candlelight-200">
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
<span class="text-sm font-semibold" style="color: var(--parch-text)">
Series Pass
</span>
</div>
<h3 class="text-xl font-bold text-white mb-1">
<h3 class="font-display text-xl font-bold mb-1" style="color: var(--parch-text)">
{{ ticket.name }}
</h3>
<p v-if="ticket.description" class="text-sm text-candlelight-900 dark:text-candlelight-200">
<p v-if="ticket.description" class="text-sm" style="color: var(--parch-text); opacity: 0.85">
{{ ticket.description }}
</p>
</div>
<div class="text-right flex-shrink-0">
<div class="text-3xl font-bold text-white text-ui-mono">
<div class="text-3xl font-bold" style="color: var(--parch-text)">
{{ formatPrice(ticket.price, ticket.currency) }}
</div>
<div
v-if="ticket.isEarlyBird"
class="text-xs text-candlelight-900 dark:text-candlelight-200 mt-1"
>
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
Early Bird Price
</div>
</div>
@ -39,29 +29,23 @@
</div>
<!-- Body -->
<div class="p-6 bg-guild-800/50 dark:bg-guild-700/30">
<div class="p-6" style="background: var(--surface)">
<!-- What's Included -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
What's Included
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2 text-guild-300 dark:text-guild-300">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<div class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<span>Access to all {{ totalEvents }} events in the series</span>
</div>
<div
v-if="ticket.isFree && !isMember"
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<span>Automatic registration for all sessions</span>
</div>
<div
v-if="memberSavings > 0"
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
</div>
</div>
@ -69,33 +53,31 @@
<!-- Events List Preview -->
<div v-if="events && events.length > 0" class="mb-6">
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
Series Schedule
</h4>
<div class="space-y-2">
<div
v-for="(event, index) in events.slice(0, 3)"
:key="event.id"
class="flex items-start gap-3 p-3 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
class="flex items-start gap-3 p-3"
>
<div
class="w-8 h-8 rounded-full bg-candlelight-600/20 border border-candlelight-500/30 flex items-center justify-center flex-shrink-0"
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
>
<span class="text-sm font-bold text-candlelight-300">{{ index + 1 }}</span>
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-guild-100 dark:text-guild-100 text-sm">
<div class="font-medium text-sm" style="color: var(--text)">
{{ event.title }}
</div>
<div class="text-xs text-guild-400 dark:text-guild-400 mt-1">
<div class="text-xs mt-1" style="color: var(--text-faint)">
{{ formatEventDate(event.startDate) }}
</div>
</div>
</div>
<div
v-if="events.length > 3"
class="text-center text-sm text-guild-400 dark:text-guild-400 pt-2"
>
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
</div>
</div>
@ -104,13 +86,14 @@
<!-- Member Benefit Callout -->
<div
v-if="ticket.isFree && isMember"
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
class="p-4 mb-6"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:sparkles" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
<Icon name="heroicons:sparkles" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
<div>
<div class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
<div class="text-sm text-candlelight-400">
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
<div class="text-sm" style="color: var(--candle)">
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
</div>
</div>
@ -120,13 +103,14 @@
<!-- Public vs Member Pricing -->
<div
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
class="p-4 mb-6"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:tag" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
<Icon name="heroicons:tag" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
<div class="flex-1">
<div class="font-semibold text-candlelight-300 mb-1">Member Savings</div>
<div class="text-sm text-candlelight-400">
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
<div class="text-sm" style="color: var(--candle)">
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
</div>
@ -136,22 +120,15 @@
<!-- Availability -->
<div v-if="availability" class="mb-6">
<div
v-if="!availability.unlimited && availability.remaining !== null"
class="flex items-center gap-2"
>
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
<Icon
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
:class="[
'w-5 h-5',
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
]"
class="w-5 h-5"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
/>
<span
:class="[
'text-sm font-medium',
availability.remaining > 5 ? 'text-candlelight-300' : 'text-ember-300'
]"
class="text-sm font-medium"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
>
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
</span>
@ -160,12 +137,12 @@
<!-- Sold Out / Waitlist -->
<div v-if="!available" class="space-y-3">
<div class="p-4 bg-ember-900/20 border border-ember-700/30 rounded-lg">
<div class="p-4" style="background: var(--ember-bg); border: 1px solid var(--ember)">
<div class="flex items-start gap-3">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 flex-shrink-0 mt-0.5" />
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
<div>
<div class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
<div class="text-sm text-ember-400">
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
<div class="text-sm" style="color: var(--ember)">
All series passes have been claimed.
</div>
</div>
@ -174,7 +151,7 @@
<UButton
v-if="availability?.waitlistAvailable"
block
color="gray"
color="neutral"
size="lg"
@click="$emit('join-waitlist')"
>

View file

@ -199,7 +199,7 @@ const formatPrice = (amount) => {
.early-bird {
color: var(--candle-dim);
border-color: rgba(122, 90, 16, 0.35);
border-color: var(--candle-faint);
}
.ticket-savings {

View file

@ -463,6 +463,7 @@ const formatEventDate = (date) => {
.consent-field input[type="checkbox"] {
margin-top: 3px;
flex-shrink: 0;
accent-color: var(--candle);
}
.consent-hint {
margin-bottom: 14px;

View file

@ -7,7 +7,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else>
<div v-else class="page-fill">
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
@ -48,7 +48,7 @@
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
/>
>
</div>
<!-- TWO-COLUMN BODY -->
@ -294,10 +294,19 @@ useHead(() => ({
margin-bottom: 4px;
}
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
flex: 1;
}
.event-main {
min-width: 0;

View file

@ -88,8 +88,8 @@
<div class="series-grid">
<NuxtLink
v-for="series in activeSeries"
:key="series._id"
:to="`/series/${series._id}`"
:key="series.id"
:to="`/series/${series.id}`"
class="series-box"
>
<h2>{{ series.title }}</h2>
@ -107,6 +107,11 @@
>
</div>
</NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div>
</div>
@ -368,14 +373,21 @@ const isAlmostFull = (event) => {
}
.series-box {
padding: 20px 24px;
border-right: 1px dashed var(--border);
text-decoration: none;
transition: background 0.15s;
border-right: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:hover {
.series-box:nth-last-child(-n + 2) {
border-bottom: none;
}
.series-box-filler {
pointer-events: none;
}
.series-box:not(.series-box-filler):hover {
background: var(--surface-hover);
}
.series-box h2 {
@ -467,8 +479,17 @@ const isAlmostFull = (event) => {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
border-bottom: none;
}
.series-box-filler {
display: none;
}
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="page-fill">
<div v-if="pending" class="loading">Loading series details...</div>
<div v-else-if="error" class="loading">
@ -8,7 +8,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else>
<div v-else class="page-fill">
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
@ -26,31 +26,44 @@
</div>
</div>
<!-- DESCRIPTION -->
<div v-if="series.description" class="section">
<!-- TWO-COLUMN BODY -->
<div class="series-body" :class="{ 'has-aside': series.tickets?.enabled }">
<!-- LEFT: MAIN CONTENT -->
<div class="series-main">
<div v-if="series.description" class="section description">
<p>{{ series.description }}</p>
</div>
<!-- EVENT LIST -->
<div class="section">
<div class="section" :class="{ 'section-flush': series.events?.length }">
<div class="section-label">Sessions</div>
<div v-if="series.events?.length">
<div v-if="series.events?.length" class="sessions-box">
<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">
<div class="event-info-head">
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
{{ event.title }}
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
<p v-if="event.description" class="event-description">{{ event.description }}</p>
</div>
</div>
</div>
<p v-else class="empty">No sessions scheduled yet.</p>
</div>
<!-- PASS PURCHASE -->
<div v-if="series.tickets?.enabled" class="section">
<!-- Questions (inline when no sidebar) -->
<div v-if="!series.tickets?.enabled" 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>
<!-- RIGHT: SIDEBAR -->
<aside v-if="series.tickets?.enabled" class="series-aside">
<SeriesPassPurchase
:series-id="series.id"
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
@ -59,13 +72,13 @@
:user-name="memberData?.name"
@purchase-success="handlePurchaseSuccess"
/>
</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 class="aside-panel">
<div class="box-title">Questions?</div>
<p class="aside-detail">Drop us a line.</p>
<a class="aside-link" href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</aside>
</div>
</div>
</div>
@ -137,28 +150,105 @@ useHead(() => ({
}
.meta-text { color: var(--text-faint); }
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.series-body {
display: grid;
grid-template-columns: 1fr;
}
.series-body.has-aside {
grid-template-columns: 1fr 280px;
flex: 1;
}
.series-main { min-width: 0; }
.series-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.series-main .section:last-child {
border-bottom: none;
}
.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); }
.section.description p { font-size: 14px; color: var(--text); }
.section-flush { padding-bottom: 0; }
.sessions-box {
border-top: 1px dashed var(--border);
margin: 10px -32px 0;
}
.event-row {
display: grid;
grid-template-columns: 32px 80px 1fr;
grid-template-columns: 32px auto 1fr;
gap: 12px;
align-items: baseline;
padding: 10px 0;
padding: 10px 32px;
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-date { color: var(--text-faint); white-space: nowrap; }
.event-info { min-width: 0; }
.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; }
.event-description {
font-size: 11px;
color: var(--text-dim);
line-height: 1.5;
margin: 4px 0 0;
max-width: 560px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty { font-size: 12px; color: var(--text-faint); }
/* ---- ASIDE PANELS ---- */
.aside-panel {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.aside-panel:last-child { border-bottom: none; }
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.aside-detail {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 4px;
}
.aside-link {
font-size: 12px;
color: var(--candle);
}
@media (max-width: 768px) {
.series-body.has-aside {
grid-template-columns: 1fr;
}
.series-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
}
</style>

View file

@ -1,197 +1,77 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
title="Event Series"
subtitle="Multi-session events on cooperative topics"
/>
<!-- Series Grid -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div v-if="pending" class="text-center py-12">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-[--ui-text-muted]">Loading series...</p>
<div v-if="pending" class="state-msg">Loading series...</div>
<div v-else-if="!filteredSeries.length" class="state-msg">
<p>
No series right now. Check back later or browse
<NuxtLink to="/events">upcoming events</NuxtLink>.
</p>
</div>
<div
v-else-if="filteredSeries.length > 0"
class="max-w-4xl mx-auto space-y-6"
>
<div
<div v-else>
<section
v-for="series in filteredSeries"
:key="series.id"
class="border border-[--ui-border] rounded overflow-hidden hover:border-primary transition-colors"
class="series-section"
>
<!-- Series Header -->
<div class="p-6 border-b border-[--ui-border]">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
>
<div class="flex-1">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span
:class="[
'inline-flex items-center px-3 py-1 rounded text-sm font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
<div class="series-head">
<h2>{{ series.title }}</h2>
<div class="series-meta-row">
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span>
<span class="meta-text">
{{ series.eventCount }} sessions<template v-if="series.totalEvents"> of {{ series.totalEvents }} planned</template>
</span>
<span
:class="[
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
series.status === 'active'
? 'bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30'
: series.status === 'upcoming'
? 'bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30'
: 'bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30',
]"
>
{{ series.status }}
<span v-if="series.startDate && series.endDate" class="meta-text">
{{ formatDateRange(series.startDate, series.endDate) }}
</span>
<span v-if="series.totalRegistrations" class="meta-text">
{{ series.totalRegistrations }} registered
</span>
</div>
<h2 class="text-display-sm font-bold text-[--ui-text] mb-2">
{{ series.title }}
</h2>
<p class="text-[--ui-text-muted] leading-relaxed">
<p v-if="series.description" class="series-desc">
{{ series.description }}
</p>
</div>
<div class="text-center md:text-right flex-shrink-0">
<div class="text-3xl font-bold text-[--ui-text] mb-1">
{{ series.eventCount }}
</div>
<div class="text-sm text-[--ui-text-muted]">Events</div>
<div
v-if="series.totalEvents"
class="text-xs text-[--ui-text-muted] mt-1"
>
of {{ series.totalEvents }} planned
</div>
</div>
</div>
</div>
<!-- Events List -->
<div class="divide-y divide-[--ui-border]">
<div v-if="series.events?.length" class="sessions">
<div
v-for="event in series.events"
v-for="(event, index) in series.events"
:key="event.id"
class="p-4 hover:bg-[--ui-bg-elevated] transition-colors"
class="event-row"
>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div
class="w-8 h-8 bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0 border border-candlelight-700/30"
>
{{ event.series?.position || "?" }}
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-[--ui-text] mb-1">
{{ event.title }}
</h3>
<div
class="flex items-center gap-4 text-sm text-[--ui-text-muted] flex-wrap"
>
<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.registrations?.length"
class="flex items-center gap-1"
>
<Icon name="heroicons:users" class="w-4 h-4" />
{{ event.registrations.length }} registered
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
getEventStatusClass(event),
]"
>
{{ getEventStatus(event) }}
<span class="event-num">
{{ String(event.series?.position || index + 1).padStart(2, '0') }}
</span>
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
<div class="event-info">
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="inline-flex items-center px-3 py-1 bg-primary text-white text-sm rounded hover:bg-primary/90 transition-colors"
class="event-title-link"
>
View
{{ event.title }}
</NuxtLink>
</div>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
</div>
</div>
<!-- Series Footer -->
<div
class="px-6 py-4 bg-[--ui-bg-elevated] border-t border-[--ui-border]"
>
<div class="flex items-center justify-between gap-4">
<div
class="flex items-center gap-4 text-sm text-[--ui-text-muted] flex-wrap"
>
<div
v-if="series.startDate && series.endDate"
class="flex items-center gap-1"
>
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div
v-if="series.totalRegistrations"
class="flex items-center gap-1"
>
<Icon name="heroicons:users" class="w-4 h-4" />
{{ series.totalRegistrations }} total registrations
</div>
</div>
<NuxtLink
:to="`/series/${series.id}`"
class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm font-medium rounded hover:bg-primary/90 transition-colors"
>
View Series
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-2" />
<div class="series-foot">
<NuxtLink :to="`/series/${series.id}`" class="view-link">
View series &rarr;
</NuxtLink>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-16">
<Icon
name="heroicons:squares-2x2"
class="w-16 h-16 text-[--ui-text-muted] mx-auto mb-4 opacity-50"
/>
<h3 class="text-display-sm font-semibold text-[--ui-text] mb-2">
No series right now
</h3>
<p class="text-[--ui-text-muted] max-w-md mx-auto">
Check back later or browse
<NuxtLink to="/events" class="text-primary">upcoming events</NuxtLink>.
</p>
</div>
</UContainer>
</section>
</div>
</div>
</template>
<script setup>
// SEO
useHead({
title: "Event Series - Ghost Guild",
meta: [
@ -203,12 +83,10 @@ useHead({
],
});
// Fetch series data
const { data: seriesData, pending } = await useFetch("/api/series", {
query: { includeHidden: false },
});
// Filter for active and upcoming series only
const filteredSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value.filter(
@ -216,7 +94,6 @@ const filteredSeries = computed(() => {
);
});
// Helper functions
const formatSeriesType = (type) => {
const types = {
workshop_series: "Workshop Series",
@ -228,25 +105,6 @@ const formatSeriesType = (type) => {
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 formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
@ -255,50 +113,133 @@ const formatEventDate = (date) => {
});
};
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: "short",
day: "numeric",
year: "numeric",
});
return `${formatter.format(start)} to ${formatter.format(end)}`;
return `${formatter.format(start)} ${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-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"
);
};
</script>
<style scoped>
.state-msg {
padding: 32px 28px;
color: var(--text-dim);
font-size: 12px;
}
.state-msg p { max-width: 560px; }
.series-section {
border-bottom: 1px dashed var(--border);
}
.series-head {
padding: 24px 28px 16px;
}
.series-head h2 {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 10px;
}
.series-meta-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
font-size: 12px;
}
.meta-text {
color: var(--text-faint);
font-size: 12px;
}
.series-desc {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
max-width: 640px;
margin: 0;
}
.sessions {
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.event-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 10px 28px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.event-row:last-child { border-bottom: none; }
.event-num {
flex: 0 0 24px;
color: var(--text-faint);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.event-date {
flex: 0 0 110px;
color: var(--text-faint);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.event-info { flex: 1 1 0; min-width: 0; }
.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;
}
.series-foot {
padding: 14px 28px 24px;
}
.view-link {
font-size: 12px;
color: var(--candle);
letter-spacing: 0.04em;
}
.view-link:hover { text-decoration: underline; }
@media (max-width: 768px) {
.series-head,
.series-foot {
padding-left: 20px;
padding-right: 20px;
}
.event-row {
flex-wrap: wrap;
padding-left: 20px;
padding-right: 20px;
row-gap: 2px;
}
.event-info {
flex-basis: 100%;
margin-left: 36px;
}
}
</style>