Event fixes

This commit is contained in:
Jennie Robinson Faber 2026-04-14 16:17:55 +01:00
parent 707447fc88
commit 19d519b153
5 changed files with 696 additions and 606 deletions

View file

@ -1,72 +1,42 @@
<template>
<div
class="ticket-card rounded-xl border p-6 transition-all duration-200"
:class="[
isSelected
? 'border-primary bg-primary/5'
: 'border-guild-600 bg-guild-800/50',
isAvailable && !alreadyRegistered
? 'hover:border-primary/50 cursor-pointer'
: 'opacity-60 cursor-not-allowed',
]"
class="ticket-card"
:class="{
'is-selected': isSelected,
'is-unavailable': !isAvailable || alreadyRegistered,
}"
@click="handleClick"
>
<!-- Ticket Header -->
<div class="flex items-start justify-between mb-4">
<div class="ticket-header">
<div>
<h3 class="text-lg font-semibold text-guild-100">
{{ ticketInfo.name }}
</h3>
<p v-if="ticketInfo.description" class="text-sm text-guild-300 mt-1">
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
<p v-if="ticketInfo.description" class="ticket-desc">
{{ ticketInfo.description }}
</p>
</div>
<!-- Badge -->
<div v-if="ticketInfo.isMember" class="flex-shrink-0 ml-4">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-500 dark:bg-candlelight-900/30 dark:text-candlelight-400"
>
Members Only
</span>
</div>
<span v-if="ticketInfo.isMember" class="badge">Members Only</span>
</div>
<!-- Price Display -->
<div class="mb-4">
<div class="flex items-baseline gap-2">
<div class="ticket-price-block">
<div class="ticket-price-row">
<span
class="text-3xl font-bold text-ui-mono"
:class="ticketInfo.isFree ? 'text-candlelight-400' : 'text-guild-100'"
class="ticket-price"
:class="{ 'is-free': ticketInfo.isFree }"
>
{{ ticketInfo.formattedPrice }}
</span>
<!-- Early Bird Badge -->
<span
v-if="ticketInfo.isEarlyBird"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-600 dark:bg-candlelight-900/35 dark:text-candlelight-400"
>
<span v-if="ticketInfo.isEarlyBird" class="badge early-bird">
Early Bird
</span>
</div>
<!-- Regular Price (if early bird) -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
class="mt-1"
>
<span class="text-sm text-guild-400 line-through">
Regular: {{ ticketInfo.formattedRegularPrice }}
</span>
<div v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice" class="ticket-regular-price">
Regular: {{ ticketInfo.formattedRegularPrice }}
</div>
<!-- Early Bird Countdown -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
class="mt-2 text-xs text-candlelight-500 dark:text-candlelight-400"
>
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
<div v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" class="ticket-deadline">
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
</div>
</div>
@ -74,59 +44,38 @@
<!-- Member Savings -->
<div
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
class="mb-4 p-3 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
class="ticket-savings"
>
<p class="text-sm text-candlelight-400">
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
</p>
<p class="text-xs text-guild-400 mt-1">
<p>You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!</p>
<p class="ticket-savings-detail">
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
</p>
</div>
<!-- Availability -->
<div class="flex items-center justify-between text-sm">
<div>
<span
v-if="alreadyRegistered"
class="text-candlelight-400 flex items-center gap-1"
>
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
You're registered
</span>
<span
v-else-if="!isAvailable"
class="text-ember-400 flex items-center gap-1"
>
<Icon name="heroicons:x-circle-solid" class="w-4 h-4" />
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="text-guild-300">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="text-guild-300"> Unlimited availability </span>
</div>
<!-- Selection Indicator -->
<div v-if="isSelected && isAvailable && !alreadyRegistered">
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
</div>
<div class="ticket-availability">
<span v-if="alreadyRegistered" class="status-registered">
You're registered
</span>
<span v-else-if="!isAvailable" class="status-sold-out">
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="status-remaining">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="status-remaining">
Unlimited availability
</span>
</div>
<!-- Waitlist Option -->
<div
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
class="mt-4 pt-4 border-t border-guild-600"
class="ticket-waitlist"
>
<UButton
color="gray"
size="sm"
block
@click.stop="$emit('join-waitlist')"
>
<button class="btn" @click.stop="$emit('join-waitlist')">
Join Waitlist
</UButton>
</button>
</div>
</div>
</template>
@ -164,13 +113,11 @@ const formatDeadline = (deadline) => {
const now = new Date();
const diff = date - now;
// If less than 24 hours, show hours
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
}
// Otherwise show date
return `on ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@ -187,6 +134,103 @@ const formatPrice = (amount) => {
<style scoped>
.ticket-card {
position: relative;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
transition: border-color 0.15s;
cursor: default;
}
.ticket-card.is-selected {
border-color: var(--candle-faint);
}
.ticket-card.is-unavailable {
opacity: 0.6;
cursor: not-allowed;
}
.ticket-card:not(.is-unavailable) {
cursor: pointer;
}
.ticket-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.ticket-name {
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
}
.ticket-desc {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.ticket-price-block {
margin-bottom: 10px;
}
.ticket-price-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.ticket-price {
font-size: 22px;
font-weight: 600;
color: var(--text-bright);
}
.ticket-price.is-free {
color: var(--candle);
}
.ticket-regular-price {
font-size: 11px;
color: var(--text-faint);
text-decoration: line-through;
margin-top: 2px;
}
.ticket-deadline {
font-size: 10px;
color: var(--candle-dim);
margin-top: 4px;
}
.early-bird {
color: var(--candle-dim);
border-color: rgba(122, 90, 16, 0.35);
}
.ticket-savings {
border: 1px dashed var(--candle-faint);
padding: 8px 12px;
margin-bottom: 10px;
font-size: 11px;
color: var(--candle);
}
.ticket-savings-detail {
font-size: 10px;
color: var(--text-faint);
margin-top: 2px;
}
.ticket-availability {
font-size: 11px;
}
.status-registered {
color: var(--green);
}
.status-sold-out {
color: var(--ember);
}
.status-remaining {
color: var(--text-dim);
}
.ticket-waitlist {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
</style>

View file

@ -1,76 +1,58 @@
<template>
<div class="event-ticket-purchase">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-guild-300">Loading ticket information...</p>
<div v-if="loading" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status">Loading ticket information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
<div v-else-if="error" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)">
Unable to Load Tickets
</h3>
<p class="text-ember-400">{{ error }}</p>
</p>
<p class="ticket-detail">{{ error }}</p>
</div>
<!-- Series Pass Required -->
<div
v-else-if="ticketInfo?.requiresSeriesPass"
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
>
<h3
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:ticket" class="w-6 h-6" />
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--candle)">
Series Pass Required
</h3>
<p class="text-candlelight-400 mb-4">
</p>
<p class="ticket-detail">
This event is part of
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
pass to attend.
</p>
<p class="text-sm text-guild-300 mb-6">
<p class="ticket-hint">
Purchase a series pass to get access to all events in this series.
</p>
<UButton
<NuxtLink
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
color="primary"
size="lg"
block
>
View Series & Purchase Pass
</UButton>
<button class="btn btn-primary">View Series &amp; Purchase Pass</button>
</NuxtLink>
</div>
<!-- Already Registered -->
<div
v-else-if="ticketInfo?.alreadyRegistered"
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
>
<h3
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
<div class="box-title">Registration</div>
<p class="ticket-status" style="color: var(--green)">
You're Registered!
</h3>
<p class="text-candlelight-400 mb-4">
</p>
<p class="ticket-detail">
<template v-if="ticketInfo.viaSeriesPass">
You have access to this event via your series pass for
<strong>{{ ticketInfo.series?.title }}</strong
>.
<strong>{{ ticketInfo.series?.title }}</strong>.
</template>
<template v-else>
You're all set for this event. Check your email for confirmation
details.
</template>
</p>
<p class="text-sm text-guild-300">
<p class="ticket-hint">
See you on {{ formatEventDate(eventStartDate) }}!
</p>
</div>
@ -83,128 +65,130 @@
:is-selected="true"
:is-available="ticketInfo.available"
:already-registered="ticketInfo.alreadyRegistered"
class="mb-6"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration Form -->
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
<h3 class="text-xl font-bold text-guild-100 mb-4">
<!-- Registration (logged-in member) -->
<div
v-if="ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn"
class="ticket-panel"
>
<div class="box-title">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</h3>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-guild-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
<p
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
This event is free for Ghost Guild members
</p>
<p
v-if="!ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
<button
class="btn btn-primary"
:disabled="processing"
@click="handleSubmit"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Register for this event"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
</div>
<!-- Registration Form (guest) -->
<div
v-else-if="ticketInfo.available && !ticketInfo.alreadyRegistered"
class="ticket-panel"
>
<div class="box-title">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</div>
<form @submit.prevent="handleSubmit">
<div class="field">
<label>Full Name</label>
<input
v-model="form.name"
type="text"
required
placeholder="Enter your full name"
:disabled="processing"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-guild-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
<div class="field">
<label>Email Address</label>
<input
v-model="form.email"
type="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
:disabled="processing"
/>
<p v-if="isLoggedIn" class="text-xs text-guild-400 mt-1">
Using your member email
</p>
</div>
<!-- Member Benefits Notice -->
<div
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
>
<p class="text-sm text-candlelight-300 flex items-center gap-2">
<Icon name="heroicons:sparkles" class="w-4 h-4" />
This event is free for Ghost Guild members
</p>
</div>
<!-- Payment Required Notice -->
<div
<p
v-if="!ticketInfo.isFree"
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
class="ticket-notice"
style="color: var(--candle)"
>
<p class="text-sm text-candlelight-300 flex items-center gap-2">
<Icon name="heroicons:credit-card" class="w-4 h-4" />
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
</div>
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="processing"
:disabled="!form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</UButton>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="processing || !form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
</form>
</div>
<!-- Sold Out with Waitlist -->
<div
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
class="text-center py-8"
class="ticket-panel"
>
<Icon
name="heroicons:ticket"
class="w-16 h-16 text-guild-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
<p class="text-guild-300 mb-6">
<div class="box-title">Waitlist</div>
<p class="ticket-status" style="color: var(--ember)">
Event Sold Out
</p>
<p class="ticket-detail">
This event is currently at capacity. Join the waitlist to be notified
if spots become available.
</p>
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
<button class="btn" @click="handleJoinWaitlist">
Join Waitlist
</UButton>
</button>
</div>
<!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="text-center py-8">
<Icon
name="heroicons:x-circle"
class="w-16 h-16 text-ember-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
<p class="text-guild-300">
<div v-else-if="!ticketInfo.available" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)">
Event Sold Out
</p>
<p class="ticket-detail">
Unfortunately, this event is at capacity and no longer accepting
registrations.
</p>
@ -231,6 +215,10 @@ const props = defineProps({
type: String,
default: null,
},
userName: {
type: String,
default: null,
},
});
const emit = defineEmits(["success", "error"]);
@ -245,7 +233,7 @@ const error = ref(null);
const ticketInfo = ref(null);
const form = ref({
name: "",
name: props.userName || "",
email: props.userEmail || "",
});
@ -270,7 +258,6 @@ const fetchTicketInfo = async () => {
if (seriesAccess.requiresSeriesPass) {
if (seriesAccess.hasSeriesPass) {
// User has series pass - show as already registered
ticketInfo.value = {
available: true,
alreadyRegistered: true,
@ -281,7 +268,6 @@ const fetchTicketInfo = async () => {
loading.value = false;
return;
} else {
// User needs to buy series pass
ticketInfo.value = {
available: false,
requiresSeriesPass: true,
@ -293,7 +279,6 @@ const fetchTicketInfo = async () => {
}
}
} catch (seriesErr) {
// If series check fails, continue with regular ticket check
console.warn("Series access check failed:", seriesErr);
}
}
@ -320,9 +305,7 @@ const handleSubmit = async () => {
try {
let transactionId = null;
// If payment is required, initialize Helcim and process payment
if (!ticketInfo.value.isFree) {
// Initialize Helcim payment
await initializeTicketPayment(
props.eventId,
form.value.email,
@ -330,14 +313,12 @@ const handleSubmit = async () => {
props.eventTitle,
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
// For purchase transactions, we get a transactionId
transactionId = paymentResult.transactionId;
if (!transactionId) {
@ -345,7 +326,6 @@ const handleSubmit = async () => {
}
}
// Purchase ticket
const response = await $fetch(
`/api/events/${props.eventId}/tickets/purchase`,
{
@ -358,18 +338,15 @@ const handleSubmit = async () => {
},
);
// Success!
toast.add({
title: "Success!",
description: ticketInfo.value.isFree
? "You're registered for this event"
: "Ticket purchased successfully!",
color: "green",
color: "success",
});
emit("success", response);
// Refresh ticket info to show registered state
await fetchTicketInfo();
} catch (err) {
console.error("Error purchasing ticket:", err);
@ -382,7 +359,7 @@ const handleSubmit = async () => {
toast.add({
title: "Registration Failed",
description: errorMessage,
color: "red",
color: "error",
});
emit("error", err);
@ -393,11 +370,10 @@ const handleSubmit = async () => {
};
const handleJoinWaitlist = () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist",
description: "Waitlist functionality coming soon!",
color: "blue",
color: "info",
});
};
@ -410,3 +386,37 @@ const formatEventDate = (date) => {
});
};
</script>
<style scoped>
.ticket-panel {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.ticket-status {
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.ticket-detail {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 10px;
line-height: 1.6;
}
.ticket-hint {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 10px;
}
.ticket-notice {
font-size: 11px;
margin-bottom: 10px;
}
.field-hint {
font-size: 10px;
color: var(--text-faint);
margin-top: 2px;
}
</style>