Compare commits

...

4 commits

Author SHA1 Message Date
d5c91dd66b Design and UI tweaks
Some checks failed
Test / vitest (push) Successful in 12m28s
Test / playwright (push) Failing after 12m9s
Test / Notify on failure (push) Successful in 2s
2026-05-25 13:28:54 +01:00
53f81b3605 refactor(css): extract .tint-candle / .tint-ember utility classes
The candle tint (color-mix accent fill + matching solid border) was inlined
as style="" in five spots across SeriesPassPurchase and EventSeriesTicketCard.
Promote to .tint-candle / .tint-ember utility classes in main.css and replace
the inline styles with the class.
2026-05-24 22:18:26 +01:00
dac423afcd fix(email): gate resend wrappers behind ALLOW_DEV_TEST_ENDPOINTS
All five resend.js send wrappers (registration, cancellation, waitlist,
series pass, welcome) dispatched live in dev. Add a skipEmailInDev guard
mirroring the gate in pre-registrants/invite.post.js so dev runs and e2e
don't fire real Resend sends. Also add the monthly-onboarding Slack-timing
line to the welcome email. Unit-tested.
2026-05-24 22:17:24 +01:00
a9312c423b fix(admin): series Delete button actually deletes the series
The /admin/series Delete handler only PUT-unlinked each event and never
called the DELETE /api/admin/series/[id] endpoint, so the series document
persisted (a no-op for empty series). Replace the redundant per-event loop
with a single DELETE call — the endpoint already unlinks events server-side.
Unskip the e2e delete test.
2026-05-24 22:17:19 +01:00
11 changed files with 589 additions and 85 deletions

302
.impeccable/design.json Normal file
View file

@ -0,0 +1,302 @@
{
"schemaVersion": 2,
"generatedAt": "2026-05-24T23:40:30.515Z",
"title": "Design System: Ghost Guild",
"extensions": {
"colorMeta": {
"candle": {
"role": "primary",
"displayName": "Candle Gold",
"canonical": "oklch(0.44 0.085 80)",
"tonalRamp": [
"oklch(0.18 0.053 80)",
"oklch(0.29 0.069 80)",
"oklch(0.4 0.08 80)",
"oklch(0.51 0.085 80)",
"oklch(0.62 0.084 80)",
"oklch(0.73 0.078 80)",
"oklch(0.84 0.066 80)",
"oklch(0.95 0.048 80)"
]
},
"ember": {
"role": "secondary",
"displayName": "Ember Rust",
"canonical": "oklch(0.46 0.11 47)",
"tonalRamp": [
"oklch(0.18 0.069 47)",
"oklch(0.29 0.09 47)",
"oklch(0.4 0.103 47)",
"oklch(0.51 0.11 47)",
"oklch(0.62 0.109 47)",
"oklch(0.73 0.1 47)",
"oklch(0.84 0.085 47)",
"oklch(0.95 0.062 47)"
]
},
"c-community": {
"role": "tertiary",
"displayName": "Community Clay",
"canonical": "oklch(0.44 0.06 40)",
"tonalRamp": [
"oklch(0.18 0.038 40)",
"oklch(0.29 0.049 40)",
"oklch(0.4 0.056 40)",
"oklch(0.51 0.06 40)",
"oklch(0.62 0.059 40)",
"oklch(0.73 0.055 40)",
"oklch(0.84 0.046 40)",
"oklch(0.95 0.034 40)"
]
},
"c-practitioner": {
"role": "tertiary",
"displayName": "Practitioner Slate",
"canonical": "oklch(0.36 0.045 230)",
"tonalRamp": [
"oklch(0.18 0.028 230)",
"oklch(0.29 0.037 230)",
"oklch(0.4 0.042 230)",
"oklch(0.51 0.045 230)",
"oklch(0.62 0.044 230)",
"oklch(0.73 0.041 230)",
"oklch(0.84 0.035 230)",
"oklch(0.95 0.025 230)"
]
},
"green": {
"role": "tertiary",
"displayName": "Guild Green",
"canonical": "oklch(0.5 0.08 135)",
"tonalRamp": [
"oklch(0.18 0.05 135)",
"oklch(0.29 0.065 135)",
"oklch(0.4 0.075 135)",
"oklch(0.51 0.08 135)",
"oklch(0.62 0.079 135)",
"oklch(0.73 0.073 135)",
"oklch(0.84 0.062 135)",
"oklch(0.95 0.045 135)"
]
},
"bg": {
"role": "neutral",
"displayName": "Cream Paper",
"canonical": "oklch(0.95 0.012 90)",
"tonalRamp": [
"oklch(0.18 0.008 90)",
"oklch(0.29 0.01 90)",
"oklch(0.4 0.011 90)",
"oklch(0.51 0.012 90)",
"oklch(0.62 0.012 90)",
"oklch(0.73 0.011 90)",
"oklch(0.84 0.009 90)",
"oklch(0.95 0.007 90)"
]
},
"surface": {
"role": "neutral",
"displayName": "Surface Tan",
"canonical": "oklch(0.9 0.025 88)",
"tonalRamp": [
"oklch(0.18 0.016 88)",
"oklch(0.29 0.02 88)",
"oklch(0.4 0.023 88)",
"oklch(0.51 0.025 88)",
"oklch(0.62 0.025 88)",
"oklch(0.73 0.023 88)",
"oklch(0.84 0.019 88)",
"oklch(0.95 0.014 88)"
]
},
"text": {
"role": "neutral",
"displayName": "Ink",
"canonical": "oklch(0.24 0.02 70)",
"tonalRamp": [
"oklch(0.18 0.013 70)",
"oklch(0.29 0.016 70)",
"oklch(0.4 0.019 70)",
"oklch(0.51 0.02 70)",
"oklch(0.62 0.02 70)",
"oklch(0.73 0.018 70)",
"oklch(0.84 0.015 70)",
"oklch(0.95 0.011 70)"
]
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
"purpose": "Hero proclamations only; one per page, commands the fold."
},
"headline": {
"displayName": "Headline",
"purpose": "Section and card headings, e.g. the circle metaphors."
},
"title": {
"displayName": "Title",
"purpose": "Smaller serif headings inside dense blocks."
},
"body": {
"displayName": "Body",
"purpose": "All prose and UI text; Commit Mono, measure capped 65-75ch."
},
"label": {
"displayName": "Label",
"purpose": "Uppercase kickers, field labels, badges; the faint structural voice."
}
},
"shadows": [
{
"name": "popover-lift",
"value": "0 4px 12px rgba(0,0,0,0.12)",
"purpose": "The only shadow in the system. Floating portaled overlays (select menus, dropdowns) only; never in-page surfaces."
}
],
"motion": [
{
"name": "state",
"value": "0.15s ease",
"purpose": "Default button/border state transitions."
},
{
"name": "reveal",
"value": "0.2s ease",
"purpose": "Hover nudges and lifts (transform only; respects prefers-reduced-motion)."
}
],
"breakpoints": [
{
"name": "content",
"value": "768px",
"purpose": "Multi-column content rows collapse to a single column."
},
{
"name": "page-collapse",
"value": "1024px",
"purpose": "Sidebar navigation collapses to a Menu disclosure (--page-collapse)."
}
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "The single call-to-action treatment; solid candle gold with a solid border.",
"html": "<button class=\"ds-btn-primary\">Become a member</button>",
"css": ".ds-btn-primary { font-family: \"Commit Mono\", monospace; font-size: 12px; letter-spacing: 0.04em; padding: 7px 18px; background: var(--candle); color: var(--bg); border: 1px solid var(--candle); border-radius: 0; cursor: pointer; transition: background 0.15s, border-color 0.15s; } .ds-btn-primary:hover { background: var(--candle-dim); border-color: var(--candle-dim); } .ds-btn-primary:focus-visible { outline: 2px dashed var(--candle); outline-offset: 3px; }"
},
{
"name": "Default Button",
"kind": "button",
"refersTo": "button-default",
"description": "Neutral action; dashed border on cream, tonal fill on hover.",
"html": "<button class=\"ds-btn\">Read the wiki</button>",
"css": ".ds-btn { font-family: \"Commit Mono\", monospace; font-size: 12px; letter-spacing: 0.04em; padding: 7px 18px; background: var(--bg); color: var(--text); border: 1px dashed var(--border); border-radius: 0; cursor: pointer; transition: background 0.15s, border-color 0.15s; } .ds-btn:hover { background: var(--surface-hover); border-color: var(--border-d); } .ds-btn:focus-visible { outline: 2px dashed var(--candle); outline-offset: 3px; }"
},
{
"name": "Danger Button",
"kind": "button",
"refersTo": "button-danger",
"description": "Destructive action; ember text and border, inverts to ember fill on hover.",
"html": "<button class=\"ds-btn-danger\">Delete</button>",
"css": ".ds-btn-danger { font-family: \"Commit Mono\", monospace; font-size: 12px; letter-spacing: 0.04em; padding: 7px 18px; background: var(--bg); color: var(--ember); border: 1px dashed var(--ember); border-radius: 0; cursor: pointer; transition: background 0.15s, color 0.15s; } .ds-btn-danger:hover { background: var(--ember); color: var(--bg); border-style: solid; }"
},
{
"name": "Text Field",
"kind": "input",
"refersTo": "input",
"description": "Editable surface; dashed border that goes solid candle on focus.",
"html": "<label class=\"ds-field\"><span>Display name</span><input type=\"text\" placeholder=\"ghost\" /></label>",
"css": ".ds-field span { display: block; font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-faint); margin-bottom: 3px; } .ds-field input { width: 100%; box-sizing: border-box; font-family: \"Commit Mono\", monospace; font-size: 13px; padding: 5px 8px; color: var(--text-bright); background: var(--input-bg); border: 1px dashed var(--border); border-radius: 0; outline: none; } .ds-field input:focus { border-color: var(--candle); border-style: solid; }"
},
{
"name": "Circle Badge",
"kind": "chip",
"refersTo": "badge",
"description": "Membership-tier identity tag; dashed border in the circle hue. Not a generic tag.",
"html": "<span class=\"ds-badge\">Practitioner</span>",
"css": ".ds-badge { display: inline-block; font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 8px; color: var(--c-practitioner); border: 1px dashed rgba(42,70,80,0.35); border-radius: 0; }"
},
{
"name": "Sidebar Nav Item",
"kind": "nav",
"refersTo": "navigation",
"description": "Left-rail navigation entry; tonal fill when active, mono type.",
"html": "<nav class=\"ds-nav\"><a class=\"ds-nav-item is-active\">Home</a><a class=\"ds-nav-item\">About</a><a class=\"ds-nav-item\">Events</a></nav>",
"css": ".ds-nav { display: flex; flex-direction: column; font-family: \"Commit Mono\", monospace; font-size: 13px; border-right: 1px dashed var(--border); width: 200px; } .ds-nav-item { padding: 6px 12px; color: var(--text); text-decoration: none; transition: background 0.15s, color 0.15s; } .ds-nav-item:hover { background: var(--surface-hover); } .ds-nav-item.is-active { background: var(--surface); color: var(--text-bright); }"
},
{
"name": "Hero CTA Broadside",
"kind": "custom",
"refersTo": "hero-cta",
"description": "Signature: oversized serif headline with one ember word, a solid primary block, and demoted text links.",
"html": "<div class=\"ds-hero\"><h1>Game developers explore <span>cooperative</span> models.</h1><div class=\"ds-hero-links\"><a class=\"ds-hero-primary\">Become a member</a><a class=\"ds-hero-link\">Read the wiki</a></div></div>",
"css": ".ds-hero h1 { font-family: \"Brygada 1918\", serif; font-size: clamp(40px, 6.5vw, 80px); font-weight: 600; line-height: 1.04; letter-spacing: -0.022em; color: var(--text-bright); max-width: 16ch; margin: 0 0 28px; } .ds-hero h1 span { color: var(--ember); } .ds-hero-links { display: flex; align-items: center; gap: 24px; } .ds-hero-primary { font-family: \"Commit Mono\", monospace; font-size: 14px; padding: 13px 30px; background: var(--candle); color: var(--bg); border: 1px solid var(--candle); text-decoration: none; transition: background 0.2s, transform 0.2s; } .ds-hero-primary:hover { background: var(--candle-dim); transform: translateY(-2px); } .ds-hero-link { font-family: \"Commit Mono\", monospace; font-size: 14px; color: var(--candle); padding: 4px 0; border-bottom: 1px dashed var(--candle-faint); text-decoration: none; }"
},
{
"name": "Parchment Inset",
"kind": "card",
"refersTo": null,
"description": "Signature: inverted dark block for a featured passage; pinned to the same values in light and dark mode.",
"html": "<aside class=\"ds-parch\"><div class=\"ds-parch-label\">From the Wiki</div><h2>What is a cooperative studio?</h2><p>A game studio owned and governed by the people who work there.</p></aside>",
"css": ".ds-parch { background: #2a2015; color: #ede4d0; padding: 28px 32px; border-radius: 0; } .ds-parch-label { font-family: \"Commit Mono\", monospace; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #c4a448; margin-bottom: 12px; } .ds-parch h2 { font-family: \"Brygada 1918\", serif; font-size: 22px; font-weight: 500; margin: 0 0 10px; } .ds-parch p { font-family: \"Commit Mono\", monospace; font-size: 13px; line-height: 1.7; color: #b8ae98; margin: 0; }"
}
],
"narrative": {
"northStar": "The Text Adventure Hall",
"overview": "Ghost Guild is a world built from words: a monospace text adventure rendered on cream paper. Rooms instead of pages, prose instead of chrome, structure drawn in dashed ink rather than boxes and shadows. Two typefaces (Brygada 1918 serif for display, Commit Mono for everything else) do all the work over a warm cream ground with a faint noise texture. Candlelight gold is the single voice of action; ember rust is the rare focal emphasis. Depth is tonal layering, never drop shadows or glass.",
"keyCharacteristics": [
"Two fonts only: Brygada 1918 (serif display) + Commit Mono (everything else).",
"Cream paper ground with a 2.5% noise overlay; warm, tinted neutrals throughout.",
"Dashed borders for structure, solid borders for inputs and active state.",
"Square corners everywhere (border-radius: 0).",
"Flat by default: depth comes from tonal layering, not shadows.",
"Candle gold is the only call-to-action color; ember rust is the rare accent."
],
"rules": [
{
"name": "The Candlelight Rule",
"body": "Candle gold is the only color permitted on a call-to-action. If something is gold, it acts. Nothing decorative wears gold.",
"section": "colors"
},
{
"name": "The Single Ember Rule",
"body": "Ember rust appears at most once per view as emphasis. Two embers cancel each other out; the rarity is the point.",
"section": "colors"
},
{
"name": "The Two-Font Rule",
"body": "Brygada 1918 and Commit Mono. That is the entire type system. A third family is forbidden.",
"section": "typography"
},
{
"name": "The Flat Paper Rule",
"body": "Surfaces sit flat on the page. If you reach for box-shadow on an in-page element, use a dashed border or a tonal surface step instead. Shadows belong only to floating popovers.",
"section": "elevation"
}
],
"dos": [
"Do use exactly two typefaces: Brygada 1918 for display/headings, Commit Mono for everything else.",
"Do draw structure with 1px dashed borders, and switch borders to solid only for inputs and active state.",
"Do keep every corner square (border-radius: 0).",
"Do reserve Candle Gold (#7a5a10) for actions and Ember Rust (#8a4420) for a single focal emphasis per view.",
"Do convey depth through tonal layering (cream -> surface -> parchment) and the noise overlay, not shadows.",
"Do keep text contrast at WCAG AA: Ink Dim (#5a5040) and Ink Faint (#665c4b) were tuned to pass on cream.",
"Do use fluid clamp() spacing and type so the layout breathes on large viewports."
],
"donts": [
"Don't introduce a third typeface. (The Two-Font Rule.)",
"Don't round corners anywhere.",
"Don't put box-shadow on in-page surfaces; shadows belong only to floating popovers.",
"Don't use a border-left/border-right greater than 1px as a colored accent stripe.",
"Don't use gradient text or background-clip: text; emphasis comes from weight, size, or a single ember word.",
"Don't use purple/blue gradients, glassmorphism, neon-on-dark, or identical icon-title card grids.",
"Don't reach for CSS hacks: no negative margins, no magic numbers, no fragile workarounds.",
"Don't put neutral gray text on the parchment block or any colored surface; use the parchment text tokens.",
"Don't use UToggle; use USwitch (Nuxt UI 4)."
]
}
}

View file

@ -248,6 +248,17 @@ p a, blockquote a {
border-color: var(--border);
}
/* ---- ACCENT TINT BLOCKS ---- */
/* Faint accent fill + matching solid border. Replaces inline color-mix styles. */
.tint-candle {
background: color-mix(in srgb, var(--candle) 15%, transparent);
border: 1px solid var(--candle);
}
.tint-ember {
background: color-mix(in srgb, var(--ember) 15%, transparent);
border: 1px solid var(--ember);
}
/* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */
/* Negative-margin overlap: every item keeps all 4 borders,
siblings overlap by 1px, active item paints on top via z-index. */

View file

@ -63,8 +63,7 @@
class="flex items-start gap-3 p-3"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 tint-candle"
>
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
</div>
@ -86,8 +85,7 @@
<!-- Member Benefit Callout -->
<div
v-if="ticket.isFree && isMember"
class="p-4 mb-6"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
class="p-4 mb-6 tint-candle"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:sparkles" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
@ -103,8 +101,7 @@
<!-- Public vs Member Pricing -->
<div
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
class="p-4 mb-6"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
class="p-4 mb-6 tint-candle"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:tag" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
@ -162,8 +159,7 @@
<!-- Already Registered -->
<div
v-else-if="alreadyRegistered"
class="p-4"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
class="p-4 tint-candle"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />

View file

@ -100,8 +100,7 @@
<!-- Member Benefits Notice -->
<div
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
class="p-4"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
class="p-4 tint-candle"
>
<div class="flex items-start gap-3">
<Icon

View file

@ -604,15 +604,7 @@ const deleteSeries = (series) => {
confirmAction.execute = async () => {
confirmAction.running = true
try {
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null },
},
})
}
await $fetch(`/api/admin/series/${series.id}`, { method: 'DELETE' })
confirmAction.show = false
await refresh()
} catch (error) {

View file

@ -2,7 +2,7 @@
<div>
<!-- HERO -->
<div class="hero">
<h1>Ghost Guild is where game developers explore cooperative models.</h1>
<h1>Ghost Guild is where game developers explore <span class="accent">cooperative</span> models.</h1>
<p>
Resources, events, and a community of people figuring it out. Three
circles, pay what you can.
@ -208,51 +208,68 @@ const formatDate = (event) => {
<style scoped>
/* ---- HERO ---- */
.hero {
padding: 48px 32px;
padding: clamp(64px, 10vw, 144px) clamp(28px, 5vw, 72px) clamp(48px, 7vw, 104px);
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: "Brygada 1918", serif;
font-size: 36px;
font-size: clamp(40px, 6.5vw, 80px);
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
letter-spacing: -0.01em;
margin-bottom: 16px;
max-width: 540px;
line-height: 1.04;
letter-spacing: -0.022em;
margin-bottom: clamp(20px, 2.4vw, 36px);
max-width: 16ch;
}
.hero h1 .accent {
color: var(--candle);
}
.hero p {
color: var(--text-dim);
max-width: 460px;
max-width: 48ch;
font-size: clamp(13px, 1.3vw, 16px);
line-height: 1.7;
margin-bottom: 20px;
margin-bottom: clamp(28px, 3vw, 44px);
}
.hero-links {
display: flex;
gap: 16px;
font-size: 13px;
}
.hero-link {
color: var(--candle);
padding: 6px 16px;
border: 1px dashed var(--candle-faint);
transition: all 0.2s;
text-decoration: none;
}
.hero-link:hover {
border-color: var(--candle);
border-style: solid;
text-decoration: none;
align-items: center;
flex-wrap: wrap;
gap: clamp(16px, 2vw, 28px);
font-size: 14px;
}
/* Primary CTA reads as a solid block; secondary actions recede to quiet links. */
.hero-link.primary {
background: var(--candle);
color: var(--bg);
border-color: var(--candle);
border-style: solid;
padding: 13px 30px;
border: 1px solid var(--candle);
letter-spacing: 0.02em;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
text-decoration: none;
}
.hero-link.primary:hover {
background: var(--candle-dim);
border-color: var(--candle-dim);
transform: translateY(-2px);
text-decoration: none;
}
.hero-link:not(.primary) {
color: var(--candle);
padding: 4px 0;
border-bottom: 1px dashed var(--candle-faint);
transition: border-color 0.2s, color 0.2s;
text-decoration: none;
}
.hero-link:not(.primary):hover {
color: var(--candle-dim);
border-color: var(--candle);
text-decoration: none;
}
/* Keyboard focus echoes the design system's dashed-candle indicator (see .btn). */
.hero-link:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
/* ---- CONTENT GRID ---- */
@ -266,40 +283,41 @@ const formatDate = (event) => {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content-block {
padding: 24px 28px;
padding: clamp(24px, 2.6vw, 40px) clamp(24px, 2.2vw, 36px);
border-right: 1px dashed var(--border);
min-width: 0;
overflow-wrap: break-word;
align-self: stretch;
}
.content-row.two-col .content-block {
padding: 24px 0;
padding: clamp(24px, 2.6vw, 40px) 0;
}
.content-row.two-col .block-inset {
padding-left: 28px;
padding-right: 28px;
padding-left: clamp(24px, 2.2vw, 36px);
padding-right: clamp(24px, 2.2vw, 36px);
}
.content-block:last-child {
border-right: none;
}
.content-block h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-size: clamp(19px, 1.9vw, 26px);
font-weight: 500;
line-height: 1.15;
color: var(--text-bright);
margin-bottom: 8px;
margin-bottom: 10px;
}
.content-block p {
color: var(--text-dim);
font-size: 12px;
line-height: 1.65;
font-size: 13px;
line-height: 1.7;
}
.content-block .label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
margin-bottom: 10px;
}
/* ---- EVENT LIST ---- */
@ -316,10 +334,10 @@ const formatDate = (event) => {
align-items: baseline;
padding-top: 10px;
padding-bottom: 10px;
transition: padding-left 0.2s;
transition: transform 0.2s;
}
.content-row.two-col .event-item:hover .event-item-inner {
padding-left: calc(28px + 4px);
transform: translateX(4px);
}
.event-date {
color: var(--text-faint);
@ -382,5 +400,23 @@ const formatDate = (event) => {
.hero-link {
text-align: center;
}
/* Comfortable tap targets; tight dashed underline replaces the bottom border. */
.hero-link:not(.primary) {
padding: 11px 0;
border-bottom: none;
text-decoration: underline dashed var(--candle-faint);
text-underline-offset: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-link.primary,
.event-item-inner {
transition: none;
}
.hero-link.primary:hover,
.content-row.two-col .event-item:hover .event-item-inner {
transform: none;
}
}
</style>

View file

@ -3,7 +3,7 @@
<!-- Already a member -->
<template v-if="isAuthenticated">
<div class="full-section">
<h2>You're already a member</h2>
<h1>You're already a member</h1>
<p class="section-intro">
Welcome back, {{ memberData?.name || "member" }}. You're part of Ghost
Guild in the
@ -75,7 +75,7 @@
<div class="join-grid">
<div class="join-main">
<div class="join-main-inner">
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
<div v-if="errorMessage" class="error-box" role="alert">{{ errorMessage }}</div>
<form @submit.prevent="handleSubmit">
<!-- About you -->
@ -93,10 +93,12 @@
class="form-input"
type="text"
required
:aria-invalid="!!fieldErrors.name"
aria-describedby="join-name-error"
@blur="validateName"
@input="fieldErrors.name && (fieldErrors.name = '')"
>
<p v-if="fieldErrors.name" class="field-error">
<p v-if="fieldErrors.name" id="join-name-error" class="field-error">
{{ fieldErrors.name }}
</p>
</div>
@ -109,10 +111,12 @@
type="email"
placeholder="you@example.com"
required
:aria-invalid="!!fieldErrors.email"
aria-describedby="join-email-error"
@blur="validateEmail"
@input="fieldErrors.email && (fieldErrors.email = '')"
>
<p v-if="fieldErrors.email" class="field-error">
<p v-if="fieldErrors.email" id="join-email-error" class="field-error">
{{ fieldErrors.email }}
</p>
</div>
@ -132,6 +136,7 @@
type="button"
data-testid="cadence-monthly"
:class="{ active: cadence === 'monthly' }"
:aria-pressed="cadence === 'monthly'"
@click="onCadenceChange('monthly')"
>
Monthly
@ -140,6 +145,7 @@
type="button"
data-testid="cadence-annual"
:class="{ active: cadence === 'annual' }"
:aria-pressed="cadence === 'annual'"
@click="onCadenceChange('annual')"
>
Annual
@ -147,13 +153,14 @@
</div>
</div>
<p class="form-block-intro">
Equal access for everyone. Pick what fits &mdash; these aren't
Equal access for everyone. Pick what fits. These aren't
tiers.
</p>
<ul
class="pwyc-list"
aria-label="Contribution amount"
role="radiogroup"
>
<li
v-for="preset in presetRows"
@ -246,8 +253,8 @@
you set up tax receipts after you join.
</li>
<li>
<strong>Secure payment.</strong> Card entry handled by Helcim
&mdash; we never see your card details.
<strong>Secure payment.</strong> Card entry handled by Helcim;
we never see your card details.
</li>
<li>
<strong>Change anytime.</strong> Adjust your contribution or
@ -707,7 +714,7 @@ onUnmounted(() => {
.cadence-toggle button {
background: transparent;
border: none;
padding: 5px 12px;
padding: 7px 12px;
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--text-faint);
@ -883,7 +890,7 @@ onUnmounted(() => {
border: 1px solid var(--parch);
padding: 12px 24px;
cursor: pointer;
transition: all 0.15s;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.submit-btn:hover {
@ -938,7 +945,7 @@ onUnmounted(() => {
padding: 32px;
border-bottom: 1px dashed var(--border);
}
.full-section h2 {
.full-section h1 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
@ -982,7 +989,7 @@ onUnmounted(() => {
border: 1px solid var(--parch);
padding: 12px 28px;
cursor: pointer;
transition: all 0.2s;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
text-decoration: none;
text-align: center;
}

View file

@ -1,6 +1,6 @@
# Ghost Guild — Open Backlog
_Last consolidated: 2026-05-18. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
_Last consolidated: 2026-05-24. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
@ -15,6 +15,7 @@ Operational steps that have to run during cutover. Full details + env-var list i
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
- [ ] **After deploying `feature/contribution-form-polish`: run `node scripts/migrate-annual-contribution-to-cadence-unit.cjs --apply` against prod Mongo.** Converts existing annual Member rows from monthly-equivalent storage to cadence-unit storage (×12 on `contributionAmount` for `billingCadence='annual'` rows). Idempotent via transient `contributionAmountConverted` marker. Without this step, the UI will render `$15/yr` for existing annual members until they update their contribution.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
- [ ] Push local `main` to `origin/main`.
@ -47,9 +48,10 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Known gotchas (post-launch)
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
- **`/admin/series-management` "Delete" button doesn't actually delete.** Click handler iterates events to PUT-unlink each from the series, never calls `DELETE /api/admin/series/:id`. For an empty series the button is a no-op; for a series with events it just orphans them. Either rename to "Unlink events" or add the actual DELETE call. Surfaced by `e2e/admin-series.spec.js` (delete test skipped). Flagged 2026-04-30.
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account``ContributionAmountField` is rendered with `:allow-cadence-change="false"` there. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
- **`contributionAmount` is now cadence-unit (not monthly-equivalent).** Post `feature/contribution-form-polish`: `Member.contributionAmount` reads as $180 for "$180/year" annual members, not $15. Server no longer multiplies by 12 anywhere; UI uses `formatContribution(amount, cadence)` from `app/config/contributions.js`. See memory `project_contribution_form_polish`.
- ~~**S2 test fixture id/slug mismatch (local dev only).**~~ Verified resolved 2026-05-24: no `test-s2-drop-in-allowed` reference remains anywhere in `scripts/`, `tests/`, or JSON — the fixture was removed.
- ~~**`/admin/series-management` "Delete" button doesn't actually delete.**~~ Fixed 2026-05-24 (`fix/backlog-batch-2026-05-24`): `deleteSeries` (`app/pages/admin/series/index.vue`) now calls `DELETE /api/admin/series/[id]` (the endpoint already unlinks events server-side), replacing the redundant per-event PUT loop. `e2e/admin-series.spec.js` delete test un-skipped.
- **Past-deadline events and sold-out events render identically.** `EventTicketPurchase.vue` falls through to "Event Sold Out" panel for both `tickets.available.reason === 'Registration deadline has passed'` and zero-stock cases. If "Registration closed" is meant to read differently from "Sold out," add a distinct branch. Flagged 2026-04-30 (no e2e written — gated on this UX decision).
---
@ -57,7 +59,7 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Accessibility / a11y
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
- [ ] **`/board` color-contrast violations (WCAG AA).** `.block-label` ("Offering" tag) and `.slack-handle` use `#746a58` on `#e8dfc8` → 4.01:1; AA needs 4.5:1 for small text. Surfaced by `e2e/a11y.spec.js` (the `/board` route fails; test is intentionally left red until fixed). Likely a single CSS variable adjustment. Flagged 2026-04-30.
- ~~**`/board` color-contrast violations (WCAG AA).**~~ Verified done 2026-05-24: `.block-label` and `.slack-handle` in `BoardPostCard.vue` now use `var(--text-dim)` on the cream card bg (code comments note `--text-faint` was insufficient there). `--text-faint` itself was also darkened from `#746a58` to `#665c4b` (`main.css:33`, 4.94:1 on `--surface`).
---
@ -72,9 +74,9 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Wave-Slack pilot follow-ups
- [ ] **`/api/auth/member` doesn't return `slackInvited`.** Dashboard's Slack-coming note is gated on `memberData.slackInvited`, which is always `undefined` client-side, so the note shows for *every* active member regardless of state. Real bug. Add `slackInvited` (and `slackInvitedAt`) to the auth/member response. Surfaced by wave-slack §7.2 e2e (skipped pending this fix). Flagged 2026-04-30.
- [ ] **Admin members list row mutation isn't reactive.** `markSlackInvited` in `app/pages/admin/members/index.vue` does `Object.assign(member, res.member)` on a plain object inside a `useFetch` array; Vue doesn't react, so the "Mark as Slack invited" button stays visible until a manual reload. Fix: `members.value[i] = { ...members.value[i], ...res.member }` or `splice`. Detail page uses the right pattern (covered by §6.6). Surfaced by wave-slack §6.2 e2e (skipped pending this fix). Flagged 2026-04-30.
- [ ] **Deprecated `slackInviteStatus` field still serialized.** Removed from UI but still on `Member` documents and the `/api/admin/members` payload. Project it away in the API response and run a one-shot `$unset` cleanup. Surfaced by wave-slack §6.7 e2e. Flagged 2026-04-30.
- ~~**`/api/auth/member` doesn't return `slackInvited`.**~~ Verified done 2026-05-24: `server/api/auth/member.get.js:20-21` returns both `slackInvited` and `slackInvitedAt`.
- ~~**Admin members list row mutation isn't reactive.**~~ Verified done 2026-05-24: `markSlackInvited` (`app/pages/admin/members/index.vue:843`) now does `members.value[idx] = { ...members.value[idx], ...res.member }`.
- [ ] **Deprecated `slackInviteStatus` — optional DB cleanup only.** No longer serialized: removed from the `Member` schema and the UI, and `server/api/admin/members.get.js` projects only current schema paths (`Object.keys(Member.schema.paths)`), so stale doc values can't leak into the payload. Remaining: an optional one-shot `$unset` to tidy old Mongo docs — nothing reads the field. Narrowed 2026-05-24.
- [ ] **Spec vs shipped-UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 asserts "no wave/cohort/batch language" in the dashboard note, but the shipped welcome-email and dashboard copy say "monthly onboarding waves." Decide which side wins; update the other.
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 9 of 16 scaffolded tests now passing (admin Slack-invited button + non-trivial dashboard cases). 7 remain skipped pending the bugs above (7.2, 6.2), seeding gaps (7.4 — no dev endpoint to mint members of arbitrary status), Open Questions (7.8, 6.9), or spec-vs-UI conflicts (7.5, 6.7).
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
@ -82,20 +84,35 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
---
## Contribution-form-polish follow-ups
From `feature/contribution-form-polish` (14 commits, see memory `project_contribution_form_polish`). Captured 2026-05-23.
- ~~**`account.vue` `currentContributionLabel` uses long-form `/year`, `/month`.**~~ Done 2026-05-24: now returns `formatContribution(amount, cadence.value)` (`/yr`, `/mo`).
- [ ] **Annual-abandoned-signup `billingCadence` corruption — `/join` half still open + existing-row cleanup.** A member who picks annual but abandons before `/api/helcim/subscription` runs is left at `billingCadence: 'monthly'` + cadence-unit `contributionAmount` (e.g. 180) + `status: 'pending_payment'`, rendering `$180/mo` in admin views (member can't see it — not yet signed in). `billingCadence` is only corrected to `'annual'` once the subscription call runs.
- ~~Invite-accept path.~~ Fixed 2026-05-24 (`c3b1c59`): `inviteAcceptSchema` now accepts `cadence`, `accept-invite.vue` sends it, and `accept.post.js` persists `billingCadence` at `Member.create` ($0 forced to `'monthly'`).
- ~~`/join` path had the same gap.~~ Fixed 2026-05-24 (`426f233`): `helcimCustomerSchema` accepts `cadence`, `join.vue` sends it to `/api/helcim/customer`, and `customer.post.js` persists `billingCadence` in both the new-member create branch and the guest-upgrade `$set` branch ($0 forced to `'monthly'`). (`/api/members/create` carries the same omission but is uncalled by any frontend — left as-is.)
- ~~One-shot cleanup for rows corrupted before these fixes.~~ Verified no-op 2026-05-24: swept the `ghost-guild` DB (59 members) — 0 rows with `billingCadence: 'annual'`, 0 with an annual-magnitude `contributionAmount` (≥60), and all 17 `pending_payment` rows sit at monthly presets {0,5,15,30,50}. No corruption exists, so no script was written or run. Because both code fixes land before cutover, there's no pre-fix production window in which this corruption could occur. (If the fixes somehow ship *after* real annual signups have happened, re-run the sweep post-launch.)
- [ ] **`/member/payment-setup` is monthly-only by design — lossy `Math.floor(amount/12)` redirect from account.vue.** Spec already deferred this. Annual members landing on payment-setup via account.vue's `requiresPaymentSetup` recovery path get their cadence-unit amount floor-divided to monthly at the redirect (e.g. $100/yr → tier=8 → $96/yr after re-charge). Reachable only for corrupt-state annual members who lost their `helcimSubscriptionId`. Long-term: payment-setup accepts `?cadence=` and passes through to `update-contribution`. WHY comment lives at `account.vue:472-474`.
- [ ] **No unit tests on `ContributionAmountField.vue`.** The cadence-toggle math (×12 / floor(/12)) and soft-max threshold ($500/mo equiv) live entirely in the component. E2E tests on `/join` + `/accept-invite` cover the happy paths. A small Vitest suite around toggle math, preset selection, and emission would protect against regressions as the component gains more consumers. _Deferred 2026-05-24: a mounted test needs `@vue/test-utils` (not installed; not bundled by `@nuxt/test-utils`) plus a `~` alias / Nuxt env for the `client` vitest project. Out of scope for the low-risk batch — pick up with a deliberate decision on the test approach._
- [ ] **Migration script TOCTOU.** `scripts/migrate-annual-contribution-to-cadence-unit.cjs` reads then writes in two separate ops per doc. If a member updates their contribution via the UI between the read and the write, the migration overwrites the new value with `(stale × 12)`. Mitigation: run during a brief maintenance window, or refactor to a single `updateOne` with `$mul` + marker guard. Low-impact given the small annual-member population.
---
## Simplify-pass follow-ups (still open)
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
- [ ] **STATUS_LABELS dedup — verify.** The 2026-04-30 small-wins batch claimed STATUS_LABELS dedup, but `e2e/admin-members.spec.js` expansion found an inline copy still at `app/pages/admin/members/index.vue:491` and another at `app/pages/member/account.vue:420`. Either the previous dedup was partial or a new copy was reintroduced — confirm and finish dedup into a shared constants module.
- [ ] **`app/pages/admin/members/[id].vue` status select still hand-written.** Commit `441a5f5` aligned the index page's status `<select>` to `STATUS_LABELS`, but the detail page (`[id].vue`) still hand-codes raw status options. Refactor to drive from the same constant.
- ~~**Extract `.tint-candle` / `.tint-ember` utility classes.**~~ Done 2026-05-24 (`fix/backlog-batch-2026-05-24`): added `.tint-candle` / `.tint-ember` to `app/assets/css/main.css` and replaced the five inline `style=""` candle tints in `SeriesPassPurchase.vue` + `EventSeriesTicketCard.vue` with the class. (`NaturalDateInput.vue` had no such inline tint; `ImageUpload.vue:29` is a conditional drag-state `:style` with only `border-color`, left as-is.)
- ~~**Audit `member &&` truthy checks in sibling ticket/subscription routes.**~~ Verified resolved 2026-05-24: `tickets/purchase.post.js:34` already gates access/pricing via `hasMemberAccess(member) ? member : null`; the remaining `member &&` lines are harmless existence checks (recording `memberId`, the auto-login decision). No truthy pricing/access gating remains in `events`/`members`/`helcim` routes.
- ~~**STATUS_LABELS dedup — verify.**~~ Verified done 2026-05-24: single shared module `app/config/memberStatus.js`; `index.vue`, `account.vue`, and `[id].vue` all import `STATUS_LABELS` with no inline copies remaining.
- ~~**`app/pages/admin/members/[id].vue` status select still hand-written.**~~ Verified done 2026-05-24: `[id].vue:65-67` drives the status `<select>` from `STATUS_LABELS` (`v-for="(label, value) in STATUS_LABELS"`).
---
## Optional / low-priority
- [ ] **Welcome-email Slack-timing mention.** Currently the welcome email doesn't mention Slack timing — the dashboard carries that note. Could add a one-line "Slack invitation comes in monthly waves — there may be a short wait" if the dashboard turns out not to be enough signal.
- ~~**Welcome-email Slack-timing mention.**~~ Done 2026-05-24 (`fix/backlog-batch-2026-05-24`): `sendWelcomeEmail` (`server/utils/resend.js`) now includes "Your Slack invitation arrives in our monthly onboarding waves — there may be a short wait."
---
@ -103,7 +120,7 @@ Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins
Surfaced during the 2026-04-30 e2e expansion. None block a green suite, but each blocks specific coverage from being added.
- [ ] **Other email routes still send real emails in dev mode.** The `ALLOW_DEV_TEST_ENDPOINTS` short-circuit was added to `server/api/admin/pre-registrants/invite.post.js` (which calls `new Resend(...)` directly), but the five wrapper functions in `server/utils/resend.js` (event registration, cancellation, waitlist, series pass, welcome) still dispatch live. Either add the same gate to each wrapper, or refactor the wrappers into a single `sendEmail({ from, to, subject, text, html })` helper holding the gate centrally — would also dedupe ~5 near-identical try/catch blocks.
- ~~**Other email routes still send real emails in dev mode.**~~ Fixed 2026-05-24 (`fix/backlog-batch-2026-05-24`): all five `server/utils/resend.js` wrappers now early-return via a shared `skipEmailInDev` guard when `ALLOW_DEV_TEST_ENDPOINTS=true`, mirroring `pre-registrants/invite.post.js`. Covered by `tests/server/utils/resend.test.js`. (The wrappers still keep their own try/catch — full `sendEmail` dedup deferred as a non-blocking cleanup.)
- [ ] **No dev endpoint to seed members of arbitrary status.** Wave-slack §7.4 (note hidden for suspended/cancelled/guest) is gated on this. `/api/dev/test-login` only mints an `active` admin. A minimal `/api/dev/members.post` accepting `{ email, status, slackInvited, ... }` would unblock many more dashboard-state e2e tests.
- [ ] **SSR `useFetch` blocks `page.route` mocking.** Page-level fetches in `[slug].vue` files run during SSR and can't be intercepted client-side. Affects: hidden-event 404 e2e, any test that needs a mocked event payload before client hydration. Either expose a client-side fetch alternative, add a server-side test mock layer, or accept that DB seeding is required for these cases.
- [ ] **Self-cancel block on paid event registrations not e2e-tested.** Requires seeding a logged-in member with a paid registration row. Out of scope for this round.
@ -120,5 +137,6 @@ Surfaced during the 2026-04-30 e2e expansion. None block a green suite, but each
- `project_pre_registrants.md` — invitation system + pre-reg lifecycle
- `project_helcim_plan_model.md` — cadence-keyed plan model
- `project_contribution_amount_redesign.md` — arbitrary $ amount + guidance presets
- `project_contribution_form_polish.md` — ContributionAmountField component + cadence-unit contract shift (supersedes the cadence-math claims in `project_contribution_amount_redesign`)
- `project_receipts.md` — Phase 1 done, Phase 2 pending
- `project_email_automation_future.md` — Tranzac reference for full system

View file

@ -57,9 +57,34 @@ test.describe('Admin series CRUD', () => {
await expect(editedCard).toContainText(editedDescription, { timeout: 10000 })
})
// Delete is skipped: the series-management page's "Delete" button only
// unlinks events from the series via PUT /api/admin/events/:id; it does
// not call DELETE /api/admin/series/:id, so the series record remains.
// No UI affordance currently exists to remove an empty series.
test.skip('delete a series', async () => {})
test('delete a series', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-series-del-${suffix}`
// --- Create the series to delete ---
await adminPage.goto('/admin/series/create')
await expect(adminPage.locator('h1')).toContainText('Create New Series')
await adminPage
.getByPlaceholder('e.g., Cooperative Game Development Fundamentals')
.fill(title)
await adminPage
.getByPlaceholder('Describe what the series covers and its goals')
.fill('e2e delete-me series')
await adminPage.getByRole('button', { name: 'Create Series' }).click()
await adminPage.waitForURL('**/admin/series', { timeout: 15000 })
const card = adminPage.locator('.series-card', { hasText: title })
await expect(card).toBeVisible({ timeout: 10000 })
// --- Delete (card button → confirm modal) ---
await card.getByRole('button', { name: 'Delete' }).click()
const confirmModal = adminPage.locator('.modal-overlay', { hasText: 'Delete Series' })
await expect(confirmModal).toBeVisible()
await confirmModal.getByRole('button', { name: 'Delete', exact: true }).click()
// --- Series is gone ---
await expect(adminPage.locator('.series-card', { hasText: title })).toHaveCount(0, {
timeout: 10000,
})
})
})

View file

@ -2,6 +2,17 @@ import { Resend } from "resend";
const resend = new Resend(useRuntimeConfig().resendApiKey);
// In dev/test runs (ALLOW_DEV_TEST_ENDPOINTS=true) skip live email dispatch so
// local flows and e2e don't fire real Resend sends. Mirrors the gate in
// server/api/admin/pre-registrants/invite.post.js.
const skipEmailInDev = (label, to) => {
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === "true") {
console.log(`[resend] DEV MODE — skipping ${label}`, { to });
return true;
}
return false;
};
const formatEventDate = (dateString, timeZone = "America/Toronto") => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
@ -58,6 +69,10 @@ Paid: $${registration.amountPaid.toFixed(2)} CAD`;
ticketSection = "\nThis event is free for Ghost Guild members.\n";
}
if (skipEmailInDev("registration email", registration.email)) {
return { success: true, skipped: true };
}
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
@ -96,6 +111,10 @@ We look forward to seeing you there!`,
export async function sendEventCancellationEmail(registration, eventData) {
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
if (skipEmailInDev("cancellation email", registration.email)) {
return { success: true, skipped: true };
}
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
@ -129,6 +148,10 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`;
if (skipEmailInDev("waitlist notification email", waitlistEntry.email)) {
return { success: true, skipped: true };
}
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
@ -188,6 +211,10 @@ export async function sendSeriesPassConfirmation(options) {
})
.join("\n\n");
if (skipEmailInDev("series pass confirmation email", to)) {
return { success: true, skipped: true };
}
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
@ -226,6 +253,10 @@ ${eventList}`,
export async function sendWelcomeEmail(member) {
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
if (skipEmailInDev("welcome email", member.email)) {
return { success: true, skipped: true };
}
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <ghostguild@babyghosts.org>",
@ -238,6 +269,8 @@ Welcome to Ghost Guild! You're now part of the ${member.circle} circle.
Sign in to your dashboard to get started:
${baseUrl}/member/dashboard
Your Slack invitation arrives in our monthly onboarding waves there may be a short wait.
If you have questions, just reply to this email.`,
});

View file

@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
sendEventRegistrationEmail,
sendEventCancellationEmail,
sendWaitlistNotificationEmail,
sendSeriesPassConfirmation,
sendWelcomeEmail,
} from '../../../server/utils/resend.js'
// Hoisted spy so the resend mock and the assertions share one reference.
const { sendSpy } = vi.hoisted(() => ({ sendSpy: vi.fn() }))
vi.mock('resend', () => ({
Resend: class {
constructor() {
this.emails = { send: sendSpy }
}
},
}))
const eventData = {
title: 'Co-op Basics',
startDate: '2026-06-01T17:00:00.000Z',
endDate: '2026-06-01T18:00:00.000Z',
location: 'Online',
slug: 'co-op-basics',
}
const registration = { email: 'reg@example.com', name: 'Reg', ticketType: 'member', amountPaid: 0 }
const seriesPassOptions = {
to: 'pass@example.com',
name: 'Pass Holder',
series: { title: 'Workshop Series' },
ticket: { type: 'member', price: 0, currency: 'CAD', isFree: true },
events: [eventData],
paymentId: null,
}
const member = { email: 'welcome@example.com', name: 'New Member', circle: 'Community' }
describe('resend email wrappers — ALLOW_DEV_TEST_ENDPOINTS gate', () => {
beforeEach(() => {
sendSpy.mockReset()
sendSpy.mockResolvedValue({ data: { id: 'email_1' }, error: null })
})
afterEach(() => {
delete process.env.ALLOW_DEV_TEST_ENDPOINTS
})
describe('when ALLOW_DEV_TEST_ENDPOINTS=true', () => {
beforeEach(() => {
process.env.ALLOW_DEV_TEST_ENDPOINTS = 'true'
})
const cases = [
['registration', () => sendEventRegistrationEmail(registration, eventData)],
['cancellation', () => sendEventCancellationEmail(registration, eventData)],
['waitlist', () => sendWaitlistNotificationEmail(registration, eventData)],
['series pass', () => sendSeriesPassConfirmation(seriesPassOptions)],
['welcome', () => sendWelcomeEmail(member)],
]
it.each(cases)('skips the live send for %s', async (_label, call) => {
const result = await call()
expect(result).toEqual({ success: true, skipped: true })
expect(sendSpy).not.toHaveBeenCalled()
})
})
describe('when the gate is off', () => {
it('dispatches a live send and returns success', async () => {
const result = await sendWelcomeEmail(member)
expect(sendSpy).toHaveBeenCalledTimes(1)
expect(result).toEqual({ success: true, data: { id: 'email_1' } })
})
it('includes the monthly-onboarding Slack-timing line in the welcome email', async () => {
await sendWelcomeEmail(member)
const sent = sendSpy.mock.calls[0][0]
expect(sent.text).toContain('monthly onboarding waves')
})
})
})