ghostguild-org/app/components/EventSeriesTicketCard.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

241 lines
8.8 KiB
Vue

<template>
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
<!-- Header -->
<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" style="color: var(--parch-text)" />
<span class="text-sm font-semibold" style="color: var(--parch-text)">
Series Pass
</span>
</div>
<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" 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" style="color: var(--parch-text)">
{{ formatPrice(ticket.price, ticket.currency) }}
</div>
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
Early Bird Price
</div>
</div>
</div>
</div>
<!-- Body -->
<div class="p-6" style="background: var(--surface)">
<!-- What's Included -->
<div class="mb-6">
<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" 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" 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" 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>
</div>
<!-- Events List Preview -->
<div v-if="events && events.length > 0" class="mb-6">
<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"
>
<div
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" style="color: var(--candle)">{{ index + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm" style="color: var(--text)">
{{ event.title }}
</div>
<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 pt-2" style="color: var(--text-faint)">
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
</div>
</div>
</div>
<!-- Member Benefit Callout -->
<div
v-if="ticket.isFree && isMember"
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 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
<div>
<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>
</div>
</div>
<!-- Public vs Member Pricing -->
<div
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
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 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
<div class="flex-1">
<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>
</div>
</div>
</div>
<!-- Availability -->
<div v-if="availability" class="mb-6">
<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"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
/>
<span
class="text-sm font-medium"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
>
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
</span>
</div>
</div>
<!-- Sold Out / Waitlist -->
<div v-if="!available" class="space-y-3">
<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 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
<div>
<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>
</div>
</div>
<UButton
v-if="availability?.waitlistAvailable"
block
color="neutral"
size="lg"
@click="$emit('join-waitlist')"
>
Join Waitlist
</UButton>
</div>
<!-- Already Registered -->
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" />
<div>
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
<div class="text-sm text-candlelight-400">
You have a series pass and are registered for all {{ totalEvents }} events.
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
ticket: {
type: Object,
required: true,
// Expected: { name, description, price, currency, type, isFree, isEarlyBird }
},
availability: {
type: Object,
default: null,
// Expected: { remaining, unlimited, waitlistAvailable }
},
available: {
type: Boolean,
default: true,
},
alreadyRegistered: {
type: Boolean,
default: false,
},
isMember: {
type: Boolean,
default: false,
},
totalEvents: {
type: Number,
required: true,
},
events: {
type: Array,
default: () => [],
// Expected: Array of { id, title, startDate }
},
publicPrice: {
type: Number,
default: null,
},
});
defineEmits(['join-waitlist']);
const memberSavings = computed(() => {
if (props.publicPrice && props.ticket.price < props.publicPrice) {
return props.publicPrice - props.ticket.price;
}
return 0;
});
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
</script>