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

1
.gitignore vendored
View file

@ -34,3 +34,4 @@ e2e/.auth/
# Worktrees # Worktrees
.worktrees/ .worktrees/
.claude/worktrees/ .claude/worktrees/
.superpowers/

View file

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

View file

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

View file

@ -125,153 +125,15 @@
<div v-if="!event.isCancelled" class="event-aside"> <div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System --> <!-- Ticket System -->
<EventTicketPurchase <EventTicketPurchase
v-if="event.tickets?.enabled"
:event-id="event._id || event.id" :event-id="event._id || event.id"
:event-start-date="event.startDate" :event-start-date="event.startDate"
:event-title="event.title" :event-title="event.title"
:user-email="memberData?.email" :user-email="memberData?.email"
:user-name="memberData?.name"
@success="handleTicketSuccess" @success="handleTicketSuccess"
@error="handleTicketError" @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 --> <!-- Event Details Box -->
<div class="dashed-box"> <div class="dashed-box">
<div class="box-title">Event Details</div> <div class="box-title">Event Details</div>
@ -322,129 +184,16 @@ if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: "Event not found" }); throw createError({ statusCode: 404, statusMessage: "Event not found" });
} }
const { isMember, memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const {
isPendingPayment,
isSuspended,
isCancelled,
canRSVP,
statusConfig,
getRSVPMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
const { trackGoal, isComplete } = useOnboarding(); const { trackGoal, isComplete } = useOnboarding();
onMounted(async () => { onMounted(async () => {
await checkMemberStatus(); await checkMemberStatus();
if (memberData.value) { if (memberData.value && !isComplete.value) {
if (!isComplete.value) { trackGoal('eventPageVisited');
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();
} }
}); });
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 formatDate = (dateStr) => {
const d = new Date(dateStr); const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", { 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))}`; 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 = () => { const handleTicketSuccess = () => {
if (event.value.registeredCount !== undefined) event.value.registeredCount++; if (event.value.registeredCount !== undefined) event.value.registeredCount++;
}; };
@ -680,28 +381,6 @@ useHead(() => ({
color: var(--text-faint); color: var(--text-faint);
margin-bottom: 8px; 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 { .detail-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

@ -3,191 +3,547 @@ import Member from '../server/models/member.js'
import { connectDB } from '../server/utils/mongoose.js' import { connectDB } from '../server/utils/mongoose.js'
import dotenv from 'dotenv' import dotenv from 'dotenv'
// Load environment variables
dotenv.config() 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 = [ const sampleMembers = [
{ {
email: 'alex.rivera@pixelcollective.coop', email: 'alex.rivera@pixelcollective.coop',
name: 'Alex Rivera', name: 'Alex Rivera',
circle: 'founder', circle: 'founder',
contributionTier: '50', contributionTier: '50',
status: 'active',
avatar: 'sweet',
slackInvited: true, 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'), createdAt: new Date('2024-01-15'),
lastLogin: new Date('2025-08-20') lastLogin: new Date('2026-04-10'),
}, },
{ {
email: 'sam.chen@legalcoop.com', email: 'sam.chen@legalcoop.com',
name: 'Sam Chen', name: 'Sam Chen',
circle: 'practitioner', circle: 'practitioner',
contributionTier: '30', contributionTier: '30',
status: 'active',
avatar: 'mild',
slackInvited: true, 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'), createdAt: new Date('2024-02-03'),
lastLogin: new Date('2025-08-18') lastLogin: new Date('2026-04-08'),
}, },
{ {
email: 'maria.garcia@collectivegames.coop', email: 'maria.garcia@collectivegames.coop',
name: 'Maria Garcia', name: 'Maria Garcia',
circle: 'founder', circle: 'founder',
contributionTier: '50', contributionTier: '50',
status: 'active',
avatar: 'double-take',
helcimCustomerId: 'cust_12345', helcimCustomerId: 'cust_12345',
helcimSubscriptionId: 'sub_67890', helcimSubscriptionId: 'sub_67890',
slackInvited: true, 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'), createdAt: new Date('2024-03-10'),
lastLogin: new Date('2025-08-25') lastLogin: new Date('2026-04-12'),
}, },
{ {
email: 'david.park@impactinvest.org', email: 'david.park@impactinvest.org',
name: 'David Park', name: 'David Park',
circle: 'practitioner', circle: 'practitioner',
contributionTier: '30', contributionTier: '30',
status: 'active',
avatar: 'exasperated',
slackInvited: true, 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'), createdAt: new Date('2024-04-12'),
lastLogin: new Date('2025-08-22') lastLogin: new Date('2026-04-09'),
}, },
{ {
email: 'jennifer.wu@grantspecialist.org', email: 'jennifer.wu@grantspecialist.org',
name: 'Jennifer Wu', name: 'Jennifer Wu',
circle: 'practitioner', circle: 'practitioner',
contributionTier: '15', contributionTier: '15',
status: 'active',
avatar: 'disbelieving',
slackInvited: true, 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'), createdAt: new Date('2024-05-08'),
lastLogin: new Date('2025-08-19') lastLogin: new Date('2026-04-05'),
}, },
{ {
email: 'jordan.lee@indiedev.com', email: 'jordan.lee@indiedev.com',
name: 'Jordan Lee', name: 'Jordan Lee',
circle: 'community', circle: 'community',
contributionTier: '15', 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'), createdAt: new Date('2024-06-20'),
lastLogin: new Date('2025-08-15') lastLogin: new Date('2026-04-07'),
}, },
{ {
email: 'taylor.smith@gamemaker.studio', email: 'taylor.smith@gamemaker.studio',
name: 'Taylor Smith', name: 'Taylor Smith',
circle: 'community', circle: 'community',
contributionTier: '5', contributionTier: '5',
status: 'active',
avatar: 'sweet',
slackInvited: true, 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'), createdAt: new Date('2024-07-15'),
lastLogin: new Date('2025-08-10') lastLogin: new Date('2026-04-01'),
}, },
{ {
email: 'casey.wong@studiocoop.dev', email: 'casey.wong@studiocoop.dev',
name: 'Casey Wong', name: 'Casey Wong',
circle: 'founder', circle: 'founder',
contributionTier: '30', contributionTier: '30',
status: 'active',
avatar: 'mild',
helcimCustomerId: 'cust_54321', helcimCustomerId: 'cust_54321',
slackInvited: true, 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'), createdAt: new Date('2024-08-01'),
lastLogin: new Date('2025-08-24') lastLogin: new Date('2026-04-11'),
}, },
{ {
email: 'riley.johnson@cooperativedev.org', email: 'riley.johnson@cooperativedev.org',
name: 'Riley Johnson', name: 'Riley Johnson',
circle: 'community', circle: 'community',
contributionTier: '0', contributionTier: '0',
status: 'active',
avatar: 'double-take',
slackInvited: false, 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'), createdAt: new Date('2024-08-15'),
lastLogin: new Date('2025-08-12') lastLogin: new Date('2026-03-28'),
}, },
{ {
email: 'morgan.davis@gamecollective.coop', email: 'morgan.davis@gamecollective.coop',
name: 'Morgan Davis', name: 'Morgan Davis',
circle: 'founder', circle: 'founder',
contributionTier: '50', contributionTier: '50',
status: 'active',
avatar: 'exasperated',
helcimCustomerId: 'cust_98765', helcimCustomerId: 'cust_98765',
helcimSubscriptionId: 'sub_13579', helcimSubscriptionId: 'sub_13579',
slackInvited: true, 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'), createdAt: new Date('2024-09-01'),
lastLogin: new Date('2025-08-26') lastLogin: new Date('2026-04-13'),
}, },
{ {
email: 'avery.brown@newdevstudio.com', email: 'avery.brown@newdevstudio.com',
name: 'Avery Brown', name: 'Avery Brown',
circle: 'community', circle: 'community',
contributionTier: '5', contributionTier: '5',
status: 'active',
avatar: 'disbelieving',
slackInvited: false, 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'), createdAt: new Date('2024-10-10'),
lastLogin: new Date('2025-08-14') lastLogin: new Date('2026-03-20'),
}, },
{ {
email: 'phoenix.martinez@coopgames.dev', email: 'phoenix.martinez@coopgames.dev',
name: 'Phoenix Martinez', name: 'Phoenix Martinez',
circle: 'practitioner', circle: 'practitioner',
contributionTier: '15', contributionTier: '15',
status: 'active',
avatar: 'wtf',
slackInvited: true, 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'), createdAt: new Date('2024-11-05'),
lastLogin: new Date('2025-08-21') lastLogin: new Date('2026-04-06'),
}, },
{ {
email: 'sage.anderson@collaborativestudio.org', email: 'sage.anderson@collaborativestudio.org',
name: 'Sage Anderson', name: 'Sage Anderson',
circle: 'community', circle: 'community',
contributionTier: '15', contributionTier: '15',
status: 'active',
avatar: 'sweet',
slackInvited: true, 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'), createdAt: new Date('2024-12-01'),
lastLogin: new Date('2025-08-16') lastLogin: new Date('2026-04-02'),
}, },
{ {
email: 'dakota.wilson@indieguildstudio.com', email: 'dakota.wilson@indieguildstudio.com',
name: 'Dakota Wilson', name: 'Dakota Wilson',
circle: 'founder', circle: 'founder',
contributionTier: '30', contributionTier: '30',
status: 'active',
avatar: 'mild',
slackInvited: true, 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'), createdAt: new Date('2025-01-10'),
lastLogin: new Date('2025-08-23') lastLogin: new Date('2026-04-10'),
}, },
{ {
email: 'charlie.thompson@gamecooperative.net', email: 'charlie.thompson@gamecooperative.net',
name: 'Charlie Thompson', name: 'Charlie Thompson',
circle: 'practitioner', circle: 'practitioner',
contributionTier: '50', contributionTier: '50',
status: 'active',
avatar: 'double-take',
helcimCustomerId: 'cust_11111', helcimCustomerId: 'cust_11111',
helcimSubscriptionId: 'sub_22222', helcimSubscriptionId: 'sub_22222',
slackInvited: true, 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'), 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() { async function seedMembers() {
try { try {
await connectDB() await connectDB()
// Clear existing members // Clear existing members (except test admin)
await Member.deleteMany({}) await Member.deleteMany({ email: { $ne: 'test-admin@ghostguild.dev' } })
console.log('Cleared existing members') 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 // Insert sample members
await Member.insertMany(sampleMembers) await Member.insertMany(sampleMembers)
console.log(`Added ${sampleMembers.length} sample members`) console.log(`Added ${sampleMembers.length} sample members`)
// Verify insertion and show summary // Verify
const count = await Member.countDocuments() 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([ const circleBreakdown = await Member.aggregate([
{ $group: { _id: '$circle', count: { $sum: 1 } } }, { $group: { _id: '$circle', count: { $sum: 1 } } },
{ $sort: { _id: 1 } } { $sort: { _id: 1 } },
]) ])
console.log('\nBreakdown by circle:') console.log('\nBreakdown by circle:')
circleBreakdown.forEach(circle => { circleBreakdown.forEach((c) => console.log(` ${c._id}: ${c.count}`))
console.log(` ${circle._id}: ${circle.count} members`)
})
// Show breakdown by contribution tier const withTopics = await Member.countDocuments({ 'board.topics.0': { $exists: true } })
const tierBreakdown = await Member.aggregate([ console.log(`\nMembers with board topics: ${withTopics}`)
{ $group: { _id: '$contributionTier', count: { $sum: 1 } } },
{ $sort: { _id: 1 } }
])
console.log('\nBreakdown by contribution tier:') const withSlack = await Member.countDocuments({ 'board.slackHandle': { $exists: true, $ne: null } })
tierBreakdown.forEach(tier => { console.log(`Members with slack handles: ${withSlack}`)
console.log(` $${tier._id}: ${tier.count} members`)
})
process.exit(0) process.exit(0)
} catch (error) { } catch (error) {