Compare commits

..

No commits in common. "9c7d6fa44683ef96e732a5aa38ffb0e04b46ac18" and "d15458b30ad1e1e2538270221f33f8ccece6d758" have entirely different histories.

40 changed files with 220 additions and 369 deletions

View file

@ -158,7 +158,7 @@ const slackLinks = computed(() => {
<style scoped> <style scoped>
.board-post { .board-post {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 20px 24px; padding: 18px 22px;
background: var(--surface); background: var(--surface);
break-inside: avoid; break-inside: avoid;
-webkit-column-break-inside: avoid; -webkit-column-break-inside: avoid;
@ -219,7 +219,7 @@ const slackLinks = computed(() => {
.post-title { .post-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 18px; font-size: 19px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
margin: 0 0 12px; margin: 0 0 12px;

View file

@ -138,7 +138,7 @@ function handleSubmit() {
<style scoped> <style scoped>
.post-form { .post-form {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 16px 16px; padding: 14px 16px;
background: transparent; background: transparent;
} }
@ -147,7 +147,7 @@ function handleSubmit() {
} }
.form-title { .form-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 16px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
} }
@ -183,7 +183,7 @@ function handleSubmit() {
color: var(--text-faint); color: var(--text-faint);
text-transform: none; text-transform: none;
letter-spacing: 0; letter-spacing: 0;
font-size: 10px; font-size: 9px;
margin-left: 4px; margin-left: 4px;
opacity: 0.7; opacity: 0.7;
} }

View file

@ -48,7 +48,7 @@ defineEmits(['update:modelValue'])
.circle-option { .circle-option {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 12px 12px; padding: 14px 12px;
background: var(--bg); background: var(--bg);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@ -83,7 +83,7 @@ defineEmits(['update:modelValue'])
} }
.circle-tag { .circle-tag {
font-size: 10px; font-size: 9px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
margin-top: 6px; margin-top: 6px;

View file

@ -0,0 +1,70 @@
<template>
<div
class="series-badge p-4 bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600"
>
<div class="flex items-start justify-between gap-6">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="series-badge__label text-sm font-semibold text-guild-300 dark:text-guild-300"
>
Part of a Series
</span>
<span
v-if="totalEvents"
class="series-badge__count inline-flex items-center px-2 py-0.5 rounded-md bg-guild-700/50 dark:bg-guild-600/50 text-sm font-medium text-guild-200 dark:text-guild-200"
>
<template v-if="position">
Event {{ position }} of {{ totalEvents }}
</template>
<template v-else> {{ totalEvents }} events in series </template>
</span>
</div>
<h3
class="series-badge__title text-lg font-semibold text-guild-100 dark:text-guild-100 mb-2"
>
{{ title }}
</h3>
<p
v-if="description"
class="series-badge__description text-sm text-guild-300 dark:text-guild-300"
>
{{ description }}
</p>
</div>
<div v-if="seriesId" class="flex-shrink-0 self-start">
<UButton
:to="`/series/${seriesId}`"
color="primary"
size="md"
label="View Series"
/>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: "",
},
position: {
type: Number,
default: null,
},
totalEvents: {
type: Number,
default: null,
},
seriesId: {
type: String,
required: true,
},
});
</script>

View file

@ -160,16 +160,12 @@
</div> </div>
<!-- Already Registered --> <!-- Already Registered -->
<div <div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
v-else-if="alreadyRegistered"
class="p-4"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" /> <Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" />
<div> <div>
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div> <div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
<div class="text-sm" style="color: var(--candle)"> <div class="text-sm text-candlelight-400">
You have a series pass and are registered for all {{ totalEvents }} events. You have a series pass and are registered for all {{ totalEvents }} events.
</div> </div>
</div> </div>

View file

@ -154,19 +154,17 @@
securely securely
</p> </p>
<div class="consent-block"> <label class="consent-field">
<label class="consent-field"> <input
<input v-model="form.createAccount"
v-model="form.createAccount" type="checkbox"
type="checkbox" :disabled="processing"
:disabled="processing" >
> <span>Create a free guest account so I can manage my registration</span>
<span>Create a free guest account so I can manage my registration</span> </label>
</label> <p class="field-hint consent-hint">
<p class="field-hint consent-hint"> Guest accounts let you view your tickets and register faster next time. We won't add you to member communications.
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications. </p>
</p>
</div>
<button <button
type="submit" type="submit"
@ -452,26 +450,22 @@ const formatEventDate = (date) => {
margin-top: 2px; margin-top: 2px;
} }
.consent-block {
display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start;
column-gap: 8px;
row-gap: 4px;
margin-bottom: 14px;
}
.consent-field { .consent-field {
display: contents; display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px; font-size: 12px;
color: var(--text); color: var(--text);
margin-bottom: 4px;
cursor: pointer; cursor: pointer;
} }
.consent-field input[type="checkbox"] { .consent-field input[type="checkbox"] {
margin-top: 3px; margin-top: 3px;
flex-shrink: 0;
accent-color: var(--candle); accent-color: var(--candle);
} }
.consent-hint { .consent-hint {
grid-column: 2; margin-bottom: 14px;
margin: 0; padding-left: 24px;
} }
</style> </style>

View file

@ -104,7 +104,7 @@ const formatDate = (dateStr) => {
} }
.em-circle { .em-circle {
font-size: 10px; font-size: 9px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
margin-top: 2px; margin-top: 2px;

View file

@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
<style scoped> <style scoped>
.filter-bar { .filter-bar {
padding: 16px 28px; padding: 14px 32px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -5,16 +5,14 @@
<img <img
:src="transformedImageUrl" :src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'" :alt="modelValue.alt || 'Event image'"
class="w-full h-48 object-cover" class="w-full h-48 object-cover rounded-lg border border-guild-700"
style="border: 1px solid var(--border)"
@error="console.log('Image failed to load:', transformedImageUrl)" @error="console.log('Image failed to load:', transformedImageUrl)"
@load="console.log('Image loaded successfully:', transformedImageUrl)" @load="console.log('Image loaded successfully:', transformedImageUrl)"
> />
<button <button
type="button"
class="absolute top-2 right-2 p-1 rounded-full transition-colors"
style="background: var(--ember); color: var(--parch-text)"
@click="removeImage" @click="removeImage"
type="button"
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors"
> >
<Icon name="heroicons:x-mark" class="w-4 h-4" /> <Icon name="heroicons:x-mark" class="w-4 h-4" />
</button> </button>
@ -23,89 +21,67 @@
<!-- Upload Area --> <!-- Upload Area -->
<div <div
v-if="!modelValue?.url" v-if="!modelValue?.url"
class="border-2 border-dashed p-6 text-center transition-colors" class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors"
:style="
isDragging
? 'border-color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent)'
: 'border-color: var(--border)'
"
@dragover.prevent="isDragging = true" @dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" @dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
:class="{ 'border-candlelight-400 bg-candlelight-900/20': isDragging }"
> >
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept="image/*" accept="image/*"
class="hidden"
@change="handleFileSelect" @change="handleFileSelect"
> class="hidden"
/>
<div class="space-y-3"> <div class="space-y-3">
<Icon <Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" />
name="heroicons:photo"
class="w-12 h-12 mx-auto"
style="color: var(--text-dim)"
/>
<div> <div>
<p style="color: var(--text-dim)"> <p class="text-guild-400">
<button <button
type="button" type="button"
class="font-medium"
style="color: var(--candle)"
@click="$refs.fileInput.click()" @click="$refs.fileInput.click()"
class="text-candlelight-400 hover:text-candlelight-300 font-medium"
> >
Click to upload Click to upload
</button> </button>
or drag and drop or drag and drop
</p> </p>
<p class="text-sm" style="color: var(--text-faint)"> <p class="text-sm text-guild-500">PNG, JPG, GIF up to 10MB</p>
PNG, JPG, GIF up to 10MB
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Alt Text Input --> <!-- Alt Text Input -->
<div v-if="modelValue?.url"> <div v-if="modelValue?.url">
<label <label class="block text-sm font-medium text-guild-100 mb-1">
class="block text-sm font-medium mb-1"
style="color: var(--text-bright)"
>
Alt Text (for accessibility) Alt Text (for accessibility)
</label> </label>
<input <input
:value="modelValue.alt || ''" :value="modelValue.alt || ''"
placeholder="Describe this image..."
class="w-full px-3 py-2"
style="
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
"
@input="updateAltText($event.target.value)" @input="updateAltText($event.target.value)"
> placeholder="Describe this image..."
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-candlelight-500 focus:border-transparent"
/>
</div> </div>
<!-- Upload Progress --> <!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2"> <div v-if="isUploading" class="space-y-2">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span style="color: var(--text-dim)">Uploading...</span> <span class="text-guild-400">Uploading...</span>
<span style="color: var(--text-dim)">{{ uploadProgress }}%</span> <span class="text-guild-400">{{ uploadProgress }}%</span>
</div> </div>
<div <div class="w-full bg-guild-800 rounded-full h-2">
class="w-full rounded-full h-2"
style="background: var(--surface)"
>
<div <div
class="h-2 rounded-full transition-all duration-300" class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%; background: var(--candle)`" :style="`width: ${uploadProgress}%`"
/> />
</div> </div>
</div> </div>
<!-- Error Message --> <!-- Error Message -->
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)"> <div v-if="errorMessage" class="text-sm text-ember-400">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
</div> </div>

View file

@ -40,7 +40,7 @@
type="email" type="email"
placeholder="your.email@example.com" placeholder="your.email@example.com"
required required
> />
</div> </div>
<div class="info-box"> <div class="info-box">
@ -182,7 +182,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.modal-overline { .modal-overline {
font-family: 'Brygada 1918', serif; font-family: 'Brygada 1918', serif;
font-size: 13px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--candle); color: var(--candle);
margin-bottom: 12px; margin-bottom: 12px;
@ -218,7 +218,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.info-box { .info-box {
font-size: 11px; font-size: 11px;
color: var(--text-faint); color: var(--text-faint);
padding: 12px 16px; padding: 10px 14px;
border: 1px dashed var(--border); border: 1px dashed var(--border);
margin-bottom: 16px; margin-bottom: 16px;
line-height: 1.6; line-height: 1.6;

View file

@ -18,14 +18,12 @@
<Icon <Icon
v-if="isValidParse && naturalInput.trim()" v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle" name="heroicons:check-circle"
class="w-5 h-5" class="w-5 h-5 text-candlelight-500"
style="color: var(--candle)"
/> />
<Icon <Icon
v-else-if="hasError && naturalInput.trim()" v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle" name="heroicons:exclamation-circle"
class="w-5 h-5" class="w-5 h-5 text-ember-500"
style="color: var(--ember)"
/> />
</template> </template>
</UInput> </UInput>
@ -33,8 +31,7 @@
<div <div
v-if="parsedDate && isValidParse" v-if="parsedDate && isValidParse"
class="text-sm px-3 py-2" class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800"
style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" /> <Icon name="heroicons:calendar" class="w-4 h-4" />
@ -44,8 +41,7 @@
<div <div
v-if="hasError && naturalInput.trim()" v-if="hasError && naturalInput.trim()"
class="text-sm px-3 py-2" class="text-sm text-ember-400 bg-ember-900/20 px-3 py-2 rounded-lg border border-ember-800"
style="color: var(--ember); background: color-mix(in srgb, var(--ember) 15%, transparent); border: 1px solid var(--ember)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" /> <Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
@ -55,7 +51,7 @@
<!-- Fallback datetime-local input --> <!-- Fallback datetime-local input -->
<details class="text-sm"> <details class="text-sm">
<summary class="cursor-pointer" style="color: var(--text-dim)"> <summary class="cursor-pointer text-guild-400 hover:text-guild-100">
Use traditional date picker Use traditional date picker
</summary> </summary>
<div class="mt-2"> <div class="mt-2">

View file

@ -118,7 +118,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
display: inline-block; display: inline-block;
margin-top: 8px; margin-top: 8px;
padding: 4px 12px; padding: 4px 12px;
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent); border: 1px dashed rgba(237, 228, 208, 0.25);
color: var(--parch-accent); color: var(--parch-accent);
font-size: 11px; font-size: 11px;
text-decoration: none; text-decoration: none;
@ -134,7 +134,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
.ow-progress { .ow-progress {
margin-top: 10px; margin-top: 10px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent); border-top: 1px dashed rgba(237, 228, 208, 0.12);
font-size: 11px; font-size: 11px;
color: var(--parch-text-dim); color: var(--parch-text-dim);
display: flex; display: flex;
@ -153,7 +153,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
} }
.ow-bar-empty { .ow-bar-empty {
color: color-mix(in srgb, var(--parch-text) 20%, transparent); color: rgba(237, 228, 208, 0.2);
} }
.ow-skip { .ow-skip {

View file

@ -9,11 +9,14 @@
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="error" class="error-state p-6"> <div
<h3 class="error-state__heading text-lg font-semibold mb-2"> 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">
Unable to Load Series Pass Unable to Load Series Pass
</h3> </h3>
<p class="error-state__body">{{ error }}</p> <p class="text-ember-400">{{ error }}</p>
</div> </div>
<!-- Content --> <!-- Content -->
@ -45,7 +48,7 @@
<!-- Registration Form --> <!-- Registration Form -->
<div <div
v-if="passInfo.available && !passInfo.alreadyRegistered" v-if="passInfo.available && !passInfo.alreadyRegistered"
class="registration-form p-6" class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
> >
<h3 class="text-xl font-bold text-[--ui-text] mb-6"> <h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{ {{
@ -100,20 +103,18 @@
<!-- Member Benefits Notice --> <!-- Member Benefits Notice -->
<div <div
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember" v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
class="p-4" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<Icon <Icon
name="heroicons:sparkles" name="heroicons:sparkles"
class="w-5 h-5 flex-shrink-0 mt-0.5" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
style="color: var(--candle)"
/> />
<div> <div>
<div class="font-semibold mb-1" style="color: var(--candle)"> <div class="font-semibold text-candlelight-300 mb-1">
Member Benefit Member Benefit
</div> </div>
<div class="text-sm" style="color: var(--candle)"> <div class="text-sm text-candlelight-400">
This series pass is free for Ghost Guild members! This series pass is free for Ghost Guild members!
</div> </div>
</div> </div>
@ -354,18 +355,3 @@ const formatPrice = (price, currency = "CAD") => {
}).format(price); }).format(price);
}; };
</script> </script>
<style scoped>
.error-state {
background: color-mix(in srgb, var(--ember) 8%, transparent);
border: 1px dashed var(--ember);
}
.error-state__heading,
.error-state__body {
color: var(--ember);
}
.registration-form {
background: var(--surface);
border: 1px dashed var(--border);
}
</style>

View file

@ -108,7 +108,7 @@ const stepLabel = computed(() => {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50; z-index: 50;
background: color-mix(in srgb, var(--parch) 72%, transparent); background: rgba(42, 32, 21, 0.72);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -38,16 +38,16 @@
<div class="section-label">The Circles</div> <div class="section-label">The Circles</div>
<div class="circles-grid"> <div class="circles-grid">
<div id="community" class="circle-cell"> <div id="community" class="circle-cell">
<h2 style="color: var(--c-community)">Community</h2> <h3 style="color: var(--c-community)">Community</h3>
<p>For anyone exploring cooperative models.</p> <p>For anyone exploring cooperative models.</p>
</div> </div>
<div id="founder" class="circle-cell"> <div id="founder" class="circle-cell">
<h2 style="color: var(--c-founder)">Founder</h2> <h3 style="color: var(--c-founder)">Founder</h3>
<p>For people actively building cooperatives.</p> <p>For people actively building cooperatives.</p>
</div> </div>
<div id="practitioner" class="circle-cell"> <div id="practitioner" class="circle-cell">
<h2 style="color: var(--c-practitioner)">Practitioner</h2> <h3 style="color: var(--c-practitioner)">Practitioner</h3>
<p>For experienced practitioners sharing what they know.</p> <p>For experienced practitioners sharing what they know.</p>
</div> </div>
</div> </div>

View file

@ -570,7 +570,7 @@ tbody td {
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent); border: 1px dashed rgba(138, 68, 32, 0.3);
padding: 2px 8px; padding: 2px 8px;
} }
@ -583,7 +583,7 @@ tbody td {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent); border: 1px dashed rgba(138, 68, 32, 0.4);
border-radius: 50%; border-radius: 50%;
} }
@ -632,12 +632,12 @@ tbody td {
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: color-mix(in srgb, var(--candle) 30%, transparent); border-color: rgba(122, 90, 16, 0.3);
} }
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: color-mix(in srgb, var(--green) 30%, transparent); border-color: rgba(74, 106, 56, 0.3);
} }
.status-past { .status-past {
@ -647,7 +647,7 @@ tbody td {
.status-cancelled { .status-cancelled {
color: var(--ember); color: var(--ember);
border-color: color-mix(in srgb, var(--ember) 30%, transparent); border-color: rgba(138, 68, 32, 0.3);
margin-top: 4px; margin-top: 4px;
} }

View file

@ -65,7 +65,7 @@
<span class="item-sub">{{ member.email }}</span> <span class="item-sub">{{ member.email }}</span>
</div> </div>
<div class="item-meta"> <div class="item-meta">
<CircleBadge :circle="member.circle" /> <span class="badge" :class="member.circle">{{ member.circle }}</span>
<span class="item-date">{{ formatDate(member.createdAt) }}</span> <span class="item-date">{{ formatDate(member.createdAt) }}</span>
</div> </div>
</div> </div>

View file

@ -16,7 +16,7 @@
<p v-if="member" class="member-email">{{ member.email }}</p> <p v-if="member" class="member-email">{{ member.email }}</p>
</div> </div>
<div v-if="member" class="header-badges"> <div v-if="member" class="header-badges">
<CircleBadge :circle="member.circle" /> <span class="badge" :class="member.circle">{{ member.circle }}</span>
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span> <span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div> </div>
</div> </div>
@ -56,9 +56,6 @@
<div class="field"> <div class="field">
<label>Contribution ($/mo)</label> <label>Contribution ($/mo)</label>
<input v-model.number="form.contributionAmount" type="number" min="0" step="1"> <input v-model.number="form.contributionAmount" type="number" min="0" step="1">
<p class="field-hint field-hint--warn">
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard this form does not sync.
</p>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
@ -537,24 +534,6 @@ onMounted(loadActivity)
margin-top: 12px; margin-top: 12px;
} }
.field-hint {
font-size: 11px;
color: var(--text-faint);
margin: 6px 0 0;
line-height: 1.4;
}
.field-hint--warn {
color: var(--ember);
border-left: 2px solid var(--ember);
padding: 4px 0 4px 8px;
}
.field-hint code {
font-family: "Commit Mono", monospace;
font-size: 10px;
}
.form-actions { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;

View file

@ -42,7 +42,7 @@
<select v-model="statusFilter" aria-label="Filter by status"> <select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="pending_payment">Payment setup incomplete</option> <option value="pending_payment">Pending Payment</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
</select> </select>
@ -108,7 +108,9 @@
</td> </td>
<td class="col-email">{{ member.email }}</td> <td class="col-email">{{ member.email }}</td>
<td> <td>
<CircleBadge :circle="member.circle" /> <span class="badge" :class="member.circle">{{
member.circle
}}</span>
</td> </td>
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td> <td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
<td> <td>
@ -267,7 +269,7 @@
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Circle</th> <th>Circle</th>
<th>Contribution</th> <th>Tier</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -371,7 +373,7 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="editingMember.status"> <select v-model="editingMember.status">
<option value="pending_payment">Payment setup incomplete</option> <option value="pending_payment">Pending Payment</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
@ -488,7 +490,7 @@ const sortDir = ref("desc");
const STATUS_LABELS = { const STATUS_LABELS = {
active: "Active", active: "Active",
pending_payment: "Payment setup incomplete", pending_payment: "Pending",
suspended: "Suspended", suspended: "Suspended",
cancelled: "Cancelled", cancelled: "Cancelled",
}; };
@ -1147,7 +1149,7 @@ th.sortable:hover {
text-transform: uppercase; text-transform: uppercase;
} }
.badge.status-active { .badge.status-active {
color: var(--green); color: var(--green, #3a6b3a);
border-color: rgba(58, 107, 58, 0.45); border-color: rgba(58, 107, 58, 0.45);
} }
.badge.status-pending_payment { .badge.status-pending_payment {
@ -1156,7 +1158,7 @@ th.sortable:hover {
} }
.badge.status-suspended { .badge.status-suspended {
color: var(--ember); color: var(--ember);
border-color: color-mix(in srgb, var(--ember) 45%, transparent); border-color: rgba(138, 68, 32, 0.45);
} }
.badge.status-cancelled { .badge.status-cancelled {
color: var(--text-faint); color: var(--text-faint);
@ -1304,7 +1306,7 @@ th.sortable:hover {
} }
.row-error { .row-error {
background: color-mix(in srgb, var(--ember) 4%, transparent); background: rgba(138, 68, 32, 0.04);
} }
/* ---- PREVIEW BOX ---- */ /* ---- PREVIEW BOX ---- */

View file

@ -643,8 +643,8 @@ tbody td {
} }
.status-accepted { .status-accepted {
color: var(--green); color: var(--green, #4a7);
border-color: var(--green); border-color: var(--green, #4a7);
} }
.status-expired { .status-expired {
@ -671,7 +671,7 @@ tbody td {
/* ---- STATUS INDICATORS ---- */ /* ---- STATUS INDICATORS ---- */
.status-ok { .status-ok {
color: var(--green); color: var(--green, #4a7);
font-size: 11px; font-size: 11px;
} }

View file

@ -850,7 +850,7 @@ const exportSeriesData = () => {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent); border: 1px dashed rgba(138, 68, 32, 0.4);
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
@ -931,12 +931,12 @@ const exportSeriesData = () => {
.status-active { .status-active {
color: var(--green); color: var(--green);
border-color: color-mix(in srgb, var(--green) 30%, transparent); border-color: rgba(74, 106, 56, 0.3);
} }
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: color-mix(in srgb, var(--candle) 30%, transparent); border-color: rgba(122, 90, 16, 0.3);
} }
.status-completed { .status-completed {
@ -946,7 +946,7 @@ const exportSeriesData = () => {
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: color-mix(in srgb, var(--green) 30%, transparent); border-color: rgba(74, 106, 56, 0.3);
} }
/* ---- LINK BUTTONS ---- */ /* ---- LINK BUTTONS ---- */

View file

@ -954,8 +954,8 @@ const applyBatchVisibility = async (hidden) => {
} }
.sync-created { .sync-created {
color: var(--green); color: var(--green, #4a7);
border-color: var(--green); border-color: var(--green, #4a7);
} }
.sync-updated { .sync-updated {

View file

@ -82,7 +82,7 @@ if (import.meta.server && !xsrf.value) {
.auth-title { .auth-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 700;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);

View file

@ -46,7 +46,7 @@ useHead({ title: "Signed Out — Ghost Guild" });
.auth-title { .auth-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 700;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);

View file

@ -70,7 +70,7 @@ const hasDetail = computed(
.auth-title { .auth-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 700;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);
@ -97,7 +97,7 @@ const hasDetail = computed(
.auth-detail-code { .auth-detail-code {
color: var(--ember); color: var(--ember);
font-weight: 600; font-weight: 700;
margin: 0 0 4px; margin: 0 0 4px;
} }

View file

@ -172,8 +172,8 @@ function resetForm() {
.wiki-login-title { .wiki-login-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 36px; font-size: 32px;
font-weight: 600; font-weight: 700;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);
@ -240,7 +240,7 @@ function resetForm() {
.wiki-login-sent-heading { .wiki-login-sent-heading {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 700;
color: var(--text-bright); color: var(--text-bright);
margin: 0; margin: 0;
} }

View file

@ -357,13 +357,13 @@ onMounted(async () => {
/* ---- LOADING / EMPTY ---- */ /* ---- LOADING / EMPTY ---- */
.loading-state { .loading-state {
padding: 64px 24px; padding: 60px 24px;
text-align: center; text-align: center;
color: var(--text-faint); color: var(--text-faint);
font-size: 12px; font-size: 12px;
} }
.empty-state { .empty-state {
padding: 64px 24px; padding: 60px 24px;
text-align: center; text-align: center;
} }
.empty-title { .empty-title {

View file

@ -124,7 +124,7 @@ const handleLogout = async () => {
.coming-soon-title { .coming-soon-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 3rem; font-size: 3rem;
font-weight: 600; font-weight: 700;
color: var(--text-bright); color: var(--text-bright);
margin-bottom: 8px; margin-bottom: 8px;
} }

View file

@ -309,7 +309,7 @@ useHead({
} }
.guidelines-section ul li { .guidelines-section ul li {
position: relative; position: relative;
padding: 2px 0 2px 16px; padding: 2px 0 2px 18px;
font-size: 13px; font-size: 13px;
color: var(--text-dim); color: var(--text-dim);
line-height: 1.7; line-height: 1.7;
@ -365,7 +365,7 @@ useHead({
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-style: italic; font-style: italic;
color: var(--text-bright); color: var(--text-bright);
font-size: 16px; font-size: 15px;
margin-top: 12px; margin-top: 12px;
} }

View file

@ -430,10 +430,6 @@ const isAlmostFull = (event) => {
border-color: var(--candle-faint); border-color: var(--candle-faint);
color: var(--text-dim); color: var(--text-dim);
} }
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.past-toggle.active { .past-toggle.active {
border-color: var(--candle); border-color: var(--candle);
border-style: solid; border-style: solid;

View file

@ -747,7 +747,7 @@ onUnmounted(() => {
padding: 0; padding: 0;
} }
.tier-list li { .tier-list li {
padding: 4px 0; padding: 5px 0;
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);

View file

@ -283,7 +283,7 @@
form.contributionAmount === Number(memberData.contributionAmount || 0) || form.contributionAmount === Number(memberData.contributionAmount || 0) ||
isUpdating isUpdating
" "
@click="handleUpdateContribution" @click="handleUpdateTier"
> >
{{ isUpdating ? "Updating…" : "Update Contribution" }} {{ isUpdating ? "Updating…" : "Update Contribution" }}
</button> </button>
@ -482,7 +482,7 @@ const refreshNextBillingIfStale = async () => {
} }
}; };
const handleUpdateContribution = async () => { const handleUpdateTier = async () => {
isUpdating.value = true; isUpdating.value = true;
try { try {
await $fetch("/api/members/update-contribution", { await $fetch("/api/members/update-contribution", {

View file

@ -37,7 +37,9 @@
<span class="profile-pronouns">{{ member.pronouns }}</span> <span class="profile-pronouns">{{ member.pronouns }}</span>
</div> </div>
<div class="profile-meta"> <div class="profile-meta">
<CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[member.circle]" /> <span v-if="member.circle" class="badge" :class="member.circle">
{{ circleLabels[member.circle] }}
</span>
<template v-if="member.studio"> <template v-if="member.studio">
<span class="meta-sep">&middot;</span> <span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span> <span class="profile-studio">{{ member.studio }}</span>
@ -370,7 +372,7 @@ useHead({
} }
.profile-name { .profile-name {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 36px; font-size: 42px;
font-weight: 600; font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
margin: 0; margin: 0;

View file

@ -185,7 +185,7 @@ const getEventStatus = (event) => {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 12px;
padding: 12px 28px; padding: 10px 28px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
font-size: 12px; font-size: 12px;
} }

View file

@ -132,16 +132,13 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co
See `docs/TODO.md` for: See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5). - Button minimum target size (WCAG AAA 2.5.5).
- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`. - `/oidc/interaction/[uid]` routing quirk.
- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted. - Admin layout migration from `guild-*` tokens to zine spec.
- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29. - Admin dashboard quick-action button contrast.
- ~~Members table NAME column clipping~~ — verified stale 2026-04-29. - Members table NAME column clipping.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption). - OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`. - `tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI.
- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`. - Simplify-pass follow-ups (2026-04-25): source-grep test bloat, login/verify rate-limit gap, stringly-typed `metadata.type`, reconcile-payments sequential loop, stale `new Date()` in events list, `loadPublicSeries` helper extraction.
- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
### Known gotchas worth addressing post-launch ### Known gotchas worth addressing post-launch
@ -153,13 +150,14 @@ See `docs/TODO.md` for:
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach. Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice). - **Success-state color convention (4 instances).** "You're Registered!" blocks use `--candle` (gold) instead of `--green`. Touches `EventSeriesTicketCard.vue:186-196` (still uses phantom `candlelight-*` classes — preserved byte-for-byte pending decision) and registered-state wrappers in `SeriesPassPurchase.vue`. Needs a UX call on whether success should render gold (zine-consistent) or green (semantic). Once decided, finish the phantom-palette removal on those 4 lines.
- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line). - **Sidebar breakpoint unverified.** `app/layouts/default.vue:89` hides the sidebar at ≤1024px per spec. Browser `resize_window` tool refused viewport changes during the audit, so the actual crossover and any layout shift at 10231025px was never visually confirmed. Do a manual responsive check before declaring the sidebar pattern shipped.
- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach. - **`EventTicketPurchase.vue:469` magic padding.** `.consent-hint { padding-left: 24px; }` is a hardcoded offset to align the hint under the checkbox text. Cosmetic; swap for a gap/grid approach when touching the consent block next.
- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge. - **`.section-label` extraction candidate.** Several audited files repeat the same uppercase/letter-spaced small label pattern inline. Low-priority refactor into a utility class in `main.css`.
- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted. - **Past-events toggle component.** Existing, untouched this pass; noted in findings doc as a future consistency check.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior) ### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message). - Update error log message referencing "tier" in `server/api/members/update-contribution.post.js:221`.
- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.

View file

@ -6,7 +6,6 @@ import {
checkTicketAvailability, checkTicketAvailability,
checkUserSeriesPass, checkUserSeriesPass,
formatPrice, formatPrice,
hasMemberAccess,
} from "../../../../utils/tickets.js"; } from "../../../../utils/tickets.js";
/** /**
@ -112,7 +111,7 @@ export default defineEventHandler(async (event) => {
); );
} }
if (hasMemberAccess(member) && eventData.tickets?.public?.available) { if (member && eventData.tickets?.public?.available) {
response.publicTicket = { response.publicTicket = {
price: eventData.tickets.public.price, price: eventData.tickets.public.price,
formattedPrice: formatPrice( formattedPrice: formatPrice(

View file

@ -88,11 +88,12 @@ export default defineEventHandler(async (event) => {
member member
}) })
// Signup completes (paid checkout or free activation) before the magic // Paid-tier signups need to complete Helcim checkout in the same tab
// link is clicked, so issue a short-lived, payment-only bridge cookie // before the magic link can be clicked. Issue a short-lived, payment-only
// that lets /api/helcim/initialize-payment and /api/helcim/subscription // bridge cookie so /api/helcim/initialize-payment accepts the request.
// identify the member without a verified auth session. if (body.contributionAmount > 0) {
setPaymentBridgeCookie(event, member) setPaymentBridgeCookie(event, member)
}
return { return {
success: true, success: true,

View file

@ -203,10 +203,6 @@ export default defineEventHandler(async (event) => {
} }
const memberCadence = member.billingCadence || 'monthly'; const memberCadence = member.billingCadence || 'monthly';
// TODO: Cadence-switch UI on /member/account. Plain Helcim subscription
// updates can't change billing period — would need a sub-replacement flow
// (cancel current, create new at desired cadence). See
// docs/LAUNCH_READINESS.md "Known gotchas" → "Cadence switch rejected".
if (body.cadence && body.cadence !== memberCadence) { if (body.cadence && body.cadence !== memberCadence) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
@ -254,7 +250,7 @@ export default defineEventHandler(async (event) => {
}; };
} catch (error) { } catch (error) {
if (error.statusCode) throw error; if (error.statusCode) throw error;
console.error("Error updating contribution amount:", error); console.error("Error updating contribution tier:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "An unexpected error occurred", statusMessage: "An unexpected error occurred",

View file

@ -86,7 +86,9 @@ export async function getOidcProvider() {
}, },
features: { features: {
devInteractions: { enabled: false }, devInteractions: {
enabled: process.env.NODE_ENV !== "production",
},
revocation: { enabled: true }, revocation: { enabled: true },
rpInitiatedLogout: { rpInitiatedLogout: {
enabled: true, enabled: true,

View file

@ -1,142 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js'
import { createHelcimCustomer } from '../../../server/utils/helcim.js'
import customerHandler from '../../../server/api/helcim/customer.post.js'
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
// Deliberately does NOT mock server/utils/auth.js — the bridge-cookie
// hand-off between the two handlers is the contract under test.
vi.mock('../../../server/models/member.js', () => ({
default: {
findOne: vi.fn(),
create: vi.fn(),
findByIdAndUpdate: vi.fn(),
findById: vi.fn(),
findOneAndUpdate: vi.fn()
}
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn(),
createHelcimSubscription: vi.fn(),
generateIdempotencyKey: vi.fn().mockReturnValue('idem-1'),
listHelcimCustomerTransactions: vi.fn().mockResolvedValue([])
}))
vi.mock('../../../server/utils/magicLink.js', () => ({
sendMagicLink: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../../../server/utils/resend.js', () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true })
}))
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null)
}))
vi.mock('../../../server/utils/payments.js', () => ({
upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true })
}))
vi.stubGlobal('helcimCustomerSchema', {})
vi.stubGlobal('helcimSubscriptionSchema', {})
const ALLOWED_ORIGIN = 'https://ghostguild.test'
const MEMBER_ID = '69f231152939bf109ac79d83'
const SUBSCRIPTION_BODY = {
customerId: 999,
customerCode: 'CST999',
contributionAmount: 0,
cadence: 'monthly',
cardToken: null
}
function extractBridgeCookie(event) {
const setCookie = event.node.res.getHeader('set-cookie')
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge='))
if (!match) return null
return match.match(/payment-bridge=([^;]+)/)[1]
}
describe('signup → subscription bridge-cookie hand-off', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRateLimit()
process.env.BASE_URL = ALLOWED_ORIGIN
createHelcimCustomer.mockResolvedValue({ id: 999, customerCode: 'CST999' })
})
it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => {
Member.findOne.mockResolvedValue(null)
Member.create.mockResolvedValue({
_id: MEMBER_ID,
email: 'free@example.com',
name: 'Free User',
circle: 'community',
contributionAmount: 0,
status: 'pending_payment'
})
const customerEvent = createMockEvent({
method: 'POST',
path: '/api/helcim/customer',
headers: { origin: ALLOWED_ORIGIN },
body: {
name: 'Free User',
email: 'free@example.com',
circle: 'community',
contributionAmount: 0,
agreedToGuidelines: true,
billingAddress: { country: 'CA' }
}
})
const result1 = await customerHandler(customerEvent)
expect(result1.success).toBe(true)
expect(result1.member.status).toBe('pending_payment')
const bridgeToken = extractBridgeCookie(customerEvent)
expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy()
Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
Member.findById.mockResolvedValue({
_id: MEMBER_ID,
email: 'free@example.com',
name: 'Free User',
circle: 'community',
contributionAmount: 0,
status: 'active'
})
const subscriptionEvent = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
headers: { origin: ALLOWED_ORIGIN },
cookies: { 'payment-bridge': bridgeToken },
body: SUBSCRIPTION_BODY
})
const result2 = await subscriptionHandler(subscriptionEvent)
expect(result2.success).toBe(true)
expect(result2.member.status).toBe('active')
expect(sendWelcomeEmail).toHaveBeenCalledTimes(1)
})
it('$0 signup with no bridge cookie carried forward → subscription returns 401', async () => {
const subscriptionEvent = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
headers: { origin: ALLOWED_ORIGIN },
body: SUBSCRIPTION_BODY
})
await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({
statusCode: 401
})
})
})