ghostguild-org/app/pages/events/[slug].vue
Jennie Robinson Faber 0f2f1d1cbf 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.
2026-04-25 18:41:04 +01:00

423 lines
10 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="pending" class="loading">Loading event details...</div>
<div v-else-if="error" class="loading">
<h2>Event Not Found</h2>
<p>The event you're looking for doesn't exist.</p>
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
<div class="event-meta-row">
<div class="event-meta-item">
<span class="meta-label">Date</span>
{{ formatDate(event.startDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Time</span>
{{ formatTime(event.startDate, event.endDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Location</span>
{{ event.location }}
</div>
<div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" />
</div>
<div v-if="event.maxAttendees" class="event-meta-item">
<span class="meta-label">Capacity</span>
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</div>
</div>
</div>
<!-- CANCELLED NOTICE -->
<div v-if="event.isCancelled" class="cancelled-notice">
<strong>Event Cancelled</strong>
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
<p v-else>
This event has been cancelled. We apologize for any inconvenience.
</p>
</div>
<!-- FEATURE IMAGE -->
<div v-if="event.featureImage?.url" class="event-feature-image">
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
>
</div>
<!-- TWO-COLUMN BODY -->
<div class="event-body">
<!-- LEFT: MAIN CONTENT -->
<div class="event-main">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="section">
<div class="series-note">
<span class="section-label">Part of Series</span>
<NuxtLink :to="`/series/${event.series.id}`">{{
event.series.title
}}</NuxtLink>
&mdash; Event {{ event.series.position }} of
{{ event.series.totalEvents }}
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles?.length" class="section">
<span class="section-label">Recommended for</span>
<div class="circle-badges">
<CircleBadge
v-for="circle in event.targetCircles"
:key="circle"
:circle="circle"
/>
</div>
</div>
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<p>{{ event.description }}</p>
</div>
<!-- Series Description -->
<div
v-if="event.series?.isSeriesEvent && event.series.description"
class="section"
>
<h2>About the {{ event.series.title }} Series</h2>
<p>{{ event.series.description }}</p>
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ol class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">
{{ item }}
</li>
</ol>
</div>
<!-- Speakers -->
<div v-if="event.speakers?.length" class="section">
<h2>Speakers</h2>
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="speaker"
>
<div class="speaker-name">{{ speaker.name }}</div>
<div v-if="speaker.role" class="speaker-role">
{{ speaker.role }}
</div>
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
</div>
</div>
</div>
<!-- RIGHT: SIDEBAR PANELS -->
<div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System -->
<EventTicketPurchase
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:user-email="memberData?.email"
:user-name="memberData?.name"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Event Details Box -->
<div class="dashed-box">
<div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span>
<span class="detail-val">{{ event.eventType }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
<span class="detail-val">Yes</span>
</div>
</div>
<!-- Questions -->
<div class="dashed-box">
<div class="box-title">Questions?</div>
<p
style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px"
>
Drop us a line.
</p>
<a href="mailto:events@ghostguild.org" style="font-size: 12px"
>events@ghostguild.org</a
>
</div>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute();
const toast = useToast();
const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.slug}`);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
pageBreadcrumbTitle.value = event.value?.title || "";
onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: "Event not found" });
}
const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding();
onMounted(async () => {
await checkMemberStatus();
if (memberData.value && !isComplete.value) {
trackGoal('eventPageVisited');
}
});
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(d);
};
const formatTime = (start, end) => {
const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`;
};
const handleTicketSuccess = () => {
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
};
const handleTicketError = (err) => {
console.error("Ticket purchase failed:", err);
};
useHead(() => ({
title: event.value
? `${event.value.title} - Ghost Guild Events`
: "Event - Ghost Guild",
meta: [
{
name: "description",
content: event.value?.description || "View event details and register",
},
],
}));
</script>
<style scoped>
.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;
}
.event-feature-image {
border-bottom: 1px dashed var(--border);
}
.event-feature-image img {
display: block;
width: 100%;
max-height: 400px;
object-fit: cover;
}
.event-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.event-header h1 {
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 16px;
}
.event-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 12px;
color: var(--text-dim);
}
.meta-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 2px;
}
.cancelled-notice {
padding: 20px 32px;
border-bottom: 1px dashed var(--border);
color: var(--ember);
font-size: 12px;
}
.cancelled-notice strong {
display: block;
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;
}
.event-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.event-aside .dashed-box {
margin: 0;
border: none;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
}
.event-aside .dashed-box:hover {
border-color: var(--border);
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.circle-badges {
display: flex;
gap: 6px;
margin-top: 4px;
}
.series-note {
font-size: 12px;
color: var(--text-dim);
}
.agenda-list {
padding-left: 20px;
font-size: 12px;
color: var(--text-dim);
line-height: 2;
}
.speaker {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.speaker:last-child {
border-bottom: none;
}
.speaker-name {
font-size: 13px;
color: var(--text-bright);
font-weight: 500;
}
.speaker-role {
font-size: 11px;
color: var(--text-dim);
}
.speaker-bio {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-key {
color: var(--text-faint);
}
.detail-val {
color: var(--text);
}
@media (max-width: 768px) {
.event-body {
grid-template-columns: 1fr;
}
.event-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
.event-meta-row {
flex-direction: column;
gap: 8px;
}
}
</style>