Event fixes
This commit is contained in:
parent
707447fc88
commit
19d519b153
5 changed files with 696 additions and 606 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -34,3 +34,4 @@ e2e/.auth/
|
|||
# Worktrees
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
.superpowers/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -125,153 +125,15 @@
|
|||
<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"
|
||||
:user-name="memberData?.name"
|
||||
@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>
|
||||
<p v-if="!event.membersOnly" class="reg-open">Open to everyone — no membership required</p>
|
||||
<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>
|
||||
|
|
@ -322,129 +184,16 @@ 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();
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { trackGoal, isComplete } = useOnboarding();
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus();
|
||||
if (memberData.value) {
|
||||
if (!isComplete.value) {
|
||||
trackGoal('eventPageVisited');
|
||||
}
|
||||
registrationForm.value.name = memberData.value.name;
|
||||
registrationForm.value.email = memberData.value.email;
|
||||
registrationForm.value.membershipLevel =
|
||||
memberData.value.membershipLevel || "non-member";
|
||||
await checkRegistrationStatus();
|
||||
checkWaitlistStatus();
|
||||
if (memberData.value && !isComplete.value) {
|
||||
trackGoal('eventPageVisited');
|
||||
}
|
||||
});
|
||||
|
||||
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", {
|
||||
|
|
@ -464,54 +213,6 @@ const formatTime = (start, end) => {
|
|||
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++;
|
||||
};
|
||||
|
|
@ -680,28 +381,6 @@ useHead(() => ({
|
|||
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;
|
||||
}
|
||||
.reg-open {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cal-link {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -3,191 +3,547 @@ import Member from '../server/models/member.js'
|
|||
import { connectDB } from '../server/utils/mongoose.js'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
|
||||
const COOPERATIVE_SLUGS = [
|
||||
'governance', 'finance-and-budgeting', 'legal-structures', 'conflict-resolution',
|
||||
'consensus-decision-making', 'revenue-sharing', 'cooperative-bylaws', 'member-onboarding',
|
||||
'democratic-management', 'worker-ownership', 'platform-cooperativism', 'cooperative-marketing',
|
||||
'shared-resources', 'cooperative-funding', 'community-building', 'equity-and-inclusion',
|
||||
'cooperative-tech', 'sustainability', 'collective-bargaining', 'inter-coop-collaboration',
|
||||
]
|
||||
|
||||
const CRAFT_SLUGS = [
|
||||
'game-design', 'programming', 'narrative-design', 'art-and-animation',
|
||||
'audio-and-music', 'production-management', 'qa-and-testing', 'community-management',
|
||||
'marketing-and-comms', 'ux-and-ui-design', 'business-development', 'devops-and-tools',
|
||||
'localization', 'accessibility', 'analytics-and-data', 'education-and-mentoring',
|
||||
]
|
||||
|
||||
const AVATARS = ['disbelieving', 'double-take', 'exasperated', 'mild', 'sweet', 'wtf']
|
||||
const STATES = ['help', 'interested', 'seeking']
|
||||
|
||||
function pick(arr, n) {
|
||||
const shuffled = [...arr].sort(() => Math.random() - 0.5)
|
||||
return shuffled.slice(0, n)
|
||||
}
|
||||
|
||||
function randomState() {
|
||||
return STATES[Math.floor(Math.random() * STATES.length)]
|
||||
}
|
||||
|
||||
const sampleMembers = [
|
||||
{
|
||||
email: 'alex.rivera@pixelcollective.coop',
|
||||
name: 'Alex Rivera',
|
||||
circle: 'founder',
|
||||
contributionTier: '50',
|
||||
status: 'active',
|
||||
avatar: 'sweet',
|
||||
slackInvited: true,
|
||||
craftTags: ['game-design', 'production-management', 'business-development'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'governance', state: 'help' },
|
||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
||||
{ tagSlug: 'worker-ownership', state: 'interested' },
|
||||
{ tagSlug: 'cooperative-bylaws', state: 'help' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'alex.rivera',
|
||||
},
|
||||
createdAt: new Date('2024-01-15'),
|
||||
lastLogin: new Date('2025-08-20')
|
||||
lastLogin: new Date('2026-04-10'),
|
||||
},
|
||||
{
|
||||
email: 'sam.chen@legalcoop.com',
|
||||
name: 'Sam Chen',
|
||||
circle: 'practitioner',
|
||||
contributionTier: '30',
|
||||
status: 'active',
|
||||
avatar: 'mild',
|
||||
slackInvited: true,
|
||||
craftTags: ['business-development', 'marketing-and-comms'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'legal-structures', state: 'help' },
|
||||
{ tagSlug: 'cooperative-bylaws', state: 'help' },
|
||||
{ tagSlug: 'governance', state: 'interested' },
|
||||
{ tagSlug: 'conflict-resolution', state: 'help' },
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'sam.chen',
|
||||
},
|
||||
createdAt: new Date('2024-02-03'),
|
||||
lastLogin: new Date('2025-08-18')
|
||||
lastLogin: new Date('2026-04-08'),
|
||||
},
|
||||
{
|
||||
email: 'maria.garcia@collectivegames.coop',
|
||||
name: 'Maria Garcia',
|
||||
circle: 'founder',
|
||||
contributionTier: '50',
|
||||
status: 'active',
|
||||
avatar: 'double-take',
|
||||
helcimCustomerId: 'cust_12345',
|
||||
helcimSubscriptionId: 'sub_67890',
|
||||
slackInvited: true,
|
||||
craftTags: ['programming', 'devops-and-tools', 'game-design', 'qa-and-testing'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'cooperative-tech', state: 'help' },
|
||||
{ tagSlug: 'platform-cooperativism', state: 'interested' },
|
||||
{ tagSlug: 'shared-resources', state: 'help' },
|
||||
{ tagSlug: 'democratic-management', state: 'seeking' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'maria.g',
|
||||
},
|
||||
createdAt: new Date('2024-03-10'),
|
||||
lastLogin: new Date('2025-08-25')
|
||||
lastLogin: new Date('2026-04-12'),
|
||||
},
|
||||
{
|
||||
email: 'david.park@impactinvest.org',
|
||||
name: 'David Park',
|
||||
circle: 'practitioner',
|
||||
contributionTier: '30',
|
||||
status: 'active',
|
||||
avatar: 'exasperated',
|
||||
slackInvited: true,
|
||||
craftTags: ['business-development', 'analytics-and-data'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'cooperative-funding', state: 'help' },
|
||||
{ tagSlug: 'finance-and-budgeting', state: 'help' },
|
||||
{ tagSlug: 'sustainability', state: 'interested' },
|
||||
{ tagSlug: 'revenue-sharing', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'david.park',
|
||||
},
|
||||
createdAt: new Date('2024-04-12'),
|
||||
lastLogin: new Date('2025-08-22')
|
||||
lastLogin: new Date('2026-04-09'),
|
||||
},
|
||||
{
|
||||
email: 'jennifer.wu@grantspecialist.org',
|
||||
name: 'Jennifer Wu',
|
||||
circle: 'practitioner',
|
||||
contributionTier: '15',
|
||||
status: 'active',
|
||||
avatar: 'disbelieving',
|
||||
slackInvited: true,
|
||||
craftTags: ['education-and-mentoring', 'community-management'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'cooperative-funding', state: 'help' },
|
||||
{ tagSlug: 'community-building', state: 'help' },
|
||||
{ tagSlug: 'member-onboarding', state: 'interested' },
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'help' },
|
||||
],
|
||||
offerPeerSupport: false,
|
||||
},
|
||||
createdAt: new Date('2024-05-08'),
|
||||
lastLogin: new Date('2025-08-19')
|
||||
lastLogin: new Date('2026-04-05'),
|
||||
},
|
||||
{
|
||||
email: 'jordan.lee@indiedev.com',
|
||||
name: 'Jordan Lee',
|
||||
circle: 'community',
|
||||
contributionTier: '15',
|
||||
slackInvited: false,
|
||||
status: 'active',
|
||||
avatar: 'wtf',
|
||||
slackInvited: true,
|
||||
craftTags: ['programming', 'game-design', 'audio-and-music'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'worker-ownership', state: 'seeking' },
|
||||
{ tagSlug: 'governance', state: 'seeking' },
|
||||
{ tagSlug: 'cooperative-tech', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'jordan.lee',
|
||||
},
|
||||
createdAt: new Date('2024-06-20'),
|
||||
lastLogin: new Date('2025-08-15')
|
||||
lastLogin: new Date('2026-04-07'),
|
||||
},
|
||||
{
|
||||
email: 'taylor.smith@gamemaker.studio',
|
||||
name: 'Taylor Smith',
|
||||
circle: 'community',
|
||||
contributionTier: '5',
|
||||
status: 'active',
|
||||
avatar: 'sweet',
|
||||
slackInvited: true,
|
||||
craftTags: ['art-and-animation', 'ux-and-ui-design', 'accessibility'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
||||
{ tagSlug: 'community-building', state: 'seeking' },
|
||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
||||
],
|
||||
offerPeerSupport: false,
|
||||
},
|
||||
createdAt: new Date('2024-07-15'),
|
||||
lastLogin: new Date('2025-08-10')
|
||||
lastLogin: new Date('2026-04-01'),
|
||||
},
|
||||
{
|
||||
email: 'casey.wong@studiocoop.dev',
|
||||
name: 'Casey Wong',
|
||||
circle: 'founder',
|
||||
contributionTier: '30',
|
||||
status: 'active',
|
||||
avatar: 'mild',
|
||||
helcimCustomerId: 'cust_54321',
|
||||
slackInvited: true,
|
||||
craftTags: ['programming', 'devops-and-tools', 'production-management'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'cooperative-tech', state: 'help' },
|
||||
{ tagSlug: 'shared-resources', state: 'help' },
|
||||
{ tagSlug: 'platform-cooperativism', state: 'help' },
|
||||
{ tagSlug: 'democratic-management', state: 'interested' },
|
||||
{ tagSlug: 'inter-coop-collaboration', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'casey.w',
|
||||
},
|
||||
createdAt: new Date('2024-08-01'),
|
||||
lastLogin: new Date('2025-08-24')
|
||||
lastLogin: new Date('2026-04-11'),
|
||||
},
|
||||
{
|
||||
email: 'riley.johnson@cooperativedev.org',
|
||||
name: 'Riley Johnson',
|
||||
circle: 'community',
|
||||
contributionTier: '0',
|
||||
status: 'active',
|
||||
avatar: 'double-take',
|
||||
slackInvited: false,
|
||||
craftTags: ['narrative-design', 'localization'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'community-building', state: 'interested' },
|
||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
||||
{ tagSlug: 'member-onboarding', state: 'seeking' },
|
||||
],
|
||||
offerPeerSupport: false,
|
||||
},
|
||||
createdAt: new Date('2024-08-15'),
|
||||
lastLogin: new Date('2025-08-12')
|
||||
lastLogin: new Date('2026-03-28'),
|
||||
},
|
||||
{
|
||||
email: 'morgan.davis@gamecollective.coop',
|
||||
name: 'Morgan Davis',
|
||||
circle: 'founder',
|
||||
contributionTier: '50',
|
||||
status: 'active',
|
||||
avatar: 'exasperated',
|
||||
helcimCustomerId: 'cust_98765',
|
||||
helcimSubscriptionId: 'sub_13579',
|
||||
slackInvited: true,
|
||||
craftTags: ['game-design', 'production-management', 'marketing-and-comms', 'business-development'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'governance', state: 'help' },
|
||||
{ tagSlug: 'cooperative-bylaws', state: 'help' },
|
||||
{ tagSlug: 'revenue-sharing', state: 'help' },
|
||||
{ tagSlug: 'worker-ownership', state: 'help' },
|
||||
{ tagSlug: 'collective-bargaining', state: 'interested' },
|
||||
{ tagSlug: 'inter-coop-collaboration', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'morgan.d',
|
||||
},
|
||||
createdAt: new Date('2024-09-01'),
|
||||
lastLogin: new Date('2025-08-26')
|
||||
lastLogin: new Date('2026-04-13'),
|
||||
},
|
||||
{
|
||||
email: 'avery.brown@newdevstudio.com',
|
||||
name: 'Avery Brown',
|
||||
circle: 'community',
|
||||
contributionTier: '5',
|
||||
status: 'active',
|
||||
avatar: 'disbelieving',
|
||||
slackInvited: false,
|
||||
craftTags: ['programming', 'qa-and-testing'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'cooperative-tech', state: 'seeking' },
|
||||
{ tagSlug: 'worker-ownership', state: 'seeking' },
|
||||
{ tagSlug: 'sustainability', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: false,
|
||||
},
|
||||
createdAt: new Date('2024-10-10'),
|
||||
lastLogin: new Date('2025-08-14')
|
||||
lastLogin: new Date('2026-03-20'),
|
||||
},
|
||||
{
|
||||
email: 'phoenix.martinez@coopgames.dev',
|
||||
name: 'Phoenix Martinez',
|
||||
circle: 'practitioner',
|
||||
contributionTier: '15',
|
||||
status: 'active',
|
||||
avatar: 'wtf',
|
||||
slackInvited: true,
|
||||
craftTags: ['community-management', 'education-and-mentoring', 'marketing-and-comms'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'cooperative-marketing', state: 'help' },
|
||||
{ tagSlug: 'community-building', state: 'help' },
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'help' },
|
||||
{ tagSlug: 'member-onboarding', state: 'help' },
|
||||
{ tagSlug: 'conflict-resolution', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'phoenix.m',
|
||||
},
|
||||
createdAt: new Date('2024-11-05'),
|
||||
lastLogin: new Date('2025-08-21')
|
||||
lastLogin: new Date('2026-04-06'),
|
||||
},
|
||||
{
|
||||
email: 'sage.anderson@collaborativestudio.org',
|
||||
name: 'Sage Anderson',
|
||||
circle: 'community',
|
||||
contributionTier: '15',
|
||||
status: 'active',
|
||||
avatar: 'sweet',
|
||||
slackInvited: true,
|
||||
craftTags: ['narrative-design', 'accessibility', 'education-and-mentoring'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
||||
{ tagSlug: 'sustainability', state: 'seeking' },
|
||||
{ tagSlug: 'community-building', state: 'interested' },
|
||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'sage.a',
|
||||
},
|
||||
createdAt: new Date('2024-12-01'),
|
||||
lastLogin: new Date('2025-08-16')
|
||||
lastLogin: new Date('2026-04-02'),
|
||||
},
|
||||
{
|
||||
email: 'dakota.wilson@indieguildstudio.com',
|
||||
name: 'Dakota Wilson',
|
||||
circle: 'founder',
|
||||
contributionTier: '30',
|
||||
status: 'active',
|
||||
avatar: 'mild',
|
||||
slackInvited: true,
|
||||
craftTags: ['game-design', 'art-and-animation', 'audio-and-music'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'governance', state: 'interested' },
|
||||
{ tagSlug: 'finance-and-budgeting', state: 'seeking' },
|
||||
{ tagSlug: 'cooperative-bylaws', state: 'seeking' },
|
||||
{ tagSlug: 'revenue-sharing', state: 'interested' },
|
||||
{ tagSlug: 'democratic-management', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'dakota.w',
|
||||
},
|
||||
createdAt: new Date('2025-01-10'),
|
||||
lastLogin: new Date('2025-08-23')
|
||||
lastLogin: new Date('2026-04-10'),
|
||||
},
|
||||
{
|
||||
email: 'charlie.thompson@gamecooperative.net',
|
||||
name: 'Charlie Thompson',
|
||||
circle: 'practitioner',
|
||||
contributionTier: '50',
|
||||
status: 'active',
|
||||
avatar: 'double-take',
|
||||
helcimCustomerId: 'cust_11111',
|
||||
helcimSubscriptionId: 'sub_22222',
|
||||
slackInvited: true,
|
||||
craftTags: ['business-development', 'analytics-and-data', 'production-management'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'finance-and-budgeting', state: 'help' },
|
||||
{ tagSlug: 'cooperative-funding', state: 'help' },
|
||||
{ tagSlug: 'collective-bargaining', state: 'help' },
|
||||
{ tagSlug: 'sustainability', state: 'help' },
|
||||
{ tagSlug: 'governance', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'charlie.t',
|
||||
},
|
||||
createdAt: new Date('2025-02-14'),
|
||||
lastLogin: new Date('2025-08-25')
|
||||
}
|
||||
lastLogin: new Date('2026-04-12'),
|
||||
},
|
||||
// Additional members for more Board density
|
||||
{
|
||||
email: 'robin.nakamura@workerowned.games',
|
||||
name: 'Robin Nakamura',
|
||||
circle: 'founder',
|
||||
contributionTier: '50',
|
||||
status: 'active',
|
||||
avatar: 'exasperated',
|
||||
slackInvited: true,
|
||||
craftTags: ['programming', 'game-design', 'devops-and-tools'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'worker-ownership', state: 'help' },
|
||||
{ tagSlug: 'cooperative-tech', state: 'help' },
|
||||
{ tagSlug: 'platform-cooperativism', state: 'help' },
|
||||
{ tagSlug: 'shared-resources', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'robin.n',
|
||||
},
|
||||
createdAt: new Date('2025-03-01'),
|
||||
lastLogin: new Date('2026-04-13'),
|
||||
},
|
||||
{
|
||||
email: 'emery.okafor@solidaritygames.org',
|
||||
name: 'Emery Okafor',
|
||||
circle: 'community',
|
||||
contributionTier: '15',
|
||||
status: 'active',
|
||||
avatar: 'wtf',
|
||||
slackInvited: true,
|
||||
craftTags: ['art-and-animation', 'community-management'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'help' },
|
||||
{ tagSlug: 'conflict-resolution', state: 'interested' },
|
||||
{ tagSlug: 'community-building', state: 'help' },
|
||||
{ tagSlug: 'consensus-decision-making', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'emery.o',
|
||||
},
|
||||
createdAt: new Date('2025-03-15'),
|
||||
lastLogin: new Date('2026-04-11'),
|
||||
},
|
||||
{
|
||||
email: 'quinn.fairweather@mutualgames.dev',
|
||||
name: 'Quinn Fairweather',
|
||||
circle: 'practitioner',
|
||||
contributionTier: '30',
|
||||
status: 'active',
|
||||
avatar: 'disbelieving',
|
||||
slackInvited: true,
|
||||
craftTags: ['production-management', 'business-development', 'education-and-mentoring'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'governance', state: 'help' },
|
||||
{ tagSlug: 'democratic-management', state: 'help' },
|
||||
{ tagSlug: 'cooperative-bylaws', state: 'interested' },
|
||||
{ tagSlug: 'member-onboarding', state: 'help' },
|
||||
{ tagSlug: 'inter-coop-collaboration', state: 'help' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'quinn.f',
|
||||
},
|
||||
createdAt: new Date('2025-04-01'),
|
||||
lastLogin: new Date('2026-04-14'),
|
||||
},
|
||||
{
|
||||
email: 'wren.castellano@commonsdev.coop',
|
||||
name: 'Wren Castellano',
|
||||
circle: 'founder',
|
||||
contributionTier: '30',
|
||||
status: 'active',
|
||||
avatar: 'sweet',
|
||||
slackInvited: true,
|
||||
craftTags: ['ux-and-ui-design', 'accessibility', 'narrative-design'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'platform-cooperativism', state: 'interested' },
|
||||
{ tagSlug: 'cooperative-marketing', state: 'seeking' },
|
||||
{ tagSlug: 'shared-resources', state: 'interested' },
|
||||
{ tagSlug: 'sustainability', state: 'seeking' },
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: false,
|
||||
},
|
||||
createdAt: new Date('2025-05-10'),
|
||||
lastLogin: new Date('2026-04-09'),
|
||||
},
|
||||
{
|
||||
email: 'indigo.ramirez@collectivecraft.studio',
|
||||
name: 'Indigo Ramirez',
|
||||
circle: 'community',
|
||||
contributionTier: '5',
|
||||
status: 'active',
|
||||
avatar: 'mild',
|
||||
slackInvited: true,
|
||||
craftTags: ['audio-and-music', 'localization'],
|
||||
board: {
|
||||
topics: [
|
||||
{ tagSlug: 'collective-bargaining', state: 'seeking' },
|
||||
{ tagSlug: 'revenue-sharing', state: 'seeking' },
|
||||
{ tagSlug: 'worker-ownership', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'indigo.r',
|
||||
},
|
||||
createdAt: new Date('2025-06-01'),
|
||||
lastLogin: new Date('2026-04-04'),
|
||||
},
|
||||
]
|
||||
|
||||
// Board topics for the test admin so the logged-in user sees matches
|
||||
const TEST_ADMIN_BOARD = {
|
||||
topics: [
|
||||
{ tagSlug: 'governance', state: 'interested' },
|
||||
{ tagSlug: 'worker-ownership', state: 'seeking' },
|
||||
{ tagSlug: 'cooperative-tech', state: 'interested' },
|
||||
{ tagSlug: 'community-building', state: 'seeking' },
|
||||
{ tagSlug: 'equity-and-inclusion', state: 'interested' },
|
||||
{ tagSlug: 'revenue-sharing', state: 'seeking' },
|
||||
{ tagSlug: 'cooperative-funding', state: 'interested' },
|
||||
{ tagSlug: 'sustainability', state: 'interested' },
|
||||
{ tagSlug: 'consensus-decision-making', state: 'seeking' },
|
||||
{ tagSlug: 'platform-cooperativism', state: 'interested' },
|
||||
],
|
||||
offerPeerSupport: true,
|
||||
slackHandle: 'test-admin',
|
||||
}
|
||||
|
||||
async function seedMembers() {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
// Clear existing members
|
||||
await Member.deleteMany({})
|
||||
console.log('Cleared existing members')
|
||||
// Clear existing members (except test admin)
|
||||
await Member.deleteMany({ email: { $ne: 'test-admin@ghostguild.dev' } })
|
||||
console.log('Cleared existing members (kept test admin)')
|
||||
|
||||
// Update test admin with board topics so the Board page shows matches
|
||||
const adminUpdate = await Member.findOneAndUpdate(
|
||||
{ email: 'test-admin@ghostguild.dev' },
|
||||
{
|
||||
$set: {
|
||||
board: TEST_ADMIN_BOARD,
|
||||
craftTags: ['game-design', 'programming', 'production-management'],
|
||||
},
|
||||
},
|
||||
)
|
||||
if (adminUpdate) {
|
||||
console.log('Updated test admin with board topics')
|
||||
} else {
|
||||
console.log('Test admin not found — run /api/dev/test-login first to create it')
|
||||
}
|
||||
|
||||
// Insert sample members
|
||||
await Member.insertMany(sampleMembers)
|
||||
console.log(`Added ${sampleMembers.length} sample members`)
|
||||
|
||||
// Verify insertion and show summary
|
||||
// Verify
|
||||
const count = await Member.countDocuments()
|
||||
console.log(`Total members in database: ${count}`)
|
||||
console.log(`\nTotal members in database: ${count}`)
|
||||
|
||||
// Show breakdown by circle
|
||||
const circleBreakdown = await Member.aggregate([
|
||||
{ $group: { _id: '$circle', count: { $sum: 1 } } },
|
||||
{ $sort: { _id: 1 } }
|
||||
{ $sort: { _id: 1 } },
|
||||
])
|
||||
|
||||
console.log('\nBreakdown by circle:')
|
||||
circleBreakdown.forEach(circle => {
|
||||
console.log(` ${circle._id}: ${circle.count} members`)
|
||||
})
|
||||
circleBreakdown.forEach((c) => console.log(` ${c._id}: ${c.count}`))
|
||||
|
||||
// Show breakdown by contribution tier
|
||||
const tierBreakdown = await Member.aggregate([
|
||||
{ $group: { _id: '$contributionTier', count: { $sum: 1 } } },
|
||||
{ $sort: { _id: 1 } }
|
||||
])
|
||||
const withTopics = await Member.countDocuments({ 'board.topics.0': { $exists: true } })
|
||||
console.log(`\nMembers with board topics: ${withTopics}`)
|
||||
|
||||
console.log('\nBreakdown by contribution tier:')
|
||||
tierBreakdown.forEach(tier => {
|
||||
console.log(` $${tier._id}: ${tier.count} members`)
|
||||
})
|
||||
const withSlack = await Member.countDocuments({ 'board.slackHandle': { $exists: true, $ne: null } })
|
||||
console.log(`Members with slack handles: ${withSlack}`)
|
||||
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue