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

236 lines
5.1 KiB
Vue

<template>
<div
class="ticket-card"
:class="{
'is-selected': isSelected,
'is-unavailable': !isAvailable || alreadyRegistered,
}"
@click="handleClick"
>
<!-- Ticket Header -->
<div class="ticket-header">
<div>
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
<p v-if="ticketInfo.description" class="ticket-desc">
{{ ticketInfo.description }}
</p>
</div>
<span v-if="ticketInfo.isMember" class="badge">Members Only</span>
</div>
<!-- Price Display -->
<div class="ticket-price-block">
<div class="ticket-price-row">
<span
class="ticket-price"
:class="{ 'is-free': ticketInfo.isFree }"
>
{{ ticketInfo.formattedPrice }}
</span>
<span v-if="ticketInfo.isEarlyBird" class="badge early-bird">
Early Bird
</span>
</div>
<div v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice" class="ticket-regular-price">
Regular: {{ ticketInfo.formattedRegularPrice }}
</div>
<div v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" class="ticket-deadline">
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
</div>
</div>
<!-- Member Savings -->
<div
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
class="ticket-savings"
>
<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="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="ticket-waitlist"
>
<button class="btn" @click.stop="$emit('join-waitlist')">
Join Waitlist
</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
ticketInfo: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
isAvailable: {
type: Boolean,
default: true,
},
alreadyRegistered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select", "join-waitlist"]);
const handleClick = () => {
if (props.isAvailable && !props.alreadyRegistered) {
emit("select");
}
};
const formatDeadline = (deadline) => {
const date = new Date(deadline);
const now = new Date();
const diff = date - now;
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
}
return `on ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}`;
};
const formatPrice = (amount) => {
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
};
</script>
<style scoped>
.ticket-card {
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: var(--candle-faint);
}
.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>