Compare commits

..

No commits in common. "main" and "fix/events-matrix-p0" have entirely different histories.

181 changed files with 2256 additions and 6802 deletions

View file

@ -21,16 +21,16 @@ jobs:
playwright:
runs-on: ubuntu-latest
needs: vitest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
RESEND_API_KEY: re_ci_dummy_not_used
HELCIM_API_TOKEN: helcim_ci_dummy_not_used
OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
ALLOW_DEV_TEST_ENDPOINTS: 'true'
BASE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -39,35 +39,15 @@ jobs:
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Start MongoDB
run: |
docker rm -f mongo-ci 2>/dev/null || true
docker run -d --name mongo-ci mongo:7
# Forgejo runs each job inside its own container; attach Mongo to
# that container's network so MONGODB_URI=mongodb://mongo-ci:27017
# resolves from inside the runner.
RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
docker network connect "$RUNNER_NET" mongo-ci
docker ps
- name: Wait for MongoDB
run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done'
- name: MongoDB log on failure
if: failure()
run: docker logs mongo-ci || true
- name: Seed test data
run: node scripts/seed-all.js && node scripts/seed-tags.js
- run: npm run build
- name: Start server
run: node .output/server/index.mjs > /tmp/server.log 2>&1 &
run: node .output/server/index.mjs &
env:
PORT: 3000
- name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- name: Server log on failure
if: failure()
run: cat /tmp/server.log || true
- run: npx playwright test
- uses: actions/upload-artifact@v3
- run: npx playwright test --ignore-snapshots
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
@ -88,3 +68,39 @@ jobs:
-H 'Content-type: application/json' \
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
visual:
runs-on: ubuntu-latest
needs: vitest
continue-on-error: true
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Start server
run: node .output/server/index.mjs &
env:
PORT: 3000
- name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- run: npx playwright test e2e/visual/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: e2e/test-results/
retention-days: 7

1
.gitignore vendored
View file

@ -40,4 +40,3 @@ e2e/.auth/
.superpowers/
.claude
scripts/dump-babyghosts-preregistrations.mjs

View file

@ -3,18 +3,15 @@ project_name: "ghostguild-org"
# list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
@ -68,17 +65,53 @@ read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@ -89,14 +122,11 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@ -120,8 +150,3 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:

View file

@ -1,5 +1,5 @@
# Build stage
FROM node:22-alpine AS builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
@ -7,11 +7,8 @@ RUN npm ci --ignore-scripts && npx nuxt prepare
COPY . .
RUN npm run build
# Production stage — only the self-contained .output is needed.
# bash + curl are added so Dokploy scheduled tasks (which wrap commands in
# `bash -c "..."`) can run; alpine ships only ash and has no curl by default.
FROM node:22-alpine
RUN apk add --no-cache bash curl
# Production stage — only the self-contained .output is needed
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output .output

View file

@ -27,10 +27,7 @@
--text: #2a2015;
--text-bright: #1a1008;
--text-dim: #5a5040;
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
--text-dim (5.80:1) while meeting AA for small text. */
--text-faint: #665c4b;
--text-faint: #746a58;
--parch: #2a2015;
--parch-hover: #3a3025;
--parch-text: #ede4d0;
@ -276,14 +273,6 @@ p a, blockquote a {
min-width: 0;
}
/* ---- Nuxt UI placeholder contrast ----
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
AA on cream and white backgrounds (2.4:1). Override globally to --text-dim
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
[data-slot="placeholder"] {
color: var(--text-dim);
}
/* ---- SHARED USelectMenu STYLES ----
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */

View file

@ -158,7 +158,7 @@ const slackLinks = computed(() => {
<style scoped>
.board-post {
border: 1px dashed var(--border);
padding: 20px 24px;
padding: 18px 22px;
background: var(--surface);
break-inside: avoid;
-webkit-column-break-inside: avoid;
@ -178,8 +178,7 @@ const slackLinks = computed(() => {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
color: var(--text-faint);
}
.post-actions {
@ -220,7 +219,7 @@ const slackLinks = computed(() => {
.post-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-size: 19px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 12px;
@ -234,8 +233,7 @@ const slackLinks = computed(() => {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
color: var(--text-faint);
margin-bottom: 2px;
}
.block-text {
@ -246,8 +244,7 @@ const slackLinks = computed(() => {
.post-note {
font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
color: var(--text-faint);
font-style: italic;
margin: 8px 0;
white-space: pre-wrap;
@ -296,8 +293,7 @@ const slackLinks = computed(() => {
align-items: center;
justify-content: center;
font-size: 10px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
color: var(--text-faint);
font-family: "Commit Mono", monospace;
}
.author-name {
@ -312,8 +308,7 @@ const slackLinks = computed(() => {
}
.slack-handle {
font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
color: var(--text-faint);
font-family: "Commit Mono", monospace;
background: transparent;
border: none;

View file

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

View file

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

View file

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

View file

@ -1,27 +1,37 @@
<template>
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
<div
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
>
<!-- Header -->
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
<div
class="bg-gradient-to-br from-candlelight-500 to-candlelight-700 dark:from-candlelight-600 dark:to-candlelight-800 p-6"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
<span class="text-sm font-semibold" style="color: var(--parch-text)">
<Icon
name="heroicons:ticket"
class="w-5 h-5 text-candlelight-900 dark:text-candlelight-200"
/>
<span class="text-sm font-semibold text-candlelight-900 dark:text-candlelight-200">
Series Pass
</span>
</div>
<h3 class="font-display text-xl font-bold mb-1" style="color: var(--parch-text)">
<h3 class="text-xl font-bold text-white mb-1">
{{ ticket.name }}
</h3>
<p v-if="ticket.description" class="text-sm" style="color: var(--parch-text); opacity: 0.85">
<p v-if="ticket.description" class="text-sm text-candlelight-900 dark:text-candlelight-200">
{{ ticket.description }}
</p>
</div>
<div class="text-right flex-shrink-0">
<div class="text-3xl font-bold" style="color: var(--parch-text)">
<div class="text-3xl font-bold text-white text-ui-mono">
{{ formatPrice(ticket.price, ticket.currency) }}
</div>
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
<div
v-if="ticket.isEarlyBird"
class="text-xs text-candlelight-900 dark:text-candlelight-200 mt-1"
>
Early Bird Price
</div>
</div>
@ -29,23 +39,29 @@
</div>
<!-- Body -->
<div class="p-6" style="background: var(--surface)">
<div class="p-6 bg-guild-800/50 dark:bg-guild-700/30">
<!-- What's Included -->
<div class="mb-6">
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
What's Included
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<div class="flex items-center gap-2 text-guild-300 dark:text-guild-300">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<span>Access to all {{ totalEvents }} events in the series</span>
</div>
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<div
v-if="ticket.isFree && !isMember"
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<span>Automatic registration for all sessions</span>
</div>
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<div
v-if="memberSavings > 0"
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
</div>
</div>
@ -53,31 +69,33 @@
<!-- Events List Preview -->
<div v-if="events && events.length > 0" class="mb-6">
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
Series Schedule
</h4>
<div class="space-y-2">
<div
v-for="(event, index) in events.slice(0, 3)"
:key="event.id"
class="flex items-start gap-3 p-3"
class="flex items-start gap-3 p-3 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
>
<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 bg-candlelight-600/20 border border-candlelight-500/30 flex items-center justify-center flex-shrink-0"
>
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
<span class="text-sm font-bold text-candlelight-300">{{ index + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm" style="color: var(--text)">
<div class="font-medium text-guild-100 dark:text-guild-100 text-sm">
{{ event.title }}
</div>
<div class="text-xs mt-1" style="color: var(--text-faint)">
<div class="text-xs text-guild-400 dark:text-guild-400 mt-1">
{{ formatEventDate(event.startDate) }}
</div>
</div>
</div>
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
<div
v-if="events.length > 3"
class="text-center text-sm text-guild-400 dark:text-guild-400 pt-2"
>
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
</div>
</div>
@ -86,14 +104,13 @@
<!-- 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 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
>
<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)" />
<Icon name="heroicons:sparkles" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
<div>
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
<div class="text-sm" style="color: var(--candle)">
<div class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
<div class="text-sm text-candlelight-400">
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
</div>
</div>
@ -103,14 +120,13 @@
<!-- 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 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
>
<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)" />
<Icon name="heroicons:tag" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
<div class="flex-1">
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
<div class="text-sm" style="color: var(--candle)">
<div class="font-semibold text-candlelight-300 mb-1">Member Savings</div>
<div class="text-sm text-candlelight-400">
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
</div>
@ -120,15 +136,22 @@
<!-- Availability -->
<div v-if="availability" class="mb-6">
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
<div
v-if="!availability.unlimited && availability.remaining !== null"
class="flex items-center gap-2"
>
<Icon
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
class="w-5 h-5"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
:class="[
'w-5 h-5',
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
]"
/>
<span
class="text-sm font-medium"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
:class="[
'text-sm font-medium',
availability.remaining > 5 ? 'text-candlelight-300' : 'text-ember-300'
]"
>
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
</span>
@ -137,12 +160,12 @@
<!-- Sold Out / Waitlist -->
<div v-if="!available" class="space-y-3">
<div class="p-4" style="background: var(--ember-bg); border: 1px solid var(--ember)">
<div class="p-4 bg-ember-900/20 border border-ember-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 flex-shrink-0 mt-0.5" />
<div>
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
<div class="text-sm" style="color: var(--ember)">
<div class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
<div class="text-sm text-ember-400">
All series passes have been claimed.
</div>
</div>
@ -151,7 +174,7 @@
<UButton
v-if="availability?.waitlistAvailable"
block
color="neutral"
color="gray"
size="lg"
@click="$emit('join-waitlist')"
>
@ -160,16 +183,12 @@
</div>
<!-- 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)"
>
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
<Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" />
<div>
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
<div class="text-sm" style="color: var(--candle)">
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
<div class="text-sm text-candlelight-400">
You have a series pass and are registered for all {{ totalEvents }} events.
</div>
</div>

View file

@ -199,7 +199,7 @@ const formatPrice = (amount) => {
.early-bird {
color: var(--candle-dim);
border-color: var(--candle-faint);
border-color: rgba(122, 90, 16, 0.35);
}
.ticket-savings {

View file

@ -154,7 +154,6 @@
securely
</p>
<div class="consent-block">
<label class="consent-field">
<input
v-model="form.createAccount"
@ -166,7 +165,6 @@
<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.
</p>
</div>
<button
type="submit"
@ -332,6 +330,7 @@ const handleSubmit = async () => {
await initializeTicketPayment(
props.eventId,
form.value.email,
ticketInfo.value.price,
props.eventTitle,
);
@ -452,26 +451,21 @@ const formatEventDate = (date) => {
margin-top: 2px;
}
.consent-block {
display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start;
column-gap: 8px;
row-gap: 4px;
margin-bottom: 14px;
}
.consent-field {
display: contents;
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--text);
margin-bottom: 4px;
cursor: pointer;
}
.consent-field input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--candle);
flex-shrink: 0;
}
.consent-hint {
grid-column: 2;
margin: 0;
margin-bottom: 14px;
padding-left: 24px;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,11 +9,14 @@
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state p-6">
<h3 class="error-state__heading text-lg font-semibold mb-2">
<div
v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
Unable to Load Series Pass
</h3>
<p class="error-state__body">{{ error }}</p>
<p class="text-ember-400">{{ error }}</p>
</div>
<!-- Content -->
@ -45,7 +48,7 @@
<!-- Registration Form -->
<div
v-if="passInfo.available && !passInfo.alreadyRegistered"
class="registration-form p-6"
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
>
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{
@ -100,20 +103,18 @@
<!-- 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 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
>
<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)"
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
/>
<div>
<div class="font-semibold mb-1" style="color: var(--candle)">
<div class="font-semibold text-candlelight-300 mb-1">
Member Benefit
</div>
<div class="text-sm" style="color: var(--candle)">
<div class="text-sm text-candlelight-400">
This series pass is free for Ghost Guild members!
</div>
</div>
@ -143,7 +144,6 @@
<p class="text-xs text-[--ui-text-muted] text-center">
By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series.
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
</p>
</form>
</div>
@ -182,7 +182,7 @@ const props = defineProps({
const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast();
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
// State
const loading = ref(true);
@ -264,9 +264,10 @@ const handleSubmit = async () => {
paymentProcessing.value = true;
// Initialize Helcim payment for series pass
await initializeSeriesTicketPayment(
await initializeTicketPayment(
props.seriesId,
form.value.email,
passInfo.value.ticket.price,
props.seriesInfo.title,
);
@ -297,17 +298,12 @@ const handleSubmit = async () => {
}
);
// Refresh client auth state if server signed us in (guest upgrade)
if (purchaseResponse?.signedIn) {
await useAuth().checkMemberStatus();
}
// Show success message
toast.add({
title: "Series Pass Purchased!",
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
color: "green",
duration: 5000,
timeout: 5000,
});
// Emit success event
@ -327,7 +323,7 @@ const handleSubmit = async () => {
title: "Purchase Failed",
description: errorMessage,
color: "red",
duration: 5000,
timeout: 5000,
});
emit("purchase-error", errorMessage);
@ -354,18 +350,3 @@ const formatPrice = (price, currency = "CAD") => {
}).format(price);
};
</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

@ -33,9 +33,14 @@
</dl>
</DashedBox>
<p class="signup-flow-body" style="margin-top: 16px">
Check {{ summary?.email }} for a sign-in link to finish setting up
your account. The link expires in 15 minutes.
We've sent a confirmation email to {{ summary?.email }}. Redirecting
you to your dashboard...
</p>
<div class="button-row" style="margin-top: 20px">
<NuxtLink :to="dashboardHref" class="btn btn-primary">
Go to Dashboard Now
</NuxtLink>
</div>
</template>
<template v-if="state === 'error'">
@ -108,7 +113,7 @@ const stepLabel = computed(() => {
position: fixed;
inset: 0;
z-index: 50;
background: color-mix(in srgb, var(--parch) 72%, transparent);
background: rgba(42, 32, 21, 0.72);
backdrop-filter: blur(4px);
display: flex;
align-items: center;

View file

@ -0,0 +1,99 @@
<template>
<div class="tier-picker">
<div
v-for="tier in tiers"
:key="tier.amount"
class="tier-option"
:class="{ current: modelValue === tier.amount }"
@click="$emit('update:modelValue', tier.amount)"
>
<span class="tier-amount">{{ tier.display }}</span>
<span v-if="tier.subtitle" class="tier-subtitle">{{ tier.subtitle }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: Number, default: 0 },
tiers: {
type: Array,
default: () => [
{ amount: 0, display: "$0", label: "Free" },
{ amount: 5, display: "$5", label: "/month" },
{ amount: 15, display: "$15", label: "/month" },
{ amount: 30, display: "$30", label: "/month" },
{ amount: 50, display: "$50", label: "/month" },
],
},
});
defineEmits(["update:modelValue"]);
</script>
<style scoped>
.tier-picker {
display: flex;
gap: 0;
margin-bottom: 12px;
}
.tier-option {
flex: 1;
padding: 18px 8px;
text-align: center;
border: 1px dashed var(--border);
background: var(--bg);
cursor: pointer;
transition: all 0.15s;
position: relative;
}
/* Overlap adjacent borders so dashed lines collapse into one */
.tier-option + .tier-option {
margin-left: -1px;
}
.tier-option:hover {
background: var(--surface-hover);
}
/* Active item paints its solid border on top of any neighbor */
.tier-option.current {
border-color: var(--candle);
border-style: solid;
background: var(--surface);
z-index: 1;
}
.tier-amount {
font-size: 24px;
font-weight: 600;
color: var(--text);
font-family: "Brygada 1918", serif;
display: block;
line-height: 1.1;
}
.tier-option.current .tier-amount {
color: var(--candle);
}
.tier-subtitle {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--text-dim);
font-family: "Commit Mono", monospace;
letter-spacing: 0.02em;
}
@media (max-width: 768px) {
.tier-picker {
flex-wrap: wrap;
}
.tier-option {
min-width: 60px;
}
}
</style>

View file

@ -0,0 +1,90 @@
// Helcim API integration composable
export const useHelcim = () => {
const config = useRuntimeConfig()
const helcimToken = config.public.helcimToken
// Base URL for Helcim API
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
// Helper function to make API requests
const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
try {
const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
method,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: body ? JSON.stringify(body) : undefined
})
return response
} catch (error) {
console.error('Helcim API error:', error)
throw error
}
}
// Create a customer
const createCustomer = async (customerData) => {
return await makeHelcimRequest('/customers', 'POST', {
customerType: 'PERSON',
contactName: customerData.name,
email: customerData.email,
billingAddress: customerData.billingAddress || {}
})
}
// Create a subscription
const createSubscription = async (customerId, planId, cardToken) => {
return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
customerId,
planId,
cardToken,
startDate: new Date().toISOString().split('T')[0] // Today's date
})
}
// Get customer details
const getCustomer = async (customerId) => {
return await makeHelcimRequest(`/customers/${customerId}`)
}
// Get subscription details
const getSubscription = async (subscriptionId) => {
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
}
// Update subscription
const updateSubscription = async (subscriptionId, updates) => {
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
}
// Cancel subscription
const cancelSubscription = async (subscriptionId) => {
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
}
// Get payment plans
const getPaymentPlans = async () => {
return await makeHelcimRequest('/recurring/plans')
}
// Verify card token (for testing)
const verifyCardToken = async (cardToken) => {
return await makeHelcimRequest('/cards/verify', 'POST', {
cardToken
})
}
return {
createCustomer,
createSubscription,
getCustomer,
getSubscription,
updateSubscription,
cancelSubscription,
getPaymentPlans,
verifyCardToken
}
}

View file

@ -3,7 +3,7 @@ export const useHelcimPay = () => {
let checkoutToken = null;
let secretToken = null;
// Initialize HelcimPay.js session (membership signup flow)
// Initialize HelcimPay.js session
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
@ -12,7 +12,6 @@ export const useHelcimPay = () => {
customerId,
customerCode,
amount,
metadata: { type: "membership_signup" },
},
});
@ -29,14 +28,26 @@ export const useHelcimPay = () => {
}
};
const _initializeTicket = async (metadata, errorPrefix) => {
// Initialize payment for event ticket purchase
const initializeTicketPayment = async (
eventId,
email,
amount,
eventTitle = null,
) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
method: "POST",
body: {
customerId: null,
customerCode: metadata.email,
metadata,
customerCode: email, // Use email as customer code for event tickets
amount,
metadata: {
type: "event_ticket",
eventId,
email,
eventTitle,
},
},
});
@ -46,29 +57,16 @@ export const useHelcimPay = () => {
return {
success: true,
checkoutToken: response.checkoutToken,
amount: response.amount,
};
}
throw new Error(`Failed to initialize ${errorPrefix} session`);
throw new Error("Failed to initialize ticket payment session");
} catch (error) {
console.error(`${errorPrefix} initialization error:`, error);
console.error("Ticket payment initialization error:", error);
throw error;
}
};
const initializeTicketPayment = (eventId, email, eventTitle = null) =>
_initializeTicket(
{ type: "event_ticket", eventId, email, eventTitle },
"ticket payment",
);
const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
_initializeTicket(
{ type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
"series payment",
);
// Show payment modal
const showPaymentModal = () => {
return new Promise((resolve, reject) => {
@ -141,7 +139,6 @@ export const useHelcimPay = () => {
if (typeof window.appendHelcimPayIframe === "function") {
// Set up event listener for HelcimPay.js responses
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
let observerTimer, paymentTimer;
const handleHelcimPayEvent = (event) => {
console.log("Received window message:", event.data);
@ -151,8 +148,6 @@ export const useHelcimPay = () => {
// Remove event listener to prevent multiple responses
window.removeEventListener("message", handleHelcimPayEvent);
clearTimeout(observerTimer);
clearTimeout(paymentTimer);
// Close the Helcim modal
if (typeof window.removeHelcimPayIframe === "function") {
@ -242,10 +237,10 @@ export const useHelcimPay = () => {
);
// Clean up observer after a timeout
observerTimer = setTimeout(() => observer.disconnect(), 5000);
setTimeout(() => observer.disconnect(), 5000);
// Add timeout to clean up if no response (10 minutes for manual card entry)
paymentTimer = setTimeout(() => {
setTimeout(() => {
console.log("Payment timeout reached, cleaning up event listener...");
window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error("Payment timeout - no response received"));
@ -277,7 +272,6 @@ export const useHelcimPay = () => {
return {
initializeHelcimPay,
initializeTicketPayment,
initializeSeriesTicketPayment,
verifyPayment,
cleanup,
};

View file

@ -25,59 +25,25 @@ export const useMemberPayment = () => {
paymentSuccess.value = false
try {
// Fast-path: when both Helcim ids are already cached on the member doc
// AND a card's on file, we can skip the paid getOrCreateCustomer round
// trip entirely and go straight to subscription creation.
const hasCachedHelcimIds = Boolean(
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
)
// Step 1: Get or create Helcim customer
await getOrCreateCustomer()
let existing = null
let probedExistingCard = false
let cardToken = null
if (hasCachedHelcimIds) {
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
return null
})
probedExistingCard = true
if (existing?.cardToken) {
customerId.value = memberData.value.helcimCustomerId
customerCode.value = memberData.value.helcimCustomerCode
cardToken = existing.cardToken
}
}
if (!cardToken) {
// Skip HelcimPay verify if a card's already on file — Helcim refuses
// to re-save it, breaking retries after a partial-failed signup.
const [, existingFromFull] = await Promise.all([
getOrCreateCustomer(),
probedExistingCard
? Promise.resolve(existing)
: $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
return null
}),
])
cardToken = existingFromFull?.cardToken || null
}
if (!cardToken) {
// Step 2: Initialize Helcim payment with $0 for card verification
await initializeHelcimPay(
customerId.value,
customerCode.value,
0,
)
// Step 3: Show payment modal and get payment result
const paymentResult = await verifyPayment()
console.log('Payment result:', paymentResult)
if (!paymentResult.success) {
throw new Error('Payment verification failed')
}
// Step 4: Verify payment on backend
const verifyResult = await $fetch('/api/helcim/verify-payment', {
method: 'POST',
body: {
@ -90,16 +56,14 @@ export const useMemberPayment = () => {
throw new Error('Payment verification failed on backend')
}
cardToken = paymentResult.cardToken
}
// Step 5: Create subscription with proper contribution tier
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
method: 'POST',
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionAmount: memberData.value?.contributionAmount ?? 5,
cardToken,
cardToken: paymentResult.cardToken,
},
})
@ -107,6 +71,7 @@ export const useMemberPayment = () => {
throw new Error('Subscription creation failed')
}
// Step 6: Payment successful - refresh member data
paymentSuccess.value = true
await checkMemberStatus()

View file

@ -1,8 +0,0 @@
export const STATUS_LABELS = {
active: "Active",
pending_payment: "Payment setup incomplete",
suspended: "Paused",
cancelled: "Closed",
};
export const statusLabel = (s) => STATUS_LABELS[s] || "Pending";

View file

@ -21,15 +21,6 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
return;
}
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
try {
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
const member = await $fetch("/api/auth/member", { headers });
if (member?.role === "admin") return;
} catch {
// Not authenticated — fall through to redirect
}
// Redirect all other routes to coming-soon
return navigateTo("/coming-soon");
});

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@
<p v-if="member" class="member-email">{{ member.email }}</p>
</div>
<div v-if="member" class="header-badges">
<CircleBadge :circle="member.circle" />
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>
@ -39,11 +39,11 @@
<form class="edit-form" @submit.prevent="submitEdit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" required >
<input v-model="form.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" required >
<input v-model="form.email" type="email" required />
</div>
<div class="field">
<label>Circle</label>
@ -56,18 +56,14 @@
<div class="field">
<label>Contribution ($/mo)</label>
<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 class="field">
<label>Status</label>
<select v-model="form.status">
<option
v-for="(label, value) in STATUS_LABELS"
:key="value"
:value="value"
>{{ label }}</option>
<option value="pending_payment">pending_payment</option>
<option value="active">active</option>
<option value="suspended">suspended</option>
<option value="cancelled">cancelled</option>
</select>
</div>
<div class="field">
@ -110,19 +106,8 @@
</div>
<div class="meta-row">
<dt>Slack invite</dt>
<dd v-if="member.slackInvited" class="status-ok">
Invited {{ formatDate(member.slackInvitedAt) }}
</dd>
<dd v-else class="meta-action">
<span class="status-dim">Not yet invited</span>
<button
type="button"
class="link-btn"
:disabled="markingSlackInvited"
@click="markSlackInvited"
>
{{ markingSlackInvited ? "Marking…" : "Mark as Slack invited" }}
</button>
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
{{ member.slackInvited ? "Invited" : "Pending" }}
</dd>
</div>
<div v-if="member.helcimCustomerId" class="meta-row">
@ -170,6 +155,12 @@
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
</dd>
</div>
<div class="meta-row">
<dt>Slack status</dt>
<dd :class="slackStatusClass">
{{ member.slackInviteStatus || 'none' }}
</dd>
</div>
</dl>
</section>
@ -243,7 +234,6 @@
<script setup>
import { formatActivity } from '~/utils/activityText'
import { STATUS_LABELS } from '~/config/memberStatus'
definePageMeta({
layout: "admin",
@ -366,31 +356,12 @@ const hasBoardEngaged = computed(() => {
)
})
const markingSlackInvited = ref(false)
async function markSlackInvited() {
if (!member.value || markingSlackInvited.value) return
markingSlackInvited.value = true
try {
const res = await $fetch(
`/api/admin/members/${route.params.id}/slack-status`,
{
method: "PATCH",
body: { slackInvited: true },
},
)
member.value = { ...member.value, ...res.member }
toast.add({ title: "Marked as Slack invited", color: "success" })
} catch (err) {
toast.add({
title: "Failed to mark Slack invited",
description: err.data?.statusMessage || err.message,
color: "error",
const slackStatusClass = computed(() => {
const status = member.value?.slackInviteStatus
if (status === 'joined') return 'status-ok'
if (status === 'invited') return 'status-dim'
return 'status-dim'
})
} finally {
markingSlackInvited.value = false
}
}
// Activity log
const activityEntries = ref([])
@ -539,24 +510,6 @@ onMounted(loadActivity)
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 {
display: flex;
gap: 8px;
@ -600,32 +553,6 @@ onMounted(loadActivity)
word-break: break-all;
}
.meta-action {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.link-btn {
background: none;
border: none;
color: var(--candle);
cursor: pointer;
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 2px 6px;
}
.link-btn:hover {
text-decoration: underline;
}
.link-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mono {
font-family: "Commit Mono", monospace;
font-size: 11px;

View file

@ -41,11 +41,10 @@
<div class="field" style="margin-bottom: 0">
<select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option>
<option
v-for="(label, value) in STATUS_LABELS"
:key="value"
:value="value"
>{{ label }}</option>
<option value="active">Active</option>
<option value="pending_payment">Pending Payment</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
@ -109,7 +108,9 @@
</td>
<td class="col-email">{{ member.email }}</td>
<td>
<CircleBadge :circle="member.circle" />
<span class="badge" :class="member.circle">{{
member.circle
}}</span>
</td>
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
<td>
@ -123,11 +124,8 @@
</span>
</td>
<td>
<span v-if="member.slackInvited" class="status-ok">
Invited {{ formatDate(member.slackInvitedAt) }}
</span>
<span v-else class="status-dim">
Not yet invited
<span :class="member.slackInvited ? 'status-ok' : 'status-dim'">
{{ member.slackInvited ? "Invited" : "Pending" }}
</span>
</td>
<td class="col-mono col-date">
@ -137,12 +135,8 @@
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
>View</NuxtLink
>
<button
v-if="!member.slackInvited"
class="link-btn"
@click.stop="markSlackInvited(member)"
>
Mark as Slack invited
<button class="link-btn" @click.stop="sendSlackInvite(member)">
Slack
</button>
<button class="link-btn" @click.stop="editMember(member)">Edit</button>
</td>
@ -268,7 +262,7 @@
<th>Name</th>
<th>Email</th>
<th>Circle</th>
<th>Contribution</th>
<th>Tier</th>
</tr>
</thead>
<tbody>
@ -372,11 +366,10 @@
<div class="field">
<label>Status</label>
<select v-model="editingMember.status">
<option
v-for="(label, value) in STATUS_LABELS"
:key="value"
:value="value"
>{{ label }}</option>
<option value="pending_payment">Pending Payment</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="modal-actions">
@ -468,8 +461,6 @@
</template>
<script setup>
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
definePageMeta({
layout: "admin",
middleware: "admin",
@ -490,6 +481,14 @@ const statusFilter = ref("");
const sortKey = ref("createdAt");
const sortDir = ref("desc");
const STATUS_LABELS = {
active: "Active",
pending_payment: "Pending",
suspended: "Suspended",
cancelled: "Cancelled",
};
const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
const toggleSort = (key) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
@ -830,25 +829,8 @@ const submitInvites = async () => {
};
// --- Existing actions ---
const markSlackInvited = async (member) => {
try {
const res = await $fetch(
`/api/admin/members/${member._id}/slack-status`,
{
method: "PATCH",
body: { slackInvited: true },
},
);
const idx = members.value.findIndex((m) => m._id === member._id);
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
toast.add({ title: "Marked as Slack invited", color: "success" });
} catch (err) {
toast.add({
title: "Failed to mark Slack invited",
description: err.data?.statusMessage || err.message,
color: "error",
});
}
const sendSlackInvite = (member) => {
console.log("Send Slack invite to:", member.email);
};
// --- Edit Member ---
@ -1144,7 +1126,7 @@ th.sortable:hover {
text-transform: uppercase;
}
.badge.status-active {
color: var(--green);
color: var(--green, #3a6b3a);
border-color: rgba(58, 107, 58, 0.45);
}
.badge.status-pending_payment {
@ -1153,7 +1135,7 @@ th.sortable:hover {
}
.badge.status-suspended {
color: var(--ember);
border-color: color-mix(in srgb, var(--ember) 45%, transparent);
border-color: rgba(138, 68, 32, 0.45);
}
.badge.status-cancelled {
color: var(--text-faint);
@ -1301,7 +1283,7 @@ th.sortable:hover {
}
.row-error {
background: color-mix(in srgb, var(--ember) 4%, transparent);
background: rgba(138, 68, 32, 0.04);
}
/* ---- PREVIEW BOX ---- */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<div v-else>
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
@ -48,7 +48,7 @@
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
>
/>
</div>
<!-- TWO-COLUMN BODY -->
@ -294,19 +294,10 @@ useHead(() => ({
margin-bottom: 4px;
}
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
flex: 1;
}
.event-main {
min-width: 0;

View file

@ -88,8 +88,8 @@
<div class="series-grid">
<NuxtLink
v-for="series in activeSeries"
:key="series.id"
:to="`/series/${series.id}`"
:key="series._id"
:to="`/series/${series._id}`"
class="series-box"
>
<h2>{{ series.title }}</h2>
@ -107,11 +107,6 @@
>
</div>
</NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div>
</div>
@ -133,8 +128,9 @@ const filterOptions = [
const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series");
const filteredEvents = computed(() => {
const now = new Date();
const filteredEvents = computed(() => {
if (!eventsData.value) return [];
return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now)
@ -232,12 +228,8 @@ const isAlmostFull = (event) => {
.event-row:hover {
padding-left: 4px;
}
.event-row.is-cancelled .event-title a {
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
.event-row.is-cancelled {
opacity: 0.5;
}
.event-date-col {
@ -376,21 +368,14 @@ const isAlmostFull = (event) => {
}
.series-box {
padding: 20px 24px;
border-right: 1px dashed var(--border);
text-decoration: none;
transition: background 0.15s;
border-right: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
.series-box:last-child {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: none;
}
.series-box-filler {
pointer-events: none;
}
.series-box:not(.series-box-filler):hover {
.series-box:hover {
background: var(--surface-hover);
}
.series-box h2 {
@ -434,10 +419,6 @@ const isAlmostFull = (event) => {
border-color: var(--candle-faint);
color: var(--text-dim);
}
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.past-toggle.active {
border-color: var(--candle);
border-style: solid;
@ -486,17 +467,8 @@ const isAlmostFull = (event) => {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
border-bottom: none;
}
.series-box-filler {
display: none;
}
}
</style>

View file

@ -131,10 +131,12 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
const { data: wikiFeature } = await useFetch(
"/api/site-content/homepage.wiki_feature",
{ default: () => ({ title: "", body: "" }) },
{ default: () => ({ title: "", body: "" }) }
);
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
const hasCustomWikiFeature = computed(
() => !!wikiFeature.value?.body?.trim()
);
const customWikiParagraphs = computed(() => {
const body = wikiFeature.value?.body?.trim() || "";
@ -164,7 +166,7 @@ const circleData = [
label: "Practitioner",
metaphor: "The alcove",
blurb:
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
"Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.",
},
];

View file

@ -64,37 +64,26 @@
<!-- Left: Monthly Contribution -->
<div class="join-col">
<div class="section-label" style="margin-bottom: 12px">
{{
cadence === "annual"
? "Annual Contribution"
: "Monthly Contribution"
}}
{{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
</div>
<h2>Pay what you can</h2>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">{{ formatContributionAmount(5) }}</span> I can contribute</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I
can contribute
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
(suggested)
</li>
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I
can sustain the community (suggested)
</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(30) }}</span> I
can support others too
</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I
want to sponsor multiple members
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
members
</li>
</ul>
<p class="charity-note">
Baby Ghosts Studio Development Fund is a registered Canadian
charity. Members who file Canadian taxes can claim their
contributions. We'll help you set up tax receipts once you've
joined.
Baby Ghosts Studio Development Fund is a registered Canadian charity.
Members who file Canadian taxes can claim their contributions.
We'll help you set up tax receipts once you've joined.
</p>
<p class="solidarity-note">
Pay what you can. If you can pay more, you're making room for
@ -129,7 +118,7 @@
type="text"
placeholder="Your name"
required
/>
>
</div>
<div class="form-group">
<label class="form-label" for="join-email">Email Address</label>
@ -140,7 +129,7 @@
type="email"
placeholder="you@example.com"
required
/>
>
</div>
<div class="form-group">
<label class="form-label">Circle</label>
@ -152,7 +141,7 @@
type="radio"
name="circle"
value="community"
/>
>
<label for="circle-community">
<span
class="circle-label-name"
@ -169,7 +158,7 @@
type="radio"
name="circle"
value="founder"
/>
>
<label for="circle-founder">
<span
class="circle-label-name"
@ -186,7 +175,7 @@
type="radio"
name="circle"
value="practitioner"
/>
>
<label for="circle-practitioner">
<span
class="circle-label-name"
@ -208,7 +197,7 @@
type="radio"
name="cadence"
value="monthly"
/>
>
<label for="cadence-monthly">
<span class="circle-label-name">Per Month</span>
</label>
@ -220,7 +209,7 @@
type="radio"
name="cadence"
value="annual"
/>
>
<label for="cadence-annual">
<span class="circle-label-name">Per Year</span>
</label>
@ -241,13 +230,9 @@
step="1"
inputmode="numeric"
class="contribution-input"
/>
</div>
<div
class="contribution-presets"
role="group"
aria-label="Suggested amounts"
>
</div>
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
<button
v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount"
@ -258,30 +243,24 @@
${{ preset.amount }}
</button>
</div>
<p v-if="guidanceLabel" class="contribution-guidance">
{{ guidanceLabel }}
</p>
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
</div>
<div v-if="form.contributionAmount > 0" class="form-group">
<div class="billing-summary">
<p class="billing-summary-line">
You'll be charged <strong>${{ firstCharge }} today</strong
><span v-if="cadence === 'annual'">
(${{ form.contributionAmount }}/month &times; 12)</span
>.
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month &times; 12)</span>.
</p>
<p class="billing-summary-line">
Then
<strong
>${{ firstCharge }} every
{{ cadence === "annual" ? "year" : "month" }}</strong
>, until you cancel.
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width">
<label class="checkbox-label">
<input v-model="form.agreedToGuidelines" type="checkbox" />
<input
v-model="form.agreedToGuidelines"
type="checkbox"
>
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank"
@ -317,17 +296,13 @@
<ParchmentInset>
<h2>How membership works</h2>
<ul>
<li>Full access to the knowledge commons, events and workshops, and community</li>
<li>Full access to the knowledge commons, Slack, and peer support</li>
<li>Free access to all Ghost Guild events</li>
<li>Equal access for every member, regardless of contribution</li>
<li>Your circle reflects where you are, not rank</li>
<li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li>
<li>Higher contributions create solidarity spots for others</li>
</ul>
<p>
Community connection happens in our Slack workspace, joined in monthly
onboarding waves &mdash; there may be a short wait after you join.
</p>
</ParchmentInset>
<!-- THREE CIRCLES -->
@ -363,11 +338,12 @@
<h2>Practicing</h2>
<p>
For those already running cooperative studios or with deep
experience in cooperative practice. You're here to support newcomers
and help shape the Cooperative Foundations program.
experience in cooperative practice. You are here to teach, advise,
mentor, and help shape the program itself. Alumni.
</p>
</div>
</div>
</template>
<!-- Flow overlay: covers the page from form submit through redirect.
@ -458,8 +434,7 @@ const isFormValid = computed(() => {
form.name &&
form.email &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
});
@ -580,9 +555,10 @@ const createSubscription = async (cardToken = null) => {
flowState.value = "success";
successMessage.value = "Your membership is active.";
// Sign-in cookie is now issued by the email-verify magic link
// (see /api/helcim/customer). Don't auto-navigate to a gated page
// the success state instructs the user to check their inbox.
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
navigateTo("/welcome");
} else {
throw new Error("Subscription creation failed - response not successful");
}
@ -751,7 +727,7 @@ onUnmounted(() => {
padding: 0;
}
.tier-list li {
padding: 4px 0;
padding: 5px 0;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px dashed var(--border);
@ -855,7 +831,7 @@ onUnmounted(() => {
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
@ -872,7 +848,7 @@ onUnmounted(() => {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
@ -1042,7 +1018,6 @@ onUnmounted(() => {
.checkbox-label a,
.checkbox-label :deep(a) {
color: var(--candle);
text-decoration: underline;
}
/* ---- ERROR & SUCCESS BOXES ---- */
@ -1152,4 +1127,5 @@ onUnmounted(() => {
align-items: stretch;
}
}
</style>

View file

@ -283,7 +283,7 @@
form.contributionAmount === Number(memberData.contributionAmount || 0) ||
isUpdating
"
@click="handleUpdateContribution"
@click="handleUpdateTier"
>
{{ isUpdating ? "Updating…" : "Update Contribution" }}
</button>
@ -315,7 +315,6 @@
<script setup>
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
import { STATUS_LABELS } from '~/config/memberStatus';
definePageMeta({
middleware: "auth",
@ -418,6 +417,13 @@ const circleOptions = [
},
];
const STATUS_LABELS = {
active: "Active",
pending_payment: "Setting up payment",
suspended: "Paused",
cancelled: "Closed",
};
const formatStatus = (s) => STATUS_LABELS[s] || s;
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
@ -476,7 +482,7 @@ const refreshNextBillingIfStale = async () => {
}
};
const handleUpdateContribution = async () => {
const handleUpdateTier = async () => {
isUpdating.value = true;
try {
await $fetch("/api/members/update-contribution", {

View file

@ -38,10 +38,6 @@
<CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
</div>
<p v-if="showSlackComingNote" class="slack-coming-note">
Slack workspace access is part of your membership. Invitations are
sent in monthly onboarding waves &mdash; we'll be in touch.
</p>
</PageHeader>
<!-- Upcoming Events + Quick Actions -->
@ -228,10 +224,6 @@ const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
const route = useRoute();
const isNewSignup = computed(() => route.query.welcome === "1");
const showSlackComingNote = computed(
() =>
memberData.value?.status === "active" && !memberData.value?.slackInvited,
);
const welcomeTitle = computed(() => {
const name = memberData.value?.name || "";
return isNewSignup.value
@ -476,13 +468,6 @@ useHead({
margin-top: 8px;
}
.slack-coming-note {
margin-top: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
}
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View file

@ -72,7 +72,6 @@ const errorMessage = ref('');
const isProcessing = ref(false);
const customerId = ref('');
const customerCode = ref('');
const hasExistingCard = ref(false);
const initialize = async () => {
errorMessage.value = '';
@ -85,47 +84,13 @@ const initialize = async () => {
}
try {
// Fast-path: when both Helcim ids are already cached on the member doc
// AND a card's on file, skip the paid get-or-create-customer round trip.
const hasCachedHelcimIds = Boolean(
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
);
let existing = null;
let probedExistingCard = false;
if (hasCachedHelcimIds) {
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
return null;
const customer = await $fetch('/api/helcim/get-or-create-customer', {
method: 'POST',
});
probedExistingCard = true;
if (existing?.cardToken) {
customerId.value = memberData.value.helcimCustomerId;
customerCode.value = memberData.value.helcimCustomerCode;
hasExistingCard.value = true;
}
}
if (!hasExistingCard.value) {
// Skip HelcimPay verify if a card's already on file Helcim refuses
// to re-save it, breaking retries after a partial-failed signup.
const [customer, existingFromFull] = await Promise.all([
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
probedExistingCard
? Promise.resolve(existing)
: $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
return null;
}),
]);
customerId.value = customer.customerId;
customerCode.value = customer.customerCode;
hasExistingCard.value = Boolean(existingFromFull?.cardToken);
if (!hasExistingCard.value) {
await initializeHelcimPay(customerId.value, customerCode.value, 0);
}
}
step.value = 'ready';
} catch (err) {
console.error('Payment setup init failed:', err);
@ -141,7 +106,6 @@ const openModal = async () => {
errorMessage.value = '';
try {
if (!hasExistingCard.value) {
const result = await verifyPayment();
if (!result?.success) throw new Error('Payment was not completed.');
@ -152,7 +116,6 @@ const openModal = async () => {
customerId: customerId.value,
},
});
}
// Update circle first if it changed update-contribution only touches tier.
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {

View file

@ -712,6 +712,10 @@ useHead({
.posts-empty-link {
color: var(--candle);
text-decoration: none;
}
.posts-empty-link:hover {
text-decoration: underline;
}

View file

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

View file

@ -1,116 +0,0 @@
<template>
<PageShell title="Refund Policy" subtitle="How Ghost Guild handles refund requests">
<div class="policy-prose">
<p class="policy-updated">Last updated: April 20, 2026</p>
<section class="policy-section">
<p>
Ghost Guild is a program of Baby Ghosts, a Canadian non-profit.
Contributions and event ticket revenue go directly toward running the
program. We handle refund requests on a case-by-case basis rather
than by blanket rule.
</p>
</section>
<section class="policy-section">
<h2>Membership dues</h2>
<p>
Membership is pay-what-you-can, and you can change or pause your
contribution any time as your situation changes. We don't refund dues
that have already been charged. If paying ever becomes a problem, the
Solidarity Fund is there for that reason; reach out and we'll work it
out.
</p>
</section>
<section class="policy-section">
<h2>Event tickets</h2>
<p>
Paid event registrations can't be cancelled from your account page.
Refunds for events are considered case-by-case at admin discretion,
taking into account how close to the event you're asking, whether
your spot can be filled, and the circumstances behind the request.
</p>
<p>
If you can no longer attend an event you've paid for, email
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a> as
early as you can and we'll sort it out with you.
</p>
</section>
<section class="policy-section">
<h2>Contact</h2>
<p>
Refund requests, questions, anything else:
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>
</p>
</section>
</div>
</PageShell>
</template>
<script setup>
useHead({
title: 'Refund Policy · Ghost Guild',
})
</script>
<style scoped>
.policy-prose {
max-width: 720px;
padding: 32px;
}
.policy-updated {
font-family: "Commit Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--candle);
margin-bottom: 24px;
}
.policy-section {
padding: 28px 0;
border-bottom: 1px dashed var(--border);
}
.policy-section:first-of-type {
padding-top: 0;
}
.policy-section:last-child {
border-bottom: none;
}
.policy-section h2 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.01em;
margin-bottom: 16px;
line-height: 1.25;
}
.policy-section p {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 12px;
}
.policy-section a {
color: var(--candle);
}
.policy-section strong {
color: var(--text-bright);
font-weight: 600;
}
@media (max-width: 640px) {
.policy-prose {
padding: 20px 16px;
}
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="page-fill">
<div>
<div v-if="pending" class="loading">Loading series details...</div>
<div v-else-if="error" class="loading">
@ -8,7 +8,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<div v-else>
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
@ -26,44 +26,31 @@
</div>
</div>
<!-- TWO-COLUMN BODY -->
<div class="series-body" :class="{ 'has-aside': series.tickets?.enabled }">
<!-- LEFT: MAIN CONTENT -->
<div class="series-main">
<div v-if="series.description" class="section description">
<!-- DESCRIPTION -->
<div v-if="series.description" class="section">
<p>{{ series.description }}</p>
</div>
<div class="section" :class="{ 'section-flush': series.events?.length }">
<!-- EVENT LIST -->
<div class="section">
<div class="section-label">Sessions</div>
<div v-if="series.events?.length" class="sessions-box">
<div v-if="series.events?.length">
<div v-for="(event, index) in series.events" :key="event._id || index" class="event-row">
<span class="event-num">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<div class="event-info">
<div class="event-info-head">
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
{{ event.title }}
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
<p v-if="event.description" class="event-description">{{ event.description }}</p>
</div>
</div>
</div>
<p v-else class="empty">No sessions scheduled yet.</p>
</div>
<!-- Questions (inline when no sidebar) -->
<div v-if="!series.tickets?.enabled" class="section">
<div class="section-label">Questions?</div>
<p>If you have questions about this series, reach out to us.</p>
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</div>
<!-- RIGHT: SIDEBAR -->
<aside v-if="series.tickets?.enabled" class="series-aside">
<!-- PASS PURCHASE -->
<div v-if="series.tickets?.enabled" class="section">
<SeriesPassPurchase
:series-id="series.id"
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
@ -72,13 +59,13 @@
:user-name="memberData?.name"
@purchase-success="handlePurchaseSuccess"
/>
<div class="aside-panel">
<div class="box-title">Questions?</div>
<p class="aside-detail">Drop us a line.</p>
<a class="aside-link" href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</aside>
<!-- QUESTIONS -->
<div class="section">
<div class="section-label">Questions?</div>
<p>If you have questions about this series, reach out to us.</p>
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</div>
</div>
@ -150,105 +137,28 @@ useHead(() => ({
}
.meta-text { color: var(--text-faint); }
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.series-body {
display: grid;
grid-template-columns: 1fr;
}
.series-body.has-aside {
grid-template-columns: 1fr 280px;
flex: 1;
}
.series-main { min-width: 0; }
.series-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.series-main .section:last-child {
border-bottom: none;
}
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
.section a { font-size: 12px; color: var(--candle); }
.section.description p { font-size: 14px; color: var(--text); }
.section-flush { padding-bottom: 0; }
.sessions-box {
border-top: 1px dashed var(--border);
margin: 10px -32px 0;
}
.event-row {
display: grid;
grid-template-columns: 32px auto 1fr;
grid-template-columns: 32px 80px 1fr;
gap: 12px;
align-items: baseline;
padding: 10px 32px;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.event-row:last-child { border-bottom: none; }
.event-num { color: var(--text-faint); font-size: 11px; }
.event-date { color: var(--text-faint); white-space: nowrap; }
.event-info { min-width: 0; }
.event-date { color: var(--text-faint); }
.event-title-link { color: var(--text); text-decoration: none; font-size: 13px; }
.event-title-link:hover { color: var(--candle); }
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; }
.event-description {
font-size: 11px;
color: var(--text-dim);
line-height: 1.5;
margin: 4px 0 0;
max-width: 560px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty { font-size: 12px; color: var(--text-faint); }
/* ---- ASIDE PANELS ---- */
.aside-panel {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.aside-panel:last-child { border-bottom: none; }
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.aside-detail {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 4px;
}
.aside-link {
font-size: 12px;
color: var(--candle);
}
@media (max-width: 768px) {
.series-body.has-aside {
grid-template-columns: 1fr;
}
.series-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
}
</style>

View file

@ -1,77 +1,197 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
title="Event Series"
subtitle="Multi-session events on cooperative topics"
/>
<div v-if="pending" class="state-msg">Loading series...</div>
<div v-else-if="!filteredSeries.length" class="state-msg">
<p>
No series right now. Check back later or browse
<NuxtLink to="/events">upcoming events</NuxtLink>.
</p>
<!-- Series Grid -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div v-if="pending" class="text-center py-12">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-[--ui-text-muted]">Loading series...</p>
</div>
<div v-else>
<section
<div
v-else-if="filteredSeries.length > 0"
class="max-w-4xl mx-auto space-y-6"
>
<div
v-for="series in filteredSeries"
:key="series.id"
class="series-section"
class="border border-[--ui-border] rounded overflow-hidden hover:border-primary transition-colors"
>
<div class="series-head">
<h2>{{ series.title }}</h2>
<div class="series-meta-row">
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span>
<span class="meta-text">
{{ series.eventCount }} sessions<template v-if="series.totalEvents"> of {{ series.totalEvents }} planned</template>
<!-- Series Header -->
<div class="p-6 border-b border-[--ui-border]">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
>
<div class="flex-1">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span
:class="[
'inline-flex items-center px-3 py-1 rounded text-sm font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</span>
<span v-if="series.startDate && series.endDate" class="meta-text">
{{ formatDateRange(series.startDate, series.endDate) }}
</span>
<span v-if="series.totalRegistrations" class="meta-text">
{{ series.totalRegistrations }} registered
<span
:class="[
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
series.status === 'active'
? 'bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30'
: series.status === 'upcoming'
? 'bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30'
: 'bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30',
]"
>
{{ series.status }}
</span>
</div>
<p v-if="series.description" class="series-desc">
<h2 class="text-display-sm font-bold text-[--ui-text] mb-2">
{{ series.title }}
</h2>
<p class="text-[--ui-text-muted] leading-relaxed">
{{ series.description }}
</p>
</div>
<div v-if="series.events?.length" class="sessions">
<div class="text-center md:text-right flex-shrink-0">
<div class="text-3xl font-bold text-[--ui-text] mb-1">
{{ series.eventCount }}
</div>
<div class="text-sm text-[--ui-text-muted]">Events</div>
<div
v-for="(event, index) in series.events"
:key="event.id"
class="event-row"
v-if="series.totalEvents"
class="text-xs text-[--ui-text-muted] mt-1"
>
<span class="event-num">
{{ String(event.series?.position || index + 1).padStart(2, '0') }}
of {{ series.totalEvents }} planned
</div>
</div>
</div>
</div>
<!-- Events List -->
<div class="divide-y divide-[--ui-border]">
<div
v-for="event in series.events"
:key="event.id"
class="p-4 hover:bg-[--ui-bg-elevated] transition-colors"
>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div
class="w-8 h-8 bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0 border border-candlelight-700/30"
>
{{ event.series?.position || "?" }}
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-[--ui-text] mb-1">
{{ event.title }}
</h3>
<div
class="flex items-center gap-4 text-sm text-[--ui-text-muted] flex-wrap"
>
<div class="flex items-center gap-1">
<Icon
name="heroicons:calendar-days"
class="w-4 h-4"
/>
{{ formatEventDate(event.startDate) }}
</div>
<div class="flex items-center gap-1">
<Icon name="heroicons:clock" class="w-4 h-4" />
{{ formatEventTime(event.startDate) }}
</div>
<div
v-if="event.registrations?.length"
class="flex items-center gap-1"
>
<Icon name="heroicons:users" class="w-4 h-4" />
{{ event.registrations.length }} registered
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
getEventStatusClass(event),
]"
>
{{ getEventStatus(event) }}
</span>
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
<div class="event-info">
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="event-title-link"
class="inline-flex items-center px-3 py-1 bg-primary text-white text-sm rounded hover:bg-primary/90 transition-colors"
>
{{ event.title }}
View
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
</div>
</div>
</div>
<div class="series-foot">
<NuxtLink :to="`/series/${series.id}`" class="view-link">
View series &rarr;
<!-- Series Footer -->
<div
class="px-6 py-4 bg-[--ui-bg-elevated] border-t border-[--ui-border]"
>
<div class="flex items-center justify-between gap-4">
<div
class="flex items-center gap-4 text-sm text-[--ui-text-muted] flex-wrap"
>
<div
v-if="series.startDate && series.endDate"
class="flex items-center gap-1"
>
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div
v-if="series.totalRegistrations"
class="flex items-center gap-1"
>
<Icon name="heroicons:users" class="w-4 h-4" />
{{ series.totalRegistrations }} total registrations
</div>
</div>
<NuxtLink
:to="`/series/${series.id}`"
class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm font-medium rounded hover:bg-primary/90 transition-colors"
>
View Series
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-2" />
</NuxtLink>
</div>
</section>
</div>
</div>
</div>
<div v-else class="text-center py-16">
<Icon
name="heroicons:squares-2x2"
class="w-16 h-16 text-[--ui-text-muted] mx-auto mb-4 opacity-50"
/>
<h3 class="text-display-sm font-semibold text-[--ui-text] mb-2">
No series right now
</h3>
<p class="text-[--ui-text-muted] max-w-md mx-auto">
Check back later or browse
<NuxtLink to="/events" class="text-primary">upcoming events</NuxtLink>.
</p>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
// SEO
useHead({
title: "Event Series - Ghost Guild",
meta: [
@ -83,10 +203,12 @@ useHead({
],
});
// Fetch series data
const { data: seriesData, pending } = await useFetch("/api/series", {
query: { includeHidden: false },
});
// Filter for active and upcoming series only
const filteredSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value.filter(
@ -94,6 +216,7 @@ const filteredSeries = computed(() => {
);
});
// Helper functions
const formatSeriesType = (type) => {
const types = {
workshop_series: "Workshop Series",
@ -105,6 +228,25 @@ const formatSeriesType = (type) => {
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
workshop_series:
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
recurring_meetup:
"bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30",
multi_day:
"bg-ember-900/20 text-ember-500 dark:text-ember-400 border border-ember-700/30",
course:
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
tournament:
"bg-ember-900/20 text-ember-500 dark:text-ember-400 border border-ember-700/30",
};
return (
classes[type] ||
"bg-earth-900/20 text-earth-400 dark:text-earth-400 border border-earth-700/30"
);
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
@ -113,133 +255,50 @@ const formatEventDate = (date) => {
});
};
const formatEventTime = (date) => {
return new Date(date).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
const formatDateRange = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return `${formatter.format(start)} ${formatter.format(end)}`;
return `${formatter.format(start)} to ${formatter.format(end)}`;
};
const getEventStatus = (event) => {
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing";
return "Completed";
};
const getEventStatusClass = (event) => {
const status = getEventStatus(event);
const classes = {
Upcoming:
"bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30",
Ongoing:
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
Completed:
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30",
};
return (
classes[status] ||
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30"
);
};
</script>
<style scoped>
.state-msg {
padding: 32px 28px;
color: var(--text-dim);
font-size: 12px;
}
.state-msg p { max-width: 560px; }
.series-section {
border-bottom: 1px dashed var(--border);
}
.series-head {
padding: 24px 28px 16px;
}
.series-head h2 {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 10px;
}
.series-meta-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
font-size: 12px;
}
.meta-text {
color: var(--text-faint);
font-size: 12px;
}
.series-desc {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
max-width: 640px;
margin: 0;
}
.sessions {
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.event-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 28px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.event-row:last-child { border-bottom: none; }
.event-num {
flex: 0 0 24px;
color: var(--text-faint);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.event-date {
flex: 0 0 110px;
color: var(--text-faint);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.event-info { flex: 1 1 0; min-width: 0; }
.event-title-link {
color: var(--text);
text-decoration: none;
font-size: 13px;
}
.event-title-link:hover { color: var(--candle); }
.event-status {
font-size: 10px;
color: var(--text-faint);
margin-left: 8px;
}
.series-foot {
padding: 14px 28px 24px;
}
.view-link {
font-size: 12px;
color: var(--candle);
letter-spacing: 0.04em;
}
.view-link:hover { text-decoration: underline; }
@media (max-width: 768px) {
.series-head,
.series-foot {
padding-left: 20px;
padding-right: 20px;
}
.event-row {
flex-wrap: wrap;
padding-left: 20px;
padding-right: 20px;
row-gap: 2px;
}
.event-info {
flex-basis: 100%;
margin-left: 36px;
}
}
</style>

View file

@ -1,122 +0,0 @@
# Ghost Guild — Open Backlog
_Last consolidated: 2026-04-30. 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 live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
---
## Pre-cutover (do once)
Operational steps that have to run during cutover. Full details + env-var list in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
- [ ] 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.**
- [ ] 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`.
- [ ] Deploy.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic.**
- [ ] Audit prod for pre-fix series-pass bypass registrations (registrations on pass-only series children with `registeredAt < 2026-04-20` from non-pass-holders). Decide per case.
- [ ] In Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303 (we send our own CRA-safe version via Resend).
- [ ] Run one real test charge and verify (a) Payment doc in Mongo and (b) exactly one CRA-compliant confirmation email.
- [ ] Rotate `HELCIM_API_TOKEN` in the Helcim merchant portal and update the Dokploy env var.
- [ ] Trigger the daily reconcile task once manually in Dokploy to confirm it's wired correctly.
## Pilot smoke walks (before first wave)
Once cutover lands, before the first Slack onboarding wave goes out:
- [ ] **Pilot smoke walk for Slack-invited workflow.** One admin manually clicks "Mark as Slack invited" against a real test member in production, confirms the row updates in place, and confirms the dashboard "Slack coming" note disappears for that member. Unit tests cover the pieces; nothing covers the live admin-to-member round-trip.
---
## Bylaws-decoupling (waiting on amendment ratification)
Membership status is being decoupled from payment status. Copy + UI gates already align; behavioral changes below remain.
- [ ] **B1.** `server/api/members/cancel-subscription.post.js:31,48` flips status to `pending_payment` on cancel. Under the new bylaws, cancellation should keep status `active` (just zero contribution). Update the `findByIdAndUpdate` payload + response, the comment at line 26, and add coverage in `tests/server/api/cancel-subscription.test.js`.
- ~~B3 cancelled.~~ `pending_payment` stays.
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
---
## 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.
- **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).
---
## 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.
---
## Deferred features (own session each)
- [ ] **Email automation system.** Patterned after Tranzac's implementation (separate project, already built). HTML email bodies with template management and drip sequences. Deferred 2026-04-20 — ruled wasted work given the larger system is designed elsewhere. Current transactional email lives in `server/utils/resend.js` + inline in `server/api/auth/login.post.js`, `server/routes/oidc/interaction/login.post.ts`, `server/api/admin/{members,pre-registrants}/invite.post.js`. Copy dump at `docs/email-copy-dump.md`. See memory: `project_email_automation_future`.
- [ ] **Receipts for event ticket purchases (Phase 2).** Phase 1 receipts only cover membership payments. Event tickets — especially guest purchases without member accounts — need a receipt flow. Likely an emailed PDF/HTML receipt at purchase time. Build target: JuneOct 2026, live Jan 2027. See memory: `project_receipts`.
- [ ] **Series/event waitlist.** Admin can configure `tickets.waitlist.enabled` and `maxSize`; `server/utils/tickets.js` returns `waitlistAvailable: true` when full; `app/components/SeriesPassPurchase.vue:341` and `EventTicketPurchase.vue` have stub `handleJoinWaitlist` that toasts "Waitlist Coming Soon." No server endpoint, no confirmation email, no `event_waitlisted` activity hook. Either implement end-to-end or hide the button by removing the `v-if="availability?.waitlistAvailable"` branches in `EventSeriesTicketCard.vue:175` and `EventTicketCard.vue:73`.
- [ ] **ASVS Phase 4.** File-upload validation pipeline, granular RBAC, credential encryption.
---
## 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.
- [ ] **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.
- [ ] **`slack_invite_failed` enum slug cleanup.** Detector and alert removed in `d15458b`, but the slug remains in `server/models/adminAlertDismissal.js` enum so historical dismissal rows continue to validate. Full removal needs a one-shot cleanup of stale dismissal rows in the DB. Roll into a future schema-tidy pass.
---
## 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.
---
## 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.
---
## E2e infrastructure gaps
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.
- [ ] **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.
- [ ] **Visual snapshot for `join — desktop` is stale.** 12,676px diff (2% of image) from layout drift. Regenerate via `npx playwright test --update-snapshots e2e/visual/pages.spec.js` once a designer eyeballs the diff.
- [ ] **E2e cross-file races on admin specs.** With `fullyParallel: false` + `workers: 4` + `retries: 1`, ~1 admin CRUD test still fails per full-suite run (rotates between `admin-events` CRUD, `board` page-loads, and wave-slack §6.4). Each passes 100% in isolation. Root cause: tests anchor on "first row" / "any visible button" rather than uniquely-identified data, so they race when other admin specs mutate the shared dev DB. Proper fix is per-test data isolation: each test creates its own scoped record with a `Date.now()` suffix and queries by that exact identifier. Out of scope for the e2e expansion.
---
## Deeplink memories
- `project_post_launch_backlog.md` — high-level digest of this file
- `project_launch_readiness.md` — cutover status (NOT YET happened)
- `project_launch_flow_map.md` — onboarding flow + Slack wave model
- `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_receipts.md` — Phase 1 done, Phase 2 pending
- `project_email_automation_future.md` — Tranzac reference for full system

View file

@ -1,18 +1,33 @@
# Launch Readiness
**Status as of 2026-04-30. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
**Status as of 2026-04-20.** Target launch: before 2026-05-01.
Pre-cutover deploy checklist is the live content on this page. Everything else (post-launch work, bylaws decoupling, deferred features, simplify follow-ups, a11y) lives in [`BACKLOG.md`](./BACKLOG.md). Completed launch-blocker items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`.
Single source of truth for work that must happen before cutover. P0 blocks launch. P1 is strongly preferred but survivable. Completed items have been archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`.
---
## Current state
- Vitest snapshot 2026-04-25 ~18:23 local: **703 passing / 8 failing / 2 skipped (713 total)**. The previously-flagged 6 helcim-payment failures are now green. The 8 current failures are in `tests/server/api/auth-verify.test.js` and `tests/server/api/cancel-subscription.smoke.test.js`, both belonging to in-flight Phase 5 fixes (#10 and #9) being landed by parallel impl subagents — they will resolve as those branches merge.
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet.
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
- Contribution-amount migration has **NOT** yet been run against prod.
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
- Vitest on `main`: **652/658 passing**. 6 pre-existing failures in `tests/server/api/helcim-payment.test.js` — unrelated to launch-blocking work, noted in the deploy checklist for visibility.
- `main` is now caught up locally (2026-04-20): `feature/helcim-plan-consolidation` (40 commits) and `feature/contribution-amount-redesign` (17 commits) fast-forwarded in. Not pushed — site is not on Netlify yet.
- Helcim plan consolidation migration ran against prod 2026-04-18 (Monthly plan id `50302`, Annual plan id `50303`). **Contribution-amount migration has NOT yet been run against prod.**
- Cadence/contribution UX unified across signup + edit surfaces 2026-04-20. Uncommitted in working tree — see "Cadence UX refinements" below.
- **Charitable receipts Phase 1 built on `feature/receipts-phase-1` (commits `bf5a333..91711aa`, 2026-04-20). Unmerged.** All four spec items shipped: `Payment` model + idempotent `upsertPaymentFromHelcim` helper, synchronous payment logging on both new paid subscriptions and free→paid upgrades, nightly reconciliation script, `/join` charity note, and `taxReceiptPreferences` schema field (no UI — Phase 2). Resend-owned confirmation email (`server/emails/paymentConfirmation.js`) is CRA-safe. Remaining work is deploy-time only (merge branch, disable Helcim default email on plans 50302 + 50303, backfill, real staging charge) — tracked in Deploy checklist.
### Cadence UX refinements (2026-04-20, uncommitted)
Shipped across `accept-invite.vue`, `join.vue`, `member/account.vue`, `welcome.vue`, `member/dashboard.vue`, and a new shared `SignupFlowOverlay.vue`:
- **Shared SignupFlowOverlay component.** Extracted from `/join` progress overlay; now used by both `/join` and `/accept-invite`.
- **Static "Monthly Contribution" label** on all three contribution inputs (previously dynamic — flipped to "Annual Contribution" when annual cadence was selected, which was misleading because the stored value is always the monthly base).
- **"Per Year" / "Per Month"** toggle copy (was "Annual" / "Monthly"). On `/accept-invite`, Per Year is now the default; `/join` stays on Per Month by default.
- **Live billing-summary card** below the contribution input on both signup flows — reads e.g. "You'll be charged $180 today ($15/month × 12). Then $180 every year, until you cancel."
- **Welcome heading on dashboard** for new signups: `/member/dashboard?welcome=1` renders "Welcome to Ghost Guild, {name}" instead of "Welcome back, {name}". `/welcome` redirect now always carries the param; `/accept-invite` navigates to the dashboard with the param directly.
- **$0 member polish on `/member/account`**: Payment History section hidden for $0 members with no prior charges (condition now `contributionAmount > 0 || paymentHistory.length > 0` — fixes a regression where paid-then-$0 members lost visibility of their past payments). Solidarity-Fund sentence in the Danger Zone also hidden at $0.
- **Next charge row above payment history** on `/member/account`: When a member has an upcoming charge, a "Next charge: $X on DATE" row renders above the transaction list (dashed `--candle` border). Separate from the existing compact "Next payment" row in the Membership Card summary.
- **Fixed `subscription.get.js` Helcim field mapping.** Helcim's GET `/subscriptions/:id` returns `data` as a single object (not array) with the field `dateBilling` (not `nextBillingDate`). The lazy refresh endpoint now handles both shapes — previously it returned empty strings, so neither the Membership-card "Next payment" nor the new "Next charge" row rendered for any member whose cached `nextBillingDate` was missing. Note: `subscription.post.js` and `update-contribution.post.js` still read `subscription.nextBillingDate` from Helcim's CREATE response (same wrong field), which is why the cache was empty to begin with. Left unfixed in this pass — the lazy GET refresh now masks it. Worth cleaning up post-launch.
- **State-aware contribution-change hint** on `/member/account`: "You'll be charged $X today to start your subscription." ($0 → paid) / "Your paid subscription will be cancelled." (paid → $0) / "Changes apply on your next billing cycle." (paid → paid, different amount).
- **Server-side invite accept** now creates the Helcim customer and sets the auth cookie before returning, for both free and paid branches.
---
@ -24,89 +39,137 @@ None outstanding.
## P1 — Strongly preferred before launch
None outstanding.
### Charitable receipts — Phase 1 ✅ COMPLETE (`docs/specs/receipts-launch-spec.md`)
Built on `feature/receipts-phase-1`, commits `bf5a333..91711aa` (2026-04-20). **Unmerged.** All four spec items shipped; remaining work is deploy-time only (tracked in Deploy checklist).
Shipped:
- **Payment logging.** New `Payment` model (`server/models/payment.js`) + idempotent `upsertPaymentFromHelcim` helper keyed on unique `helcimTransactionId` (`server/utils/payments.js`). Synchronous write paths:
- New paid subscription → `server/api/helcim/subscription.post.js` fetches the newest paid Helcim tx and upserts a Payment with `paymentType` from cadence + `sendConfirmation: true`. Wrapped in try/catch so a logging failure cannot break subscription creation.
- Free → paid upgrade → `server/api/members/update-contribution.post.js` (Case 1 branch) does the same.
- Paid → paid amount change (Case 3) is intentionally **not** wired synchronously — no new tx at the moment of change; the next recurring charge is captured by the reconciliation script.
- **Confirmation email via Resend, not Helcim.** Spec alternative (b) chosen. `server/emails/paymentConfirmation.js` is CRA-safe: charity name "Baby Ghosts Studio Development Fund" + "not an official donation receipt / tax receipts available later in 2026" disclaimer. Triggered only on new Payment inserts; send failures are swallowed. Helcim's default confirmation must be disabled on plans 50302 + 50303 at cutover (Deploy checklist).
- **Join page copy.** Factual charity note below contribution tiers on `/join` only (`app/pages/join.vue:83`). `/accept-invite` and `/member/account` intentionally untouched per spec §3.
- **Member schema field.** `taxReceiptPreferences` nested object added to `server/models/member.js` (filesCanadianTaxes, middleInitial, confirmedAddress sub-object, setupCompletedAt). Defaults null/false — existing members read as "not set up." Schema-only; no Zod, no route, no UI. Phase 2 binds to it without migration.
- **Reconciliation script.** `scripts/reconcile-helcim-payments.mjs` iterates every Member with `helcimCustomerId`, pulls recent Helcim transactions, and upserts via the same helper. Idempotent. Dry-run by default; `--apply` to write. No confirmation emails sent during reconcile. Dual purpose: launch-day backfill for the ~34 pre-existing members, and nightly cron post-launch to catch recurring charges that bypass the synchronous write paths.
Remaining (deploy-time, not code):
- [ ] Merge `feature/receipts-phase-1` into `main`.
- Manual Helcim-dashboard step + prod reconcile + staging test charge — see Deploy checklist.
---
## Deploy checklist
Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-repo `Dockerfile` (`node:20-alpine`, runs `node .output/server/index.mjs` on port 3000); Dokploy autodetects it. Traefik (Dokploy's reverse proxy) handles SSL; `oidc-provider.ts:194` and the rate-limit middleware already trust `X-Forwarded-Proto` / `X-Forwarded-For`.
### One-time host setup
- [ ] **Provision the Dokploy app** pointing at this repo. Build context: repo root. Default Dockerfile. Container port: `3000`.
- [ ] **Set env vars in the Dokploy UI** (full list below). The `validate-env.js` Nitro plugin fails fast at boot if `MONGODB_URI` / `JWT_SECRET` / `RESEND_API_KEY` / `HELCIM_API_TOKEN` are missing — container refuses to start, so misconfig surfaces immediately in logs.
- [ ] **`BASE_URL` must exactly match the public origin** (e.g. `https://ghostguild.org`, no trailing slash). The `/api/helcim/customer` origin check at `server/api/helcim/customer.post.js:11-15` does exact-match comparison against the `Origin` header — if `BASE_URL` is wrong or unset, signup 403s.
- [ ] **`NODE_ENV=production`** must be set. Without it: `Secure` cookie flag, HSTS, and CSP all silently no-op.
- [ ] **Add a Dokploy Scheduled Task** for daily reconciliation. Command:
```
curl -fsS -X POST "$BASE_URL/api/internal/reconcile-payments" -H "X-Reconcile-Token: $NUXT_RECONCILE_TOKEN"
```
Schedule: `0 4 * * *` (or any time of day). The Nitro route does the heavy lifting (Mongo iteration, Helcim API, retries) — the scheduler just wakes it up.
### Cutover
Applies when the site is connected to Netlify / production hosting. Nothing here is actionable until that connection exists; kept here so nothing gets forgotten at cutover.
- [ ] Push local `main` to `origin/main`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.** Idempotent; dry-run on local counted 34 members. Requires `MONGODB_URI` in env. The script writes `contributionAmount` (Number) derived from existing `contributionTier` (String) on every Member doc; the old field is left intact for a window.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in Dokploy env.
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy env.
- [ ] **Set `NUXT_RECONCILE_TOKEN`** to any 32+ char random string. Shared secret between the Dokploy scheduled task and `/api/internal/reconcile-payments`.
- [ ] Deploy.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); the daily Dokploy cron picks it up from there.
- [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in production env.
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in production env.
- [ ] Decide on the 6 failing tests in `tests/server/api/helcim-payment.test.js` — either fix or consciously accept. Not launch-blocking, but pre-existing red tests tend to mask new regressions.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); safe to re-run as a nightly reconciliation job post-launch.
- [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails.
- [ ] **Run one real test charge against the deployed app** and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
- [ ] **Run one real test charge on staging** via the cloudflared tunnel and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
**Env vars required in Dokploy (reference):**
- `NODE_ENV=production`
- `BASE_URL` (exact public origin, no trailing slash)
**Env vars required in production (reference):**
- `MONGODB_URI`
- `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins)
- `RESEND_API_KEY`
- `HELCIM_API_TOKEN`
- `NUXT_HELCIM_MONTHLY_PLAN_ID=50302`
- `NUXT_HELCIM_ANNUAL_PLAN_ID=50303`
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
- `NUXT_RECONCILE_TOKEN` (32+ char random string)
- `NUXT_HELCIM_MONTHLY_PLAN_ID`
- `NUXT_HELCIM_ANNUAL_PLAN_ID`
- `SLACK_BOT_TOKEN`
- `BASE_URL`
- `OIDC_COOKIE_SECRET`
---
## Fixed 2026-04-25
Day-of-launch security and correctness audit. All commit shas TBD until Phase 5.
### CRITICAL (security)
- **Fix #1**`HELCIM_API_TOKEN` removed from public runtime config + dead `useHelcim.js` deleted. **Token must be rotated post-deploy** (was previously exposed via `window.__NUXT__`).
- **Fix #2**`/api/helcim/customer` gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated `setAuthCookie`).
- **Fix #3**`/api/events/[id]/payment` deleted (dead code with auth bypass). `processHelcimPayment` stub + `eventPaymentSchema` removed.
- **Fix #4**`/api/helcim/initialize-payment` re-derives ticket amount server-side via `calculateTicketPrice`; new `series_ticket` metadata type.
- **Fix #5**`/api/helcim/customer` upgrades existing `status:guest` members in place rather than rejecting with 409.
### HIGH (correctness)
- **Fix #6** — Recurring reconciliation: Netlify scheduled function calls `/api/internal/reconcile-payments` daily. Requires `NUXT_RECONCILE_TOKEN` env var.
- **Fix #7**`validateBeforeSave: false` added to event subdoc saves (waitlist endpoints) to dodge legacy location validators.
- **Fix #8** — Series-pass purchase always creates a guest Member when caller is unauthenticated, mirroring event-ticket flow.
- **Fix #9**`cancel-subscription` leaves status `active` (per ratified bylaws); adds `lastCancelledAt` audit field.
- **Fix #10**`/api/auth/verify` uses `validateBody` with `.strict()` Zod schema.
- **Fix #11** — Added 8 vitest cases for `cancel-subscription.post.js` (was uncovered).
### Side-quests
- Visual audit Phase 4 changes (events/series surface)
- Per-fix branch verification: see `docs/superpowers/specs/2026-04-25-fix-*.md`
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
---
## Manual browser tests still needed
None outstanding. All launch-blocking flows verified via local dev or cloudflared tunnel with real Helcim test card + real email (see archive for the full log). The one remaining browser verification is the staging test charge bundled into the Deploy checklist above.
Cannot be verified by Vitest. Both require a real browser + real Helcim test card + real email, via cloudflared tunnel or ngrok HTTPS (Helcim requires HTTPS for the pay.js iframe).
**Shared setup (do once):**
- `npx nuxi dev --https` in one terminal, `cloudflared tunnel --url https://localhost:3000` (or `ngrok http https://localhost:3000`) in another. Use the tunnel URL as `BASE_URL` in `.env`.
- Helcim sandbox test card: see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/reference_helcim_sandbox.md`.
- Apply the contribution-amount migration against local Mongo first so seeded members match the new schema:
```
node scripts/migrate-contribution-amount.cjs # dry-run
node scripts/migrate-contribution-amount.cjs --apply # apply
```
After applying, confirm in mongosh: `db.members.countDocuments({ contributionAmount: { $exists: true } })` should equal total member count; `db.members.countDocuments({ contributionAmount: { $type: 'string' } })` must be `0`.
---
## Post-launch & deferred work
- [x] **Pre-registrant invite → accept flow with a paid contribution amount.** ✅ Passed 2026-04-20 — both Monthly $7 and Annual $15 variants completed end-to-end. DB verified programmatically: `contributionAmount` stored as Number, `billingCadence` correct, `helcimCustomerId` + `helcimSubscriptionId` populated, `status: active`, no `contributionTier` field, preReg transitioned to `accepted` with `memberId` set.
Bylaws decoupling, post-launch a11y, ASVS Phase 4, deferred features, simplify-pass follow-ups, known gotchas, wave-Slack pilot follow-ups — **everything that isn't a deploy step has moved to [`BACKLOG.md`](./BACKLOG.md).**
- **Contribution-amount redesign end-to-end.** Covers the full surface of the `contributionTier``contributionAmount` rename.
- [x] **Signup flows — `/join`:** ✅ Passed 2026-04-20. All 5 variants ran functionally clean (welcome-heading regression was caught, fixed via `?welcome=1` propagation through `/welcome`, not retested — trusted):
1. `$0` Monthly — Member created with no Helcim subscription.
2. `$5` Monthly (preset) — Helcim `recurringAmount: 5`.
3. `$17` Monthly (non-preset) — Helcim `recurringAmount: 17`, `$15` chip label via `findLast`.
4. `$17` Annual — Helcim `recurringAmount: 204`, `billingCadence: 'annual'`, Mongo stores monthly-equivalent `17`.
5. `$50` Annual (top preset) — Helcim `recurringAmount: 600`.
- [x] **Edit flows — `/member/account` as an active paid member:** ✅ Passed 2026-04-20 against Cleo's Annual subscription (Helcim sub 138682).
- Raise $15 → $30 annual: `updateHelcimSubscription` hit with `recurringAmount: 360`, Mongo `contributionAmount: 30` (Number).
- Lower $30 → $5 annual: `recurringAmount: 60`, Mongo `contributionAmount: 5` (Number).
- ~~Switch cadence (Monthly $17 ↔ Annual $17).~~ **Deferred from launch.** Server (`update-contribution.post.js:184-189`) explicitly rejects cadence changes on existing subscriptions; no UI toggle exists on `/member/account`. Re-scope post-launch if/when we want to support cadence switch (would need Helcim subscription replacement flow, not a plain update).
- [x] **Admin flow — `/admin/members/[id]` edit:** ✅ Passed 2026-04-20.
- Changed Cleo $5 → $15 via admin PUT. Mongo wrote `contributionAmount: 15` (Number). `contributionTier` field absent across all 34 members (`countDocuments({ contributionTier: { $exists: true } }) === 0`).
- Known non-blocker: admin edit does not sync the change to Helcim's `recurringAmount`. Admin override is direct Mongo-only by design; had to PATCH Helcim manually to re-sync Cleo post-test. Worth noting in docs or surfacing in admin UI post-launch.
**Assert across all flows:**
- Mongo `contributionAmount` is always `Number`, never `String`.
- No `contributionTier` values written anywhere (greppable: `db.members.findOne({}, { contributionTier: 1 })` should return whatever the migration left; no *new* writes to that field).
- No "save $X", "2 months free", or discount copy appears in any UI surface. Annual is just `amount × 12` exactly.
- Guidance chip labels (`$0`/`$5`/`$15`/`$30`/`$50`) are matched via `findLast`, so $17 lands on the `$15` label, $49 lands on `$30`, $51 lands on `$50`.
**Key files if debugging:** `app/pages/join.vue`, `app/pages/member/account.vue`, `app/pages/admin/members/[id].vue`, `server/api/helcim/subscription.post.js`, `server/api/members/update-contribution.post.js`, `server/api/admin/members/[id].put.js`, `app/config/contributions.js` + `server/config/contributions.js`.
**Cosmetic follow-ups noted in Post-launch backlog below** — won't block this test (they're naming, not behavior).
---
## Bylaws decoupling — follow-ups (added 2026-04-18)
Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in `useMemberStatus.js` and `account.vue` (2026-04-18). Server-side status gating shipped as B2 (see archive). The behavioral changes below remain.
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
### B1. `cancel-subscription` flips status to `pending_payment`
- `server/api/members/cancel-subscription.post.js:31,48`
- When a member cancels their paid subscription, status is set to `pending_payment` and contribution amount to `0`. Under the new model, cancelling a payment plan moves the member to the $0 contribution — status should stay `active`.
- **Fix:** change `status: 'pending_payment'``status: 'active'` in both the `findByIdAndUpdate` payload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing).
- Add coverage in `tests/server/api/cancel-subscription.test.js` if it doesn't already exist.
### B3. Vestigial `pending_payment` status
- Once payment is fully decoupled, `pending_payment` no longer gates anything and is functionally equivalent to `active`. Consider removing it from the enum (`server/models/member.js:38`, `server/utils/schemas.js:299`) and treating new signups as `active` from the moment of account creation.
- Touches: signup flow (`helcim/customer.post.js:34`, `invite/accept.post.js:48`), admin filter UI (`app/pages/admin/members/index.vue:45,382,499,1145`, `[id].vue:69,286`), admin alerts (`server/utils/adminAlerts.js:22,100-116`, `server/models/adminAlertDismissal.js:6`), and a data migration to flip existing `pending_payment` rows to `active`.
- Larger refactor — break out into its own ticket once B1 lands.
### B4. Admin "Pending Payment" filter label (cosmetic)
- `app/pages/admin/members/index.vue:45,499`, `[id].vue:69` show `pending_payment` as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep `pending_payment` for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
---
## Post-launch backlog
See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5).
- `/oidc/interaction/[uid]` routing quirk.
- Admin layout migration from `guild-*` tokens to zine spec.
- Admin dashboard quick-action button contrast.
- Members table NAME column clipping.
- 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.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
- Delete dead `app/components/TierPicker.vue`.
- Update stale tier comment in `app/composables/useMemberPayment.js:59`.
- Update error log message referencing "tier" in `server/api/members/update-contribution.post.js:221`.
- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -7,20 +7,16 @@ const publicPages = [
{ name: "Join", path: "/join" },
{ name: "Events", path: "/events" },
{ name: "Coming Soon", path: "/coming-soon" },
{ name: "Accept Invite", path: "/accept-invite" },
];
const memberPages = [
{ name: "Member Dashboard", path: "/member/dashboard" },
{ name: "Member Profile", path: "/member/profile" },
{ name: "Member Account", path: "/member/account" },
{ name: "Board", path: "/board" },
];
const adminPages = [
{ name: "Admin Members", path: "/admin/members" },
{ name: "Admin Events Create", path: "/admin/events/create" },
{ name: "Admin Pre-Registrants", path: "/admin/pre-registrants" },
];
test.describe("accessibility — public pages", () => {

View file

@ -1,170 +0,0 @@
import { test, expect } from '@playwright/test'
const FAKE_TOKEN = 'fake-invite-token-for-e2e'
const FAKE_PREREG_ID = '000000000000000000000001'
async function mockVerifyOk(page, overrides = {}) {
await page.route('**/api/invite/verify', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
preRegistrationId: FAKE_PREREG_ID,
name: overrides.name ?? 'Pre Registered User',
email: overrides.email ?? `prereg-${Date.now()}@example.com`,
city: overrides.city ?? 'Vancouver, BC',
}),
})
})
}
async function mockAcceptFree(page) {
await page.route('**/api/invite/accept', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
requiresPayment: false,
redirectUrl: '/member/dashboard',
member: {
id: 'mem-1',
email: 'prereg@example.com',
name: 'Pre Registered User',
circle: 'community',
contributionAmount: 0,
status: 'active',
},
}),
})
})
await page.route('**/api/auth/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
authenticated: true,
member: { id: 'mem-1', name: 'Pre Registered User', status: 'active' },
status: 'active',
}),
})
})
}
async function gotoAcceptInvite(page) {
await page.goto(`/accept-invite#${FAKE_TOKEN}`)
}
test.describe('Accept Invite — pre-registrant signup', () => {
test('verifies invitation and shows form fields', async ({ page }) => {
await mockVerifyOk(page, { name: 'Ada Lovelace', email: 'ada@example.com' })
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toBeVisible()
await expect(page.locator('#accept-name')).toHaveValue('Ada Lovelace')
await expect(page.locator('#accept-email')).toHaveValue('ada@example.com')
await expect(page.locator('#circle-community')).toBeAttached()
await expect(page.locator('#circle-founder')).toBeAttached()
await expect(page.locator('#circle-practitioner')).toBeAttached()
await expect(page.locator('#accept-cadence-monthly')).toBeAttached()
await expect(page.locator('#accept-cadence-annual')).toBeAttached()
await expect(page.locator('#accept-contribution')).toBeVisible()
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
await expect(page.locator('.form-submit')).toBeVisible()
})
test('shows error when no token in URL hash', async ({ page }) => {
await page.goto('/accept-invite')
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
await expect(page.locator('.error-box')).toContainText(/No invitation token/)
})
test('shows error when token verification fails', async ({ page }) => {
await page.route('**/api/invite/verify', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' }),
})
})
await gotoAcceptInvite(page)
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
await expect(page.locator('.error-box')).toContainText(/Invalid or expired/)
})
test('submit disabled until name + agreement filled', async ({ page }) => {
await mockVerifyOk(page, { name: '' })
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toBeVisible()
await expect(page.locator('.form-submit')).toBeDisabled()
await page.locator('#accept-name').fill('New Member')
await expect(page.locator('.form-submit')).toBeDisabled()
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
})
test('cadence toggle updates billing summary total', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await expect(page.locator('#accept-contribution')).toBeVisible()
await page.locator('#accept-contribution').fill('10')
await page.locator('label[for="accept-cadence-monthly"]').click()
await expect(page.locator('.billing-summary')).toContainText('$10 today')
await page.locator('label[for="accept-cadence-annual"]').click()
await expect(page.locator('.billing-summary')).toContainText('$120 today')
await expect(page.locator('.billing-summary')).toContainText('$10/month')
})
test('preset chip sets contribution amount', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
const chip = page.locator('.contribution-preset-chip').nth(1)
const chipText = await chip.textContent()
const expected = chipText.replace(/[^0-9]/g, '')
await chip.click()
await expect(page.locator('#accept-contribution')).toHaveValue(expected)
})
test('free tier happy path shows welcome state', async ({ page }) => {
await mockVerifyOk(page, { name: 'Free Tester', email: `free-${Date.now()}@example.com` })
await mockAcceptFree(page)
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
await page.locator('#circle-community').check({ force: true })
await page.locator('#accept-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
await expect(page.locator('.form-submit')).toContainText(/Accept Invitation/)
await page.locator('.form-submit').click()
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
})
test('paid tier submit button copy switches to Continue to Payment', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await page.locator('#accept-contribution').fill('10')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
})
// Skipped: full paid-tier submission requires intercepting HelcimPay.js modal
// (external script loads an iframe and posts a message back to verifyPayment).
// Feasible but out of scope for this initial coverage pass.
test.skip('paid tier full flow with mocked HelcimPay', async () => {})
})

View file

@ -11,7 +11,6 @@ test.describe('Admin board channels page', () => {
test('create, edit, and delete a channel', async ({ adminPage }) => {
await adminPage.goto('/admin/board-channels')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
timeout: 15000,
})
@ -19,14 +18,14 @@ test.describe('Admin board channels page', () => {
const suffix = Date.now().toString().slice(-6)
const channelName = `e2e-channel-${suffix}`
const editedName = `e2e-channel-${suffix}-edited`
const slackId = `C${suffix}XYZ`
// --- Create ---
// Create flow only takes a name; the Slack channel ID is auto-assigned on
// creation and only becomes editable in the Edit modal.
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
await adminPage.locator('input[placeholder="e.g., coop-formation"]').fill(channelName)
await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
// Select the first available cooperative tag if any are present
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
@ -45,7 +44,7 @@ test.describe('Admin board channels page', () => {
await row.getByRole('button', { name: 'Edit' }).click()
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
const nameInput = adminPage.locator('input[placeholder="e.g., coop-formation"]')
const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
await nameInput.fill(editedName)
await adminPage.getByRole('button', { name: 'Save Changes' }).click()

View file

@ -53,116 +53,3 @@ test.describe('Admin events access control', () => {
expect(page.url()).not.toContain('/admin/events')
})
})
test.describe('Admin events CRUD', () => {
test('create, edit, and delete an event', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-event-${suffix}`
const editedTitle = `e2e-event-${suffix}-edited`
// Re-prime the auth cookie immediately before this multi-step flow.
// The shared test-admin account's tokenVersion is bumped whenever
// auth.spec.js's logout test runs in parallel, which would otherwise
// surface mid-flow as "Session has been revoked" on the first POST.
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (loginRes.status() !== 302) {
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
}
// --- Create ---
await adminPage.goto('/admin/events/create')
await expect(adminPage.locator('h1')).toContainText('Create Event')
// Ensure Vue has hydrated (initial $fetch for series/tags has resolved)
// before interacting — under cross-file load, hydration can lag and a
// pre-hydration submit will native-POST against an empty form.
await adminPage.waitForLoadState('networkidle')
await adminPage
.getByPlaceholder('Enter a clear, descriptive event title')
.fill(title)
await adminPage
.getByPlaceholder(
'Provide a clear description of what attendees can expect from this event'
)
.fill('e2e test event description')
await adminPage
.getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name')
.fill('https://example.com/zoom')
const startInput = adminPage.getByPlaceholder(
"e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
)
await startInput.fill('next Tuesday at 3pm')
await startInput.blur()
const endInput = adminPage.getByPlaceholder(
"e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
)
await endInput.fill('next Tuesday at 5pm')
await endInput.blur()
await adminPage.getByRole('button', { name: 'Create Event' }).click()
// The form posts via $fetch and then auto-redirects after a 1.5s setTimeout.
// Under cross-file load that auto-redirect can race against waitForURL.
// Wait for the surfaced success/error state, fail fast on error, then
// navigate explicitly so subsequent assertions are deterministic.
await expect(
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
).toBeVisible({ timeout: 15000 })
await expect(adminPage.locator('.success-box')).toBeVisible()
await adminPage.goto('/admin/events')
await adminPage.waitForLoadState('networkidle')
// Filter to just our event — orphan rows from prior failed runs can push
// the new row off page 1 of the paginated list.
await adminPage.getByPlaceholder('Search events...').fill(title)
const row = adminPage.locator('tr', { hasText: title })
await expect(row).toBeVisible({ timeout: 10000 })
// --- Edit ---
// Find the event ID from the row's "View" link (href is /events/<slug-or-id>),
// and use the row's Edit button. Pair the click with waitForURL so we don't
// miss the navigation event under load.
await Promise.all([
adminPage.waitForURL(/\/admin\/events\/create\?edit=/, { timeout: 15000 }),
row.getByRole('button', { name: 'Edit' }).click(),
])
await expect(adminPage.locator('h1')).toContainText('Edit Event')
const titleInput = adminPage.getByPlaceholder(
'Enter a clear, descriptive event title'
)
await titleInput.fill(editedTitle)
await adminPage.getByRole('button', { name: 'Update Event' }).click()
await expect(
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
).toBeVisible({ timeout: 15000 })
await expect(adminPage.locator('.success-box')).toBeVisible()
await adminPage.goto('/admin/events')
await adminPage.waitForLoadState('networkidle')
// Filter to the edited event's unique title for the same pagination reason.
await adminPage.getByPlaceholder('Search events...').fill(editedTitle)
const editedRow = adminPage.locator('tr', { hasText: editedTitle })
await expect(editedRow).toBeVisible({ timeout: 10000 })
// --- Delete (custom modal, not browser dialog) ---
await editedRow.getByRole('button', { name: 'Del' }).click()
await expect(
adminPage.getByRole('heading', { name: 'Delete Event' })
).toBeVisible()
await adminPage
.locator('.modal')
.getByRole('button', { name: 'Delete' })
.click()
await expect(
adminPage.locator('tr', { hasText: editedTitle })
).toHaveCount(0, { timeout: 10000 })
})
})

View file

@ -66,68 +66,4 @@ test.describe("Admin members page", () => {
adminPage.getByPlaceholder("email@example.com"),
).toBeVisible();
});
test("create member, status select reflects STATUS_LABELS, change persists, detail page renders", async ({ adminPage }) => {
const stamp = Date.now();
const memberName = `E2E Member ${stamp}`;
const memberEmail = `e2e-member-${stamp}@example.test`;
await adminPage.goto("/admin/members");
await adminPage.waitForLoadState("networkidle");
await expect(adminPage.locator("h1")).toHaveText("Members");
await adminPage.getByRole("button", { name: "Add Member" }).click();
await adminPage.getByPlaceholder("Full name").fill(memberName);
await adminPage.getByPlaceholder("email@example.com").fill(memberEmail);
await adminPage.getByRole("button", { name: "Create Member" }).click();
// Verify the new member shows up via search
const searchInput = adminPage.getByPlaceholder("Search members...");
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.fill(memberEmail);
const memberRow = adminPage.locator("tr", { hasText: memberEmail });
await expect(memberRow).toBeVisible({ timeout: 10000 });
await expect(memberRow.getByText(memberName)).toBeVisible();
// Open the edit modal for this member, where the STATUS_LABELS-driven <select> lives
await memberRow.getByRole("button", { name: "Edit" }).click();
const statusSelect = adminPage.locator(".modal select").filter({ hasText: "Active" });
await expect(statusSelect).toBeVisible({ timeout: 10000 });
// STATUS_LABELS keys (values) and the rendered labels
const expectedOptions = [
{ value: "active", label: "Active" },
{ value: "pending_payment", label: "Payment setup incomplete" },
{ value: "suspended", label: "Paused" },
{ value: "cancelled", label: "Closed" },
];
for (const { value, label } of expectedOptions) {
const opt = statusSelect.locator(`option[value="${value}"]`);
await expect(opt).toHaveCount(1);
await expect(opt).toHaveText(label);
}
// Change status to suspended and save
await statusSelect.selectOption("suspended");
await adminPage.getByRole("button", { name: "Save Changes" }).click();
// Modal closes; verify the row badge reflects the new status
await expect(adminPage.locator(".modal")).toHaveCount(0, { timeout: 10000 });
await expect(memberRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
// Reload to confirm persistence
await adminPage.reload();
await adminPage.waitForLoadState("networkidle");
await adminPage.getByPlaceholder("Search members...").fill(memberEmail);
const reloadedRow = adminPage.locator("tr", { hasText: memberEmail });
await expect(reloadedRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
// Click the member name (link to detail page) and verify URL + heading
await reloadedRow.getByRole("link", { name: memberName }).click();
await adminPage.waitForURL(/\/admin\/members\/[a-f0-9]{24}$/, { timeout: 10000 });
await expect(adminPage.locator("h1")).toHaveText(memberName);
await expect(adminPage.locator(".member-email")).toHaveText(memberEmail);
});
});

View file

@ -1,111 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin pre-registrants page', () => {
test('page loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
).toBeVisible({ timeout: 15000 })
})
test('header action buttons render', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('button', { name: /^Mark as Selected/ })).toBeVisible()
await expect(adminPage.getByRole('button', { name: /^Send Invites/ })).toBeVisible()
})
test('search input filters list without crashing', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
const search = adminPage.getByPlaceholder('Search by name, email, city, role...')
await expect(search).toBeVisible({ timeout: 15000 })
await search.fill(`nonexistent-prereg-${Date.now()}`)
await expect(
adminPage.getByText('No pre-registrants found matching your criteria'),
).toBeVisible({ timeout: 10000 })
})
test('status filter changes selection', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
const statusFilter = adminPage.getByLabel('Filter by status')
await expect(statusFilter).toBeVisible({ timeout: 15000 })
await statusFilter.selectOption('expired')
await expect(statusFilter).toHaveValue('expired')
await expect(
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
).toBeVisible({ timeout: 10000 })
await statusFilter.selectOption('')
await expect(statusFilter).toHaveValue('')
})
test('Send Invites button is disabled with no selection', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('button', { name: 'Send Invites (0)' })).toBeDisabled()
await expect(adminPage.getByRole('button', { name: 'Mark as Selected (0)' })).toBeDisabled()
})
test('send invite action', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
// Filter to invitable statuses; pick the first row if available.
const statusFilter = adminPage.getByLabel('Filter by status')
await statusFilter.selectOption('pending')
await adminPage.waitForLoadState('networkidle')
const firstRow = adminPage.locator('tbody tr').first()
if (await firstRow.count() === 0) {
test.skip(true, 'No pending pre-registrants in dev DB to invite')
return
}
await firstRow.locator('.col-name').click()
const sendButton = adminPage.getByRole('button', { name: /^Send Invites \(\d+\)/ })
await expect(sendButton).toBeEnabled()
await sendButton.click()
await expect(adminPage.getByRole('heading', { name: 'Send Invitation Emails' })).toBeVisible()
const submitButton = adminPage.getByRole('button', { name: /^Send \d+ invitation/ })
await submitButton.click()
// ALLOW_DEV_TEST_ENDPOINTS=true short-circuits the Resend call; result still reports sent.
await expect(adminPage.getByText(/^\d+ sent$/)).toBeVisible({ timeout: 15000 })
})
test('non-admin redirect', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/admin/pre-registrants')
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/pre-registrants')
await context.close()
})
})

View file

@ -1,65 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin series management page', () => {
test('series list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/series-management')
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('link', { name: 'Create Series' })).toBeVisible()
})
})
test.describe('Admin series access control', () => {
test('non-admin redirect', async ({ page }) => {
await page.goto('/admin/series-management')
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/series-management')
})
})
test.describe('Admin series CRUD', () => {
test('create and edit a series', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-series-${suffix}`
const description = 'e2e test series description'
const editedDescription = 'e2e test series description edited'
// --- Create ---
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(description)
await adminPage.getByRole('button', { name: 'Create Series' }).click()
await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 })
const card = adminPage.locator('.series-card', { hasText: title })
await expect(card).toBeVisible({ timeout: 10000 })
await expect(card).toContainText(description)
// --- Edit (in-page modal) ---
await card.getByRole('button', { name: 'Edit' }).click()
await expect(adminPage.getByRole('heading', { name: 'Edit Series' })).toBeVisible()
const descInput = adminPage.locator('textarea[placeholder="Brief description of this series"]')
await descInput.fill(editedDescription)
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
const editedCard = adminPage.locator('.series-card', { hasText: title })
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 () => {})
})

View file

@ -1,85 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
const WHITELISTED_KEYS = ['homepage.wiki_feature']
test.describe('Admin site content page', () => {
test('page loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/site-content')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
})
test('renders one block per whitelisted key', async ({ adminPage }) => {
await adminPage.goto('/admin/site-content')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
const blocks = adminPage.locator('.content-block')
await expect(blocks).toHaveCount(WHITELISTED_KEYS.length)
for (const key of WHITELISTED_KEYS) {
await expect(adminPage.locator('.block-key', { hasText: key })).toBeVisible()
}
})
test('edit, save, persist, and reflect on public page', async ({ adminPage }) => {
const key = 'homepage.wiki_feature'
await adminPage.goto('/admin/site-content')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
const original = await adminPage.evaluate(
async (k) => await (await fetch(`/api/site-content/${k}`)).json(),
key,
)
const originalTitle = original.title || ''
const originalBody = original.body || ''
const stamp = Date.now()
const newTitle = `e2e title ${stamp}`
const newBody = `e2e body paragraph ${stamp}`
const block = adminPage.locator('.content-block', {
has: adminPage.locator('.block-key', { hasText: key }),
})
await expect(block).toBeVisible()
const titleInput = block.locator('input[type="text"]')
const bodyTextarea = block.locator('textarea')
await titleInput.fill(newTitle)
await bodyTextarea.fill(newBody)
await block.getByRole('button', { name: 'Save' }).click()
await expect(block.locator('.block-meta')).toContainText('Updated', { timeout: 10000 })
await adminPage.reload()
await adminPage.waitForLoadState('networkidle')
const reloadedBlock = adminPage.locator('.content-block', {
has: adminPage.locator('.block-key', { hasText: key }),
})
await expect(reloadedBlock.locator('input[type="text"]')).toHaveValue(newTitle)
await expect(reloadedBlock.locator('textarea')).toHaveValue(newBody)
await adminPage.goto('/')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByText(newBody)).toBeVisible({ timeout: 15000 })
await adminPage.evaluate(
async ({ k, t, b }) => {
await fetch(`/api/admin/site-content/${k}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: t, body: b }),
})
},
{ k: key, t: originalTitle, b: originalBody },
)
})
})

View file

@ -44,7 +44,6 @@ test.describe('Authentication flows', () => {
test('logout clears auth', async ({ page }) => {
await loginAsAdmin(page)
await page.goto('/admin')
await page.waitForLoadState('networkidle')
await expect(page.locator('.admin-tag')).toBeVisible({ timeout: 15000 })
// Set up response listener BEFORE clicking to avoid race

View file

@ -1,36 +1,14 @@
import { test, expect } from './helpers/fixtures.js'
import { loginAsMember } from './helpers/auth.js'
// The default `memberPage` fixture authenticates as test-admin@ghostguild.dev,
// the same account auth.spec.js's logout test revokes mid-suite. Bypass the
// fixture and use a seeded, non-shared member instead so cross-file logout
// can't strand this file mid-flow.
const SEEDED_MEMBER_EMAIL = 'riley.johnson@cooperativedev.org'
const newMemberPage = async (browser) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, SEEDED_MEMBER_EMAIL)
return { context, page }
}
test.describe('Board page', () => {
test('page loads for authenticated member', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
test('page loads for authenticated member', async ({ memberPage }) => {
await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
} finally {
await context.close()
}
})
test('clicking New Post reveals the form', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
test('clicking New Post reveals the form', async ({ memberPage }) => {
await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
timeout: 15000,
})
@ -40,16 +18,11 @@ test.describe('Board page', () => {
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
await expect(memberPage.locator('#post-title')).toBeVisible()
await expect(memberPage.locator('#post-seeking')).toBeVisible()
} finally {
await context.close()
}
})
test('tags drawer toggles open and closed', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
test('tags drawer toggles open and closed', async ({ memberPage }) => {
await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
// Drawer toggle only appears if cooperative tags exist — skip quietly if not
@ -63,16 +36,10 @@ test.describe('Board page', () => {
await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
} finally {
await context.close()
}
})
test('create, edit, and delete own post', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
test('create, edit, and delete own post', async ({ memberPage }) => {
await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
timeout: 15000,
})
@ -88,7 +55,7 @@ test.describe('Board page', () => {
await memberPage.locator('#post-title').fill(originalTitle)
await memberPage.locator('#post-seeking').fill('Playwright test seeking text')
await memberPage.getByRole('button', { name: 'Post', exact: true }).click()
await memberPage.getByRole('button', { name: 'Post' }).click()
await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
timeout: 10000,
@ -108,16 +75,13 @@ test.describe('Board page', () => {
timeout: 10000,
})
// --- Delete (in-card two-step confirm; not a native dialog) ---
// --- Delete (confirm dialog) ---
memberPage.once('dialog', (dialog) => dialog.accept())
const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
await editedCard.getByRole('button', { name: 'Delete' }).click()
await editedCard.getByRole('button', { name: 'Confirm' }).click()
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
timeout: 10000,
})
} finally {
await context.close()
}
})
})

View file

@ -67,128 +67,3 @@ test.describe('Events list page', () => {
await expect(page.locator('h1')).toBeVisible()
})
})
async function navigateToFirstEventDetail(page) {
await page.goto('/events')
await page.locator('.past-toggle').click()
await page.waitForLoadState('networkidle')
const eventLinks = page.locator('.event-row a')
const count = await eventLinks.count()
if (count === 0) return null
const href = await eventLinks.first().getAttribute('href')
return href
}
test.describe('Event detail — ticket gating', () => {
test('series-pass-required shows pass-required notice instead of buy button', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: false,
reason: 'series_pass_required',
requiresSeriesPass: true,
series: { id: 'series-stub', slug: 'series-stub', title: 'Stub Series' }
})
})
})
await page.route('**/api/events/*/check-series-access**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ requiresSeriesPass: false })
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const ticketPanel = page.locator('.event-ticket-purchase')
await expect(ticketPanel.locator('.ticket-status', { hasText: 'Series Pass Required' })).toBeVisible()
await expect(ticketPanel.locator('button', { hasText: /Pay |Register for this event|Complete Registration/ })).toHaveCount(0)
await expect(ticketPanel.locator('a[href="/series/series-stub"] button')).toBeVisible()
})
test('memberSavings line is hidden for anonymous viewers', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: true,
alreadyRegistered: false,
isFree: false,
isMember: false,
name: 'General Admission',
formattedPrice: '$25.00',
remaining: 10,
memberSavings: 0,
publicTicket: null
})
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const ticketCard = page.locator('.ticket-card')
await expect(ticketCard).toBeVisible()
await expect(page.locator('.ticket-savings')).toHaveCount(0)
await expect(page.locator('text=/save .* as a member/i')).toHaveCount(0)
})
test('memberSavings line is shown when API reports savings', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: true,
alreadyRegistered: false,
isFree: false,
isMember: true,
name: 'Member Ticket',
formattedPrice: '$10.00',
remaining: 10,
memberSavings: 15,
publicTicket: { formattedPrice: '$25.00' }
})
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const savings = page.locator('.ticket-savings')
await expect(savings).toBeVisible()
await expect(savings).toContainText(/save/i)
})
test.skip('hidden event returns 404', async () => {
// Skipped: hidden-event gating happens during SSR useFetch in [slug].vue,
// which page.route cannot intercept. Verifying this gate requires either
// seeding a hidden event in the dev DB or a server-side mock layer.
})
test.skip('past-deadline event shows registration-closed copy', async () => {
// Skipped: when the available endpoint returns reason
// "Registration deadline has passed", the current UI surfaces it as the
// generic "Event Sold Out" panel — there is no distinct "Registration
// closed" string to assert against without changing the component.
})
test.skip('member with paid registration cannot self-cancel', async () => {
// Skipped: requires seeding an authed member with a paid registration in
// the DB, which is out of scope for API-level mocking.
})
})

View file

@ -1,32 +1,36 @@
/**
* Login helpers using dev endpoints.
*
* Implementation note: hits the dev endpoints via the APIRequestContext
* (no page navigation). The Set-Cookie response writes auth-token to the
* BrowserContext's cookie jar, so any subsequent page.goto() is authed.
* Avoids the Nuxt-dev networkidle race that made page.goto-based login flaky.
* These set real httpOnly JWT cookies so all middleware works naturally.
*/
/**
* Login as admin via the dev test-login endpoint.
* Creates a test admin user if none exists and sets the auth cookie.
* Waits for networkidle so the client-side auth check (admin middleware +
* auth-init plugin) completes before the test navigates anywhere.
*/
export async function loginAsAdmin(page) {
const res = await page.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (res.status() !== 302) {
throw new Error(`/api/dev/test-login returned ${res.status()}; expected 302`)
}
const cookies = await page.context().cookies()
if (!cookies.find((c) => c.name === 'auth-token')) {
throw new Error('/api/dev/test-login did not set auth-token cookie')
await page.goto('/api/dev/test-login', { waitUntil: 'domcontentloaded' })
// The endpoint sets the cookie and redirects to /admin.
// waitForURL fires as soon as the URL changes — not when JS finishes.
// waitForLoadState('networkidle') ensures the auth-init plugin and admin
// middleware have both completed their checkMemberStatus() calls before
// the test proceeds.
try {
await page.waitForURL(/\/admin/, { timeout: 15000 })
await page.waitForLoadState('networkidle')
} catch {
// Cookie should be set even if redirect failed — navigate manually
await page.goto('/admin', { waitUntil: 'networkidle' })
await page.waitForURL(/\/admin/)
}
}
/**
* Login as a specific member by email via the dev member-login endpoint.
*/
export async function loginAsMember(page, email) {
const res = await page.context().request.get(
`/api/dev/member-login?email=${encodeURIComponent(email)}`,
{ maxRedirects: 0 }
)
if (res.status() !== 302) {
throw new Error(`/api/dev/member-login returned ${res.status()}; expected 302`)
}
const cookies = await page.context().cookies()
if (!cookies.find((c) => c.name === 'auth-token')) {
throw new Error('/api/dev/member-login did not set auth-token cookie')
}
await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { waitUntil: 'domcontentloaded' })
await page.waitForURL(/\/member\//)
}

View file

@ -68,12 +68,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('Test User')
await expect(page.locator('.form-submit')).toBeDisabled()
// Fill email — agreement still unchecked, so still disabled
// Fill email too — now all fields are populated and button should be enabled
await page.locator('#join-email').fill('incomplete-test@example.com')
await expect(page.locator('.form-submit')).toBeDisabled()
// Check the Community Guidelines agreement — now all required fields satisfied
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
})
@ -87,9 +83,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('E2E Test User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
// Contribution is now a numeric input with preset chips, not a select
await page.locator('#join-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await page.locator('#join-contribution').click()
await page.getByRole('option', { name: '$0/mo' }).click()
await expect(page.locator('.form-submit')).toBeEnabled()
@ -98,108 +93,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('.form-submit').click()
// Free tier flips the SignupFlowOverlay into its success state
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
})
test('cadence toggle updates billing summary to annual ×12', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
await page.locator('#join-contribution').fill('10')
await page.locator('label[for="cadence-annual"]').click()
const summary = page.locator('.billing-summary')
await expect(summary).toBeVisible()
await expect(summary).toContainText('$120 today')
await expect(summary).toContainText('$10/month × 12')
await expect(summary).toContainText('$120 every year')
await page.locator('label[for="cadence-monthly"]').click()
await expect(summary).toContainText('$10 today')
await expect(summary).toContainText('$10 every month')
})
test('contribution guidance label changes with amount tier', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
const guidance = page.locator('.contribution-guidance')
await page.locator('#join-contribution').fill('5')
await expect(guidance).toHaveText(/I can contribute/)
await page.locator('#join-contribution').fill('30')
await expect(guidance).toHaveText(/I can support others too/)
})
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
const uniqueEmail = `test-e2e-paid-${Date.now()}@example.com`
// Stub HelcimPay window globals before the page loads so the composable's
// script-load path is bypassed and we resolve verifyPayment synchronously.
await page.addInitScript(() => {
window.appendHelcimPayIframe = (checkoutToken) => {
const eventName = 'helcim-pay-js-' + checkoutToken
setTimeout(() => {
window.postMessage({
eventName,
eventStatus: 'SUCCESS',
eventMessage: JSON.stringify({
data: {
data: {
transactionId: 'stub-txn-1',
cardToken: 'stub-card-token-1',
cardNumber: '4111111111111234',
cardType: 'visa'
}
}
})
}, '*')
}, 50)
}
window.removeHelcimPayIframe = () => {}
})
await page.goto('/join')
await page.waitForLoadState('networkidle')
await mockHelcimAPIs(page)
await page.route('**/api/helcim/initialize-payment', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
checkoutToken: 'stub-checkout-token',
secretToken: 'stub-secret-token'
})
})
})
await page.route('**/api/helcim/verify-payment', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true })
})
})
await page.locator('#join-name').fill('Paid E2E User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').fill('15')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
await page.locator('.form-submit').click()
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
// Free tier creates subscription then shows confirmation (step 3)
await expect(page.locator('.success-box')).toBeVisible({ timeout: 15000 })
})
test('duplicate email shows error', async ({ page }) => {
@ -214,13 +109,12 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('Dup Test User')
await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await page.locator('#join-contribution').click()
await page.getByRole('option', { name: '$0/mo' }).click()
await page.locator('.form-submit').click()
// Helcim 409 puts SignupFlowOverlay into its error state
const overlayError = page.locator('.signup-flow-overlay .error-box')
await expect(overlayError).toBeVisible({ timeout: 10000 })
await expect(overlayError).toContainText(/already/i)
// Should show an error about the email already existing
await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.error-box')).toContainText(/already/i)
})
})

View file

@ -3,11 +3,9 @@ import { test, expect } from './helpers/fixtures.js'
test.describe('Member profile page', () => {
test('profile page loads', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
await adminPage.waitForLoadState('networkidle')
// Auth is checked client-side in onMounted — wait for profile form to render
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
// Verify a stable structural section label, not transient marketing copy
await expect(adminPage.getByText('Show in Member Directory')).toBeVisible()
await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
})
test('form fields are present', async ({ adminPage }) => {
@ -26,7 +24,6 @@ test.describe('Member profile page', () => {
test('bio field accepts input', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
const bio = adminPage.locator('textarea[placeholder*="Share your background"]')

180
e2e/visual/pages.spec.js Normal file
View file

@ -0,0 +1,180 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin } from '../helpers/auth.js'
import path from 'path'
import fs from 'fs'
const viewports = {
desktop: { width: 1280, height: 720 },
mobile: { width: 375, height: 667 },
}
const publicPages = [
{ name: 'home', path: '/' },
{ name: 'join', path: '/join' },
{ name: 'events', path: '/events' },
{ name: 'coming-soon', path: '/coming-soon' },
// about and members have no auth middleware — accessible publicly
{ name: 'about', path: '/about' },
{ name: 'members', path: '/members' },
]
const authenticatedPages = [
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'admin-members', path: '/admin/members' },
{ name: 'admin-events-create', path: '/admin/events/create' },
// New authenticated pages
{ name: 'member-account', path: '/member/account' },
{ name: 'connections', path: '/connections' },
{ name: 'admin-dashboard', path: '/admin' },
]
// Pages that need mobile coverage captured while authenticated.
// These cover column-collapse breakpoints critical for the page-shell refactor.
// Snapshots use the -mobile-auth suffix to distinguish from the public mobile loop
// (which also captures about-mobile unauthenticated, so names must not collide).
const authenticatedMobilePages = [
{ name: 'about', path: '/about' },
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'member-account', path: '/member/account' },
{ name: 'connections', path: '/connections' },
]
// Path where the saved admin auth state (cookies) will be stored within a run.
const authStatePath = path.resolve('e2e/.auth/admin.json')
// Wait for fonts and images to load before taking screenshots
async function waitForStable(page) {
await page.waitForLoadState('networkidle')
// Wait for web fonts to load
await page.evaluate(() => document.fonts.ready)
}
// Common mask selectors for dynamic content
function commonMasks(page) {
return [
// Dates and times throughout the app
page.locator('.event-date'),
page.locator('.event-count'),
page.locator('time'),
page.locator('.member-since'),
// Activity log timestamps
page.locator('.tl-time'),
// Admin dashboard stat values (member counts, revenue, etc.)
page.locator('.stat-val'),
// Recent member join dates in admin dashboard
page.locator('.item-date'),
// Member avatars (ghost images may not load deterministically)
page.locator('.mc-avatar'),
page.locator('.cc-avatar'),
page.locator('.profile-avatar'),
// Member count text in members page filter bar
page.locator('.filter-count'),
// Connections page: filter bar and suggestions vary based on tag/topic
// state and async fetch ordering. Mask them to keep the structural
// (PageShell + page-level) regression coverage stable.
page.locator('.filter-bar'),
page.locator('.skills-bar'),
page.locator('.connections-section'),
page.locator('.loading-state'),
]
}
// All visual tests run serially in a single top-level describe block.
//
// Auth is handled with a beforeAll that saves the cookie to disk once. All
// authenticated sub-describes load from that saved state, avoiding repeated
// /api/dev/test-login calls that exhaust the dev server's MongoDB connections.
test.describe('visual regression', () => {
test.describe.configure({ mode: 'serial' })
// Log in once before all tests and save the auth cookie.
// serial mode guarantees this runs before any test in this describe tree.
test.beforeAll(async ({ browser }) => {
fs.mkdirSync(path.dirname(authStatePath), { recursive: true })
const page = await browser.newPage()
await loginAsAdmin(page)
await page.context().storageState({ path: authStatePath })
await page.close()
})
// ── Public pages (desktop + mobile) ──────────────────────────────────────
test.describe('public pages', () => {
for (const { name, path } of publicPages) {
for (const [viewportName, viewport] of Object.entries(viewports)) {
test(`${name}${viewportName}`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
}
})
// ── Authenticated pages (desktop) ─────────────────────────────────────────
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
test.describe('authenticated pages', () => {
test.use({ storageState: authStatePath })
for (const { name, path } of authenticatedPages) {
test(`${name} — desktop`, async ({ page }) => {
await page.setViewportSize(viewports.desktop)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
// members-detail: navigate to the test admin's own profile page.
// The test admin is created by /api/dev/test-login (email: test-admin@ghostguild.dev,
// status: active). We fetch their _id from /api/auth/member using the saved cookie.
// Even if showInDirectory is false, the page renders a stable error or profile shell.
test('members-detail — desktop', async ({ page }) => {
await page.setViewportSize(viewports.desktop)
const response = await page.request.get('/api/auth/member')
// /api/auth/member returns the member object directly (not nested under a 'member' key)
const authData = response.ok() ? await response.json() : null
const memberId = authData?._id || authData?.id
if (!memberId) {
// Skip gracefully if we can't retrieve the member ID
test.skip(true, 'Could not retrieve test admin member ID from /api/auth/member')
return
}
await page.goto(`/members/${memberId}`)
await waitForStable(page)
await expect(page).toHaveScreenshot('members-detail-desktop.png', {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
})
// ── Authenticated pages (mobile — column-collapse coverage) ───────────────
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
test.describe('authenticated pages (mobile)', () => {
test.use({ storageState: authStatePath })
for (const { name, path } of authenticatedMobilePages) {
test(`${name} — mobile`, async ({ page }) => {
await page.setViewportSize(viewports.mobile)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-mobile-auth.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
})
})

View file

@ -1,222 +0,0 @@
// Spec: docs/specs/wave-based-slack-onboarding.md
// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7
import { test, expect } from './helpers/fixtures.js'
import { loginAsMember } from './helpers/auth.js'
const SLACK_NOTE_RE = /Slack workspace access is part of your membership/i
test.describe('Member dashboard — Slack-coming note (§7)', () => {
test('shows note for active member without Slack (7.1)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(SLACK_NOTE_RE)).toBeVisible()
await context.close()
})
test.skip('hides note once slackInvited:true (7.2)', async () => {
// BUG: /api/auth/member does not return slackInvited, so memberData.slackInvited
// is always undefined on the client. The dashboard condition
// (status==="active" && !slackInvited) currently shows the note for ALL
// active members regardless of slackInvited. Fix the API to expose the
// field before unskipping.
})
test('hides note for pending_payment member (7.3)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'pending-payment-test@example.test')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Pending Payment Tester/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(SLACK_NOTE_RE)).toHaveCount(0)
await context.close()
})
test.skip('hides note for suspended/cancelled/guest (7.4)', async () => {
// No suspended/cancelled/guest members exist in the dev DB and there is
// no dev endpoint to seed members with arbitrary status. Implementing
// this would require a new server-side helper (out of scope).
})
test.skip('copy contains no wave/cohort/batch language (7.5)', async () => {
// The shipped UI uses the phrase "monthly onboarding waves" — this test's
// \bwave\b assertion contradicts the current copy. Resolve the spec/UI
// divergence before unskipping.
})
test('renders as plain text — no banner / modal / callout styling (7.6)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
const note = page.getByText(SLACK_NOTE_RE)
await expect(note).toBeVisible()
const tag = await note.evaluate((el) => el.tagName.toLowerCase())
expect(tag).toBe('p')
const inDialog = await note.evaluate((el) => !!el.closest('[role="dialog"]'))
expect(inDialog).toBe(false)
const inAlert = await note.evaluate((el) => !!el.closest('[role="alert"], .alert'))
expect(inAlert).toBe(false)
await context.close()
})
test('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
const response = await page.goto('/member/dashboard')
const ssrHtml = await response.text()
expect(ssrHtml).not.toMatch(SLACK_NOTE_RE)
await context.close()
})
test.skip('copy matches approved wording (7.8)', async () => {
// Awaiting resolution of the Open Question on the final approved string.
})
})
test.describe('Admin members — Slack-invited control (§6)', () => {
test('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await expect(
adminPage.getByRole('button', { name: /Mark as Slack invited/i }).first()
).toBeVisible()
})
test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => {
// BUG: in admin/members/index.vue, markSlackInvited does
// Object.assign(member, res.member) on a plain object inside the
// useFetch array — Vue does not pick up the per-item mutation, so the
// row UI does not refresh until the page reloads. The same control on
// the detail page (which reassigns member.value) does work — see 6.6.
})
test('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => {
// Re-prime the auth cookie. The shared test-admin account's tokenVersion
// is bumped whenever auth.spec.js's logout test runs in parallel, which
// would otherwise surface mid-flow as a silent 401 on the create POST.
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (loginRes.status() !== 302) {
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
}
// Create a dedicated test member so the row we operate on is uniquely
// identifiable by email and can't be displaced by parallel test mutations.
// We use the admin UI flow (vs API) because the POST endpoint is
// CSRF-protected and the modal is the documented happy path.
const stamp = Date.now()
const memberEmail = `e2e-slack-6-4-${stamp}@example.test`
const memberName = `E2E Slack 6.4 ${stamp}`
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await adminPage.waitForLoadState('networkidle')
await adminPage.getByRole('button', { name: 'Add Member' }).click()
await adminPage.getByPlaceholder('Full name').fill(memberName)
await adminPage.getByPlaceholder('email@example.com').fill(memberEmail)
await adminPage.getByRole('button', { name: 'Create Member' }).click()
// Modal closes after successful create
await expect(adminPage.getByPlaceholder('Full name')).toHaveCount(0, { timeout: 10000 })
const patchRequests = []
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
const req = route.request()
patchRequests.push({ method: req.method(), url: req.url() })
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
member: {
slackInvited: true,
slackInvitedAt: new Date().toISOString(),
},
}),
})
})
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
// Wait for hydration so v-model bindings on the search input are wired up
// and the click on the row's button reaches the Vue handler.
await adminPage.waitForLoadState('networkidle')
// Filter the list down to our specific member so the row anchor is unambiguous.
const searchInput = adminPage.getByPlaceholder('Search members...')
await expect(searchInput).toBeVisible({ timeout: 10000 })
await searchInput.fill(memberEmail)
const targetRow = adminPage.locator('tbody tr', { hasText: memberEmail })
await expect(targetRow).toBeVisible({ timeout: 10000 })
// Wait until the table has filtered down to only our row — confirms the
// search v-model has been processed.
await expect(adminPage.locator('tbody tr')).toHaveCount(1, { timeout: 10000 })
await targetRow.getByRole('button', { name: /Mark as Slack invited/i }).click()
await expect.poll(() => patchRequests.length, { timeout: 5000 }).toBe(1)
expect(patchRequests[0].method).toBe('PATCH')
expect(patchRequests[0].url).toMatch(/\/api\/admin\/members\/[^/]+\/slack-status$/)
await adminPage.waitForTimeout(500)
expect(patchRequests).toHaveLength(1)
})
test('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
const html = await adminPage.content()
expect(html).not.toMatch(/Slack:\s*Pending/i)
})
test('member detail page mirrors list controls (6.6)', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
const row = adminPage.locator('tr', {
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
}).first()
const href = await row.locator('a.member-name-link').getAttribute('href')
expect(href).toMatch(/\/admin\/members\/[a-f0-9]+/)
await adminPage.goto(href)
await expect(adminPage.getByText('Slack invite', { exact: true })).toBeVisible()
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
})
test.skip('no UI references slackInviteStatus (6.7)', async () => {
// The deprecated slackInviteStatus field still lives on Member documents
// and is serialized into the /api/admin/members payload (visible in the
// SSR Nuxt state). The admin UI itself does not reference the field, but
// a content() check against the rendered HTML matches the JSON payload.
// Cleaning up the DB field is out of scope for this test pass.
})
test('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async ({ adminPage }) => {
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ statusMessage: 'Server error' }),
})
})
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
const row = adminPage.locator('tr', {
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
}).first()
await row.getByRole('button', { name: /Mark as Slack invited/i }).click()
await expect(row.getByText('Not yet invited')).toBeVisible()
await expect(row.getByText(/^Invited\s+\d/)).toHaveCount(0)
await expect(row.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
})
test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => {
// Dependent on Open Question — wire up if implemented.
})
})

View file

@ -7,9 +7,6 @@ export default defineNuxtConfig({
providers: {
google: false,
fontsource: false,
bunny: false,
adobe: false,
googleicons: false,
},
},
colorMode: {
@ -94,10 +91,11 @@ export default defineNuxtConfig({
},
runtimeConfig: {
// Private keys (server-side only)
mongodbUri: process.env.MONGODB_URI || "",
mongodbUri:
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
jwtSecret: process.env.JWT_SECRET || "",
resendApiKey: process.env.RESEND_API_KEY || "",
helcimApiToken: process.env.HELCIM_API_TOKEN || "",
helcimApiToken: process.env.HELCIM_API_TOKEN || "", // also exposed to client via public.helcimToken
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
slackAdminBotToken: process.env.SLACK_ADMIN_BOT_TOKEN || "",
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
@ -108,10 +106,10 @@ export default defineNuxtConfig({
outlineApiKey: process.env.OUTLINE_API_KEY || "",
helcimMonthlyPlanId: process.env.NUXT_HELCIM_MONTHLY_PLAN_ID || "",
helcimAnnualPlanId: process.env.NUXT_HELCIM_ANNUAL_PLAN_ID || "",
reconcileToken: process.env.NUXT_RECONCILE_TOKEN || "",
// Public keys (available on client-side)
public: {
helcimToken: process.env.HELCIM_API_TOKEN || "",
helcimAccountId: process.env.NUXT_PUBLIC_HELCIM_ACCOUNT_ID || "",
cloudinaryCloudName:
process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || "divzuumlr",

View file

@ -6,10 +6,11 @@ const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: "./e2e",
outputDir: "e2e/test-results",
fullyParallel: false,
snapshotDir: "e2e/__screenshots__",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 1,
workers: process.env.CI ? 1 : 4,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 60000,
use: {
@ -26,7 +27,7 @@ export default defineConfig({
webServer: {
command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`,
url: BASE_URL,
reuseExistingServer: true,
reuseExistingServer: !process.env.CI,
env: {
NUXT_PUBLIC_COMING_SOON: "false",
NODE_ENV: "development",

View file

@ -274,18 +274,6 @@ const sampleMembers = [
createdAt: new Date('2025-06-01'),
lastLogin: new Date('2026-04-04'),
},
{
email: 'pending-payment-test@example.test',
name: 'Pending Payment Tester',
circle: 'community',
contributionAmount: 5,
status: 'pending_payment',
slackInvited: false,
craftTags: [],
board: {},
createdAt: new Date('2026-04-25'),
lastLogin: new Date('2026-04-29'),
},
]
const TEST_ADMIN_BOARD = {

Some files were not shown because too many files have changed in this diff Show more