Add landing page

This commit is contained in:
Jennie Robinson Faber 2025-11-03 11:17:51 +00:00
parent 3fea484585
commit bce86ee840
47 changed files with 7119 additions and 439 deletions

View file

@ -103,6 +103,11 @@
>
Registration
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-dimmed uppercase tracking-wider"
>
Tickets
</th>
<th
class="px-6 py-4 text-right text-xs font-medium text-dimmed uppercase tracking-wider"
>
@ -258,6 +263,52 @@
</div>
</td>
<!-- Tickets Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-1">
<div v-if="event.tickets?.enabled" class="space-y-1">
<div class="flex items-center gap-1 text-xs">
<Icon
name="heroicons:ticket"
class="w-3.5 h-3.5 text-blue-600"
/>
<span class="font-medium text-default">Ticketing On</span>
</div>
<div
v-if="event.tickets?.requiresSeriesTicket"
class="text-xs text-purple-600"
>
Series Pass Required
</div>
<div v-else class="space-y-0.5">
<div
v-if="event.tickets.member?.available"
class="text-xs text-dimmed"
>
Member:
{{
event.tickets.member.isFree
? "Free"
: `$${event.tickets.member.price}`
}}
</div>
<div
v-if="event.tickets.public?.available"
class="text-xs text-dimmed"
>
Public: ${{ event.tickets.public.price || 0 }}
<span v-if="event.tickets.public.quantity" class="ml-1">
({{ event.tickets.public.sold || 0 }}/{{
event.tickets.public.quantity
}})
</span>
</div>
</div>
</div>
<div v-else class="text-xs text-dimmed">No tickets</div>
</div>
</td>
<!-- Actions Column -->
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end space-x-2">

View file

@ -217,6 +217,42 @@
</div>
</div>
<!-- Series Ticketing Info -->
<div
v-if="series.tickets?.enabled"
class="px-6 py-3 bg-blue-50 dark:bg-blue-950/20 border-t border-default"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Icon name="heroicons:ticket" class="w-5 h-5 text-blue-600" />
<div>
<span class="text-sm font-medium text-default">
Series Pass Ticketing Enabled
</span>
<p class="text-xs text-dimmed">
<span v-if="series.tickets.public?.available">
Public: ${{ series.tickets.public.price || 0 }}
</span>
<span v-if="series.tickets.member?.available" class="ml-2">
| Members:
{{
series.tickets.member.isFree
? "Free"
: `$${series.tickets.member.price || 0}`
}}
</span>
</p>
</div>
</div>
<button
@click="manageSeriesTickets(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Manage Tickets
</button>
</div>
</div>
<!-- Series Actions -->
<div class="px-6 py-3 bg-muted border-t border-default">
<div class="flex justify-between items-center">
@ -224,6 +260,12 @@
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
@click="manageSeriesTickets(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Ticketing
</button>
<button
@click="editSeries(series)"
class="text-sm text-primary hover:text-primary font-medium"
@ -459,6 +501,384 @@
</div>
</div>
</div>
<!-- Series Ticketing Modal -->
<div
v-if="editingTicketsSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-elevated rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-default">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-highlighted">
Series Pass Ticketing
</h3>
<p class="text-sm text-muted">{{ editingTicketsData.title }}</p>
</div>
<button
@click="cancelTicketsEdit"
class="text-muted hover:text-default"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<!-- Enable Ticketing Toggle -->
<div class="p-4 bg-muted rounded-lg">
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Enable Series Pass Ticketing</span
>
<p class="text-xs text-dimmed">
Allow users to purchase a pass for all events in this series
</p>
</div>
</label>
</div>
<div v-if="editingTicketsData.tickets.enabled" class="space-y-6">
<!-- Ticketing Behavior -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-highlighted">
Ticketing Behavior
</h4>
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.requiresSeriesTicket"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Require Series Pass</span
>
<p class="text-xs text-dimmed">
Users must buy the series pass; individual event tickets are
not available
</p>
</div>
</label>
<label class="flex items-start cursor-pointer">
<input
v-model="
editingTicketsData.tickets.allowIndividualEventTickets
"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Allow Individual Event Tickets</span
>
<p class="text-xs text-dimmed">
Users can attend single events without buying the full
series pass
</p>
</div>
</label>
</div>
<!-- Member Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Member Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.member.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.member.available"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.member.name"
placeholder="e.g., Member Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<div class="flex items-center gap-2">
<UInput
v-model.number="editingTicketsData.tickets.member.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="editingTicketsData.tickets.member.isFree"
class="flex-1 w-full"
/>
<label
class="flex items-center whitespace-nowrap cursor-pointer"
>
<input
v-model="editingTicketsData.tickets.member.isFree"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
<span class="ml-1 text-xs text-muted">Free</span>
</label>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.member.description"
placeholder="Describe what's included with the member series pass..."
:rows="2"
class="w-full"
/>
</div>
</div>
</div>
<!-- Public Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Public Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.public.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.public.available"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.public.name"
placeholder="e.g., Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<UInput
v-model.number="editingTicketsData.tickets.public.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.public.description"
placeholder="Describe what's included with the public series pass..."
:rows="2"
class="w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Quantity Available</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.quantity
"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.public.sold || 0 }} sold,
{{ editingTicketsData.tickets.public.reserved || 0 }}
reserved
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Price (Optional)</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.earlyBirdPrice
"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div
v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0"
>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Deadline</label
>
<UInput
v-model="
editingTicketsData.tickets.public.earlyBirdDeadline
"
type="datetime-local"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Price increases to ${{
editingTicketsData.tickets.public.price
}}
after this date
</p>
</div>
</div>
</div>
<!-- Capacity Management -->
<div class="border border-default rounded-lg p-4">
<h4 class="text-sm font-semibold text-highlighted mb-4">
Capacity Management
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Total Capacity</label
>
<UInput
v-model.number="editingTicketsData.tickets.capacity.total"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Maximum series pass holders across all types
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Currently Reserved</label
>
<UInput
v-model.number="
editingTicketsData.tickets.capacity.reserved
"
type="number"
min="0"
disabled
class="w-full bg-accented"
/>
<p class="text-xs text-dimmed mt-1">
Auto-calculated during checkout
</p>
</div>
</div>
</div>
<!-- Waitlist Configuration -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">Waitlist</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Enable Waitlist</span>
<input
v-model="editingTicketsData.tickets.waitlist.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div v-if="editingTicketsData.tickets.waitlist.enabled">
<label class="block text-sm font-medium text-default mb-2"
>Max Waitlist Size</label
>
<UInput
v-model.number="editingTicketsData.tickets.waitlist.maxSize"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }}
people currently on waitlist
</p>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-default flex justify-end gap-3">
<button
@click="cancelTicketsEdit"
class="px-4 py-2 text-muted hover:text-default"
>
Cancel
</button>
<button
@click="saveTicketsEdit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save Ticketing Settings
</button>
</div>
</div>
</div>
</div>
</template>
@ -477,6 +897,43 @@ const editingSeriesData = ref({
type: "workshop_series",
totalEvents: null,
});
const editingTicketsSeriesId = ref(null);
const editingTicketsData = ref({
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
});
// Fetch series data
const {
@ -715,6 +1172,116 @@ const deleteSeries = async (series) => {
}
};
// Ticketing management functions
const manageSeriesTickets = (series) => {
editingTicketsSeriesId.value = series.id;
// Deep clone the series data to avoid mutating the original
editingTicketsData.value = {
title: series.title,
tickets: {
enabled: series.tickets?.enabled || false,
requiresSeriesTicket: series.tickets?.requiresSeriesTicket || false,
allowIndividualEventTickets:
series.tickets?.allowIndividualEventTickets !== false,
currency: series.tickets?.currency || "CAD",
member: {
available: series.tickets?.member?.available !== false,
isFree: series.tickets?.member?.isFree !== false,
price: series.tickets?.member?.price || 0,
name: series.tickets?.member?.name || "Member Series Pass",
description: series.tickets?.member?.description || "",
},
public: {
available: series.tickets?.public?.available || false,
name: series.tickets?.public?.name || "Series Pass",
description: series.tickets?.public?.description || "",
price: series.tickets?.public?.price || 0,
quantity: series.tickets?.public?.quantity || null,
sold: series.tickets?.public?.sold || 0,
reserved: series.tickets?.public?.reserved || 0,
earlyBirdPrice: series.tickets?.public?.earlyBirdPrice || null,
earlyBirdDeadline: series.tickets?.public?.earlyBirdDeadline
? new Date(series.tickets.public.earlyBirdDeadline)
.toISOString()
.slice(0, 16)
: "",
},
capacity: {
total: series.tickets?.capacity?.total || null,
reserved: series.tickets?.capacity?.reserved || 0,
},
waitlist: {
enabled: series.tickets?.waitlist?.enabled || false,
maxSize: series.tickets?.waitlist?.maxSize || null,
entries: series.tickets?.waitlist?.entries || [],
},
},
};
};
const cancelTicketsEdit = () => {
editingTicketsSeriesId.value = null;
editingTicketsData.value = {
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
};
};
const saveTicketsEdit = async () => {
try {
// Update the series with new ticketing configuration
await $fetch("/api/admin/series/tickets", {
method: "PUT",
body: {
id: editingTicketsSeriesId.value,
tickets: editingTicketsData.value.tickets,
},
});
await refresh();
cancelTicketsEdit();
alert("Ticketing settings updated successfully");
} catch (error) {
console.error("Failed to update ticketing settings:", error);
alert(
`Failed to update ticketing settings: ${error.data?.statusMessage || error.message}`,
);
}
};
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering

14
app/pages/coming-soon.vue Normal file
View file

@ -0,0 +1,14 @@
<template>
<div class="min-h-screen w-full flex items-center justify-center">
<a href="https://babyghosts.fund/ghost-guild" class="text-center">
<h1 class="text-5xl md:text-6xl font-bold mb-4">Ghost Guild</h1>
<p class="text-xl md:text-2xl">Coming Soon</p>
</a>
</div>
</template>
<script setup>
definePageMeta({
layout: "coming-soon",
});
</script>

View file

@ -160,14 +160,14 @@
<!-- Series Description -->
<div
v-if="event.series?.isSeriesEvent && event.series.description"
class="mb-6 p-4 bg-purple-500/5 rounded-lg border border-purple-500/20"
class="event-series-description mb-6 p-4 bg-ghost-800/30 dark:bg-ghost-700/20 rounded-lg border border-ghost-600 dark:border-ghost-600"
>
<h3
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2"
class="event-series-description__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
>
About the {{ event.series.title }} Series
</h3>
<p class="text-ghost-200">
<p class="event-series-description__text text-ghost-200">
{{ event.series.description }}
</p>
</div>
@ -227,160 +227,180 @@
<!-- Registration Section -->
<div v-if="!event.isCancelled">
<!-- Already Registered Status -->
<div v-if="registrationStatus === 'registered'">
<!-- Use new ticket system if tickets are enabled -->
<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 system (for events without tickets enabled) -->
<div v-else>
<!-- Already Registered Status -->
<div v-if="registrationStatus === 'registered'">
<div
class="p-4 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-400 dark:border-green-800 mb-6"
>
<div
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
>
<div>
<p
class="font-semibold text-green-800 dark:text-green-300"
>
You're registered!
</p>
<p class="text-sm text-green-700 dark:text-green-400">
We've sent a confirmation to your email
</p>
</div>
<UButton
color="error"
size="md"
@click="handleCancelRegistration"
:loading="isCancelling"
>
Cancel Registration
</UButton>
</div>
</div>
</div>
<!-- Logged In - Can Register -->
<div
class="p-4 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-400 dark:border-green-800 mb-6"
v-else-if="memberData && (!event.membersOnly || isMember)"
class="text-center"
>
<p class="text-lg text-ghost-200 mb-6">
You are logged in, {{ memberData.name }}.
</p>
<UButton
color="primary"
size="xl"
@click="handleRegistration"
:loading="isRegistering"
class="px-12 py-4"
>
{{ isRegistering ? "Registering..." : "Register Now" }}
</UButton>
</div>
<!-- Member Gate Warning -->
<div
v-else-if="event.membersOnly && !isMember"
class="text-center"
>
<div
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
>
<p class="font-semibold text-amber-300 text-lg mb-2">
Membership Required
</p>
<p class="text-amber-400">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
</div>
<NuxtLink to="/join">
<UButton color="primary" size="xl" class="px-12 py-4">
Become a Member to Register
</UButton>
</NuxtLink>
</div>
<!-- Not Logged In - Show Registration Form -->
<div v-else>
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
<form @submit.prevent="handleRegistration" class="space-y-4">
<div>
<p class="font-semibold text-green-800 dark:text-green-300">
You're registered!
</p>
<p class="text-sm text-green-700 dark:text-green-400">
We've sent a confirmation to your email
</p>
</div>
<UButton
color="error"
size="md"
@click="handleCancelRegistration"
:loading="isCancelling"
>
Cancel Registration
</UButton>
</div>
</div>
</div>
<!-- Logged In - Can Register -->
<div
v-else-if="memberData && (!event.membersOnly || isMember)"
class="text-center"
>
<p class="text-lg text-ghost-200 mb-6">
You are logged in, {{ memberData.name }}.
</p>
<UButton
color="primary"
size="xl"
@click="handleRegistration"
:loading="isRegistering"
class="px-12 py-4"
>
{{ isRegistering ? "Registering..." : "Register Now" }}
</UButton>
</div>
<!-- Member Gate Warning -->
<div v-else-if="event.membersOnly && !isMember" class="text-center">
<div
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
>
<p class="font-semibold text-amber-300 text-lg mb-2">
Membership Required
</p>
<p class="text-amber-400">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
</div>
<NuxtLink to="/join">
<UButton color="primary" size="xl" class="px-12 py-4">
Become a Member to Register
</UButton>
</NuxtLink>
</div>
<!-- Not Logged In - Show Registration Form -->
<div v-else>
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
<form @submit.prevent="handleRegistration" class="space-y-4">
<div>
<label
for="name"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
placeholder="Enter your full name"
/>
</div>
<div>
<label
for="email"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
placeholder="Enter your email"
/>
</div>
<div>
<label
for="membershipLevel"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
/>
</div>
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{
isRegistering ? "Registering..." : "Register for Event"
}}
</UButton>
</div>
</form>
</div>
<!-- Event Capacity -->
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-ghost-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-ghost-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-ghost-100">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
<div
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
<label
for="name"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
placeholder="Enter your full name"
/>
</div>
<div>
<label
for="email"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
placeholder="Enter your email"
/>
</div>
<div>
<label
for="membershipLevel"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
/>
</div>
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{
isRegistering ? "Registering..." : "Register for Event"
}}
</UButton>
</div>
</form>
</div>
<!-- Event Capacity -->
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-ghost-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-ghost-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-ghost-100">
{{ event.registeredCount || 0 }} /
{{ event.maxAttendees }}
</span>
<div
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/>
</div>
</div>
</div>
</div>
</div>
@ -649,6 +669,20 @@ const handleCancelRegistration = async () => {
}
};
// Handle ticket purchase success
const handleTicketSuccess = (response) => {
console.log("Ticket purchased successfully:", response);
// Update registered count if needed
if (event.value.registeredCount !== undefined) {
event.value.registeredCount++;
}
};
// Handle ticket purchase error
const handleTicketError = (error) => {
console.error("Ticket purchase failed:", error);
};
// SEO Meta
useHead(() => ({
title: event.value

View file

@ -60,7 +60,6 @@
<div v-if="event.series?.isSeriesEvent" class="mt-2">
<EventSeriesBadge
:title="event.series.title"
:description="event.series.description"
:position="event.series.position"
:total-events="event.series.totalEvents"
:series-id="event.series.id"
@ -126,7 +125,7 @@
</section>
<!-- Event Series -->
<div v-if="activeSeries.length > 0" class="text-center mb-12">
<div v-if="activeSeries.length > 0" class="text-center my-12">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Current Event Series
</h2>
@ -140,24 +139,24 @@
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
:to="`/series/${series.id}`"
class="block bg-gradient-to-r from-purple-500/10 to-blue-500/10 rounded-xl p-6 border border-purple-500/30 hover:border-purple-500/50 hover:from-purple-500/15 hover:to-blue-500/15 transition-all duration-300"
class="series-list-item block bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl p-6 border border-ghost-600 dark:border-ghost-600 hover:border-ghost-500 hover:bg-ghost-800/70 dark:hover:bg-ghost-700/50 transition-all duration-300"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-2">
<span
class="text-sm font-semibold text-purple-700 dark:text-purple-300"
class="series-list-item__label text-sm font-semibold text-ghost-300 dark:text-ghost-300"
>
Event Series
</span>
<span
class="inline-flex items-center px-2 py-0.5 rounded-md bg-purple-500/20 text-sm font-medium text-purple-700 dark:text-purple-300"
class="series-list-item__count inline-flex items-center px-2 py-0.5 rounded-md bg-ghost-700/50 dark:bg-ghost-600/50 text-sm font-medium text-ghost-200 dark:text-ghost-200"
>
{{ series.eventCount }} events
</span>
</div>
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
'series-list-item__status inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming'
@ -170,46 +169,51 @@
</div>
<h3
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2"
class="series-list-item__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
>
{{ series.title }}
</h3>
<p
class="text-sm text-purple-600 dark:text-purple-400 mb-4 line-clamp-2"
class="series-list-item__description text-sm text-ghost-300 dark:text-ghost-300 mb-4 line-clamp-2"
>
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div class="series-list-item__events space-y-2 mb-4">
<div
v-for="(event, index) in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
class="series-list-item__event flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded-full flex items-center justify-center text-xs font-medium border border-purple-500/30"
class="series-list-item__event-number w-6 h-6 bg-ghost-700/50 dark:bg-ghost-600/50 text-ghost-200 dark:text-ghost-200 rounded-full flex items-center justify-center text-xs font-medium border border-ghost-600 dark:border-ghost-500"
>
{{ event.series?.position || index + 1 }}
</div>
<span class="text-purple-700 dark:text-purple-300 truncate">{{
event.title
}}</span>
<span
class="series-list-item__event-title text-ghost-200 dark:text-ghost-200 truncate"
>{{ event.title }}</span
>
</div>
<span class="text-purple-600 dark:text-purple-400">
<span
class="series-list-item__event-date text-ghost-300 dark:text-ghost-300"
>
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div
v-if="series.events.length > 3"
class="text-xs text-purple-600 dark:text-purple-400 text-center pt-1"
class="series-list-item__more-events text-xs text-ghost-300 dark:text-ghost-300 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div>
<div class="text-sm text-purple-600 dark:text-purple-400">
<div
class="series-list-item__date-range text-sm text-ghost-300 dark:text-ghost-300"
>
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
</NuxtLink>

View file

@ -105,7 +105,7 @@
<!-- Status Message -->
<div
v-if="series.statistics.isOngoing"
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">
@ -117,7 +117,7 @@
</div>
<div
v-else-if="series.statistics.isUpcoming"
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">
@ -129,7 +129,7 @@
</div>
<div
v-else-if="series.statistics.isCompleted"
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">
@ -144,6 +144,30 @@
</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>
@ -154,7 +178,7 @@
<div class="space-y-4">
<div
v-for="(event, index) in series.events"
v-for="(event, index) in series?.events || []"
:key="event.id"
class="group"
>
@ -170,7 +194,7 @@
{{ event.series?.position || index + 1 }}
</div>
<div
v-if="index < series.events.length - 1"
v-if="index < (series?.events?.length || 0) - 1"
class="w-0.5 h-12 bg-[--ui-border]"
></div>
</div>
@ -287,12 +311,18 @@
<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
@ -303,6 +333,15 @@ if (error.value?.statusCode === 404) {
});
}
// 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 = {
@ -335,6 +374,7 @@ const getSeriesTypeBadgeClass = (type) => {
};
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";
@ -342,6 +382,8 @@ const getSeriesStatusText = () => {
};
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)
@ -423,19 +465,32 @@ const getEventTimelineColor = (event) => {
};
// SEO Meta
useHead(() => ({
title: series.value
? `${series.value.title} - Event Series - Ghost Guild`
: "Event Series - Ghost Guild",
meta: [
{
name: "description",
content:
series.value?.description ||
"Explore our multi-event series designed for learning and growth",
},
],
}));
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>