Merge pull request 'chore/visual-fidelity-fixes' (#2) from chore/visual-fidelity-fixes into main
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 11m11s
Test / playwright (push) Has been cancelled

Reviewed-on: #2
This commit is contained in:
jennie 2026-04-30 12:36:08 +00:00
commit 9c7d6fa446
40 changed files with 369 additions and 220 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: 18px 22px; padding: 20px 24px;
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: 19px; font-size: 18px;
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: 14px 16px; padding: 16px 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: 15px; font-size: 16px;
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: 9px; font-size: 10px;
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: 14px 12px; padding: 12px 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: 9px; font-size: 10px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
margin-top: 6px; margin-top: 6px;

View file

@ -1,70 +0,0 @@
<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,12 +160,16 @@
</div> </div>
<!-- Already Registered --> <!-- Already Registered -->
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"> <div
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 text-candlelight-400 flex-shrink-0" /> <Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
<div> <div>
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div> <div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
<div class="text-sm text-candlelight-400"> <div class="text-sm" style="color: var(--candle)">
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,6 +154,7 @@
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"
@ -165,6 +166,7 @@
<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"
@ -450,22 +452,26 @@ const formatEventDate = (date) => {
margin-top: 2px; margin-top: 2px;
} }
.consent-field { .consent-block {
display: flex; display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start; align-items: flex-start;
gap: 8px; column-gap: 8px;
row-gap: 4px;
margin-bottom: 14px;
}
.consent-field {
display: contents;
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 {
margin-bottom: 14px; grid-column: 2;
padding-left: 24px; margin: 0;
} }
</style> </style>

View file

@ -104,7 +104,7 @@ const formatDate = (dateStr) => {
} }
.em-circle { .em-circle {
font-size: 9px; font-size: 10px;
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: 14px 32px; padding: 16px 28px;
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,14 +5,16 @@
<img <img
:src="transformedImageUrl" :src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'" :alt="modelValue.alt || 'Event image'"
class="w-full h-48 object-cover rounded-lg border border-guild-700" class="w-full h-48 object-cover"
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
@click="removeImage"
type="button" type="button"
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors" class="absolute top-2 right-2 p-1 rounded-full transition-colors"
style="background: var(--ember); color: var(--parch-text)"
@click="removeImage"
> >
<Icon name="heroicons:x-mark" class="w-4 h-4" /> <Icon name="heroicons:x-mark" class="w-4 h-4" />
</button> </button>
@ -21,67 +23,89 @@
<!-- Upload Area --> <!-- Upload Area -->
<div <div
v-if="!modelValue?.url" v-if="!modelValue?.url"
class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors" class="border-2 border-dashed p-6 text-center 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/*"
@change="handleFileSelect"
class="hidden" class="hidden"
/> @change="handleFileSelect"
>
<div class="space-y-3"> <div class="space-y-3">
<Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" /> <Icon
name="heroicons:photo"
class="w-12 h-12 mx-auto"
style="color: var(--text-dim)"
/>
<div> <div>
<p class="text-guild-400"> <p style="color: var(--text-dim)">
<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 text-guild-500">PNG, JPG, GIF up to 10MB</p> <p class="text-sm" style="color: var(--text-faint)">
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 class="block text-sm font-medium text-guild-100 mb-1"> <label
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 || ''"
@input="updateAltText($event.target.value)"
placeholder="Describe this image..." 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" 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)"
>
</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 class="text-guild-400">Uploading...</span> <span style="color: var(--text-dim)">Uploading...</span>
<span class="text-guild-400">{{ uploadProgress }}%</span> <span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
</div> </div>
<div class="w-full bg-guild-800 rounded-full h-2">
<div <div
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300" class="w-full rounded-full h-2"
:style="`width: ${uploadProgress}%`" style="background: var(--surface)"
>
<div
class="h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%; background: var(--candle)`"
/> />
</div> </div>
</div> </div>
<!-- Error Message --> <!-- Error Message -->
<div v-if="errorMessage" class="text-sm text-ember-400"> <div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
{{ 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: 14px; font-size: 13px;
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: 10px 14px; padding: 12px 16px;
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,12 +18,14 @@
<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 text-candlelight-500" class="w-5 h-5"
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 text-ember-500" class="w-5 h-5"
style="color: var(--ember)"
/> />
</template> </template>
</UInput> </UInput>
@ -31,7 +33,8 @@
<div <div
v-if="parsedDate && isValidParse" v-if="parsedDate && isValidParse"
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800" class="text-sm px-3 py-2"
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" />
@ -41,7 +44,8 @@
<div <div
v-if="hasError && naturalInput.trim()" v-if="hasError && naturalInput.trim()"
class="text-sm text-ember-400 bg-ember-900/20 px-3 py-2 rounded-lg border border-ember-800" class="text-sm px-3 py-2"
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" />
@ -51,7 +55,7 @@
<!-- Fallback datetime-local input --> <!-- Fallback datetime-local input -->
<details class="text-sm"> <details class="text-sm">
<summary class="cursor-pointer text-guild-400 hover:text-guild-100"> <summary class="cursor-pointer" style="color: var(--text-dim)">
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 rgba(237, 228, 208, 0.25); border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
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 rgba(237, 228, 208, 0.12); border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
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: rgba(237, 228, 208, 0.2); color: color-mix(in srgb, var(--parch-text) 20%, transparent);
} }
.ow-skip { .ow-skip {

View file

@ -9,14 +9,11 @@
</div> </div>
<!-- Error State --> <!-- Error State -->
<div <div v-else-if="error" class="error-state p-6">
v-else-if="error" <h3 class="error-state__heading text-lg font-semibold mb-2">
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="text-ember-400">{{ error }}</p> <p class="error-state__body">{{ error }}</p>
</div> </div>
<!-- Content --> <!-- Content -->
@ -48,7 +45,7 @@
<!-- Registration Form --> <!-- Registration Form -->
<div <div
v-if="passInfo.available && !passInfo.alreadyRegistered" v-if="passInfo.available && !passInfo.alreadyRegistered"
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6" class="registration-form p-6"
> >
<h3 class="text-xl font-bold text-[--ui-text] mb-6"> <h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{ {{
@ -103,18 +100,20 @@
<!-- 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 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg" 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 <Icon
name="heroicons:sparkles" name="heroicons:sparkles"
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" class="w-5 h-5 flex-shrink-0 mt-0.5"
style="color: var(--candle)"
/> />
<div> <div>
<div class="font-semibold text-candlelight-300 mb-1"> <div class="font-semibold mb-1" style="color: var(--candle)">
Member Benefit Member Benefit
</div> </div>
<div class="text-sm text-candlelight-400"> <div class="text-sm" style="color: var(--candle)">
This series pass is free for Ghost Guild members! This series pass is free for Ghost Guild members!
</div> </div>
</div> </div>
@ -355,3 +354,18 @@ 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: rgba(42, 32, 21, 0.72); background: color-mix(in srgb, var(--parch) 72%, transparent);
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">
<h3 style="color: var(--c-community)">Community</h3> <h2 style="color: var(--c-community)">Community</h2>
<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">
<h3 style="color: var(--c-founder)">Founder</h3> <h2 style="color: var(--c-founder)">Founder</h2>
<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">
<h3 style="color: var(--c-practitioner)">Practitioner</h3> <h2 style="color: var(--c-practitioner)">Practitioner</h2>
<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 rgba(138, 68, 32, 0.3); border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
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 rgba(138, 68, 32, 0.4); border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border-radius: 50%; border-radius: 50%;
} }
@ -632,12 +632,12 @@ tbody td {
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: rgba(122, 90, 16, 0.3); border-color: color-mix(in srgb, var(--candle) 30%, transparent);
} }
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: rgba(74, 106, 56, 0.3); border-color: color-mix(in srgb, var(--green) 30%, transparent);
} }
.status-past { .status-past {
@ -647,7 +647,7 @@ tbody td {
.status-cancelled { .status-cancelled {
color: var(--ember); color: var(--ember);
border-color: rgba(138, 68, 32, 0.3); border-color: color-mix(in srgb, var(--ember) 30%, transparent);
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">
<span class="badge" :class="member.circle">{{ member.circle }}</span> <CircleBadge :circle="member.circle" />
<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">
<span class="badge" :class="member.circle">{{ member.circle }}</span> <CircleBadge :circle="member.circle" />
<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,6 +56,9 @@
<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>
@ -534,6 +537,24 @@ 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">Pending Payment</option> <option value="pending_payment">Payment setup incomplete</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
</select> </select>
@ -108,9 +108,7 @@
</td> </td>
<td class="col-email">{{ member.email }}</td> <td class="col-email">{{ member.email }}</td>
<td> <td>
<span class="badge" :class="member.circle">{{ <CircleBadge :circle="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>
@ -269,7 +267,7 @@
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Circle</th> <th>Circle</th>
<th>Tier</th> <th>Contribution</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -373,7 +371,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">Pending Payment</option> <option value="pending_payment">Payment setup incomplete</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>
@ -490,7 +488,7 @@ const sortDir = ref("desc");
const STATUS_LABELS = { const STATUS_LABELS = {
active: "Active", active: "Active",
pending_payment: "Pending", pending_payment: "Payment setup incomplete",
suspended: "Suspended", suspended: "Suspended",
cancelled: "Cancelled", cancelled: "Cancelled",
}; };
@ -1149,7 +1147,7 @@ th.sortable:hover {
text-transform: uppercase; text-transform: uppercase;
} }
.badge.status-active { .badge.status-active {
color: var(--green, #3a6b3a); color: var(--green);
border-color: rgba(58, 107, 58, 0.45); border-color: rgba(58, 107, 58, 0.45);
} }
.badge.status-pending_payment { .badge.status-pending_payment {
@ -1158,7 +1156,7 @@ th.sortable:hover {
} }
.badge.status-suspended { .badge.status-suspended {
color: var(--ember); color: var(--ember);
border-color: rgba(138, 68, 32, 0.45); border-color: color-mix(in srgb, var(--ember) 45%, transparent);
} }
.badge.status-cancelled { .badge.status-cancelled {
color: var(--text-faint); color: var(--text-faint);
@ -1306,7 +1304,7 @@ th.sortable:hover {
} }
.row-error { .row-error {
background: rgba(138, 68, 32, 0.04); background: color-mix(in srgb, var(--ember) 4%, transparent);
} }
/* ---- PREVIEW BOX ---- */ /* ---- PREVIEW BOX ---- */

View file

@ -643,8 +643,8 @@ tbody td {
} }
.status-accepted { .status-accepted {
color: var(--green, #4a7); color: var(--green);
border-color: var(--green, #4a7); border-color: var(--green);
} }
.status-expired { .status-expired {
@ -671,7 +671,7 @@ tbody td {
/* ---- STATUS INDICATORS ---- */ /* ---- STATUS INDICATORS ---- */
.status-ok { .status-ok {
color: var(--green, #4a7); color: var(--green);
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 rgba(138, 68, 32, 0.4); border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
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: rgba(74, 106, 56, 0.3); border-color: color-mix(in srgb, var(--green) 30%, transparent);
} }
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: rgba(122, 90, 16, 0.3); border-color: color-mix(in srgb, var(--candle) 30%, transparent);
} }
.status-completed { .status-completed {
@ -946,7 +946,7 @@ const exportSeriesData = () => {
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: rgba(74, 106, 56, 0.3); border-color: color-mix(in srgb, var(--green) 30%, transparent);
} }
/* ---- LINK BUTTONS ---- */ /* ---- LINK BUTTONS ---- */

View file

@ -954,8 +954,8 @@ const applyBatchVisibility = async (hidden) => {
} }
.sync-created { .sync-created {
color: var(--green, #4a7); color: var(--green);
border-color: var(--green, #4a7); border-color: var(--green);
} }
.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: 700; font-weight: 600;
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: 700; font-weight: 600;
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: 700; font-weight: 600;
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: 700; font-weight: 600;
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: 32px; font-size: 36px;
font-weight: 700; font-weight: 600;
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: 700; font-weight: 600;
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: 60px 24px; padding: 64px 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: 60px 24px; padding: 64px 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: 700; font-weight: 600;
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 18px; padding: 2px 0 2px 16px;
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: 15px; font-size: 16px;
margin-top: 12px; margin-top: 12px;
} }

View file

@ -430,6 +430,10 @@ 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: 5px 0; padding: 4px 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="handleUpdateTier" @click="handleUpdateContribution"
> >
{{ isUpdating ? "Updating…" : "Update Contribution" }} {{ isUpdating ? "Updating…" : "Update Contribution" }}
</button> </button>
@ -482,7 +482,7 @@ const refreshNextBillingIfStale = async () => {
} }
}; };
const handleUpdateTier = async () => { const handleUpdateContribution = async () => {
isUpdating.value = true; isUpdating.value = true;
try { try {
await $fetch("/api/members/update-contribution", { await $fetch("/api/members/update-contribution", {

View file

@ -37,9 +37,7 @@
<span class="profile-pronouns">{{ member.pronouns }}</span> <span class="profile-pronouns">{{ member.pronouns }}</span>
</div> </div>
<div class="profile-meta"> <div class="profile-meta">
<span v-if="member.circle" class="badge" :class="member.circle"> <CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[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>
@ -372,7 +370,7 @@ useHead({
} }
.profile-name { .profile-name {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 42px; font-size: 36px;
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: 10px 28px; padding: 12px 28px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
font-size: 12px; font-size: 12px;
} }

View file

@ -132,13 +132,16 @@ 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. - ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`.
- Admin layout migration from `guild-*` tokens to zine spec. - ~~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 dashboard quick-action button contrast. - ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
- Members table NAME column clipping. - ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
- 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 — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI. - ~~`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)`.
- 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. - 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`.
- ~~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
@ -150,14 +153,13 @@ 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).** "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. - ~~**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).
- **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. - ~~**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).
- **`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. - ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
- **`.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`. - ~~**`.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.
- **Past-events toggle component.** Existing, untouched this pass; noted in findings doc as a future consistency check. - ~~**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.
### 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`).
- Update error log message referencing "tier" in `server/api/members/update-contribution.post.js:221`. SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).
- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.

View file

@ -6,6 +6,7 @@ import {
checkTicketAvailability, checkTicketAvailability,
checkUserSeriesPass, checkUserSeriesPass,
formatPrice, formatPrice,
hasMemberAccess,
} from "../../../../utils/tickets.js"; } from "../../../../utils/tickets.js";
/** /**
@ -111,7 +112,7 @@ export default defineEventHandler(async (event) => {
); );
} }
if (member && eventData.tickets?.public?.available) { if (hasMemberAccess(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,12 +88,11 @@ export default defineEventHandler(async (event) => {
member member
}) })
// Paid-tier signups need to complete Helcim checkout in the same tab // Signup completes (paid checkout or free activation) before the magic
// before the magic link can be clicked. Issue a short-lived, payment-only // link is clicked, so issue a short-lived, payment-only bridge cookie
// bridge cookie so /api/helcim/initialize-payment accepts the request. // that lets /api/helcim/initialize-payment and /api/helcim/subscription
if (body.contributionAmount > 0) { // identify the member without a verified auth session.
setPaymentBridgeCookie(event, member) setPaymentBridgeCookie(event, member)
}
return { return {
success: true, success: true,

View file

@ -203,6 +203,10 @@ 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,
@ -250,7 +254,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 tier:", error); console.error("Error updating contribution amount:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "An unexpected error occurred", statusMessage: "An unexpected error occurred",

View file

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

View file

@ -0,0 +1,142 @@
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
})
})
})