UX/UI improvements.

This commit is contained in:
Jennie Robinson Faber 2026-04-05 13:26:51 +01:00
parent 418d3cc402
commit 4e6f5d36b8
14 changed files with 1964 additions and 924 deletions

725
app/pages/events/[slug].vue Normal file
View file

@ -0,0 +1,725 @@
<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>
<!-- 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
v-if="event.tickets?.enabled"
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:user-email="memberData?.email"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Legacy Registration -->
<template v-else>
<!-- Already Registered -->
<div v-if="registrationStatus === 'registered'" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--green)">
You're registered!
</p>
<p class="reg-price">Confirmation sent to your email</p>
<button
class="btn btn-danger"
:disabled="isCancelling"
@click="handleCancelRegistration"
>
{{ isCancelling ? "Cancelling..." : "Cancel Registration" }}
</button>
</div>
<!-- Member Status Issues -->
<div v-else-if="memberData && !canRSVP" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember)">
{{ statusConfig.label }}
</p>
<p class="reg-price">{{ getRSVPMessage() }}</p>
<NuxtLink
v-if="isPendingPayment"
to="#"
@click.prevent="completePayment"
>
<button class="btn btn-primary" :disabled="isProcessingPayment">
{{ isProcessingPayment ? "Processing..." : "Complete Payment" }}
</button>
</NuxtLink>
</div>
<!-- Members-Only Gate -->
<div
v-else-if="event.membersOnly && memberData && !isMember"
class="dashed-box"
>
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember)">
Membership Required
</p>
<p class="reg-price">This event is exclusive to members.</p>
<NuxtLink to="/join"
><button class="btn btn-primary">
Become a Member
</button></NuxtLink
>
</div>
<!-- Can Register (logged in) -->
<div
v-else-if="memberData && (!event.membersOnly || isMember)"
class="dashed-box"
>
<div class="box-title">Registration</div>
<div v-if="event.maxAttendees" class="reg-status">
{{ event.maxAttendees - (event.registeredCount || 0) }} spots
remaining
</div>
<div class="reg-price">Free for members</div>
<button
class="btn btn-primary"
:disabled="isRegistering"
@click="handleRegistration"
>
{{ isRegistering ? "Registering..." : "Register for this event" }}
</button>
<a
:href="`/api/events/${route.params.slug}/calendar`"
download
class="cal-link"
>Add to calendar</a
>
</div>
<!-- Not Logged In -->
<div v-else class="dashed-box">
<div class="box-title">Registration</div>
<form @submit.prevent="handleRegistration">
<div class="field">
<label>Name</label>
<input v-model="registrationForm.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="registrationForm.email" type="email" required />
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="isRegistering"
>
{{ isRegistering ? "Registering..." : "Register for Event" }}
</button>
</form>
</div>
<!-- Waitlist -->
<div
v-if="event.tickets?.waitlist?.enabled && isEventFull"
class="dashed-box"
>
<div class="box-title">Waitlist</div>
<div v-if="isOnWaitlist">
<p class="reg-status">
You're on the waitlist (#{{ waitlistPosition }})
</p>
<button
class="btn"
@click="handleLeaveWaitlist"
:disabled="isJoiningWaitlist"
>
Leave Waitlist
</button>
</div>
<div v-else>
<p class="reg-status" style="color: var(--ember)">
This event is full
</p>
<form @submit.prevent="handleJoinWaitlist">
<div v-if="!memberData" class="field">
<label>Email</label>
<input v-model="waitlistForm.email" type="email" required />
</div>
<button type="submit" class="btn" :disabled="isJoiningWaitlist">
{{ isJoiningWaitlist ? "Joining..." : "Join Waitlist" }}
</button>
</form>
</div>
</div>
</template>
<!-- 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 { isMember, memberData, checkMemberStatus } = useAuth();
const {
isPendingPayment,
isSuspended,
isCancelled,
canRSVP,
statusConfig,
getRSVPMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
onMounted(async () => {
await checkMemberStatus();
if (memberData.value) {
registrationForm.value.name = memberData.value.name;
registrationForm.value.email = memberData.value.email;
registrationForm.value.membershipLevel =
memberData.value.membershipLevel || "non-member";
await checkRegistrationStatus();
checkWaitlistStatus();
}
});
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return;
try {
const response = await $fetch(
`/api/events/${route.params.slug}/check-registration`,
{
method: "POST",
body: { email: memberData.value.email },
},
);
if (response.isRegistered) registrationStatus.value = "registered";
} catch (err) {
console.error("Failed to check registration status:", err);
}
};
const registrationForm = ref({
name: "",
email: "",
membershipLevel: "non-member",
});
const isRegistering = ref(false);
const isCancelling = ref(false);
const registrationStatus = ref("not-registered");
const isJoiningWaitlist = ref(false);
const isOnWaitlist = ref(false);
const waitlistPosition = ref(0);
const waitlistForm = ref({ email: "" });
const isEventFull = computed(() => {
if (!event.value?.maxAttendees) return false;
return (event.value.registeredCount || 0) >= event.value.maxAttendees;
});
const checkWaitlistStatus = () => {
const email = memberData.value?.email || waitlistForm.value.email;
if (!email || !event.value?.tickets?.waitlist?.enabled) return;
const entries = event.value.tickets.waitlist.entries || [];
const idx = entries.findIndex(
(e) => e.email.toLowerCase() === email.toLowerCase(),
);
if (idx !== -1) {
isOnWaitlist.value = true;
waitlistPosition.value = idx + 1;
}
};
const handleJoinWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
const name = memberData.value?.name || "Guest";
const response = await $fetch(`/api/events/${route.params.slug}/waitlist`, {
method: "POST",
body: { email, name },
});
isOnWaitlist.value = true;
waitlistPosition.value = response.position;
toast.add({
title: "Added to Waitlist",
description: `You're #${response.position} on the waitlist.`,
color: "orange",
});
} catch (err) {
toast.add({
title: "Couldn't Join Waitlist",
description: err.data?.statusMessage || "Please try again.",
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
const handleLeaveWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
await $fetch(`/api/events/${route.params.slug}/waitlist`, {
method: "DELETE",
body: { email },
});
isOnWaitlist.value = false;
waitlistPosition.value = 0;
toast.add({ title: "Removed from Waitlist", color: "blue" });
} catch (err) {
toast.add({
title: "Error",
description: "Failed to leave waitlist.",
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
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 handleRegistration = async () => {
isRegistering.value = true;
try {
await $fetch(`/api/events/${route.params.slug}/register`, {
method: "POST",
body: registrationForm.value,
});
registrationStatus.value = "registered";
toast.add({
title: "Registered!",
description: `You're registered for ${event.value.title}.`,
color: "green",
});
if (event.value.registeredCount !== undefined)
event.value.registeredCount++;
} catch (err) {
toast.add({
title: "Registration Failed",
description: err.data?.statusMessage || "Please try again.",
color: "red",
});
} finally {
isRegistering.value = false;
}
};
const handleCancelRegistration = async () => {
isCancelling.value = true;
try {
await $fetch(`/api/events/${route.params.slug}/cancel-registration`, {
method: "POST",
body: { email: registrationForm.value.email || memberData.value?.email },
});
registrationStatus.value = "not-registered";
toast.add({ title: "Registration Cancelled", color: "blue" });
if (event.value.registeredCount !== undefined)
event.value.registeredCount--;
} catch (err) {
toast.add({
title: "Cancellation Failed",
description: err.data?.statusMessage || "Please try again.",
color: "red",
});
} finally {
isCancelling.value = false;
}
};
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;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
}
.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;
}
.reg-status {
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.reg-price {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 10px;
}
.cal-link {
display: block;
margin-top: 8px;
font-size: 11px;
color: var(--candle);
}
.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>