Compare commits

..

No commits in common. "main" and "board-classifieds-redesign" have entirely different histories.

259 changed files with 4853 additions and 15561 deletions

View file

@ -6,8 +6,6 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild
# HELCIM_API_TOKEN=your-live-helcim-api-token # HELCIM_API_TOKEN=your-live-helcim-api-token
HELCIM_API_TOKEN=your-test-helcim-api-token HELCIM_API_TOKEN=your-test-helcim-api-token
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
NUXT_HELCIM_MONTHLY_PLAN_ID=<set_after_migration>
NUXT_HELCIM_ANNUAL_PLAN_ID=<set_after_migration>
# Email Configuration (Resend) # Email Configuration (Resend)
RESEND_API_KEY=your-resend-api-key RESEND_API_KEY=your-resend-api-key

View file

@ -21,16 +21,16 @@ jobs:
playwright: playwright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: vitest needs: vitest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env: env:
MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret 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' NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development NODE_ENV: development
ALLOW_DEV_TEST_ENDPOINTS: 'true'
BASE_URL: http://localhost:3000
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -39,35 +39,15 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - 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 - run: npm run build
- name: Start server - name: Start server
run: node .output/server/index.mjs > /tmp/server.log 2>&1 & run: node .output/server/index.mjs &
env: env:
PORT: 3000 PORT: 3000
- name: Wait for server - name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done' run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- name: Server log on failure - run: npx playwright test --ignore-snapshots
if: failure() - uses: actions/upload-artifact@v4
run: cat /tmp/server.log || true
- run: npx playwright test
- uses: actions/upload-artifact@v3
if: failure() if: failure()
with: with:
name: playwright-report name: playwright-report
@ -88,3 +68,39 @@ jobs:
-H 'Content-type: application/json' \ -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\"}" --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

6
.gitignore vendored
View file

@ -26,9 +26,6 @@ logs
!.env.example !.env.example
scripts/*.js scripts/*.js
# Migration backup files
.migration-backup-*.json
# Playwright # Playwright
e2e/test-results/ e2e/test-results/
playwright-report/ playwright-report/
@ -38,6 +35,3 @@ e2e/.auth/
.worktrees/ .worktrees/
.claude/worktrees/ .claude/worktrees/
.superpowers/ .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: # list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp # al bash clojure cpp csharp
# cpp_ccls crystal csharp csharp_omnisharp dart # csharp_omnisharp dart elixir elm erlang
# elixir elm erlang fortran fsharp # fortran fsharp go groovy haskell
# go groovy haskell haxe hlsl # java julia kotlin lua markdown
# java json julia kotlin lean4 # matlab nix pascal perl php
# lua luau markdown matlab msl # php_phpactor powershell python python_jedi r
# nix ocaml pascal perl php # rego ruby ruby_solargraph rust scala
# php_phpactor powershell python python_jedi python_ty # swift terraform toml typescript typescript_vts
# r rego ruby ruby_solargraph rust # vue yaml zig
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# (This list may be outdated. For the current list, see values of Language enum here: # (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 # 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.) # 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. # list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration) # 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: [] excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # 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). # 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: [] included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of 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. # 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: [] fixed_tools: []
# list of mode names to that are always to be included in the set of active modes # 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. # Set this to a list of mode names to always include the respective modes for this project.
base_modes: base_modes:
# list of mode names that are to be activated by default, overriding the setting in the global configuration. # list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. # The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # 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). # 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). # 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: default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project # 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. # Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"] # Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: [] 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 # Build stage
FROM node:22-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
@ -7,11 +7,8 @@ RUN npm ci --ignore-scripts && npx nuxt prepare
COPY . . COPY . .
RUN npm run build RUN npm run build
# Production stage — only the self-contained .output is needed. # Production stage — only the self-contained .output is needed
# bash + curl are added so Dokploy scheduled tasks (which wrap commands in FROM node:20-alpine
# `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
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.output .output COPY --from=builder /app/.output .output

View file

@ -27,10 +27,7 @@
--text: #2a2015; --text: #2a2015;
--text-bright: #1a1008; --text-bright: #1a1008;
--text-dim: #5a5040; --text-dim: #5a5040;
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b --text-faint: #746a58;
(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;
--parch: #2a2015; --parch: #2a2015;
--parch-hover: #3a3025; --parch-hover: #3a3025;
--parch-text: #ede4d0; --parch-text: #ede4d0;
@ -276,98 +273,6 @@ p a, blockquote a {
min-width: 0; 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. */
button.zine-select,
button.timezone-select {
display: flex !important;
width: 100%;
padding: 5px 8px !important;
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: none !important;
outline: none !important;
min-height: 0;
--tw-ring-shadow: 0 0 #0000;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-color: transparent;
}
button.zine-select:hover,
button.timezone-select:hover {
background: var(--input-bg) !important;
}
button.zine-select:focus,
button.zine-select:focus-visible,
button.zine-select[aria-expanded="true"],
button.timezone-select:focus,
button.timezone-select:focus-visible,
button.timezone-select[aria-expanded="true"] {
border-color: var(--candle) !important;
}
.tz-content {
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
font-family: "Commit Mono", monospace !important;
}
.tz-input {
border-bottom: 1px dashed var(--border) !important;
}
.tz-input input {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: transparent !important;
border-radius: 0 !important;
padding: 6px 8px !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
}
.tz-item {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text) !important;
border-radius: 0 !important;
padding: 6px 8px !important;
}
.tz-item::before {
border-radius: 0 !important;
}
.tz-item[data-highlighted]::before,
.tz-item[data-highlighted]:not([data-disabled])::before {
background: var(--surface-hover) !important;
}
.tz-item[data-highlighted],
.tz-item[data-highlighted]:not([data-disabled]) {
color: var(--text-bright) !important;
}
/* ---- MOBILE ---- */ /* ---- MOBILE ---- */
@media (max-width: 1023px) { @media (max-width: 1023px) {
body { body {

View file

@ -25,6 +25,11 @@
/> />
</NuxtLink> </NuxtLink>
</li> </li>
<li>
<a href="#" class="sign-out" @click.prevent="handleLogout"
>Sign out</a
>
</li>
</ul> </ul>
<div class="sidebar-section">Explore</div> <div class="sidebar-section">Explore</div>
@ -133,11 +138,11 @@
<div class="sidebar-meta"> <div class="sidebar-meta">
<ClientOnly> <ClientOnly>
Part of Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br > <a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br />
A Canadian nonprofit A Canadian nonprofit
<template #fallback> <template #fallback>
Part of Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br > <a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br />
A Canadian nonprofit A Canadian nonprofit
</template> </template>
</ClientOnly> </ClientOnly>
@ -199,6 +204,7 @@ const youItems = [
{ label: "Dashboard", path: "/member/dashboard" }, { label: "Dashboard", path: "/member/dashboard" },
{ label: "Profile", path: "/member/profile" }, { label: "Profile", path: "/member/profile" },
{ label: "Account", path: "/member/account" }, { label: "Account", path: "/member/account" },
{ label: "Activity Log", path: "/member/activity" },
]; ];
const exploreItems = [ const exploreItems = [
@ -330,9 +336,8 @@ const exploreItems = [
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--green); background: var(--candle);
margin-left: 0px; margin-left: 6px;
vertical-align: middle; vertical-align: middle;
transform: translateY(-1px);
} }
</style> </style>

View file

@ -2,18 +2,13 @@
<article class="board-post"> <article class="board-post">
<header class="post-header"> <header class="post-header">
<span class="post-meta">{{ typeLabel }}</span> <span class="post-meta">{{ typeLabel }}</span>
<div v-if="editable && !pendingDelete" class="post-actions"> <div v-if="editable" class="post-actions">
<button type="button" class="action-btn" @click="$emit('edit', post)">Edit</button> <button type="button" class="action-btn" @click="$emit('edit', post)">Edit</button>
<button type="button" class="action-btn danger" @click="$emit('delete', post)">Delete</button> <button type="button" class="action-btn danger" @click="$emit('delete', post)">Delete</button>
</div> </div>
<div v-else-if="editable && pendingDelete" class="post-actions confirm">
<span class="confirm-label">Delete?</span>
<button type="button" class="action-btn" @click="$emit('cancel-delete', post)">Cancel</button>
<button type="button" class="action-btn danger" @click="$emit('confirm-delete', post)">Confirm</button>
</div>
</header> </header>
<h2 class="post-title">{{ post.title }}</h2> <h3 class="post-title">{{ post.title }}</h3>
<div v-if="post.seeking" class="post-block"> <div v-if="post.seeking" class="post-block">
<div class="block-label">Seeking</div> <div class="block-label">Seeking</div>
@ -39,7 +34,7 @@
:alt="post.author.name" :alt="post.author.name"
class="author-avatar" class="author-avatar"
> >
<span v-else class="author-avatar avatar-placeholder" aria-hidden="true">{{ authorInitial }}</span> <span v-else class="author-avatar avatar-placeholder" />
<span class="author-name">{{ post.author ? post.author.name : 'Unknown' }}</span> <span class="author-name">{{ post.author ? post.author.name : 'Unknown' }}</span>
<span v-if="slackHandle" class="slack-handle-wrap"> <span v-if="slackHandle" class="slack-handle-wrap">
<button <button
@ -81,12 +76,9 @@ const props = defineProps({
channels: { type: Array, default: () => [] }, channels: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] }, tags: { type: Array, default: () => [] },
editable: { type: Boolean, default: false }, editable: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false },
}) })
defineEmits(['edit', 'delete', 'confirm-delete', 'cancel-delete']) defineEmits(['edit', 'delete'])
const { slackUrl } = useBoardChannels()
const capitalizeAvatar = (str) => { const capitalizeAvatar = (str) => {
if (str.toLowerCase() === 'wtf') return 'WTF' if (str.toLowerCase() === 'wtf') return 'WTF'
@ -104,11 +96,6 @@ const authorAvatar = computed(() => {
const slackHandle = computed(() => props.post.author?.board?.slackHandle || '') const slackHandle = computed(() => props.post.author?.board?.slackHandle || '')
const authorInitial = computed(() => {
const name = props.post.author?.name || ''
return name.trim().charAt(0).toUpperCase() || '?'
})
const copied = ref(false) const copied = ref(false)
const copySlackHandle = async () => { const copySlackHandle = async () => {
if (!slackHandle.value) return if (!slackHandle.value) return
@ -150,7 +137,7 @@ const slackLinks = computed(() => {
.map((c) => ({ .map((c) => ({
id: c.slackChannelId, id: c.slackChannelId,
name: c.slackChannelName || c.name || c.slackChannelId, name: c.slackChannelName || c.name || c.slackChannelId,
url: slackUrl(c.slackChannelId), url: `https://gammaspace.slack.com/archives/${c.slackChannelId}`,
})) }))
}) })
</script> </script>
@ -158,7 +145,7 @@ const slackLinks = computed(() => {
<style scoped> <style scoped>
.board-post { .board-post {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 20px 24px; padding: 18px 22px;
background: var(--surface); background: var(--surface);
break-inside: avoid; break-inside: avoid;
-webkit-column-break-inside: avoid; -webkit-column-break-inside: avoid;
@ -178,21 +165,12 @@ const slackLinks = computed(() => {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
} }
.post-actions { .post-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center;
}
.post-actions.confirm .confirm-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ember);
margin-right: 2px;
} }
.action-btn { .action-btn {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
@ -213,14 +191,10 @@ const slackLinks = computed(() => {
color: var(--ember); color: var(--ember);
border-color: var(--ember); border-color: var(--ember);
} }
.action-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.post-title { .post-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 18px; font-size: 19px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
margin: 0 0 12px; margin: 0 0 12px;
@ -234,8 +208,7 @@ const slackLinks = computed(() => {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
margin-bottom: 2px; margin-bottom: 2px;
} }
.block-text { .block-text {
@ -246,8 +219,7 @@ const slackLinks = computed(() => {
.post-note { .post-note {
font-size: 11px; font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
font-style: italic; font-style: italic;
margin: 8px 0; margin: 8px 0;
white-space: pre-wrap; white-space: pre-wrap;
@ -290,15 +262,7 @@ const slackLinks = computed(() => {
object-fit: cover; object-fit: cover;
} }
.avatar-placeholder { .avatar-placeholder {
background: transparent; background: var(--surface);
border: 1px dashed var(--border);
display: inline-flex;
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);
font-family: "Commit Mono", monospace;
} }
.author-name { .author-name {
font-size: 11px; font-size: 11px;
@ -312,8 +276,7 @@ const slackLinks = computed(() => {
} }
.slack-handle { .slack-handle {
font-size: 11px; font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
background: transparent; background: transparent;
border: none; border: none;
@ -323,11 +286,6 @@ const slackLinks = computed(() => {
.slack-handle:hover { .slack-handle:hover {
color: var(--candle); color: var(--candle);
} }
.slack-handle:focus-visible,
.copy-link:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.copy-link { .copy-link {
font-size: 11px; font-size: 11px;
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;

View file

@ -1,7 +1,7 @@
<template> <template>
<form class="post-form" @submit.prevent="handleSubmit"> <form class="post-form" @submit.prevent="handleSubmit">
<div class="form-header"> <div class="form-header">
<h2 class="form-title">{{ isEdit ? 'Edit post' : 'New post' }}</h2> <h3 class="form-title">{{ isEdit ? 'Edit post' : 'New post' }}</h3>
<p class="form-hint">Fill in <em>Seeking</em> or <em>Offering</em> (or both).</p> <p class="form-hint">Fill in <em>Seeking</em> or <em>Offering</em> (or both).</p>
</div> </div>
@ -138,8 +138,8 @@ function handleSubmit() {
<style scoped> <style scoped>
.post-form { .post-form {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 16px 16px; padding: 14px 16px;
background: transparent; background: var(--bg);
} }
.form-header { .form-header {
@ -147,7 +147,7 @@ function handleSubmit() {
} }
.form-title { .form-title {
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
font-size: 16px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
} }
@ -183,7 +183,7 @@ function handleSubmit() {
color: var(--text-faint); color: var(--text-faint);
text-transform: none; text-transform: none;
letter-spacing: 0; letter-spacing: 0;
font-size: 10px; font-size: 9px;
margin-left: 4px; margin-left: 4px;
opacity: 0.7; opacity: 0.7;
} }
@ -204,6 +204,14 @@ function handleSubmit() {
border-color: var(--candle); border-color: var(--candle);
} }
.char-count {
font-size: 9px;
font-family: "Commit Mono", monospace;
color: var(--text-faint);
text-align: right;
margin-top: 2px;
}
.pill-grid { .pill-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -212,11 +220,11 @@ function handleSubmit() {
.pill { .pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 8px; padding: 2px 9px;
border: 1px dashed var(--border); border: 1px dashed var(--border);
background: transparent; background: transparent;
color: var(--text-faint); color: var(--text-faint);
font-size: 10px; font-size: 11px;
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@ -233,10 +241,6 @@ function handleSubmit() {
border-color: var(--candle); border-color: var(--candle);
border-style: solid; border-style: solid;
} }
.pill:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.form-error { .form-error {
font-size: 11px; font-size: 11px;

View file

@ -4,16 +4,13 @@
v-for="circle in circles" v-for="circle in circles"
:key="circle.value" :key="circle.value"
class="circle-option" class="circle-option"
:class="{ :class="{ current: modelValue === circle.value }"
selected: modelValue === circle.value,
current: savedValue === circle.value,
}"
@click="$emit('update:modelValue', circle.value)" @click="$emit('update:modelValue', circle.value)"
> >
<span class="circle-name">{{ circle.label }}</span> <span class="circle-name">{{ circle.label }}</span>
<span class="circle-desc">{{ circle.description }}</span> <span class="circle-desc">{{ circle.description }}</span>
<span <span
v-if="savedValue === circle.value" v-if="modelValue === circle.value"
class="circle-tag" class="circle-tag"
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }" :style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
>Current</span> >Current</span>
@ -24,7 +21,6 @@
<script setup> <script setup>
defineProps({ defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
savedValue: { type: String, default: '' },
circles: { circles: {
type: Array, type: Array,
default: () => [ default: () => [
@ -48,7 +44,7 @@ defineEmits(['update:modelValue'])
.circle-option { .circle-option {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 12px 12px; padding: 14px 12px;
background: var(--bg); background: var(--bg);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@ -58,7 +54,7 @@ defineEmits(['update:modelValue'])
background: var(--surface-hover); background: var(--surface-hover);
} }
.circle-option.selected { .circle-option.current {
border-color: var(--candle); border-color: var(--candle);
border-style: solid; border-style: solid;
background: var(--surface); background: var(--surface);
@ -71,19 +67,19 @@ defineEmits(['update:modelValue'])
margin-bottom: 4px; margin-bottom: 4px;
} }
.circle-option.selected .circle-name { .circle-option.current .circle-name {
color: var(--candle); color: var(--candle);
} }
.circle-desc { .circle-desc {
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-faint);
line-height: 1.5; line-height: 1.5;
display: block; display: block;
} }
.circle-tag { .circle-tag {
font-size: 10px; font-size: 9px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
margin-top: 6px; margin-top: 6px;

View file

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

View file

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

View file

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

View file

@ -120,29 +120,23 @@
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="field"> <div class="field">
<label for="ticket-name">Full Name</label> <label>Full Name</label>
<input <input
id="ticket-name"
v-model="form.name" v-model="form.name"
name="name"
type="text" type="text"
autocomplete="name"
required required
:disabled="processing" :disabled="processing"
> />
</div> </div>
<div class="field"> <div class="field">
<label for="ticket-email">Email Address</label> <label>Email Address</label>
<input <input
id="ticket-email"
v-model="form.email" v-model="form.email"
name="email"
type="email" type="email"
autocomplete="email"
required required
:disabled="processing" :disabled="processing"
> />
</div> </div>
<p <p
@ -154,20 +148,6 @@
securely securely
</p> </p>
<div class="consent-block">
<label class="consent-field">
<input
v-model="form.createAccount"
type="checkbox"
:disabled="processing"
>
<span>Create a free guest account so I can manage my registration</span>
</label>
<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 <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
@ -255,7 +235,6 @@ const ticketInfo = ref(null);
const form = ref({ const form = ref({
name: props.userName || "", name: props.userName || "",
email: props.userEmail || "", email: props.userEmail || "",
createAccount: true,
}); });
const isLoggedIn = computed(() => !!props.userEmail); const isLoggedIn = computed(() => !!props.userEmail);
@ -265,13 +244,11 @@ onMounted(async () => {
await fetchTicketInfo(); await fetchTicketInfo();
}); });
const fetchTicketInfo = async (emailOverride = null) => { const fetchTicketInfo = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const effectiveEmail = emailOverride || props.userEmail;
// First check if this event requires a series pass // First check if this event requires a series pass
if (props.userEmail) { if (props.userEmail) {
try { try {
@ -307,7 +284,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
} }
// Regular ticket availability check // Regular ticket availability check
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : ""; const params = props.userEmail ? `?email=${props.userEmail}` : "";
const response = await $fetch( const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`, `/api/events/${props.eventId}/tickets/available${params}`,
); );
@ -332,6 +309,7 @@ const handleSubmit = async () => {
await initializeTicketPayment( await initializeTicketPayment(
props.eventId, props.eventId,
form.value.email, form.value.email,
ticketInfo.value.price,
props.eventTitle, props.eventTitle,
); );
@ -348,18 +326,15 @@ const handleSubmit = async () => {
} }
} }
const body = {
name: form.value.name,
email: form.value.email,
createAccount: form.value.createAccount,
};
if (transactionId) body.transactionId = transactionId;
const response = await $fetch( const response = await $fetch(
`/api/events/${props.eventId}/tickets/purchase`, `/api/events/${props.eventId}/tickets/purchase`,
{ {
method: "POST", method: "POST",
body, body: {
name: form.value.name,
email: form.value.email,
transactionId,
},
}, },
); );
@ -372,14 +347,7 @@ const handleSubmit = async () => {
}); });
emit("success", response); emit("success", response);
await fetchTicketInfo();
if (response?.signedIn) {
// New guest account or returning guest refresh client auth state so the
// rest of the app sees them as logged in.
await useAuth().checkMemberStatus();
}
await fetchTicketInfo(form.value.email);
} catch (err) { } catch (err) {
console.error("Error purchasing ticket:", err); console.error("Error purchasing ticket:", err);
@ -451,27 +419,4 @@ const formatEventDate = (date) => {
color: var(--text-faint); color: var(--text-faint);
margin-top: 2px; margin-top: 2px;
} }
.consent-block {
display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start;
column-gap: 8px;
row-gap: 4px;
margin-bottom: 14px;
}
.consent-field {
display: contents;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.consent-field input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--candle);
}
.consent-hint {
grid-column: 2;
margin: 0;
}
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

@ -26,12 +26,6 @@
<div class="ow-progress"> <div class="ow-progress">
<span class="ow-bar"><span class="ow-bar-fill">{{ barFill }}</span><span class="ow-bar-empty">{{ barEmpty }}</span></span> <span class="ow-bar"><span class="ow-bar-fill">{{ barFill }}</span><span class="ow-bar-empty">{{ barEmpty }}</span></span>
{{ completedCount }} of 4 explored {{ completedCount }} of 4 explored
<button
v-if="currentSuggestion.key"
type="button"
class="ow-skip"
@click="handleSkip"
>Skip this</button>
</div> </div>
</template> </template>
@ -68,12 +62,7 @@
</template> </template>
<script setup> <script setup>
const { goals, isComplete, currentSuggestion, trackGoal, skipSuggestion, loading } = useOnboarding() const { goals, isComplete, currentSuggestion, trackGoal, loading } = useOnboarding()
const handleSkip = () => {
const key = currentSuggestion.value?.key
if (key) skipSuggestion(key)
}
const completedCount = computed(() => { const completedCount = computed(() => {
const g = goals.value const g = goals.value
@ -118,7 +107,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
display: inline-block; display: inline-block;
margin-top: 8px; margin-top: 8px;
padding: 4px 12px; padding: 4px 12px;
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent); border: 1px dashed rgba(237, 228, 208, 0.25);
color: var(--parch-accent); color: var(--parch-accent);
font-size: 11px; font-size: 11px;
text-decoration: none; text-decoration: none;
@ -134,7 +123,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
.ow-progress { .ow-progress {
margin-top: 10px; margin-top: 10px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent); border-top: 1px dashed rgba(237, 228, 208, 0.12);
font-size: 11px; font-size: 11px;
color: var(--parch-text-dim); color: var(--parch-text-dim);
display: flex; display: flex;
@ -153,24 +142,6 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
} }
.ow-bar-empty { .ow-bar-empty {
color: color-mix(in srgb, var(--parch-text) 20%, transparent); color: rgba(237, 228, 208, 0.2);
}
.ow-skip {
margin-left: auto;
background: none;
border: none;
color: var(--parch-text-dim);
font-family: inherit;
font-size: 11px;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
}
.ow-skip:hover {
color: var(--parch-accent);
} }
</style> </style>

View file

@ -0,0 +1,66 @@
<template>
<div class="priv segmented">
<span
v-for="opt in options"
:key="opt.value"
:class="{ on: modelValue === opt.value }"
@click="$emit('update:modelValue', opt.value)"
>{{ opt.label }}</span
>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: String, default: "public" },
});
defineEmits(["update:modelValue"]);
const options = [
{ label: "Public", value: "public" },
{ label: "Members", value: "members" },
{ label: "Private", value: "private" },
];
</script>
<style scoped>
.priv {
display: inline-flex;
gap: 0;
font-size: 9px;
font-family: "Commit Mono", monospace;
letter-spacing: 0.02em;
}
.priv span {
padding: 2px 7px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
color: var(--text-faint);
cursor: pointer;
transition: all 0.12s;
user-select: none;
white-space: nowrap;
position: relative;
}
.priv span + span {
margin-left: -1px;
}
.priv span:hover {
color: var(--text-dim);
}
.priv span.on {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
z-index: 1;
}
</style>

View file

@ -4,16 +4,19 @@
<div v-if="loading" class="text-center py-8"> <div v-if="loading" class="text-center py-8">
<div <div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" 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 pass information...</p> <p class="text-[--ui-text-muted]">Loading series pass information...</p>
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="error" class="error-state p-6"> <div
<h3 class="error-state__heading text-lg font-semibold mb-2"> v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
Unable to Load Series Pass Unable to Load Series Pass
</h3> </h3>
<p class="error-state__body">{{ error }}</p> <p class="text-ember-400">{{ error }}</p>
</div> </div>
<!-- Content --> <!-- Content -->
@ -45,7 +48,7 @@
<!-- Registration Form --> <!-- Registration Form -->
<div <div
v-if="passInfo.available && !passInfo.alreadyRegistered" v-if="passInfo.available && !passInfo.alreadyRegistered"
class="registration-form p-6" class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
> >
<h3 class="text-xl font-bold text-[--ui-text] mb-6"> <h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{ {{
@ -55,7 +58,7 @@
}} }}
</h3> </h3>
<form class="space-y-6" @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Name Field --> <!-- Name Field -->
<div> <div>
<label <label
@ -100,20 +103,18 @@
<!-- Member Benefits Notice --> <!-- Member Benefits Notice -->
<div <div
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember" v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
class="p-4" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<Icon <Icon
name="heroicons:sparkles" name="heroicons:sparkles"
class="w-5 h-5 flex-shrink-0 mt-0.5" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
style="color: var(--candle)"
/> />
<div> <div>
<div class="font-semibold mb-1" style="color: var(--candle)"> <div class="font-semibold text-candlelight-300 mb-1">
Member Benefit Member Benefit
</div> </div>
<div class="text-sm" style="color: var(--candle)"> <div class="text-sm text-candlelight-400">
This series pass is free for Ghost Guild members! This series pass is free for Ghost Guild members!
</div> </div>
</div> </div>
@ -143,7 +144,6 @@
<p class="text-xs text-[--ui-text-muted] text-center"> <p class="text-xs text-[--ui-text-muted] text-center">
By registering, you'll be automatically registered for all By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series. {{ 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> </p>
</form> </form>
</div> </div>
@ -182,7 +182,7 @@ const props = defineProps({
const emit = defineEmits(["purchase-success", "purchase-error"]); const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast(); const toast = useToast();
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay(); const { initializeTicketPayment, verifyPayment } = useHelcimPay();
// State // State
const loading = ref(true); const loading = ref(true);
@ -264,9 +264,10 @@ const handleSubmit = async () => {
paymentProcessing.value = true; paymentProcessing.value = true;
// Initialize Helcim payment for series pass // Initialize Helcim payment for series pass
await initializeSeriesTicketPayment( await initializeTicketPayment(
props.seriesId, props.seriesId,
form.value.email, form.value.email,
passInfo.value.ticket.price,
props.seriesInfo.title, props.seriesInfo.title,
); );
@ -285,7 +286,6 @@ const handleSubmit = async () => {
const purchaseBody = { const purchaseBody = {
name: form.value.name, name: form.value.name,
email: form.value.email, email: form.value.email,
ticketType: passInfo.value.ticket.type,
}; };
if (transactionId) purchaseBody.paymentId = transactionId; if (transactionId) purchaseBody.paymentId = transactionId;
@ -297,17 +297,12 @@ const handleSubmit = async () => {
} }
); );
// Refresh client auth state if server signed us in (guest upgrade)
if (purchaseResponse?.signedIn) {
await useAuth().checkMemberStatus();
}
// Show success message // Show success message
toast.add({ toast.add({
title: "Series Pass Purchased!", title: "Series Pass Purchased!",
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`, description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
color: "green", color: "green",
duration: 5000, timeout: 5000,
}); });
// Emit success event // Emit success event
@ -327,7 +322,7 @@ const handleSubmit = async () => {
title: "Purchase Failed", title: "Purchase Failed",
description: errorMessage, description: errorMessage,
color: "red", color: "red",
duration: 5000, timeout: 5000,
}); });
emit("purchase-error", errorMessage); emit("purchase-error", errorMessage);
@ -354,18 +349,3 @@ const formatPrice = (price, currency = "CAD") => {
}).format(price); }).format(price);
}; };
</script> </script>
<style scoped>
.error-state {
background: color-mix(in srgb, var(--ember) 8%, transparent);
border: 1px dashed var(--ember);
}
.error-state__heading,
.error-state__body {
color: var(--ember);
}
.registration-form {
background: var(--surface);
border: 1px dashed var(--border);
}
</style>

View file

@ -1,186 +0,0 @@
<template>
<Teleport to="body">
<div v-if="state !== 'idle'" class="signup-flow-overlay">
<div class="signup-flow-card">
<div class="signup-flow-step">{{ stepLabel }}</div>
<template v-if="isProgress">
<h2 class="signup-flow-heading">{{ progressHeading }}</h2>
<p class="signup-flow-body">
Please don't close this window. This usually takes a few seconds.
</p>
</template>
<template v-if="state === 'success'">
<h2 class="signup-flow-heading">Welcome to Ghost Guild!</h2>
<DashedBox :hoverable="false">
<div class="section-label" style="margin-bottom: 12px">
Membership Details
</div>
<dl class="details-list">
<div class="details-row">
<dt>Name</dt><dd>{{ summary?.name }}</dd>
</div>
<div class="details-row">
<dt>Email</dt><dd>{{ summary?.email }}</dd>
</div>
<div class="details-row">
<dt>Circle</dt><dd class="capitalize">{{ summary?.circle }}</dd>
</div>
<div class="details-row">
<dt>Contribution</dt><dd>{{ summary?.contribution }}</dd>
</div>
</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.
</p>
</template>
<template v-if="state === 'error'">
<h2 class="signup-flow-heading">We couldn't complete your signup</h2>
<div v-if="errorMessage" class="error-box">
{{ errorMessage }}
</div>
<div class="button-row" style="margin-top: 20px">
<button class="btn" @click="$emit('close')">
Back to form
</button>
</div>
</template>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
state: { type: String, required: true },
summary: { type: Object, default: null },
errorMessage: { type: String, default: "" },
dashboardHref: { type: String, default: "/welcome" },
});
defineEmits(["close"]);
const PROGRESS_STATES = [
"creating-customer",
"opening-payment",
"processing-payment",
"creating-subscription",
];
const isProgress = computed(() => PROGRESS_STATES.includes(props.state));
const progressHeading = computed(() => {
switch (props.state) {
case "creating-customer": return "Creating your account...";
case "opening-payment": return "Opening secure payment...";
case "processing-payment": return "Confirming your card...";
case "creating-subscription": return "Activating your membership...";
default: return "";
}
});
const stepLabel = computed(() => {
switch (props.state) {
case "creating-customer":
case "opening-payment":
return "Step 2 of 3 — Payment";
case "processing-payment":
case "creating-subscription":
return "Step 2 of 3 — Finalizing";
case "success":
return "Step 3 of 3 — Welcome";
case "error":
return "Something went wrong";
default:
return "";
}
});
</script>
<style scoped>
.signup-flow-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: color-mix(in srgb, var(--parch) 72%, transparent);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.signup-flow-card {
background: var(--bg);
border: 1px dashed var(--border);
padding: 32px;
max-width: 520px;
width: 100%;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
.signup-flow-step {
font-family: var(--font-body);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
}
.signup-flow-heading {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--text-bright);
margin: 0 0 16px;
}
.signup-flow-body {
font-family: var(--font-body);
color: var(--text);
line-height: 1.5;
margin: 0;
}
.details-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.details-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
}
.details-row dt {
color: var(--text-faint);
}
.details-row dd {
color: var(--text-bright);
font-weight: 500;
}
.error-box {
border: 1px dashed var(--ember);
color: var(--ember);
padding: 12px 16px;
font-size: 12px;
}
.button-row {
display: flex;
gap: 12px;
align-items: center;
}
</style>

View file

@ -0,0 +1,102 @@
<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 class="tier-label">{{ tier.label }}</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: 10px 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: 16px;
font-weight: 600;
color: var(--text);
font-family: "Brygada 1918", serif;
display: block;
}
.tier-option.current .tier-amount {
color: var(--candle);
}
.tier-label {
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-top: 2px;
}
.tier-option.current .tier-label {
color: var(--candle-dim);
}
@media (max-width: 768px) {
.tier-picker {
flex-wrap: wrap;
}
.tier-option {
min-width: 60px;
}
}
</style>

View file

@ -17,7 +17,7 @@
</span> </span>
</slot> </slot>
</span> </span>
<span class="right"> <span>
<slot name="right"> <slot name="right">
<ClientOnly> <ClientOnly>
<template v-if="memberData"> <template v-if="memberData">
@ -27,7 +27,7 @@
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`" :src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
:alt="memberData.name" :alt="memberData.name"
class="member-avatar" class="member-avatar"
> />
<svg <svg
v-else v-else
class="member-avatar default-ghost" class="member-avatar default-ghost"
@ -56,10 +56,6 @@
</svg> </svg>
{{ memberData.name }} {{ memberData.name }}
</NuxtLink> </NuxtLink>
<span class="sep" aria-hidden="true">/</span>
<a href="#" class="sign-out" @click.prevent="handleLogout"
>sign out</a
>
</template> </template>
<template v-else> The Baby Ghosts member program </template> <template v-else> The Baby Ghosts member program </template>
<template #fallback> The Baby Ghosts member program </template> <template #fallback> The Baby Ghosts member program </template>
@ -74,12 +70,7 @@ const props = defineProps({
pagePath: { type: String, default: "" }, pagePath: { type: String, default: "" },
}); });
const { memberData, logout } = useAuth(); const { memberData } = useAuth();
const handleLogout = async () => {
await logout();
navigateTo("/");
};
const capitalize = (str) => { const capitalize = (str) => {
if (!str) return ""; if (!str) return "";
@ -121,9 +112,6 @@ const breadcrumbs = computed(() => {
gap: 6px; gap: 6px;
text-decoration: none; text-decoration: none;
} }
.member-link:hover {
text-decoration: underline;
}
.member-avatar { .member-avatar {
width: 18px; width: 18px;
height: 18px; height: 18px;
@ -132,23 +120,6 @@ const breadcrumbs = computed(() => {
.default-ghost { .default-ghost {
color: var(--border); color: var(--border);
} }
.right {
display: inline-flex;
align-items: center;
}
.sep {
color: var(--text-faint);
margin: 0 8px;
}
.top-strip a.sign-out {
font-size: 12px;
color: var(--ember);
text-decoration: none;
}
.top-strip a.sign-out:hover {
color: var(--ember);
text-decoration: underline;
}
.breadcrumb-nav { .breadcrumb-nav {
display: inline; display: inline;

View file

@ -1,3 +1,7 @@
/**
* Board Channels Composable
* Shared state + helpers for mapping board tags to Slack channels.
*/
export function useBoardChannels() { export function useBoardChannels() {
const channels = useState('board.channels', () => []) const channels = useState('board.channels', () => [])
@ -7,6 +11,15 @@ export function useBoardChannels() {
return channels.value return channels.value
} }
function resolveTagChannel(tagSlugs = []) {
if (!tagSlugs?.length) return null
return (
channels.value.find((channel) =>
(channel.tagSlugs || []).some((slug) => tagSlugs.includes(slug))
) || null
)
}
function slackUrl(channelId) { function slackUrl(channelId) {
return `https://gammaspace.slack.com/archives/${channelId}` return `https://gammaspace.slack.com/archives/${channelId}`
} }
@ -14,6 +27,7 @@ export function useBoardChannels() {
return { return {
channels: readonly(channels), channels: readonly(channels),
fetchChannels, fetchChannels,
resolveTagChannel,
slackUrl, slackUrl,
} }
} }

View file

@ -1,3 +1,7 @@
/**
* Board Posts Composable
* Shared state + CRUD for board posts.
*/
export function useBoardPosts() { export function useBoardPosts() {
const posts = useState('board.posts', () => []) const posts = useState('board.posts', () => [])
const loading = useState('board.loading', () => false) const loading = useState('board.loading', () => false)
@ -13,29 +17,29 @@ export function useBoardPosts() {
} }
} }
async function createPost(body) { async function createPost(body, refreshParams = {}) {
const created = await $fetch('/api/board/posts', { const created = await $fetch('/api/board/posts', {
method: 'POST', method: 'POST',
body, body,
}) })
await fetchPosts() await fetchPosts(refreshParams)
return created return created
} }
async function updatePost(id, body) { async function updatePost(id, body, refreshParams = {}) {
const updated = await $fetch(`/api/board/posts/${id}`, { const updated = await $fetch(`/api/board/posts/${id}`, {
method: 'PATCH', method: 'PATCH',
body, body,
}) })
await fetchPosts() await fetchPosts(refreshParams)
return updated return updated
} }
async function deletePost(id) { async function deletePost(id, refreshParams = {}) {
const result = await $fetch(`/api/board/posts/${id}`, { const result = await $fetch(`/api/board/posts/${id}`, {
method: 'DELETE', method: 'DELETE',
}) })
await fetchPosts() await fetchPosts(refreshParams)
return result return result
} }

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

View file

@ -25,59 +25,25 @@ export const useMemberPayment = () => {
paymentSuccess.value = false paymentSuccess.value = false
try { try {
// Fast-path: when both Helcim ids are already cached on the member doc // Step 1: Get or create Helcim customer
// AND a card's on file, we can skip the paid getOrCreateCustomer round await getOrCreateCustomer()
// trip entirely and go straight to subscription creation.
const hasCachedHelcimIds = Boolean(
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
)
let existing = null // Step 2: Initialize Helcim payment with $0 for card verification
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) {
await initializeHelcimPay( await initializeHelcimPay(
customerId.value, customerId.value,
customerCode.value, customerCode.value,
0, 0,
) )
// Step 3: Show payment modal and get payment result
const paymentResult = await verifyPayment() const paymentResult = await verifyPayment()
console.log('Payment result:', paymentResult)
if (!paymentResult.success) { if (!paymentResult.success) {
throw new Error('Payment verification failed') throw new Error('Payment verification failed')
} }
// Step 4: Verify payment on backend
const verifyResult = await $fetch('/api/helcim/verify-payment', { const verifyResult = await $fetch('/api/helcim/verify-payment', {
method: 'POST', method: 'POST',
body: { body: {
@ -90,16 +56,14 @@ export const useMemberPayment = () => {
throw new Error('Payment verification failed on backend') 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', { const subscriptionResponse = await $fetch('/api/helcim/subscription', {
method: 'POST', method: 'POST',
body: { body: {
customerId: customerId.value, customerId: customerId.value,
customerCode: customerCode.value, customerCode: customerCode.value,
contributionAmount: memberData.value?.contributionAmount ?? 5, contributionTier: memberData.value?.contributionTier || '5',
cardToken, cardToken: paymentResult.cardToken,
}, },
}) })
@ -107,6 +71,7 @@ export const useMemberPayment = () => {
throw new Error('Subscription creation failed') throw new Error('Subscription creation failed')
} }
// Step 6: Payment successful - refresh member data
paymentSuccess.value = true paymentSuccess.value = true
await checkMemberStatus() await checkMemberStatus()

View file

@ -12,16 +12,16 @@ export const MEMBER_STATUSES = {
export const MEMBER_STATUS_CONFIG = { export const MEMBER_STATUS_CONFIG = {
pending_payment: { pending_payment: {
label: "Setting up payment", label: "Payment Pending",
color: "orange", color: "orange",
bgColor: "bg-orange-500/10", bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/30", borderColor: "border-orange-500/30",
textColor: "text-orange-300", textColor: "text-orange-300",
icon: "heroicons:exclamation-triangle", icon: "heroicons:exclamation-triangle",
severity: "warning", severity: "warning",
canRSVP: true, canRSVP: false,
canAccessMembers: true, canAccessMembers: true,
canPeerSupport: true, canPeerSupport: false,
}, },
active: { active: {
label: "Active Member", label: "Active Member",
@ -126,21 +126,24 @@ export const useMemberStatus = () => {
// Get banner message based on status // Get banner message based on status
const getBannerMessage = () => { const getBannerMessage = () => {
if (isPendingPayment.value) { if (isPendingPayment.value) {
return "Your payment setup isn't finished yet. Your membership and access aren't affected — finish whenever you're ready, or reach out if there's a snag."; return "Your membership is pending payment. Please complete your payment to unlock full features.";
} }
if (isSuspended.value) { if (isSuspended.value) {
return "Your account is paused while we work through a community issue. We'll be in touch."; return "Your membership has been suspended. Please contact support to reactivate your account.";
} }
if (isCancelled.value) { if (isCancelled.value) {
return "Your account is closed. Reach out if you'd like to come back."; return "Your membership has been cancelled. Would you like to reactivate?";
} }
return null; return null;
}; };
// Get RSVP restriction message // Get RSVP restriction message
const getRSVPMessage = () => { const getRSVPMessage = () => {
if (isPendingPayment.value) {
return "Complete your payment to register for events";
}
if (isSuspended.value || isCancelled.value) { if (isSuspended.value || isCancelled.value) {
return "Your account isn't active right now. Reach out if you have questions."; return "Your membership status prevents RSVP. Please reactivate your account.";
} }
return null; return null;
}; };

View file

@ -10,13 +10,6 @@ export function useOnboarding(options = {}) {
hasClickedWiki: false, hasClickedWiki: false,
})) }))
const skipped = useState('onboarding.skipped', () => ({
profileTags: false,
visitEvent: false,
board: false,
wiki: false,
}))
const completedAt = useState('onboarding.completedAt', () => null) const completedAt = useState('onboarding.completedAt', () => null)
const loading = useState('onboarding.loading', () => false) const loading = useState('onboarding.loading', () => false)
const recommendations = useState('onboarding.recommendations', () => ({ const recommendations = useState('onboarding.recommendations', () => ({
@ -27,21 +20,12 @@ export function useOnboarding(options = {}) {
// Track whether we've already fetched status this session // Track whether we've already fetched status this session
const _fetched = useState('onboarding._fetched', () => false) const _fetched = useState('onboarding._fetched', () => false)
// For the purpose of advancing the suggestion widget, a skipped goal is
// treated as "done" — the underlying goal/graduation check is unchanged.
const effectiveGoals = computed(() => ({
hasProfileTags: goals.value.hasProfileTags || skipped.value.profileTags,
hasVisitedEvent: goals.value.hasVisitedEvent || skipped.value.visitEvent,
hasEngagedBoard: goals.value.hasEngagedBoard || skipped.value.board,
hasClickedWiki: goals.value.hasClickedWiki || skipped.value.wiki,
}))
const isComplete = computed(() => const isComplete = computed(() =>
!!completedAt.value || !!completedAt.value ||
(effectiveGoals.value.hasProfileTags && (goals.value.hasProfileTags &&
effectiveGoals.value.hasVisitedEvent && goals.value.hasVisitedEvent &&
effectiveGoals.value.hasEngagedBoard && goals.value.hasEngagedBoard &&
effectiveGoals.value.hasClickedWiki) goals.value.hasClickedWiki)
) )
const pickCategory = options.pickCategory || ((categories) => { const pickCategory = options.pickCategory || ((categories) => {
@ -49,9 +33,9 @@ export function useOnboarding(options = {}) {
}) })
const currentSuggestion = computed(() => { const currentSuggestion = computed(() => {
// Not graduated — return highest-priority incomplete, non-skipped goal // Not graduated — return highest-priority incomplete goal
if (!isComplete.value) { if (!isComplete.value) {
if (!effectiveGoals.value.hasProfileTags) { if (!goals.value.hasProfileTags) {
return { return {
key: 'profileTags', key: 'profileTags',
text: 'Complete your profile by adding your craft and community tags', text: 'Complete your profile by adding your craft and community tags',
@ -59,7 +43,7 @@ export function useOnboarding(options = {}) {
actionText: 'Set up tags', actionText: 'Set up tags',
} }
} }
if (!effectiveGoals.value.hasVisitedEvent) { if (!goals.value.hasVisitedEvent) {
return { return {
key: 'visitEvent', key: 'visitEvent',
text: 'Check out upcoming events', text: 'Check out upcoming events',
@ -67,7 +51,7 @@ export function useOnboarding(options = {}) {
actionText: 'Browse events', actionText: 'Browse events',
} }
} }
if (!effectiveGoals.value.hasEngagedBoard) { if (!goals.value.hasEngagedBoard) {
return { return {
key: 'board', key: 'board',
text: 'Explore the board to find collaborators', text: 'Explore the board to find collaborators',
@ -75,7 +59,7 @@ export function useOnboarding(options = {}) {
actionText: 'Explore board', actionText: 'Explore board',
} }
} }
if (!effectiveGoals.value.hasClickedWiki) { if (!goals.value.hasClickedWiki) {
return { return {
key: 'wiki', key: 'wiki',
text: 'Browse the wiki for resources and guides', text: 'Browse the wiki for resources and guides',
@ -134,9 +118,6 @@ export function useOnboarding(options = {}) {
if (data?.goals) { if (data?.goals) {
goals.value = { ...goals.value, ...data.goals } goals.value = { ...goals.value, ...data.goals }
} }
if (data?.skipped) {
skipped.value = { ...skipped.value, ...data.skipped }
}
if (data?.completedAt) { if (data?.completedAt) {
completedAt.value = data.completedAt completedAt.value = data.completedAt
} }
@ -176,21 +157,6 @@ export function useOnboarding(options = {}) {
} }
} }
async function skipSuggestion(key) {
// Optimistically advance locally; server call is fire-and-forget.
if (skipped.value[key] !== undefined) {
skipped.value = { ...skipped.value, [key]: true }
}
try {
await $fetch('/api/onboarding/track', {
method: 'POST',
body: { skip: key },
})
} catch {
// Non-fatal — will re-fetch on next session
}
}
// Initialize on first use // Initialize on first use
fetchStatus() fetchStatus()
@ -200,8 +166,6 @@ export function useOnboarding(options = {}) {
completedAt: readonly(completedAt), completedAt: readonly(completedAt),
currentSuggestion, currentSuggestion,
trackGoal, trackGoal,
skipSuggestion,
skipped: readonly(skipped),
recommendations: readonly(recommendations), recommendations: readonly(recommendations),
loading: readonly(loading), loading: readonly(loading),
} }

View file

@ -1,22 +1,82 @@
// Guidance presets for the contribution amount input. // Central configuration for Ghost Guild Contribution Levels and Helcim Plans
// These are NOT tiers — just suggested amounts with matching guidance copy. export const CONTRIBUTION_TIERS = {
export const CONTRIBUTION_PRESETS = [ FREE: {
{ amount: 0, label: "I need support right now" }, value: "0",
{ amount: 5, label: "I can contribute" }, amount: 0,
{ amount: 15, label: "I can sustain the community" }, label: "$0 - I need support right now",
{ amount: 30, label: "I can support others too" }, tier: "free",
{ amount: 50, label: "I want to sponsor multiple members" }, helcimPlanId: null, // No Helcim plan needed for free tier
] },
SUPPORTER: {
value: "5",
amount: 5,
label: "$5 - I can contribute",
tier: "supporter",
helcimPlanId: "supporter-monthly-5",
},
MEMBER: {
value: "15",
amount: 15,
label: "$15 - I can sustain the community",
tier: "member",
helcimPlanId: "member-monthly-15",
},
ADVOCATE: {
value: "30",
amount: 30,
label: "$30 - I can support others too",
tier: "advocate",
helcimPlanId: "advocate-monthly-30",
},
CHAMPION: {
value: "50",
amount: 50,
label: "$50 - I want to sponsor multiple members",
tier: "champion",
helcimPlanId: "champion-monthly-50",
},
};
export const requiresPayment = (amount) => amount > 0 // Get all contribution options as an array (useful for forms)
export const getContributionOptions = () => {
return Object.values(CONTRIBUTION_TIERS);
};
export const isValidContributionAmount = (amount) => // Get valid contribution values for validation
Number.isInteger(amount) && amount >= 0 export const getValidContributionValues = () => {
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
};
export const getGuidanceLabel = (amount) => { // Get contribution tier by value
if (amount === null || amount === undefined) return null export const getContributionTierByValue = (value) => {
const n = Number(amount) return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
if (!Number.isFinite(n) || n < 0) return null };
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
return match?.label ?? null // Get Helcim plan ID for a contribution tier
} export const getHelcimPlanId = (contributionValue) => {
const tier = getContributionTierByValue(contributionValue);
return tier?.helcimPlanId || null;
};
// Check if a contribution tier requires payment
export const requiresPayment = (contributionValue) => {
const tier = getContributionTierByValue(contributionValue);
return tier?.amount > 0;
};
// Check if a contribution value is valid
export const isValidContributionValue = (value) => {
return getValidContributionValues().includes(value);
};
// Get contribution tier by Helcim plan ID
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
return Object.values(CONTRIBUTION_TIERS).find(
(tier) => tier.helcimPlanId === helcimPlanId,
);
};
// Get paid tiers only (excluding free tier)
export const getPaidContributionTiers = () => {
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
};

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

@ -1,39 +0,0 @@
// Curated IANA timezone options for the profile editor.
// Grouped roughly by region; values are standard IANA identifiers.
export const TIMEZONE_OPTIONS = [
// Americas
{ label: 'Pacific — Los Angeles', value: 'America/Los_Angeles' },
{ label: 'Pacific — Vancouver', value: 'America/Vancouver' },
{ label: 'Mountain — Denver', value: 'America/Denver' },
{ label: 'Mountain — Edmonton', value: 'America/Edmonton' },
{ label: 'Central — Chicago', value: 'America/Chicago' },
{ label: 'Central — Mexico City', value: 'America/Mexico_City' },
{ label: 'Eastern — Toronto', value: 'America/Toronto' },
{ label: 'Eastern — New York', value: 'America/New_York' },
{ label: 'Atlantic — Halifax', value: 'America/Halifax' },
{ label: 'Newfoundland — St. Johns', value: 'America/St_Johns' },
{ label: 'Brazil — São Paulo', value: 'America/Sao_Paulo' },
{ label: 'Argentina — Buenos Aires', value: 'America/Argentina/Buenos_Aires' },
// Europe / Africa
{ label: 'UTC', value: 'UTC' },
{ label: 'UK — London', value: 'Europe/London' },
{ label: 'Ireland — Dublin', value: 'Europe/Dublin' },
{ label: 'Central Europe — Berlin', value: 'Europe/Berlin' },
{ label: 'Central Europe — Paris', value: 'Europe/Paris' },
{ label: 'Central Europe — Madrid', value: 'Europe/Madrid' },
{ label: 'Eastern Europe — Helsinki', value: 'Europe/Helsinki' },
{ label: 'Africa — Lagos', value: 'Africa/Lagos' },
{ label: 'Africa — Johannesburg', value: 'Africa/Johannesburg' },
// Asia / Oceania
{ label: 'Middle East — Dubai', value: 'Asia/Dubai' },
{ label: 'India — Kolkata', value: 'Asia/Kolkata' },
{ label: 'Southeast Asia — Bangkok', value: 'Asia/Bangkok' },
{ label: 'China — Shanghai', value: 'Asia/Shanghai' },
{ label: 'Japan — Tokyo', value: 'Asia/Tokyo' },
{ label: 'Korea — Seoul', value: 'Asia/Seoul' },
{ label: 'Australia — Sydney', value: 'Australia/Sydney' },
{ label: 'Australia — Perth', value: 'Australia/Perth' },
{ label: 'New Zealand — Auckland', value: 'Pacific/Auckland' },
];

View file

@ -66,14 +66,6 @@
Board Channels Board Channels
</NuxtLink> </NuxtLink>
</li> </li>
<li>
<NuxtLink
to="/admin/site-content"
:class="{ active: route.path.startsWith('/admin/site-content') }"
>
Site Content
</NuxtLink>
</li>
</ul> </ul>
<div class="sidebar-section">Site</div> <div class="sidebar-section">Site</div>
@ -84,7 +76,7 @@
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
<span class="admin-tag">admin</span><br > <span class="admin-tag">admin</span><br />
<a href="#" @click.prevent="logout">Sign out</a> <a href="#" @click.prevent="logout">Sign out</a>
</div> </div>
</aside> </aside>
@ -178,15 +170,6 @@
Board Channels Board Channels
</NuxtLink> </NuxtLink>
</li> </li>
<li>
<NuxtLink
to="/admin/site-content"
:class="{ active: route.path.startsWith('/admin/site-content') }"
@click="isMobileMenuOpen = false"
>
Site Content
</NuxtLink>
</li>
</ul> </ul>
<div class="sidebar-section">Site</div> <div class="sidebar-section">Site</div>
@ -207,7 +190,7 @@
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
<span class="admin-tag">admin</span><br > <span class="admin-tag">admin</span><br />
<a href="#" @click.prevent="logout">Sign out</a> <a href="#" @click.prevent="logout">Sign out</a>
</div> </div>
</aside> </aside>

View file

@ -12,24 +12,11 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if ( if (
to.path === "/coming-soon" || to.path === "/coming-soon" ||
to.path === "/auth/wiki-login" || to.path === "/auth/wiki-login" ||
to.path === "/auth/oidc-error" ||
to.path === "/auth/logout-confirm" ||
to.path === "/auth/logout-success" ||
to.path === "/verify" ||
to.path.startsWith("/admin") to.path.startsWith("/admin")
) { ) {
return; 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 // Redirect all other routes to coming-soon
return navigateTo("/coming-soon"); return navigateTo("/coming-soon");
}); });

View file

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

View file

@ -32,7 +32,7 @@
class="form-input" class="form-input"
type="text" type="text"
required required
> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="accept-email">Email</label> <label class="form-label" for="accept-email">Email</label>
@ -42,7 +42,7 @@
class="form-input" class="form-input"
type="email" type="email"
disabled disabled
> />
<p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p> <p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -53,7 +53,7 @@
class="form-input" class="form-input"
type="text" type="text"
placeholder="e.g. they/them, she/her" placeholder="e.g. they/them, she/her"
> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="accept-location">City / Region</label> <label class="form-label" for="accept-location">City / Region</label>
@ -63,7 +63,7 @@
class="form-input" class="form-input"
type="text" type="text"
placeholder="e.g. Vancouver, BC" placeholder="e.g. Vancouver, BC"
> />
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
@ -77,7 +77,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="community" value="community"
> />
<label for="circle-community"> <label for="circle-community">
<span class="circle-label-name" style="color: var(--c-community);">Community</span> <span class="circle-label-name" style="color: var(--c-community);">Community</span>
<span class="circle-label-desc">Learning about co-ops</span> <span class="circle-label-desc">Learning about co-ops</span>
@ -90,7 +90,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="founder" value="founder"
> />
<label for="circle-founder"> <label for="circle-founder">
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span> <span class="circle-label-name" style="color: var(--c-founder);">Founder</span>
<span class="circle-label-desc">Building your studio</span> <span class="circle-label-desc">Building your studio</span>
@ -103,7 +103,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="practitioner" value="practitioner"
> />
<label for="circle-practitioner"> <label for="circle-practitioner">
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span> <span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span>
<span class="circle-label-desc">Leading and mentoring</span> <span class="circle-label-desc">Leading and mentoring</span>
@ -120,90 +120,36 @@
class="form-input" class="form-input"
rows="3" rows="3"
placeholder="2-3 sentences about what you're looking for" placeholder="2-3 sentences about what you're looking for"
/> ></textarea>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">Billing Cadence</label> <label class="form-label" for="accept-tier">Monthly Contribution</label>
<div class="cadence-radios"> <select
<div class="circle-radio"> id="accept-tier"
<input v-model="form.contributionTier"
id="accept-cadence-annual" class="form-select"
v-model="cadence"
type="radio"
name="cadence"
value="annual"
> >
<label for="accept-cadence-annual"> <option value="0">$0/mo -- I need support right now</option>
<span class="circle-label-name">Per Year</span> <option value="5">$5/mo -- I can contribute</option>
</label> <option value="15">$15/mo -- I can sustain the community (suggested)</option>
</div> <option value="30">$30/mo -- I can support others too</option>
<div class="circle-radio"> <option value="50">$50/mo -- I want to sponsor multiple members</option>
<input </select>
id="accept-cadence-monthly"
v-model="cadence"
type="radio"
name="cadence"
value="monthly"
>
<label for="accept-cadence-monthly">
<span class="circle-label-name">Per Month</span>
</label>
</div>
</div>
</div>
<div class="form-group full-width">
<label class="form-label" for="accept-contribution">
Monthly Contribution
</label>
<div class="contribution-input-row">
<span class="contribution-currency">$</span>
<input
id="accept-contribution"
v-model.number="form.contributionAmount"
type="number"
min="0"
step="1"
inputmode="numeric"
class="contribution-input"
>
</div>
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
<button
v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount"
type="button"
class="contribution-preset-chip"
@click="form.contributionAmount = preset.amount"
>
${{ preset.amount }}
</button>
</div>
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
<p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p> <p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p>
</div> </div>
<div v-if="form.contributionAmount > 0" class="form-group full-width">
<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>.
</p>
<p class="billing-summary-line">
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="checkbox-label"> <label class="checkbox-label">
<input <input
v-model="form.agreedToGuidelines"
type="checkbox" type="checkbox"
> v-model="form.agreedToTerms"
/>
<span> <span>
I agree to the Ghost Guild I've read and agree to the
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>. <NuxtLink to="/agreement" target="_blank">Member Agreement</NuxtLink>
and
<NuxtLink to="/guidelines" target="_blank">Code of Conduct</NuxtLink>
</span> </span>
</label> </label>
</div> </div>
@ -223,28 +169,43 @@
</form> </form>
</div> </div>
<!-- Flow overlay: covers the page through payment + redirect. --> <!-- Payment Step -->
<SignupFlowOverlay <div v-else-if="step === 'payment'" class="form-container">
:state="flowState" <h1>Payment Information</h1>
:summary="flowSummary" <p class="form-intro">
:error-message="errorMessage" You're signing up for ${{ form.contributionTier }} CAD / month.
dashboard-href="/member/dashboard?welcome=1" </p>
@close="closeFlowOverlay"
/> <div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
<DashedBox :hoverable="false">
<p class="payment-instruction">Click "Complete Payment" below to open the secure payment modal and verify your payment method.</p>
</DashedBox>
<div class="button-row" style="margin-top: 24px;">
<button class="btn" :disabled="isSubmitting" @click="step = 'form'">Back</button>
<button class="form-submit" :disabled="isSubmitting" @click="processPayment">
<span v-if="isSubmitting">Processing...</span>
<span v-else>Complete Payment</span>
</button>
</div>
</div>
<!-- Confirmation -->
<div v-else-if="step === 'confirmation'" class="center-box">
<h1>Welcome to Ghost Guild!</h1>
<p>Your membership is active. Redirecting to your dashboard...</p>
<NuxtLink to="/welcome" class="btn btn-primary" style="margin-top: 16px">Go to Dashboard</NuxtLink>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import { requiresPayment } from "~/config/contributions";
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
definePageMeta({ layout: false }); definePageMeta({ layout: false });
const { checkMemberStatus } = useAuth(); const { checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
const step = ref("verifying"); const step = ref("verifying");
const errorMessage = ref(""); const errorMessage = ref("");
@ -252,10 +213,6 @@ const isSubmitting = ref(false);
const preRegId = ref(null); const preRegId = ref(null);
const preRegEmail = ref(""); const preRegEmail = ref("");
const token = ref(""); const token = ref("");
const cadence = ref("annual"); // 'monthly' | 'annual'
// Flow overlay state drives the post-submit full-viewport UI.
const flowState = ref("idle");
const form = reactive({ const form = reactive({
name: "", name: "",
@ -263,49 +220,22 @@ const form = reactive({
location: "", location: "",
circle: "community", circle: "community",
motivation: "", motivation: "",
contributionAmount: 15, contributionTier: "15",
agreedToGuidelines: false, agreedToTerms: false,
}); });
const isFormValid = computed(() => { const isFormValid = computed(() => {
return ( return form.name && form.circle && form.contributionTier && form.agreedToTerms;
form.name &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
}); });
const needsPayment = computed(() => { const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount); return requiresPayment(form.contributionTier);
}); });
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); // Helcim state for paid tiers
const memberId = ref(null);
const firstCharge = computed(() => { const customerId = ref(null);
const amount = form.contributionAmount || 0; const customerCode = ref(null);
return cadence.value === "annual" ? amount * 12 : amount;
});
const formatContributionAmount = (amount) => {
if (!amount || amount === 0) return "$0";
const display = cadence.value === "annual" ? amount * 12 : amount;
const suffix = cadence.value === "annual" ? "/yr" : "/mo";
return `$${display}${suffix}`;
};
const flowSummary = computed(() => ({
name: form.name,
email: preRegEmail.value,
circle: form.circle,
contribution: formatContributionAmount(form.contributionAmount),
}));
const closeFlowOverlay = () => {
flowState.value = "idle";
errorMessage.value = "";
};
// On mount: extract token from fragment, verify // On mount: extract token from fragment, verify
onMounted(async () => { onMounted(async () => {
@ -341,10 +271,9 @@ const handleAccept = async () => {
isSubmitting.value = true; isSubmitting.value = true;
errorMessage.value = ""; errorMessage.value = "";
flowState.value = "creating-customer";
try { try {
const accepted = await $fetch("/api/invite/accept", { const result = await $fetch("/api/invite/accept", {
method: "POST", method: "POST",
body: { body: {
preRegistrationId: preRegId.value, preRegistrationId: preRegId.value,
@ -353,59 +282,96 @@ const handleAccept = async () => {
location: form.location || undefined, location: form.location || undefined,
circle: form.circle, circle: form.circle,
motivation: form.motivation || undefined, motivation: form.motivation || undefined,
contributionAmount: form.contributionAmount, contributionTier: form.contributionTier,
agreedToGuidelines: form.agreedToGuidelines, agreedToTerms: form.agreedToTerms,
token: token.value, token: token.value,
}, },
}); });
if (!accepted.requiresPayment) { memberId.value = result.member.id;
if (result.requiresPayment) {
// Need to create Helcim customer + payment
await setupPayment(result.member);
} else {
// Free tier session cookie already set by accept endpoint // Free tier session cookie already set by accept endpoint
await checkMemberStatus(); await checkMemberStatus();
flowState.value = "success"; step.value = "confirmation";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500); setTimeout(() => navigateTo("/welcome"), 3000);
return;
} }
} catch (err) {
errorMessage.value =
err.data?.statusMessage || "Failed to accept invitation. Please try again.";
} finally {
isSubmitting.value = false;
}
};
// Paid tier: initialize HelcimPay session, auto-open modal const setupPayment = async (member) => {
flowState.value = "opening-payment"; try {
await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0); // Create Helcim customer for paid tier
const customerResult = await $fetch("/api/helcim/customer", {
method: "POST",
body: {
name: member.name,
email: member.email,
circle: member.circle,
contributionTier: form.contributionTier,
},
});
customerId.value = customerResult.customerId;
customerCode.value = customerResult.customerCode;
// Initialize HelcimPay.js
const { initializeHelcimPay } = useHelcimPay();
await initializeHelcimPay(customerId.value, customerCode.value, 0);
step.value = "payment";
} catch (err) {
errorMessage.value =
err.data?.statusMessage || "Failed to set up payment. Please try again.";
}
};
const processPayment = async () => {
if (isSubmitting.value) return;
isSubmitting.value = true;
errorMessage.value = "";
try {
const { verifyPayment } = useHelcimPay();
const paymentResult = await verifyPayment(); const paymentResult = await verifyPayment();
if (!paymentResult?.success) {
throw new Error("Payment was not completed.");
}
flowState.value = "processing-payment"; if (paymentResult.success) {
// Verify payment on server
await $fetch("/api/helcim/verify-payment", { await $fetch("/api/helcim/verify-payment", {
method: "POST", method: "POST",
body: { body: {
cardToken: paymentResult.cardToken, cardToken: paymentResult.cardToken,
customerId: accepted.customerId, customerId: customerId.value,
}, },
}); });
flowState.value = "creating-subscription"; // Create subscription
await $fetch("/api/helcim/subscription", { await $fetch("/api/helcim/subscription", {
method: "POST", method: "POST",
body: { body: {
customerId: accepted.customerId, customerId: customerId.value,
customerCode: accepted.customerCode, customerCode: customerCode.value,
contributionAmount: form.contributionAmount, contributionTier: form.contributionTier,
cadence: cadence.value,
cardToken: paymentResult.cardToken, cardToken: paymentResult.cardToken,
}, },
}); });
await checkMemberStatus(); await checkMemberStatus();
flowState.value = "success"; step.value = "confirmation";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500); setTimeout(() => navigateTo("/welcome"), 3000);
}
} catch (err) { } catch (err) {
errorMessage.value = errorMessage.value =
err.data?.statusMessage || err.message || "Payment verification failed. Please try again.";
err.message ||
"Failed to accept invitation. Please try again.";
flowState.value = "error";
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
@ -523,72 +489,6 @@ textarea.form-input {
line-height: 1.4; line-height: 1.4;
} }
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
.contribution-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.contribution-currency {
font-weight: 600;
}
.contribution-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
outline: none;
border-color: var(--candle);
}
.contribution-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.contribution-preset-chip {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
.contribution-preset-chip:hover {
border-style: solid;
border-color: var(--candle);
}
.contribution-guidance {
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--ink-soft, currentColor);
}
/* ---- BILLING SUMMARY ---- */
.billing-summary {
padding: 12px 16px;
border: 1px dashed var(--border);
background: var(--surface);
}
.billing-summary-line {
font-size: 13px;
color: var(--text);
line-height: 1.5;
margin: 0;
}
.billing-summary-line + .billing-summary-line {
margin-top: 4px;
}
.billing-summary-line strong {
color: var(--text-bright);
font-weight: 600;
}
/* ---- CIRCLE RADIOS ---- */ /* ---- CIRCLE RADIOS ---- */
.circle-radios { .circle-radios {
display: grid; display: grid;
@ -596,12 +496,6 @@ textarea.form-input {
gap: 8px; gap: 8px;
} }
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.circle-radio { .circle-radio {
position: relative; position: relative;
} }
@ -719,9 +613,5 @@ textarea.form-input {
.circle-radios { .circle-radios {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.cadence-radios {
grid-template-columns: 1fr;
}
} }
</style> </style>

View file

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

View file

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

View file

@ -16,7 +16,7 @@
<p v-if="member" class="member-email">{{ member.email }}</p> <p v-if="member" class="member-email">{{ member.email }}</p>
</div> </div>
<div v-if="member" class="header-badges"> <div v-if="member" class="header-badges">
<CircleBadge :circle="member.circle" /> <span class="badge" :class="member.circle">{{ member.circle }}</span>
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span> <span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div> </div>
</div> </div>
@ -39,11 +39,11 @@
<form class="edit-form" @submit.prevent="submitEdit"> <form class="edit-form" @submit.prevent="submitEdit">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
<input v-model="form.name" type="text" required > <input v-model="form.name" type="text" required />
</div> </div>
<div class="field"> <div class="field">
<label>Email</label> <label>Email</label>
<input v-model="form.email" type="email" required > <input v-model="form.email" type="email" required />
</div> </div>
<div class="field"> <div class="field">
<label>Circle</label> <label>Circle</label>
@ -54,20 +54,22 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Contribution ($/mo)</label> <label>Contribution tier ($/mo)</label>
<input v-model.number="form.contributionAmount" type="number" min="0" step="1"> <select v-model="form.contributionTier">
<p class="field-hint field-hint--warn"> <option value="0">$0</option>
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. <option value="5">$5</option>
</p> <option value="15">$15</option>
<option value="30">$30</option>
<option value="50">$50</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="form.status"> <select v-model="form.status">
<option <option value="pending_payment">pending_payment</option>
v-for="(label, value) in STATUS_LABELS" <option value="active">active</option>
:key="value" <option value="suspended">suspended</option>
:value="value" <option value="cancelled">cancelled</option>
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
@ -110,19 +112,8 @@
</div> </div>
<div class="meta-row"> <div class="meta-row">
<dt>Slack invite</dt> <dt>Slack invite</dt>
<dd v-if="member.slackInvited" class="status-ok"> <dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
Invited {{ formatDate(member.slackInvitedAt) }} {{ member.slackInvited ? "Invited" : "Pending" }}
</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> </dd>
</div> </div>
<div v-if="member.helcimCustomerId" class="meta-row"> <div v-if="member.helcimCustomerId" class="meta-row">
@ -170,6 +161,12 @@
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }} {{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
</dd> </dd>
</div> </div>
<div class="meta-row">
<dt>Slack status</dt>
<dd :class="slackStatusClass">
{{ member.slackInviteStatus || 'none' }}
</dd>
</div>
</dl> </dl>
</section> </section>
@ -243,7 +240,6 @@
<script setup> <script setup>
import { formatActivity } from '~/utils/activityText' import { formatActivity } from '~/utils/activityText'
import { STATUS_LABELS } from '~/config/memberStatus'
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
@ -274,7 +270,7 @@ const form = reactive({
name: "", name: "",
email: "", email: "",
circle: "", circle: "",
contributionAmount: 0, contributionTier: "",
status: "", status: "",
role: "", role: "",
}); });
@ -286,7 +282,7 @@ function populateForm(m) {
form.name = m.name; form.name = m.name;
form.email = m.email; form.email = m.email;
form.circle = m.circle; form.circle = m.circle;
form.contributionAmount = m.contributionAmount ?? 0; form.contributionTier = String(m.contributionTier);
form.status = m.status || "pending_payment"; form.status = m.status || "pending_payment";
form.role = m.role || "member"; form.role = m.role || "member";
} }
@ -308,7 +304,7 @@ async function submitEdit() {
name: form.name, name: form.name,
email: form.email, email: form.email,
circle: form.circle, circle: form.circle,
contributionAmount: form.contributionAmount, contributionTier: form.contributionTier,
status: form.status, status: form.status,
}, },
}); });
@ -366,31 +362,12 @@ const hasBoardEngaged = computed(() => {
) )
}) })
const markingSlackInvited = ref(false) const slackStatusClass = computed(() => {
const status = member.value?.slackInviteStatus
async function markSlackInvited() { if (status === 'joined') return 'status-ok'
if (!member.value || markingSlackInvited.value) return if (status === 'invited') return 'status-dim'
markingSlackInvited.value = true return 'status-dim'
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",
}) })
} finally {
markingSlackInvited.value = false
}
}
// Activity log // Activity log
const activityEntries = ref([]) const activityEntries = ref([])
@ -539,24 +516,6 @@ onMounted(loadActivity)
margin-top: 12px; margin-top: 12px;
} }
.field-hint {
font-size: 11px;
color: var(--text-faint);
margin: 6px 0 0;
line-height: 1.4;
}
.field-hint--warn {
color: var(--ember);
border-left: 2px solid var(--ember);
padding: 4px 0 4px 8px;
}
.field-hint code {
font-family: "Commit Mono", monospace;
font-size: 10px;
}
.form-actions { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -600,32 +559,6 @@ onMounted(loadActivity)
word-break: break-all; 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 { .mono {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;

View file

@ -28,7 +28,7 @@
<!-- Search / Filter --> <!-- Search / Filter -->
<div class="filter-bar"> <div class="filter-bar">
<div class="field" style="margin-bottom: 0; flex: 1"> <div class="field" style="margin-bottom: 0; flex: 1">
<input v-model="searchQuery" placeholder="Search members..." > <input v-model="searchQuery" placeholder="Search members..." />
</div> </div>
<div class="field" style="margin-bottom: 0"> <div class="field" style="margin-bottom: 0">
<select v-model="circleFilter" aria-label="Filter by circle"> <select v-model="circleFilter" aria-label="Filter by circle">
@ -38,16 +38,6 @@
<option value="practitioner">Practitioner</option> <option value="practitioner">Practitioner</option>
</select> </select>
</div> </div>
<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>
</select>
</div>
</div> </div>
<!-- Members Table --> <!-- Members Table -->
@ -71,18 +61,17 @@
:checked="allVisibleSelected" :checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected" :indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll" @change="toggleSelectAll"
> />
<span class="check-mark" /> <span class="check-mark" />
</label> </label>
</th> </th>
<th class="sortable" @click="toggleSort('name')">Name <span class="sort-ind">{{ sortIndicator('name') }}</span></th> <th>Name</th>
<th class="sortable" @click="toggleSort('email')">Email <span class="sort-ind">{{ sortIndicator('email') }}</span></th> <th>Email</th>
<th class="sortable" @click="toggleSort('circle')">Circle <span class="sort-ind">{{ sortIndicator('circle') }}</span></th> <th>Circle</th>
<th class="sortable" @click="toggleSort('contributionAmount')">Contribution <span class="sort-ind">{{ sortIndicator('contributionAmount') }}</span></th> <th>Tier</th>
<th class="sortable" @click="toggleSort('status')">Status <span class="sort-ind">{{ sortIndicator('status') }}</span></th>
<th>Invite</th> <th>Invite</th>
<th>Slack</th> <th>Slack</th>
<th class="sortable" @click="toggleSort('createdAt')">Joined <span class="sort-ind">{{ sortIndicator('createdAt') }}</span></th> <th>Joined</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -100,7 +89,7 @@
type="checkbox" type="checkbox"
:checked="selectedMemberIds.includes(member._id)" :checked="selectedMemberIds.includes(member._id)"
@change="toggleSelect(member._id)" @change="toggleSelect(member._id)"
> />
<span class="check-mark" /> <span class="check-mark" />
</label> </label>
</td> </td>
@ -109,12 +98,11 @@
</td> </td>
<td class="col-email">{{ member.email }}</td> <td class="col-email">{{ member.email }}</td>
<td> <td>
<CircleBadge :circle="member.circle" /> <span class="badge" :class="member.circle">{{
</td> member.circle
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td> }}</span>
<td>
<span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span>
</td> </td>
<td class="col-mono">${{ member.contributionTier }}/mo</td>
<td> <td>
<span <span
:class="member.inviteEmailSent ? 'status-ok' : 'status-dim'" :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'"
@ -123,11 +111,8 @@
</span> </span>
</td> </td>
<td> <td>
<span v-if="member.slackInvited" class="status-ok"> <span :class="member.slackInvited ? 'status-ok' : 'status-dim'">
Invited {{ formatDate(member.slackInvitedAt) }} {{ member.slackInvited ? "Invited" : "Pending" }}
</span>
<span v-else class="status-dim">
Not yet invited
</span> </span>
</td> </td>
<td class="col-mono col-date"> <td class="col-mono col-date">
@ -137,14 +122,10 @@
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop <NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
>View</NuxtLink >View</NuxtLink
> >
<button <button @click.stop="sendSlackInvite(member)" class="link-btn">
v-if="!member.slackInvited" Slack
class="link-btn"
@click.stop="markSlackInvited(member)"
>
Mark as Slack invited
</button> </button>
<button class="link-btn" @click.stop="editMember(member)">Edit</button> <button @click.stop="editMember(member)" class="link-btn">Edit</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -169,10 +150,10 @@
</button> </button>
</div> </div>
<form class="modal-body" @submit.prevent="createMember"> <form @submit.prevent="createMember" class="modal-body">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
<input v-model="newMember.name" placeholder="Full name" required > <input v-model="newMember.name" placeholder="Full name" required />
</div> </div>
<div class="field"> <div class="field">
<label>Email</label> <label>Email</label>
@ -181,7 +162,7 @@
type="email" type="email"
placeholder="email@example.com" placeholder="email@example.com"
required required
> />
</div> </div>
<div class="field"> <div class="field">
<label>Circle</label> <label>Circle</label>
@ -192,8 +173,14 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Contribution ($/mo)</label> <label>Contribution Tier</label>
<input v-model.number="newMember.contributionAmount" type="number" min="0" step="1"> <select v-model="newMember.contributionTier">
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
<option value="30">$30/month</option>
<option value="50">$50/month</option>
</select>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn" @click="showCreateModal = false"> <button type="button" class="btn" @click="showCreateModal = false">
@ -223,18 +210,19 @@
<div v-if="!csvRows.length"> <div v-if="!csvRows.length">
<p class="help-text"> <p class="help-text">
Upload a CSV with columns: Upload a CSV with columns:
<code>name,email,circle,contributionAmount</code> <code>name,email,circle,contributionTier</code>
</p> </p>
<p class="help-text" style="margin-bottom: 12px"> <p class="help-text" style="margin-bottom: 12px">
Valid circles: community, founder, practitioner. Contribution: whole number 0. Valid circles: community, founder, practitioner. Valid tiers: 0,
5, 15, 30, 50.
</p> </p>
<input <input
ref="csvFileInput" ref="csvFileInput"
type="file" type="file"
accept=".csv" accept=".csv"
class="file-input"
@change="handleCsvFile" @change="handleCsvFile"
> class="file-input"
/>
</div> </div>
<div v-if="csvParseError" class="error-box"> <div v-if="csvParseError" class="error-box">
@ -255,7 +243,7 @@
> >
{{ csvRows.length - csvValidRows.length }} with errors. {{ csvRows.length - csvValidRows.length }} with errors.
</span> </span>
<button class="link-btn" @click="resetCsvImport"> <button @click="resetCsvImport" class="link-btn">
Choose different file Choose different file
</button> </button>
</div> </div>
@ -268,7 +256,7 @@
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Circle</th> <th>Circle</th>
<th>Contribution</th> <th>Tier</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -286,7 +274,7 @@
<td>{{ row.name }}</td> <td>{{ row.name }}</td>
<td class="col-email">{{ row.email }}</td> <td class="col-email">{{ row.email }}</td>
<td>{{ row.circle }}</td> <td>{{ row.circle }}</td>
<td>${{ row.contributionAmount }}/mo</td> <td>${{ row.contributionTier }}/mo</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -315,14 +303,14 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn" @click="closeImportModal"> <button @click="closeImportModal" class="btn">
{{ importResults ? "Done" : "Cancel" }} {{ importResults ? "Done" : "Cancel" }}
</button> </button>
<button <button
v-if="csvValidRows.length && !importResults" v-if="csvValidRows.length && !importResults"
:disabled="importing" :disabled="importing"
class="btn btn-primary"
@click="submitImport" @click="submitImport"
class="btn btn-primary"
> >
{{ {{
importing importing
@ -348,14 +336,14 @@
</button> </button>
</div> </div>
<form class="modal-body" @submit.prevent="submitEditMember"> <form @submit.prevent="submitEditMember" class="modal-body">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
<input v-model="editingMember.name" required > <input v-model="editingMember.name" required />
</div> </div>
<div class="field"> <div class="field">
<label>Email</label> <label>Email</label>
<input v-model="editingMember.email" type="email" required > <input v-model="editingMember.email" type="email" required />
</div> </div>
<div class="field"> <div class="field">
<label>Circle</label> <label>Circle</label>
@ -366,17 +354,22 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Contribution ($/mo)</label> <label>Contribution Tier</label>
<input v-model.number="editingMember.contributionAmount" type="number" min="0" step="1"> <select v-model="editingMember.contributionTier">
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
<option value="30">$30/month</option>
<option value="50">$50/month</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="editingMember.status"> <select v-model="editingMember.status">
<option <option value="pending_payment">Pending Payment</option>
v-for="(label, value) in STATUS_LABELS" <option value="active">Active</option>
:key="value" <option value="suspended">Suspended</option>
:value="value" <option value="cancelled">Cancelled</option>
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -414,7 +407,7 @@
<div class="field"> <div class="field">
<label>Email Template</label> <label>Email Template</label>
<textarea v-model="inviteTemplate" rows="12"/> <textarea v-model="inviteTemplate" rows="12"></textarea>
<p class="help-text" style="margin-top: 4px"> <p class="help-text" style="margin-top: 4px">
Tokens: <code>{name}</code>, <code>{loginLink}</code>, Tokens: <code>{name}</code>, <code>{loginLink}</code>,
<code>{circle}</code> <code>{circle}</code>
@ -446,14 +439,14 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn" @click="showInviteModal = false"> <button @click="showInviteModal = false" class="btn">
{{ inviteResults ? "Done" : "Cancel" }} {{ inviteResults ? "Done" : "Cancel" }}
</button> </button>
<button <button
v-if="!inviteResults" v-if="!inviteResults"
:disabled="sendingInvites" :disabled="sendingInvites"
class="btn btn-primary"
@click="submitInvites" @click="submitInvites"
class="btn btn-primary"
> >
{{ {{
sendingInvites sendingInvites
@ -468,8 +461,6 @@
</template> </template>
<script setup> <script setup>
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
middleware: "admin", middleware: "admin",
@ -486,22 +477,6 @@ const {
const searchQuery = ref(""); const searchQuery = ref("");
const circleFilter = ref(""); const circleFilter = ref("");
const statusFilter = ref("");
const sortKey = ref("createdAt");
const sortDir = ref("desc");
const toggleSort = (key) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = key === "createdAt" ? "desc" : "asc";
}
};
const sortIndicator = (key) => {
if (sortKey.value !== key) return "";
return sortDir.value === "asc" ? "▲" : "▼";
};
const showCreateModal = ref(false); const showCreateModal = ref(false);
const creating = ref(false); const creating = ref(false);
@ -523,14 +498,14 @@ const inviteResults = ref(null);
const DEFAULT_INVITE_TEMPLATE = `Hi {name}, const DEFAULT_INVITE_TEMPLATE = `Hi {name},
You've been invited to Ghost Guild. You've been invited to Ghost Guild as a member of the {circle} circle.
Sign in here to get started: Sign in here to get started:
{loginLink} {loginLink}
This link expires in 48 hours. After that, you can always request a new login link at https://ghostguild.org/login. This link expires in 48 hours. After that, you can always request a new login link at https://ghostguild.org/login.
Reply to this email if you have any trouble!`; See you inside.`;
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE); const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
@ -538,13 +513,13 @@ const newMember = reactive({
name: "", name: "",
email: "", email: "",
circle: "community", circle: "community",
contributionAmount: 0, contributionTier: "0",
}); });
const filteredMembers = computed(() => { const filteredMembers = computed(() => {
if (!members.value) return []; if (!members.value) return [];
const filtered = members.value.filter((member) => { return members.value.filter((member) => {
const matchesSearch = const matchesSearch =
!searchQuery.value || !searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
@ -553,33 +528,7 @@ const filteredMembers = computed(() => {
const matchesCircle = const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value; !circleFilter.value || member.circle === circleFilter.value;
const matchesStatus = return matchesSearch && matchesCircle;
!statusFilter.value || (member.status || "pending_payment") === statusFilter.value;
return matchesSearch && matchesCircle && matchesStatus;
});
const key = sortKey.value;
const dir = sortDir.value === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
let av = a[key];
let bv = b[key];
if (key === "contributionAmount") {
av = Number(av) || 0;
bv = Number(bv) || 0;
} else if (key === "createdAt") {
av = av ? new Date(av).getTime() : 0;
bv = bv ? new Date(bv).getTime() : 0;
} else if (key === "status") {
av = a.status || "pending_payment";
bv = b.status || "pending_payment";
} else {
av = (av || "").toString().toLowerCase();
bv = (bv || "").toString().toLowerCase();
}
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
}); });
}); });
@ -656,7 +605,7 @@ const createMember = async () => {
name: "", name: "",
email: "", email: "",
circle: "community", circle: "community",
contributionAmount: 0, contributionTier: "0",
}); });
await refresh(); await refresh();
@ -675,6 +624,7 @@ const createMember = async () => {
// --- CSV Import --- // --- CSV Import ---
const VALID_CIRCLES = ["community", "founder", "practitioner"]; const VALID_CIRCLES = ["community", "founder", "practitioner"];
const VALID_TIERS = ["0", "5", "15", "30", "50"];
const handleCsvFile = (event) => { const handleCsvFile = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
@ -703,10 +653,10 @@ const parseCsv = (text) => {
const nameIdx = header.indexOf("name"); const nameIdx = header.indexOf("name");
const emailIdx = header.indexOf("email"); const emailIdx = header.indexOf("email");
const circleIdx = header.indexOf("circle"); const circleIdx = header.indexOf("circle");
const amountIdx = header.indexOf("contributionamount"); const tierIdx = header.indexOf("contributiontier");
if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || amountIdx === -1) { if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || tierIdx === -1) {
csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionAmount`; csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionTier`;
return; return;
} }
@ -718,21 +668,20 @@ const parseCsv = (text) => {
const name = cols[nameIdx] || ""; const name = cols[nameIdx] || "";
const email = (cols[emailIdx] || "").toLowerCase(); const email = (cols[emailIdx] || "").toLowerCase();
const circle = (cols[circleIdx] || "").toLowerCase(); const circle = (cols[circleIdx] || "").toLowerCase();
const rawAmount = cols[amountIdx] || ""; const contributionTier = cols[tierIdx] || "";
const contributionAmount = Number(rawAmount);
let error = null; let error = null;
if (!name) error = "Missing name"; if (!name) error = "Missing name";
else if (!email || !email.includes("@")) error = "Invalid email"; else if (!email || !email.includes("@")) error = "Invalid email";
else if (!VALID_CIRCLES.includes(circle)) else if (!VALID_CIRCLES.includes(circle))
error = `Invalid circle: ${circle}`; error = `Invalid circle: ${circle}`;
else if (!Number.isInteger(contributionAmount) || contributionAmount < 0) else if (!VALID_TIERS.includes(contributionTier))
error = `Invalid contribution: ${rawAmount}`; error = `Invalid tier: ${contributionTier}`;
else if (seenEmails.has(email)) error = "Duplicate email in CSV"; else if (seenEmails.has(email)) error = "Duplicate email in CSV";
if (!error) seenEmails.add(email); if (!error) seenEmails.add(email);
rows.push({ name, email, circle, contributionAmount, error }); rows.push({ name, email, circle, contributionTier, error });
} }
csvRows.value = rows; csvRows.value = rows;
@ -759,11 +708,11 @@ const submitImport = async () => {
importing.value = true; importing.value = true;
try { try {
const payload = csvValidRows.value.map( const payload = csvValidRows.value.map(
({ name, email, circle, contributionAmount }) => ({ ({ name, email, circle, contributionTier }) => ({
name, name,
email, email,
circle, circle,
contributionAmount, contributionTier,
}), }),
); );
@ -830,25 +779,8 @@ const submitInvites = async () => {
}; };
// --- Existing actions --- // --- Existing actions ---
const markSlackInvited = async (member) => { const sendSlackInvite = (member) => {
try { console.log("Send Slack invite to:", member.email);
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",
});
}
}; };
// --- Edit Member --- // --- Edit Member ---
@ -859,7 +791,7 @@ const editingMember = reactive({
name: "", name: "",
email: "", email: "",
circle: "community", circle: "community",
contributionAmount: 0, contributionTier: "0",
status: "pending_payment", status: "pending_payment",
}); });
@ -869,7 +801,7 @@ const editMember = (member) => {
name: member.name, name: member.name,
email: member.email, email: member.email,
circle: member.circle, circle: member.circle,
contributionAmount: member.contributionAmount ?? 0, contributionTier: String(member.contributionTier),
status: member.status || "pending_payment", status: member.status || "pending_payment",
}); });
showEditModal.value = true; showEditModal.value = true;
@ -884,7 +816,7 @@ const submitEditMember = async () => {
name: editingMember.name, name: editingMember.name,
email: editingMember.email, email: editingMember.email,
circle: editingMember.circle, circle: editingMember.circle,
contributionAmount: editingMember.contributionAmount, contributionTier: editingMember.contributionTier,
status: editingMember.status, status: editingMember.status,
}, },
}); });
@ -1123,44 +1055,6 @@ tbody td {
font-size: 11px; font-size: 11px;
} }
/* ---- SORTABLE HEADERS ---- */
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
color: var(--candle);
}
.sort-ind {
display: inline-block;
width: 10px;
font-size: 9px;
color: var(--candle);
margin-left: 2px;
}
/* ---- MEMBER STATUS BADGES ---- */
.badge.status {
text-transform: uppercase;
}
.badge.status-active {
color: var(--green);
border-color: rgba(58, 107, 58, 0.45);
}
.badge.status-pending_payment {
color: var(--text-dim);
border-color: var(--border);
}
.badge.status-suspended {
color: var(--ember);
border-color: color-mix(in srgb, var(--ember) 45%, transparent);
}
.badge.status-cancelled {
color: var(--text-faint);
border-color: var(--border);
opacity: 0.7;
}
/* ---- MODALS ---- */ /* ---- MODALS ---- */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@ -1301,7 +1195,7 @@ th.sortable:hover {
} }
.row-error { .row-error {
background: color-mix(in srgb, var(--ember) 4%, transparent); background: rgba(138, 68, 32, 0.04);
} }
/* ---- PREVIEW BOX ---- */ /* ---- PREVIEW BOX ---- */

View file

@ -247,11 +247,9 @@ Click below to accept your invitation, choose your circle, and set your contribu
{acceptLink} {acceptLink}
This link expires in 48 hours. If it expires, we can send you a new one. Just reply to this email. This link expires in 48 hours. If it expires, we can send you a new one.
See you soon! See you inside.`;
Ghost Guild`;
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE); const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
@ -643,8 +641,8 @@ tbody td {
} }
.status-accepted { .status-accepted {
color: var(--green); color: var(--green, #4a7);
border-color: var(--green); border-color: var(--green, #4a7);
} }
.status-expired { .status-expired {
@ -671,7 +669,7 @@ tbody td {
/* ---- STATUS INDICATORS ---- */ /* ---- STATUS INDICATORS ---- */
.status-ok { .status-ok {
color: var(--green); color: var(--green, #4a7);
font-size: 11px; font-size: 11px;
} }

View file

@ -112,6 +112,7 @@
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button> <button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
<button @click="editSeries(series)" class="link-btn">Edit</button> <button @click="editSeries(series)" class="link-btn">Edit</button>
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button> <button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
<button @click="duplicateSeries(series)" class="link-btn">Duplicate</button>
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button> <button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
</div> </div>
</div> </div>
@ -170,6 +171,14 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="section-label">Series Management Tools</div> <div class="section-label">Series Management Tools</div>
<button @click="reorderAllSeries" class="btn bulk-action">
<strong>Auto-Reorder Series</strong>
<span>Fix position numbers based on event dates</span>
</button>
<button @click="validateAllSeries" class="btn bulk-action">
<strong>Validate Series Data</strong>
<span>Check for consistency issues</span>
</button>
<button @click="exportSeriesData" class="btn bulk-action"> <button @click="exportSeriesData" class="btn bulk-action">
<strong>Export Series Data</strong> <strong>Export Series Data</strong>
<span>Download series information as JSON</span> <span>Download series information as JSON</span>
@ -566,6 +575,10 @@ const addEventToSeries = (series) => {
navigateTo('/admin/events/create?series=true') navigateTo('/admin/events/create?series=true')
} }
const duplicateSeries = () => {
// TODO: Implement
}
const editSeries = (series) => { const editSeries = (series) => {
editingSeriesId.value = series.id editingSeriesId.value = series.id
editingSeriesData.value = { editingSeriesData.value = {
@ -683,6 +696,9 @@ const saveTicketsEdit = async () => {
} }
} }
const reorderAllSeries = () => { /* TODO */ }
const validateAllSeries = () => { /* TODO */ }
const exportSeriesData = () => { const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2) const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' }) const dataBlob = new Blob([dataStr], { type: 'application/json' })
@ -850,7 +866,7 @@ const exportSeriesData = () => {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--c-founder); color: var(--c-founder);
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent); border: 1px dashed rgba(138, 68, 32, 0.4);
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
@ -931,12 +947,12 @@ const exportSeriesData = () => {
.status-active { .status-active {
color: var(--green); color: var(--green);
border-color: color-mix(in srgb, var(--green) 30%, transparent); border-color: rgba(74, 106, 56, 0.3);
} }
.status-upcoming { .status-upcoming {
color: var(--candle); color: var(--candle);
border-color: color-mix(in srgb, var(--candle) 30%, transparent); border-color: rgba(122, 90, 16, 0.3);
} }
.status-completed { .status-completed {
@ -946,7 +962,7 @@ const exportSeriesData = () => {
.status-ongoing { .status-ongoing {
color: var(--green); color: var(--green);
border-color: color-mix(in srgb, var(--green) 30%, transparent); border-color: rgba(74, 106, 56, 0.3);
} }
/* ---- LINK BUTTONS ---- */ /* ---- LINK BUTTONS ---- */

View file

@ -1,225 +0,0 @@
<template>
<div class="admin-site-content">
<div class="page-header">
<h1>Site Content</h1>
<p>Editable copy rendered on the public site. Leave fields blank to use defaults.</p>
</div>
<div v-if="pending" class="loading-state">Loading</div>
<div v-else class="content-blocks">
<section v-for="entry in entries" :key="entry.key" class="content-block">
<div class="block-header">
<div>
<div class="block-key">{{ entry.key }}</div>
<div class="block-label">{{ KEY_LABELS[entry.key] || entry.key }}</div>
</div>
<div v-if="entry.updatedAt" class="block-meta">
Updated {{ formatTime(entry.updatedAt) }}
</div>
</div>
<div class="field">
<label>Title</label>
<input v-model="entry.title" type="text" maxlength="300" >
</div>
<div class="field">
<label>Body</label>
<textarea v-model="entry.body" rows="8" maxlength="5000" />
<p class="help-text">Paragraphs separated by blank lines. Plain text only.</p>
</div>
<div class="block-actions">
<button
class="btn btn-primary"
:disabled="entry.saving"
@click="save(entry)"
>
{{ entry.saving ? 'Saving…' : 'Save' }}
</button>
</div>
</section>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const toast = useToast()
const KEY_LABELS = {
'homepage.wiki_feature': 'Homepage: From the Wiki',
}
const { data: keysData } = await useFetch('/api/site-content/keys')
const knownKeys = computed(() => keysData.value?.keys || [])
const entries = ref([])
const pending = ref(true)
const load = async () => {
pending.value = true
const results = await Promise.all(
knownKeys.value.map((key) => $fetch(`/api/site-content/${key}`))
)
entries.value = results.map((r) => ({
key: r.key,
title: r.title || '',
body: r.body || '',
updatedAt: r.updatedAt || null,
saving: false,
}))
pending.value = false
}
await load()
const save = async (entry) => {
entry.saving = true
try {
const updated = await $fetch(`/api/admin/site-content/${entry.key}`, {
method: 'PUT',
body: { title: entry.title, body: entry.body },
})
entry.updatedAt = updated.updatedAt
toast.add({ title: 'Saved', color: 'green' })
} catch (err) {
toast.add({
title: 'Save failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
entry.saving = false
}
}
const formatTime = (iso) => {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
</script>
<style scoped>
.admin-site-content {
padding: 24px;
max-width: 780px;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--border);
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
margin-bottom: 4px;
}
.page-header p {
color: var(--text-dim);
font-size: 13px;
}
.loading-state {
color: var(--text-faint);
font-size: 13px;
padding: 24px 0;
}
.content-blocks {
display: flex;
flex-direction: column;
gap: 24px;
}
.content-block {
border: 1px dashed var(--border);
padding: 20px;
background: var(--bg);
}
.block-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.block-key {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
margin-bottom: 2px;
}
.block-label {
font-size: 14px;
color: var(--text-bright);
}
.block-meta {
font-size: 11px;
color: var(--text-faint);
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 6px;
}
.field input,
.field textarea {
width: 100%;
padding: 8px 10px;
background: var(--input-bg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 13px;
color: var(--text);
line-height: 1.6;
}
.field textarea {
resize: vertical;
font-family: 'Commit Mono', monospace;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: var(--candle);
}
.help-text {
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
.block-actions {
display: flex;
justify-content: flex-end;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@ const uid = route.query.uid as string;
const email = ref(""); const email = ref("");
const sent = ref(false); const sent = ref(false);
const notRegistered = ref(false);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
@ -16,21 +15,13 @@ async function sendMagicLink() {
if (!email.value || !uid) return; if (!email.value || !uid) return;
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
notRegistered.value = false;
try { try {
const response = await $fetch<{ success: boolean; registered: boolean }>( await $fetch("/oidc/interaction/login", {
"/oidc/interaction/login",
{
method: "POST", method: "POST",
body: { email: email.value, uid }, body: { email: email.value, uid },
} });
);
if (response.registered === false) {
notRegistered.value = true;
} else {
sent.value = true; sent.value = true;
}
} catch (e: any) { } catch (e: any) {
error.value = error.value =
e?.data?.statusMessage || "Something went wrong. Please try again."; e?.data?.statusMessage || "Something went wrong. Please try again.";
@ -38,12 +29,6 @@ async function sendMagicLink() {
loading.value = false; loading.value = false;
} }
} }
function resetForm() {
sent.value = false;
notRegistered.value = false;
email.value = "";
}
</script> </script>
<template> <template>
@ -54,11 +39,11 @@ function resetForm() {
<h1 class="wiki-login-title">Wiki</h1> <h1 class="wiki-login-title">Wiki</h1>
</header> </header>
<hr class="section-divider" > <hr class="section-divider" />
<Transition name="wiki-fade" mode="out-in"> <Transition name="wiki-fade" mode="out-in">
<form <form
v-if="!sent && !notRegistered" v-if="!sent"
key="form" key="form"
class="wiki-login-form" class="wiki-login-form"
@submit.prevent="sendMagicLink" @submit.prevent="sendMagicLink"
@ -73,7 +58,7 @@ function resetForm() {
autocomplete="email" autocomplete="email"
placeholder="you@example.com" placeholder="you@example.com"
:disabled="loading" :disabled="loading"
> />
</div> </div>
<p <p
@ -104,7 +89,7 @@ function resetForm() {
</form> </form>
<div <div
v-else-if="sent" v-else
key="sent" key="sent"
class="wiki-login-sent" class="wiki-login-sent"
role="status" role="status"
@ -114,35 +99,13 @@ function resetForm() {
<p class="wiki-login-sent-detail"> <p class="wiki-login-sent-detail">
A sign-in link was sent to <strong>{{ email }}</strong> A sign-in link was sent to <strong>{{ email }}</strong>
</p> </p>
<button class="wiki-login-reset" @click="resetForm"> <button
Try a different email class="wiki-login-reset"
</button> @click="
</div> sent = false;
email = '';
<div "
v-else
key="not-registered"
class="wiki-login-sent"
role="status"
aria-live="polite"
> >
<h2 class="wiki-login-sent-heading">Not a member yet</h2>
<p class="wiki-login-sent-detail">
<strong>{{ email }}</strong> isn't registered as a Ghost Guild
member. If you've pre-registered, an admin needs to invite you
before you can sign in.
</p>
<p class="wiki-login-sent-detail">
<a href="https://babyghosts.org/ghost-guild/" class="wiki-login-link"
>Pre-register at Baby Ghosts</a
>
or email
<a href="mailto:hello@babyghosts.org" class="wiki-login-link"
>hello@babyghosts.org</a
>
if you think this is a mistake.
</p>
<button class="wiki-login-reset" @click="resetForm">
Try a different email Try a different email
</button> </button>
</div> </div>
@ -172,8 +135,8 @@ function resetForm() {
.wiki-login-title { .wiki-login-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 36px; font-size: 32px;
font-weight: 600; font-weight: 700;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--candle); color: var(--candle);
@ -240,7 +203,7 @@ function resetForm() {
.wiki-login-sent-heading { .wiki-login-sent-heading {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 700;
color: var(--text-bright); color: var(--text-bright);
margin: 0; margin: 0;
} }
@ -257,16 +220,6 @@ function resetForm() {
font-weight: 600; font-weight: 600;
} }
.wiki-login-link {
color: var(--candle);
text-decoration: underline;
text-underline-offset: 2px;
}
.wiki-login-link:hover {
color: var(--candle-dim);
}
.wiki-login-reset { .wiki-login-reset {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 12px; font-size: 12px;

View file

@ -1,24 +1,21 @@
<template> <template>
<PageShell title="Bulletin Board" :subtitle="pageSubtitle"> <PageShell title="Bulletin Board" :subtitle="pageSubtitle">
<p class="page-intro"> <!-- Action bar -->
Make offers and requests related to shared interests and cooperative
topics.
</p>
<div class="action-bar"> <div class="action-bar">
<button
v-if="cooperativeTags.length > 0"
type="button"
class="drawer-btn"
@click="showTagsDrawer = !showTagsDrawer"
>
Tags...
<span v-if="activeTagFilter" class="tag-count-badge">1</span>
</button>
<button type="button" class="new-post-btn" @click="openNewForm"> <button type="button" class="new-post-btn" @click="openNewForm">
+ New Post + New Post
</button> </button>
</div> </div>
<!-- Tags Drawer Toggle -->
<div v-if="cooperativeTags.length > 0" class="tags-drawer-toggle">
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
Tags...
<span v-if="activeTagFilter" class="tag-count-badge">1</span>
</button>
</div>
<!-- Tags Drawer -->
<div v-if="showTagsDrawer && cooperativeTags.length > 0" class="tags-drawer"> <div v-if="showTagsDrawer && cooperativeTags.length > 0" class="tags-drawer">
<div class="skills-bar"> <div class="skills-bar">
<span class="tag-label">Filter:</span> <span class="tag-label">Filter:</span>
@ -43,6 +40,7 @@
</div> </div>
</div> </div>
<!-- Inline form -->
<div v-if="showForm" class="form-wrapper"> <div v-if="showForm" class="form-wrapper">
<BoardPostForm <BoardPostForm
:post="editingPost" :post="editingPost"
@ -52,6 +50,7 @@
/> />
</div> </div>
<!-- Content -->
<ClientOnly> <ClientOnly>
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<p>Loading board...</p> <p>Loading board...</p>
@ -74,11 +73,8 @@
:channels="channels" :channels="channels"
:tags="cooperativeTags" :tags="cooperativeTags"
:editable="isAuthor(post)" :editable="isAuthor(post)"
:pending-delete="pendingDeleteId === post._id"
@edit="handleEdit" @edit="handleEdit"
@delete="requestDelete" @delete="handleDelete"
@confirm-delete="confirmDelete"
@cancel-delete="cancelDelete"
/> />
</div> </div>
</template> </template>
@ -107,7 +103,6 @@ const activeTagFilter = ref(null)
const showForm = ref(false) const showForm = ref(false)
const editingPost = ref(null) const editingPost = ref(null)
const pendingDeleteId = ref(null)
const currentMemberId = computed(() => memberData.value?._id || null) const currentMemberId = computed(() => memberData.value?._id || null)
@ -149,18 +144,12 @@ const handleEdit = (post) => {
} }
} }
const requestDelete = (post) => { const handleDelete = async (post) => {
pendingDeleteId.value = post._id if (typeof window === 'undefined') return
} const ok = window.confirm('Delete this post? This cannot be undone.')
if (!ok) return
const cancelDelete = () => {
pendingDeleteId.value = null
}
const confirmDelete = async (post) => {
try { try {
await deletePost(post._id) await deletePost(post._id)
pendingDeleteId.value = null
} catch (err) { } catch (err) {
toast.add({ toast.add({
title: 'Failed to delete post', title: 'Failed to delete post',
@ -208,21 +197,11 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.page-intro {
padding: 12px 24px 0;
color: var(--text-dim);
font-size: 13px;
line-height: 1.65;
max-width: 640px;
}
.action-bar { .action-bar {
padding: 12px 24px; padding: 12px 24px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center;
gap: 12px;
} }
.new-post-btn { .new-post-btn {
@ -240,12 +219,12 @@ onMounted(async () => {
border-style: solid; border-style: solid;
background: rgba(154, 116, 32, 0.08); background: rgba(154, 116, 32, 0.08);
} }
.new-post-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
/* ---- TAGS DRAWER ---- */ /* ---- TAGS DRAWER ---- */
.tags-drawer-toggle {
padding: 8px 24px;
border-bottom: 1px dashed var(--border);
}
.drawer-btn { .drawer-btn {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;
@ -263,10 +242,6 @@ onMounted(async () => {
border-color: var(--candle-faint); border-color: var(--candle-faint);
color: var(--text); color: var(--text);
} }
.drawer-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.tag-count-badge { .tag-count-badge {
font-size: 9px; font-size: 9px;
background: var(--candle-faint); background: var(--candle-faint);
@ -313,11 +288,6 @@ onMounted(async () => {
color: var(--candle); color: var(--candle);
background: rgba(154, 116, 32, 0.08); background: rgba(154, 116, 32, 0.08);
} }
.skills-bar .skill-tag:focus-visible,
.more-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.more-btn { .more-btn {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 10px; font-size: 10px;
@ -357,13 +327,13 @@ onMounted(async () => {
/* ---- LOADING / EMPTY ---- */ /* ---- LOADING / EMPTY ---- */
.loading-state { .loading-state {
padding: 64px 24px; padding: 60px 24px;
text-align: center; text-align: center;
color: var(--text-faint); color: var(--text-faint);
font-size: 12px; font-size: 12px;
} }
.empty-state { .empty-state {
padding: 64px 24px; padding: 60px 24px;
text-align: center; text-align: center;
} }
.empty-title { .empty-title {
@ -385,11 +355,11 @@ onMounted(async () => {
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.action-bar { .tags-drawer-toggle {
padding: 12px 16px; padding: 8px 20px;
} }
.skills-bar { .skills-bar {
padding: 10px 16px; padding: 10px 20px;
} }
.post-grid, .post-grid,
.form-wrapper { .form-wrapper {

View file

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

View file

@ -1,377 +0,0 @@
<template>
<PageShell title="Community Guidelines" subtitle="What you're agreeing to when you join Ghost Guild">
<div class="guidelines-prose">
<section class="guidelines-section">
<h2>Welcome</h2>
<p>
Ghost Guild is a community for game workers exploring cooperative and
worker-centric models. By joining, you're becoming part of a growing
community of practice built on mutual support, shared learning, and
solidarity.
</p>
<p>
This page covers everything you're agreeing to as a member. Related
policies are linked throughout and are part of this agreement.
</p>
</section>
<section class="guidelines-section">
<h2>What Membership Means</h2>
<p>
Ghost Guild membership is about community and participation, not
access to hidden content. Every member gets the same access to
resources, events, and community spaces regardless of what they
contribute financially.
</p>
<p>
When you join Ghost Guild, you become a Class B member of Baby
Ghosts, our parent charity. Class A membership is held by a small
group involved in governance, mainly our directors. Class A and
Class B have equal access to resources, community, events, and the
Solidarity Fund. Voting at the Annual General Meeting is limited
to Class A members, as set out in our
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
</p>
<h3>The three circles</h3>
<p>
Our three membership circles describe where you are in your journey
with cooperative models. They're not a hierarchy.
</p>
<ul>
<li>
<strong>Community Circle:</strong> for folks learning about
cooperative principles
</li>
<li>
<strong>Founder Circle:</strong> for those actively building a
cooperative studio
</li>
<li>
<strong>Practitioner Circle:</strong> for experienced cooperative
studio leaders
</li>
</ul>
<p>
You can move between circles as your work and interests evolve. Just
reach out to the Membership Committee when you're ready.
</p>
<h3>Solidarity economics</h3>
<p>
We operate on a pay-what-you-can model. Your contribution is fully
decoupled from your circle. Members with more financial capacity help
make space for members with less.
</p>
<p>
If money is tight, choose the $0 option. If you have more capacity,
contributing at a higher tier supports others. You can adjust your
contribution anytime as your situation changes.
</p>
<p>
The Solidarity Fund is administered by the Membership Committee, and
its status is reported to the community each year.
</p>
</section>
<section class="guidelines-section">
<h2>Your Rights as a Member</h2>
<p>As a Ghost Guild member, you have:</p>
<ul>
<li>
Equal access to resources, events, community spaces, and the
Solidarity Fund, regardless of circle or contribution level
</li>
<li>Support from the Solidarity Fund if you face financial barriers</li>
<li>The ability to move between circles as your journey evolves</li>
<li>
Privacy protection in line with our
<NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink>
</li>
</ul>
</section>
<section class="guidelines-section">
<h2>Your Responsibilities as a Member</h2>
<p>As a Ghost Guild member, you commit to:</p>
<ol>
<li>
Upholding Baby Ghosts' and Gamma Space's shared values, including
cooperation, mutual support, and equity
</li>
<li>
Treating fellow members with care and following our
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
at all times
</li>
<li>
Participating within your capacity. This is a community of
practice. Show up in whatever way works for you.
</li>
<li>
Contributing dues in line with your ability, or working with the
Membership Committee to access the Solidarity Fund
</li>
<li>
Approaching disagreements with openness and using our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>
when conflicts arise
</li>
</ol>
<h3>Community privacy</h3>
<p>
Our community spaces, including our shared Slack workspace, operate
with an assumption of privacy. This means:
</p>
<ul>
<li>
Don't share screenshots, message content, or other community
content externally without the explicit consent of everyone
involved
</li>
<li>
Don't contribute community conversations, messages, or member
content to generative AI tools like ChatGPT or Claude. This
protects everyone's privacy and contributions.
</li>
<li>
Violations of these privacy norms can result in removal from the
community
</li>
</ul>
</section>
<section class="guidelines-section">
<h2>Contributing to the Commons</h2>
<p>
The Ghost Guild wiki at
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
knowledge commons. Anything you contribute to it is automatically and
irrevocably licensed under the
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>
(CC-BY-SA 4.0) at the moment you post it.
</p>
<p>In plain terms:</p>
<ul>
<li>You still hold the copyright to what you wrote</li>
<li>
Anyone (members, the public, other cooperatives, organizations
adapting the material) can use, share, adapt, and build on your
contribution, including for commercial purposes, as long as they
credit you and release their derivatives under the same license
</li>
<li>
You can't withdraw your contribution from the commons later, even
if you leave Ghost Guild
</li>
<li>
If wiki material gets republished elsewhere (like on
<a href="https://coop.love">coop.love</a>), it stays under
CC-BY-SA 4.0 and you stay credited
</li>
</ul>
<p>
This is how a knowledge commons works, and it's central to what Ghost
Guild is doing. If you have something you'd rather keep private or
under a more restrictive license, don't put it in the wiki.
</p>
<p>
Profile information, bulletin board posts, comments in member-only
spaces, and direct messages aren't part of the commons and stay under
your control. See our
<NuxtLink to="/policies/terms">Terms of Service</NuxtLink> for the
details.
</p>
</section>
<section class="guidelines-section">
<h2>Our Privacy Commitments</h2>
<p>
Your personal information is used to administer your membership and
to communicate with you about Ghost Guild.
</p>
<p>
We use a small number of third-party services to run the platform
(payment processing, email, hosting, analytics). Our
<NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink> lists who
they are and what they see.
</p>
<p>
We don't sell your data, share it for marketing, or feed any community
content into generative AI tools.
</p>
</section>
<section class="guidelines-section">
<h2>Membership Terms</h2>
<p>
Membership is valid for one year from joining or renewal. Dues can be
paid monthly or annually, and renewal happens by continuing dues
payments or arranging support through the Solidarity Fund.
</p>
<p>
You can adjust your contribution to any amount, including $0, at any
time. There's no minimum contribution to maintain membership in good
standing. A failed monthly payment doesn't end your membership. If a
payment doesn't go through, we'll reach out to work it out.
</p>
<p>
You can end your membership at any time by contacting the Membership
Committee. In rare cases, membership may be ended for serious
violations of these guidelines, following the process in our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
Dues are not refunded.
</p>
<p>
If you leave, your wiki contributions remain in the commons under
their CC-BY-SA 4.0 license. Your other personal information is handled
according to the retention rules in our
<NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink>.
</p>
</section>
<section class="guidelines-section">
<h2>Related Policies</h2>
<p>These policies are part of what you agree to by joining:</p>
<ul>
<li><NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink></li>
<li><NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink></li>
<li><NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink></li>
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
</ul>
</section>
<section class="guidelines-section">
<h2>Agreement</h2>
<p>
By joining Ghost Guild, you're confirming that you've read,
understood, and agree to these community guidelines and the policies
linked above.
</p>
<p class="welcome-line">Welcome to the community, Ghostie!</p>
</section>
</div>
</PageShell>
</template>
<script setup>
useHead({
title: 'Community Guidelines · Ghost Guild',
})
</script>
<style scoped>
.guidelines-prose {
max-width: 720px;
padding: 32px;
}
.guidelines-section {
padding: 28px 0;
border-bottom: 1px dashed var(--border);
}
.guidelines-section:last-child {
border-bottom: none;
}
.guidelines-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;
}
.guidelines-section h3 {
font-family: "Commit Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-bright);
margin: 20px 0 10px;
}
.guidelines-section p {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 12px;
}
.guidelines-section ul {
list-style: none;
padding: 0;
margin: 8px 0 14px;
}
.guidelines-section ul li {
position: relative;
padding: 2px 0 2px 16px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 4px;
}
.guidelines-section ul li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.guidelines-section ol {
list-style: none;
counter-reset: guideline-item;
padding: 0;
margin: 8px 0 14px;
}
.guidelines-section ol li {
counter-increment: guideline-item;
position: relative;
padding: 2px 0 2px 28px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 4px;
}
.guidelines-section ol li::before {
content: counter(guideline-item) ".";
position: absolute;
left: 0;
top: 2px;
width: 22px;
color: var(--candle-faint);
font-variant-numeric: tabular-nums;
text-align: right;
padding-right: 6px;
}
.guidelines-section a {
color: var(--candle);
}
.guidelines-section strong {
color: var(--text-bright);
font-weight: 600;
}
.welcome-line {
font-family: "Brygada 1918", serif;
font-style: italic;
color: var(--text-bright);
font-size: 16px;
margin-top: 12px;
}
@media (max-width: 640px) {
.guidelines-prose {
padding: 20px 16px;
}
}
</style>

View file

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

View file

@ -11,18 +11,9 @@
<!-- FILTER BAR --> <!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions"> <FilterBar v-model="activeFilter" :filters="filterOptions">
<button <label class="filter-toggle">
type="button" <input v-model="includePastEvents" type="checkbox" /> Show past events
class="past-toggle" </label>
:class="{ active: includePastEvents }"
:aria-pressed="includePastEvents"
@click="includePastEvents = !includePastEvents"
>
<span class="past-toggle-box" aria-hidden="true">
<span v-if="includePastEvents" class="past-toggle-check">×</span>
</span>
Show past events
</button>
</FilterBar> </FilterBar>
<!-- EVENT LIST --> <!-- EVENT LIST -->
@ -62,14 +53,6 @@
<span :class="{ 'seats-warn': isAlmostFull(event) }"> <span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats {{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span> </span>
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
>Sold out</span
>
<span
v-else-if="isAlmostFull(event)"
class="capacity-badge limited"
>Limited tickets</span
>
</template> </template>
<template v-else>Open</template> <template v-else>Open</template>
</span> </span>
@ -88,8 +71,8 @@
<div class="series-grid"> <div class="series-grid">
<NuxtLink <NuxtLink
v-for="series in activeSeries" v-for="series in activeSeries"
:key="series.id" :key="series._id"
:to="`/series/${series.id}`" :to="`/series/${series._id}`"
class="series-box" class="series-box"
> >
<h2>{{ series.title }}</h2> <h2>{{ series.title }}</h2>
@ -107,11 +90,6 @@
> >
</div> </div>
</NuxtLink> </NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div> </div>
</div> </div>
@ -133,8 +111,9 @@ const filterOptions = [
const { data: eventsData } = await useFetch("/api/events"); const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series"); const { data: seriesData } = await useFetch("/api/series");
const filteredEvents = computed(() => {
const now = new Date(); const now = new Date();
const filteredEvents = computed(() => {
if (!eventsData.value) return []; if (!eventsData.value) return [];
return eventsData.value.filter((event) => { return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now) if (!includePastEvents.value && new Date(event.startDate) < now)
@ -175,15 +154,9 @@ const formatLocation = (event) => {
return event.location; return event.location;
}; };
const isSoldOut = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) >= event.maxAttendees;
};
const isAlmostFull = (event) => { const isAlmostFull = (event) => {
if (!event.maxAttendees) return false; if (!event.maxAttendees) return false;
if (isSoldOut(event)) return false; return (event.registeredCount || 0) / event.maxAttendees > 0.8;
return (event.registeredCount || 0) / event.maxAttendees >= 0.8;
}; };
</script> </script>
@ -232,12 +205,8 @@ const isAlmostFull = (event) => {
.event-row:hover { .event-row:hover {
padding-left: 4px; padding-left: 4px;
} }
.event-row.is-cancelled .event-title a { .event-row.is-cancelled {
text-decoration: line-through; opacity: 0.5;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
} }
.event-date-col { .event-date-col {
@ -320,29 +289,10 @@ const isAlmostFull = (event) => {
color: var(--text-faint); color: var(--text-faint);
white-space: nowrap; white-space: nowrap;
padding-top: 2px; padding-top: 2px;
display: inline-flex;
align-items: center;
gap: 6px;
} }
.seats-warn { .seats-warn {
color: var(--ember); color: var(--ember);
} }
.capacity-badge {
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 1px 5px;
border: 1px dashed currentColor;
line-height: 1.5;
white-space: nowrap;
}
.capacity-badge.limited {
color: var(--ember);
}
.capacity-badge.sold-out {
color: var(--text-faint);
border-style: solid;
}
.event-badges { .event-badges {
display: flex; display: flex;
@ -376,21 +326,14 @@ const isAlmostFull = (event) => {
} }
.series-box { .series-box {
padding: 20px 24px; padding: 20px 24px;
border-right: 1px dashed var(--border);
text-decoration: none; text-decoration: none;
transition: background 0.15s; 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; border-right: none;
} }
.series-box:nth-last-child(-n + 2) { .series-box:hover {
border-bottom: none;
}
.series-box-filler {
pointer-events: none;
}
.series-box:not(.series-box-filler):hover {
background: var(--surface-hover); background: var(--surface-hover);
} }
.series-box h2 { .series-box h2 {
@ -415,47 +358,17 @@ const isAlmostFull = (event) => {
} }
.past-toggle { .filter-toggle {
display: inline-flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
margin-left: auto; margin-left: auto;
font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint); color: var(--text-faint);
background: transparent;
border: 1px dashed var(--border);
padding: 4px 10px;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
} }
.past-toggle:hover { .filter-toggle input {
border-color: var(--candle-faint); accent-color: var(--candle-dim);
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;
color: var(--candle);
}
.past-toggle-box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border: 1px solid currentColor;
flex-shrink: 0;
}
.past-toggle-check {
font-size: 12px;
line-height: 1;
color: var(--candle);
} }
.empty { .empty {
@ -486,17 +399,8 @@ const isAlmostFull = (event) => {
border-right: none; border-right: none;
border-bottom: 1px dashed var(--border); 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 { .series-box:last-child {
border-bottom: none; border-bottom: none;
} }
.series-box-filler {
display: none;
}
} }
</style> </style>

View file

@ -87,24 +87,18 @@
> >
From the Wiki From the Wiki
</div> </div>
<template v-if="hasCustomWikiFeature">
<h2>{{ wikiFeature.title || DEFAULT_WIKI_FEATURE_TITLE }}</h2>
<p v-for="(para, i) in customWikiParagraphs" :key="i">{{ para }}</p>
</template>
<template v-else>
<h2>What is a cooperative studio?</h2> <h2>What is a cooperative studio?</h2>
<p> <p>
A cooperative studio is a game development company owned and governed A cooperative studio is a game development company owned and governed by
by the people who work there. Decisions are made collectively. Profits the people who work there. Decisions are made collectively. Profits are
are shared according to contribution, not ownership stake. shared according to contribution, not ownership stake.
</p> </p>
<p> <p>
The games industry is full of stories about crunch, layoffs, and The games industry is full of stories about crunch, layoffs, and studios
studios that extract value from workers. Cooperatives are one that extract value from workers. Cooperatives are one alternative not
alternative not the only one, but one worth the only one, but one worth
<a href="https://wiki.ghostguild.org">practicing together</a>. <a href="https://wiki.ghostguild.org">practicing together</a>.
</p> </p>
</template>
<p> <p>
<a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a> <a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a>
</p> </p>
@ -127,23 +121,6 @@ const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
default: () => [], default: () => [],
}); });
const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
const { data: wikiFeature } = await useFetch(
"/api/site-content/homepage.wiki_feature",
{ default: () => ({ title: "", body: "" }) },
);
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
const customWikiParagraphs = computed(() => {
const body = wikiFeature.value?.body?.trim() || "";
return body
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
});
const circleData = [ const circleData = [
{ {
value: "community", value: "community",
@ -164,7 +141,7 @@ const circleData = [
label: "Practitioner", label: "Practitioner",
metaphor: "The alcove", metaphor: "The alcove",
blurb: 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

@ -32,7 +32,7 @@
<DashedBox :hoverable="false"> <DashedBox :hoverable="false">
<div class="section-label">Contribution</div> <div class="section-label">Contribution</div>
<div class="info-value"> <div class="info-value">
${{ memberData?.contributionAmount ?? 0 }} CAD/month ${{ memberData?.contributionTier || "0" }} CAD/month
</div> </div>
</DashedBox> </DashedBox>
</div> </div>
@ -59,43 +59,86 @@
<!-- Not authenticated: show full join page --> <!-- Not authenticated: show full join page -->
<template v-else> <template v-else>
<!-- HOW MEMBERSHIP WORKS -->
<ParchmentInset>
<h2>How membership works</h2>
<ul>
<li>
Full access to the knowledge commons, events, Slack community, and
peer support
</li>
<li>One member, one vote in all decisions</li>
<li>Your circle is where you are in your journey, not rank</li>
<li>
Your contribution is what you can afford ($0--50+/month, separate
from your circle)
</li>
<li>
Higher contributions create solidarity spots for those who need them
</li>
</ul>
</ParchmentInset>
<!-- THREE CIRCLES -->
<div class="content-row">
<div class="content-block">
<div class="section-label" style="color: var(--c-community)">
Community
</div>
<h2>Exploring</h2>
<p>
For game workers curious about cooperatives and people exploring
alternative work models. You might be a solo developer, a student, a
researcher, or just someone who heard about this and wants to know
more. Start here.
</p>
</div>
<div class="content-block">
<div class="section-label" style="color: var(--c-founder)">
Founder
</div>
<h2>Building</h2>
<p>
For people actively building cooperative studios. You have a team,
or you are forming one. You are working through governance, legal
structure, revenue sharing, and all the hard parts. You want
structured support and peers doing the same thing.
</p>
</div>
<div class="content-block">
<div class="section-label" style="color: var(--c-practitioner)">
Practitioner
</div>
<h2>Practicing</h2>
<p>
For those already running cooperative studios or with deep
experience in cooperative practice. You are here to teach, advise,
mentor, and help shape the program itself. Alumni.
</p>
</div>
</div>
<!-- CONTRIBUTION + SIGN UP (two columns) --> <!-- CONTRIBUTION + SIGN UP (two columns) -->
<div class="join-two-col"> <div v-if="currentStep === 1" class="join-two-col">
<!-- Left: Monthly Contribution --> <!-- Left: Monthly Contribution -->
<div class="join-col"> <div class="join-col">
<div class="section-label" style="margin-bottom: 12px"> <div class="section-label" style="margin-bottom: 12px">
{{ Monthly Contribution
cadence === "annual"
? "Annual Contribution"
: "Monthly Contribution"
}}
</div> </div>
<h2>Pay what you can</h2> <h2>Pay what you can</h2>
<ul class="tier-list"> <ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li> <li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">$5</span> I can contribute</li>
<li> <li>
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I <span class="tier-amt">$15</span> I can sustain the community
can contribute (suggested)
</li> </li>
<li><span class="tier-amt">$30</span> I can support others too</li>
<li> <li>
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I <span class="tier-amt">$50</span> I want to sponsor multiple
can sustain the community (suggested) members
</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
</li> </li>
</ul> </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.
</p>
<p class="solidarity-note"> <p class="solidarity-note">
Pay what you can. If you can pay more, you're making room for Pay what you can. If you can pay more, you're making room for
someone who can't. someone who can't.
@ -199,96 +242,24 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Billing Cadence</label> <label class="form-label" for="join-contribution"
<div class="cadence-radios"> >Monthly Contribution</label
<div class="circle-radio"> >
<input <select
id="cadence-monthly"
v-model="cadence"
type="radio"
name="cadence"
value="monthly"
/>
<label for="cadence-monthly">
<span class="circle-label-name">Per Month</span>
</label>
</div>
<div class="circle-radio">
<input
id="cadence-annual"
v-model="cadence"
type="radio"
name="cadence"
value="annual"
/>
<label for="cadence-annual">
<span class="circle-label-name">Per Year</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="join-contribution">
Monthly Contribution
</label>
<div class="contribution-input-row">
<span class="contribution-currency">$</span>
<input
id="join-contribution" id="join-contribution"
v-model.number="form.contributionAmount" v-model="form.contributionTier"
type="number" class="form-select"
min="0"
step="1"
inputmode="numeric"
class="contribution-input"
/>
</div>
<div
class="contribution-presets"
role="group"
aria-label="Suggested amounts"
> >
<button <option value="0">$0/mo -- I need support right now</option>
v-for="preset in CONTRIBUTION_PRESETS" <option value="5">$5/mo -- I can contribute</option>
:key="preset.amount" <option value="15">
type="button" $15/mo -- I can sustain the community (suggested)
class="contribution-preset-chip" </option>
@click="form.contributionAmount = preset.amount" <option value="30">$30/mo -- I can support others too</option>
> <option value="50">
${{ preset.amount }} $50/mo -- I want to sponsor multiple members
</button> </option>
</div> </select>
<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
>.
</p>
<p class="billing-summary-line">
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" />
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank"
>Community Guidelines</NuxtLink
>.
</span>
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<button <button
@ -303,7 +274,9 @@
</div> </div>
</div> </div>
<p class="form-note"> <p class="form-note">
You can change your circle or contribution at any time from your By joining you agree to our
<NuxtLink to="/guidelines">community guidelines</NuxtLink>. You
can change your circle or contribution at any time from your
dashboard. Payment is handled securely through dashboard. Payment is handled securely through
<a href="https://www.helcim.com" target="_blank" rel="noopener" <a href="https://www.helcim.com" target="_blank" rel="noopener"
>Helcim</a >Helcim</a
@ -313,72 +286,101 @@
</div> </div>
</div> </div>
<!-- HOW MEMBERSHIP WORKS --> <!-- Step 2: Payment -->
<ParchmentInset> <div v-if="currentStep === 2" class="form-section">
<h2>How membership works</h2> <h2>Payment Information</h2>
<ul> <p class="form-intro">
<li>Full access to the knowledge commons, events and workshops, and community</li> You're signing up for the {{ selectedTier.label }} plan -- ${{
<li>Free access to all Ghost Guild events</li> selectedTier.amount
<li>Equal access for every member, regardless of contribution</li> }}
<li>Your circle reflects where you are, not rank</li> CAD / month
<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> </p>
</ParchmentInset>
<!-- THREE CIRCLES --> <!-- Error Message -->
<div class="content-row"> <div v-if="errorMessage" class="error-box">
<div class="content-block"> {{ errorMessage }}
<div class="section-label" style="color: var(--c-community)">
Community
</div> </div>
<h2>Exploring</h2>
<p> <DashedBox :hoverable="false">
For game workers curious about cooperatives and people exploring <p class="payment-instruction">
alternative work models. You might be a solo developer, a student, a Click "Complete Payment" below to open the secure payment modal and
researcher, or just someone who heard about this and wants to know verify your payment method.
more. Start here.
</p> </p>
</DashedBox>
<div class="button-row" style="margin-top: 24px">
<button class="btn" :disabled="isSubmitting" @click="goBack">
Back
</button>
<button
class="form-submit"
:disabled="isSubmitting"
@click="processPayment"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else>Complete Payment</span>
</button>
</div> </div>
<div class="content-block">
<div class="section-label" style="color: var(--c-founder)">
Founder
</div> </div>
<h2>Building</h2>
<p> <!-- Step 3: Confirmation -->
For people actively building cooperative studios. You have a team, <div v-if="currentStep === 3" class="form-section">
or you are forming one. You are working through governance, legal <h2>Welcome to Ghost Guild!</h2>
structure, revenue sharing, and all the hard parts. You want
structured support and peers doing the same thing. <div v-if="successMessage" class="success-box">
{{ successMessage }}
</div>
<DashedBox :hoverable="false">
<div class="section-label" style="margin-bottom: 12px">
Membership Details
</div>
<dl class="details-list">
<div class="details-row">
<dt>Name</dt>
<dd>{{ form.name }}</dd>
</div>
<div class="details-row">
<dt>Email</dt>
<dd>{{ form.email }}</dd>
</div>
<div class="details-row">
<dt>Circle</dt>
<dd class="capitalize">{{ form.circle }}</dd>
</div>
<div class="details-row">
<dt>Contribution</dt>
<dd>{{ selectedTier.label }}</dd>
</div>
<div v-if="customerCode" class="details-row">
<dt>Member ID</dt>
<dd>{{ customerCode }}</dd>
</div>
</dl>
</DashedBox>
<p class="form-note" style="margin-top: 20px">
We've sent a confirmation email to {{ form.email }} with your
membership details.
</p> </p>
</div>
<div class="content-block"> <DashedBox :hoverable="false" style="margin-top: 16px">
<div class="section-label" style="color: var(--c-practitioner)"> <p class="redirect-note">
Practitioner You will be automatically redirected to your dashboard in a few
</div> seconds...
<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.
</p> </p>
</DashedBox>
<div class="button-row" style="margin-top: 24px">
<NuxtLink to="/member/dashboard" class="form-submit"
>Go to Dashboard Now</NuxtLink
>
<button class="btn" @click="resetForm">
Register Another Member
</button>
</div> </div>
</div> </div>
</template> </template>
<!-- Flow overlay: covers the page from form submit through redirect.
Lives outside v-if/v-else so it survives the auth state flip that
fires after checkMemberStatus() at the end of createSubscription. -->
<SignupFlowOverlay
:state="flowState"
:summary="flowSummary"
:error-message="errorMessage"
@close="closeFlowOverlay"
/>
</div> </div>
</template> </template>
@ -386,9 +388,9 @@
import { reactive, ref, computed, onMounted, onUnmounted } from "vue"; import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { getCircleOptions } from "~/config/circles"; import { getCircleOptions } from "~/config/circles";
import { import {
getContributionOptions,
requiresPayment, requiresPayment,
CONTRIBUTION_PRESETS, getContributionTierByValue,
getGuidanceLabel,
} from "~/config/contributions"; } from "~/config/contributions";
// Auth state // Auth state
@ -404,8 +406,7 @@ const form = reactive({
email: "", email: "",
name: "", name: "",
circle: "community", circle: "community",
contributionAmount: 15, contributionTier: "15",
agreedToGuidelines: false,
billingAddress: { billingAddress: {
street: "", street: "",
city: "", city: "",
@ -417,17 +418,9 @@ const form = reactive({
// UI state // UI state
const isSubmitting = ref(false); const isSubmitting = ref(false);
const currentStep = ref(1); // 1: Info, 2: Billing (paid only), 3: Payment, 4: Confirmation
const errorMessage = ref(""); const errorMessage = ref("");
const successMessage = ref(""); const successMessage = ref("");
const cadence = ref("monthly"); // 'monthly' | 'annual'
// Flow overlay state drives the post-submit full-viewport UI.
// 'idle' = overlay hidden; user is editing the form.
// 'creating-customer' | 'opening-payment' | 'processing-payment'
// | 'creating-subscription' = progress states, overlay shows a spinner + label.
// 'success' = overlay shows confirmation, auto-redirect is queued.
// 'error' = overlay shows error + Retry/Back buttons.
const flowState = ref("idle");
// Helcim state // Helcim state
const customerId = ref(null); const customerId = ref(null);
@ -438,12 +431,8 @@ const paymentToken = ref(null);
// Circle options from central config // Circle options from central config
const circleOptions = getCircleOptions(); const circleOptions = getCircleOptions();
const formatContributionAmount = (amount) => { // Contribution options from central config
if (!amount || amount === 0) return "$0"; const contributionOptions = getContributionOptions();
const display = cadence.value === "annual" ? amount * 12 : amount;
const suffix = cadence.value === "annual" ? "/yr" : "/mo";
return `$${display}${suffix}`;
};
// Initialize composables // Initialize composables
const { const {
@ -454,82 +443,88 @@ const {
// Form validation // Form validation
const isFormValid = computed(() => { const isFormValid = computed(() => {
return ( return form.name && form.email && form.circle && form.contributionTier;
form.name &&
form.email &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
}); });
// Check if payment is required // Check if payment is required
const needsPayment = computed(() => { const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount); return requiresPayment(form.contributionTier);
}); });
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); // Get selected tier info
const selectedTier = computed(() => {
const firstCharge = computed(() => { return getContributionTierByValue(form.contributionTier);
const amount = form.contributionAmount || 0;
return cadence.value === "annual" ? amount * 12 : amount;
}); });
const flowSummary = computed(() => ({ // Step 1: Create customer
name: form.name,
email: form.email,
circle: form.circle,
contribution: formatContributionAmount(form.contributionAmount),
}));
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting.value || !isFormValid.value) return; if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true; isSubmitting.value = true;
errorMessage.value = ""; errorMessage.value = "";
flowState.value = "creating-customer";
try { try {
// Create customer // Create customer in Helcim
const response = await $fetch("/api/helcim/customer", { const response = await $fetch("/api/helcim/customer", {
method: "POST", method: "POST",
body: { body: {
name: form.name, name: form.name,
email: form.email, email: form.email,
circle: form.circle, circle: form.circle,
contributionAmount: form.contributionAmount, contributionTier: form.contributionTier,
agreedToGuidelines: form.agreedToGuidelines,
billingAddress: form.billingAddress, billingAddress: form.billingAddress,
}, },
}); });
if (!response.success) { if (response.success) {
throw new Error("Failed to create account.");
}
customerId.value = response.customerId; customerId.value = response.customerId;
customerCode.value = response.customerCode; customerCode.value = response.customerCode;
// Free tier: no Helcim modal, go straight to subscription. // Token is now set as httpOnly cookie by the server
if (!needsPayment.value) { // No need to manually set cookie on client side
flowState.value = "creating-subscription";
await createSubscription();
return;
}
// Paid tier: initialize HelcimPay session, then auto-open modal. // Move to next step
flowState.value = "opening-payment"; if (needsPayment.value) {
currentStep.value = 2;
// Initialize HelcimPay.js session for card verification
await initializeHelcimPay(customerId.value, customerCode.value, 0); await initializeHelcimPay(customerId.value, customerCode.value, 0);
} else {
// For free tier, create subscription directly
await createSubscription();
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
const paymentResult = await verifyPayment(); // Automatically redirect to welcome page after a short delay
if (!paymentResult?.success) { setTimeout(() => {
throw new Error("Payment was not completed."); navigateTo("/welcome");
}, 3000); // 3 second delay to show success message
} }
}
} catch (error) {
console.error("Error creating customer:", error);
errorMessage.value =
error.data?.message || "Failed to create account. Please try again.";
} finally {
isSubmitting.value = false;
}
};
// Step 2: Process payment
const processPayment = async () => {
if (isSubmitting.value) return;
isSubmitting.value = true;
errorMessage.value = "";
try {
// Verify payment through HelcimPay.js
const paymentResult = await verifyPayment();
if (paymentResult.success) {
paymentToken.value = paymentResult.cardToken; paymentToken.value = paymentResult.cardToken;
flowState.value = "processing-payment"; // Verify payment on server
await $fetch("/api/helcim/verify-payment", { const verifyResult = await $fetch("/api/helcim/verify-payment", {
method: "POST", method: "POST",
body: { body: {
cardToken: paymentResult.cardToken, cardToken: paymentResult.cardToken,
@ -537,25 +532,26 @@ const handleSubmit = async () => {
}, },
}); });
flowState.value = "creating-subscription"; // Create subscription (don't let subscription errors prevent form progression)
const subscriptionResult = await createSubscription( const subscriptionResult = await createSubscription(
paymentResult.cardToken, paymentResult.cardToken,
); );
if (!subscriptionResult || subscriptionResult.success === false) { if (!subscriptionResult || !subscriptionResult.success) {
// Payment succeeded but subscription couldn't be created. console.warn(
// Keep overlay in success state; admin follow-up will reconcile. "Subscription creation failed but payment succeeded:",
subscriptionResult?.error,
);
// Still progress to success page since payment worked
currentStep.value = 3;
successMessage.value = successMessage.value =
"Payment successful. Subscription setup may need manual completion."; "Payment successful! Subscription setup may need manual completion.";
flowState.value = "success"; }
} }
} catch (error) { } catch (error) {
console.error("Join flow error:", error); console.error("Payment process error:", error);
errorMessage.value = errorMessage.value =
error.data?.message || error.message || "Payment verification failed. Please try again.";
error.message ||
"Something went wrong. Please try again.";
flowState.value = "error";
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
@ -569,20 +565,23 @@ const createSubscription = async (cardToken = null) => {
body: { body: {
customerId: customerId.value, customerId: customerId.value,
customerCode: customerCode.value, customerCode: customerCode.value,
contributionAmount: form.contributionAmount, contributionTier: form.contributionTier,
cadence: cadence.value,
cardToken: cardToken, cardToken: cardToken,
}, },
}); });
if (response.success) { if (response.success) {
subscriptionData.value = response.subscription; subscriptionData.value = response.subscription;
flowState.value = "success"; currentStep.value = 3;
successMessage.value = "Your membership is active."; successMessage.value = "Your membership is active.";
// Sign-in cookie is now issued by the email-verify magic link // Check member status to ensure user is properly authenticated
// (see /api/helcim/customer). Don't auto-navigate to a gated page await checkMemberStatus();
// the success state instructs the user to check their inbox.
// Automatically redirect to welcome page after a short delay
setTimeout(() => {
navigateTo("/welcome");
}, 3000); // 3 second delay to show success message
} else { } else {
throw new Error("Subscription creation failed - response not successful"); throw new Error("Subscription creation failed - response not successful");
} }
@ -606,9 +605,27 @@ const createSubscription = async (cardToken = null) => {
} }
}; };
const closeFlowOverlay = () => { // Go back to previous step
flowState.value = "idle"; const goBack = () => {
if (currentStep.value > 1) {
currentStep.value--;
errorMessage.value = ""; errorMessage.value = "";
}
};
// Reset form
const resetForm = () => {
currentStep.value = 1;
customerId.value = null;
customerCode.value = null;
subscriptionData.value = null;
paymentToken.value = null;
errorMessage.value = "";
successMessage.value = "";
form.email = "";
form.name = "";
form.circle = "community";
form.contributionTier = "15";
}; };
// Cleanup on unmount // Cleanup on unmount
@ -654,12 +671,11 @@ onUnmounted(() => {
position: relative; position: relative;
} }
:deep(.parchment-inset ul li::before) { :deep(.parchment-inset ul li::before) {
content: ""; content: "--";
position: absolute; position: absolute;
left: 0; left: 0;
color: var(--candle-faint); color: var(--candle-dim);
font-size: 14px; opacity: 0.5;
line-height: 1.4;
} }
.parchment-link { .parchment-link {
@ -751,7 +767,7 @@ onUnmounted(() => {
padding: 0; padding: 0;
} }
.tier-list li { .tier-list li {
padding: 4px 0; padding: 5px 0;
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
@ -773,13 +789,6 @@ onUnmounted(() => {
margin-top: 16px; margin-top: 16px;
} }
.charity-note {
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
margin-top: 16px;
}
/* ---- FORM SECTION ---- */ /* ---- FORM SECTION ---- */
.form-section { .form-section {
padding: 32px; padding: 32px;
@ -834,79 +843,6 @@ onUnmounted(() => {
color: var(--text-faint); color: var(--text-faint);
} }
/* ---- CADENCE RADIOS ---- */
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
.contribution-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.contribution-currency {
font-weight: 600;
}
.contribution-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: "Commit Mono", monospace;
font-size: 1rem;
}
.contribution-input:focus {
outline: none;
border-color: var(--candle);
}
.contribution-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.contribution-preset-chip {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: "Commit Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
}
.contribution-preset-chip:hover {
border-style: solid;
border-color: var(--candle);
}
.contribution-guidance {
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--ink-soft, currentColor);
}
/* ---- BILLING SUMMARY ---- */
.billing-summary {
padding: 12px 16px;
border: 1px dashed var(--border);
background: var(--surface);
}
.billing-summary-line {
font-size: 13px;
color: var(--text);
line-height: 1.5;
margin: 0;
}
.billing-summary-line + .billing-summary-line {
margin-top: 4px;
}
.billing-summary-line strong {
color: var(--text-bright);
font-weight: 600;
}
/* ---- CIRCLE RADIOS ---- */ /* ---- CIRCLE RADIOS ---- */
.circle-radios { .circle-radios {
display: grid; display: grid;
@ -1025,26 +961,6 @@ onUnmounted(() => {
color: var(--candle-dim); color: var(--candle-dim);
} }
/* ---- CHECKBOX ---- */
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.checkbox-label input {
margin-top: 3px;
flex-shrink: 0;
}
.checkbox-label a,
.checkbox-label :deep(a) {
color: var(--candle);
text-decoration: underline;
}
/* ---- ERROR & SUCCESS BOXES ---- */ /* ---- ERROR & SUCCESS BOXES ---- */
.error-box { .error-box {
border: 1px dashed var(--ember); border: 1px dashed var(--ember);
@ -1063,6 +979,26 @@ onUnmounted(() => {
max-width: 600px; max-width: 600px;
} }
/* ---- DETAILS LIST (confirmation) ---- */
.details-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.details-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
}
.details-row dt {
color: var(--text-faint);
}
.details-row dd {
color: var(--text-bright);
font-weight: 500;
}
/* ---- PAYMENT INSTRUCTION ---- */ /* ---- PAYMENT INSTRUCTION ---- */
.payment-instruction { .payment-instruction {
font-size: 13px; font-size: 13px;

View file

@ -34,7 +34,7 @@
<span <span
class="status-dot" class="status-dot"
:class="memberData.status || 'active'" :class="memberData.status || 'active'"
/> ></span>
<span>{{ <span>{{
formatStatus(memberData.status || "active") formatStatus(memberData.status || "active")
}}</span> }}</span>
@ -57,11 +57,9 @@
</div> </div>
<div class="membership-row"> <div class="membership-row">
<span class="membership-k">Contribution</span> <span class="membership-k">Contribution</span>
<span class="membership-v">{{ currentContributionLabel }}</span> <span class="membership-v"
</div> >${{ memberData.contributionTier || 0 }} / month</span
<div v-if="nextPaymentDate" class="membership-row"> >
<span class="membership-k">Next payment</span>
<span class="membership-v">{{ formatNextPaymentDate(nextPaymentDate) }}</span>
</div> </div>
<div class="membership-row"> <div class="membership-row">
<span class="membership-k">Member since</span> <span class="membership-k">Member since</span>
@ -72,89 +70,6 @@
</div> </div>
</PageSection> </PageSection>
<!-- PAYMENT HISTORY (shown when a paid plan is active OR past payments exist) -->
<PageSection
v-if="memberData.helcimCustomerId && ((memberData.contributionAmount || 0) > 0 || paymentHistory.length > 0)"
divider="top"
>
<div class="section-label">Payment history</div>
<div v-if="nextPaymentDate" class="next-charge">
<span class="next-charge-label">Next charge</span>
<span class="next-charge-value">${{ nextChargeAmount }} on {{ formatNextPaymentDate(nextPaymentDate) }}</span>
</div>
<div v-if="paymentHistoryLoading" class="history-card">
<div class="history-row history-state">Loading</div>
</div>
<div
v-else-if="paymentHistoryError"
class="history-card"
>
<div class="history-row history-state">
Payment history temporarily unavailable. Try again in a few minutes.
</div>
</div>
<div
v-else-if="paymentHistory.length === 0"
class="history-card"
>
<div class="history-row history-state">
No payments yet. Your first charge will appear here after your next billing cycle.
</div>
</div>
<div v-else class="history-card">
<div
v-for="txn in paymentHistory"
:key="txn.id"
class="history-row"
>
<span class="history-date">{{ formatTxnDate(txn.date) }}</span>
<span class="history-amount">{{ formatTxnAmount(txn.amount, txn.currency) }}</span>
<span
class="history-status"
:class="`status-${txn.status}`"
>{{ formatTxnStatus(txn.status) }}</span>
</div>
</div>
</PageSection>
<!-- CHANGE CARD (only for active subscriptions) -->
<PageSection
v-if="canChangeCard"
divider="top"
>
<div class="section-label">Change card</div>
<p class="change-card-hint">
Replace the card on file. Future charges will use the new card.
</p>
<button
class="btn btn-primary btn-section"
:disabled="isChangingCard"
@click="handleChangeCard"
>
{{ changeCardButtonLabel }}
</button>
</PageSection>
<!-- ADVANCED BILLING LINK (escape hatch) -->
<PageSection
v-if="helcimPortalUrl && memberData.helcimCustomerId"
divider="top"
>
<a
:href="helcimPortalUrl"
target="_blank"
rel="noopener"
class="billing-link"
>
Advanced billing in Helcim &rarr;
</a>
</PageSection>
<PageSection divider="top"> <PageSection divider="top">
<div class="section-label">Email</div> <div class="section-label">Email</div>
@ -169,26 +84,26 @@
<div class="field"> <div class="field">
<label>New email address</label> <label>New email address</label>
<input <input
v-model="newEmail"
type="email" type="email"
v-model="newEmail"
placeholder="you@example.com" placeholder="you@example.com"
autofocus
@keydown.enter="handleUpdateEmail" @keydown.enter="handleUpdateEmail"
@keydown.escape="cancelEmailEdit" @keydown.escape="cancelEmailEdit"
> autofocus
/>
</div> </div>
<div class="email-edit-actions"> <div class="email-edit-actions">
<button <button
class="btn btn-primary" class="btn btn-primary"
:disabled="isUpdatingEmail || !newEmail.trim()"
@click="handleUpdateEmail" @click="handleUpdateEmail"
:disabled="isUpdatingEmail || !newEmail.trim()"
> >
{{ isUpdatingEmail ? "Saving…" : "Save" }} {{ isUpdatingEmail ? "Saving…" : "Save" }}
</button> </button>
<button <button
class="btn" class="btn"
:disabled="isUpdatingEmail"
@click="cancelEmailEdit" @click="cancelEmailEdit"
:disabled="isUpdatingEmail"
> >
Cancel Cancel
</button> </button>
@ -204,12 +119,9 @@
<div class="section-label danger">Danger Zone</div> <div class="section-label danger">Danger Zone</div>
<div class="danger-zone"> <div class="danger-zone">
<p> <p>
Cancelling closes your account and ends access to member-only Cancelling your membership will immediately revoke access to
spaces, including Slack.<template v-if="(memberData.contributionAmount || 0) > 0"> If you're cancelling because of a member-only resources, events, and the Slack workspace.
money issue, the <strong>This action cannot be easily undone.</strong>
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
and the $0 tier are always available reach out before you
go.</template>
</p> </p>
<div v-if="showCancelConfirm" class="cancel-confirm"> <div v-if="showCancelConfirm" class="cancel-confirm">
<p class="cancel-confirm-prompt"> <p class="cancel-confirm-prompt">
@ -218,8 +130,8 @@
<div class="cancel-confirm-actions"> <div class="cancel-confirm-actions">
<button <button
class="btn btn-danger" class="btn btn-danger"
:disabled="isCancelling"
@click="confirmCancelMembership" @click="confirmCancelMembership"
:disabled="isCancelling"
> >
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }} {{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
</button> </button>
@ -231,8 +143,8 @@
<button <button
v-else v-else
class="btn btn-danger" class="btn btn-danger"
:disabled="isCancelling"
@click="handleCancelMembership" @click="handleCancelMembership"
:disabled="isCancelling"
> >
Cancel Membership Cancel Membership
</button> </button>
@ -245,45 +157,17 @@
<PageSection> <PageSection>
<div class="section-label">Change Contribution</div> <div class="section-label">Change Contribution</div>
<div class="form-group"> <TierPicker v-model="selectedTier" :tiers="tiers" />
<label class="form-label" for="account-contribution"> <div class="tier-hint">
Monthly Contribution Changes take effect on your next billing cycle
</label>
<div class="contribution-input-row">
<span class="contribution-currency">$</span>
<input
id="account-contribution"
v-model.number="form.contributionAmount"
type="number"
min="0"
step="1"
inputmode="numeric"
class="contribution-input"
>
</div>
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
<button
v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount"
type="button"
class="contribution-preset-chip"
@click="form.contributionAmount = preset.amount"
>
${{ preset.amount }}
</button>
</div>
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
</div>
<div v-if="contributionChangeHint" class="tier-hint">
{{ contributionChangeHint }}
</div> </div>
<button <button
class="btn btn-primary btn-section" class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled=" :disabled="
form.contributionAmount === Number(memberData.contributionAmount || 0) || selectedTier === Number(memberData.contributionTier || 0) ||
isUpdating isUpdating
" "
@click="handleUpdateContribution"
> >
{{ isUpdating ? "Updating…" : "Update Contribution" }} {{ isUpdating ? "Updating…" : "Update Contribution" }}
</button> </button>
@ -294,13 +178,12 @@
<CirclePicker <CirclePicker
v-model="selectedCircle" v-model="selectedCircle"
:saved-value="memberData.circle"
:circles="circleOptions" :circles="circleOptions"
/> />
<button <button
class="btn btn-primary btn-section" class="btn btn-primary btn-section"
:disabled="selectedCircle === memberData.circle || isUpdating"
@click="handleUpdateCircle" @click="handleUpdateCircle"
:disabled="selectedCircle === memberData.circle || isUpdating"
> >
{{ isUpdating ? "Updating…" : "Update Circle" }} {{ isUpdating ? "Updating…" : "Update Circle" }}
</button> </button>
@ -314,20 +197,15 @@
</template> </template>
<script setup> <script setup>
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
import { STATUS_LABELS } from '~/config/memberStatus';
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
}); });
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal(); const { openLoginModal } = useLoginModal();
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay();
const toast = useToast(); const toast = useToast();
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
const form = reactive({ contributionAmount: 0 }); const selectedTier = ref(0);
const selectedCircle = ref(""); const selectedCircle = ref("");
const isUpdating = ref(false); const isUpdating = ref(false);
const isCancelling = ref(false); const isCancelling = ref(false);
@ -337,68 +215,13 @@ const showEmailEdit = ref(false);
const newEmail = ref(""); const newEmail = ref("");
const isUpdatingEmail = ref(false); const isUpdatingEmail = ref(false);
// Payment history state const tiers = [
const paymentHistory = ref([]); { amount: 0, display: "$0", label: "I need support right now" },
const paymentHistoryLoading = ref(false); { amount: 5, display: "$5", label: "I can contribute" },
const paymentHistoryError = ref(false); { amount: 15, display: "$15", label: "I can sustain the community" },
const paymentHistoryLoaded = ref(false); { amount: 30, display: "$30", label: "I can support others too" },
{ amount: 50, display: "$50", label: "I want to sponsor multiple members" },
// Next payment (refreshed lazily from Helcim when cached date is stale) ];
const refreshedNextBillingDate = ref(null);
const nextBillingRefreshed = ref(false);
const nextPaymentDate = computed(() => {
const m = memberData.value;
if (!m) return null;
if (m.status !== 'active') return null;
if (!Number(m.contributionAmount)) return null;
return refreshedNextBillingDate.value || m.nextBillingDate || null;
});
// Change-card state
const isChangingCard = ref(false);
const changeCardButtonLabel = ref("Change card");
const canChangeCard = computed(() => {
const m = memberData.value;
if (!m) return false;
if (!m.helcimCustomerId) return false;
if (!["active", "pending_payment"].includes(m.status)) return false;
// $0 tier has no subscription to attach a card to
if (!requiresPayment(Number(m.contributionAmount || 0))) return false;
return true;
});
const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
const contributionChangeHint = computed(() => {
const current = Number(memberData.value?.contributionAmount || 0);
const next = Number(form.contributionAmount || 0);
if (current === next) return "";
if (current === 0 && next > 0) {
const firstCharge = cadence.value === "annual" ? next * 12 : next;
return `You'll be charged $${firstCharge} today to start your subscription.`;
}
if (current > 0 && next === 0) {
return "Your paid subscription will be cancelled.";
}
return "Changes apply on your next billing cycle.";
});
const currentContributionLabel = computed(() => {
const amount = Number(memberData.value?.contributionAmount || 0);
if (!amount) return '$0';
const displayAmount = cadence.value === 'annual' ? amount * 12 : amount;
return cadence.value === 'annual' ? `$${displayAmount} / year` : `$${displayAmount} / month`;
});
const nextChargeAmount = computed(() => {
const amount = Number(memberData.value?.contributionAmount || 0);
if (!amount) return null;
return cadence.value === 'annual' ? amount * 12 : amount;
});
const circleOptions = [ const circleOptions = [
{ {
@ -418,6 +241,13 @@ const circleOptions = [
}, },
]; ];
const STATUS_LABELS = {
active: "Active",
pending_payment: "Pending",
suspended: "Suspended",
cancelled: "Cancelled",
};
const formatStatus = (s) => STATUS_LABELS[s] || s; const formatStatus = (s) => STATUS_LABELS[s] || s;
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
@ -425,7 +255,7 @@ const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
// Initialize from member data // Initialize from member data
watchEffect(() => { watchEffect(() => {
if (memberData.value) { if (memberData.value) {
form.contributionAmount = Number(memberData.value.contributionAmount || 0); selectedTier.value = Number(memberData.value.contributionTier || 0);
selectedCircle.value = memberData.value.circle || "community"; selectedCircle.value = memberData.value.circle || "community";
} }
}); });
@ -438,71 +268,21 @@ const formatMemberSince = (dateStr) => {
}); });
}; };
const formatNextPaymentDate = (dateStr) => { const handleUpdateTier = async () => {
if (!dateStr) return "";
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
};
const STALE_WINDOW_MS = 24 * 60 * 60 * 1000;
const isNextBillingStale = (dateStr) => {
if (!dateStr) return true;
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return true;
return d.getTime() - Date.now() < STALE_WINDOW_MS;
};
const refreshNextBillingIfStale = async () => {
if (nextBillingRefreshed.value) return;
const m = memberData.value;
if (!m) return;
if (m.status !== 'active') return;
if (!Number(m.contributionAmount)) return;
if (!isNextBillingStale(m.nextBillingDate)) return;
nextBillingRefreshed.value = true;
try {
const response = await $fetch("/api/helcim/subscription");
const fresh = response?.subscription?.nextBillingDate;
if (fresh) refreshedNextBillingDate.value = fresh;
} catch (err) {
// Silent fall back to cached value (if any)
}
};
const handleUpdateContribution = async () => {
isUpdating.value = true; isUpdating.value = true;
try { try {
await $fetch("/api/members/update-contribution", { await $fetch("/api/members/update-contribution", {
method: "POST", method: "POST",
body: { body: { contributionTier: String(selectedTier.value) },
contributionAmount: form.contributionAmount,
cadence: cadence.value,
},
}); });
await checkMemberStatus(); await checkMemberStatus();
toast.add({ title: "Contribution updated", color: "success" }); toast.add({ title: "Contribution updated", color: "green" });
} catch (err) { } catch (err) {
// Paid upgrade without a saved card route to payment setup instead of erroring. selectedTier.value = Number(memberData.value?.contributionTier || 0);
if (err.data?.data?.requiresPaymentSetup) {
await navigateTo(
`/member/payment-setup?tier=${form.contributionAmount}&circle=${
selectedCircle.value || memberData.value?.circle || 'community'
}`,
);
return;
}
form.contributionAmount = Number(memberData.value?.contributionAmount || 0);
toast.add({ toast.add({
title: "Update failed", title: "Update failed",
description: err.data?.statusMessage || "Please try again.", description: err.data?.statusMessage || "Please try again.",
color: "error", color: "red",
}); });
} finally { } finally {
isUpdating.value = false; isUpdating.value = false;
@ -517,13 +297,13 @@ const handleUpdateCircle = async () => {
body: { circle: selectedCircle.value }, body: { circle: selectedCircle.value },
}); });
await checkMemberStatus(); await checkMemberStatus();
toast.add({ title: "Circle updated", color: "success" }); toast.add({ title: "Circle updated", color: "green" });
} catch (err) { } catch (err) {
selectedCircle.value = memberData.value?.circle || "community"; selectedCircle.value = memberData.value?.circle || "community";
toast.add({ toast.add({
title: "Update failed", title: "Update failed",
description: err.data?.statusMessage || "Please try again.", description: err.data?.statusMessage || "Please try again.",
color: "error", color: "red",
}); });
} finally { } finally {
isUpdating.value = false; isUpdating.value = false;
@ -546,163 +326,18 @@ const handleUpdateEmail = async () => {
}); });
await checkMemberStatus(); await checkMemberStatus();
cancelEmailEdit(); cancelEmailEdit();
toast.add({ title: "Email updated", color: "success" }); toast.add({ title: "Email updated", color: "green" });
} catch (err) { } catch (err) {
toast.add({ toast.add({
title: "Update failed", title: "Update failed",
description: err.data?.statusMessage || "Please try again.", description: err.data?.statusMessage || "Please try again.",
color: "error", color: "red",
}); });
} finally { } finally {
isUpdatingEmail.value = false; isUpdatingEmail.value = false;
} }
}; };
// Payment history
const loadPaymentHistory = async () => {
if (paymentHistoryLoaded.value) return;
if (!memberData.value?.helcimCustomerId) return;
paymentHistoryLoading.value = true;
paymentHistoryError.value = false;
try {
const response = await $fetch("/api/helcim/payment-history");
if (response?.error === "unavailable") {
paymentHistoryError.value = true;
paymentHistory.value = [];
} else {
paymentHistory.value = Array.isArray(response?.transactions)
? response.transactions
: [];
}
} catch (err) {
paymentHistoryError.value = true;
paymentHistory.value = [];
} finally {
paymentHistoryLoading.value = false;
paymentHistoryLoaded.value = true;
}
};
onMounted(() => {
if (memberData.value?.helcimCustomerId) {
loadPaymentHistory();
}
refreshNextBillingIfStale();
});
watch(
() => memberData.value?.helcimCustomerId,
(id) => {
if (id && !paymentHistoryLoaded.value) {
loadPaymentHistory();
}
},
);
watch(
() => memberData.value?.status,
() => {
refreshNextBillingIfStale();
},
);
const formatTxnDate = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
};
const formatTxnAmount = (amount, currency) => {
const num = Number(amount) || 0;
const cur = currency ? ` ${currency}` : "";
return `$${num.toFixed(2)}${cur}`;
};
const STATUS_BADGE = {
paid: "Paid",
refunded: "Refunded",
failed: "Failed",
other: "—",
};
const formatTxnStatus = (s) => STATUS_BADGE[s] || "—";
// Change card
const handleChangeCard = async () => {
if (isChangingCard.value) return;
if (!canChangeCard.value) return;
isChangingCard.value = true;
changeCardButtonLabel.value = "Opening…";
try {
// Fetch current customer id + code
const customerResponse = await $fetch("/api/helcim/customer-code");
const customerId = customerResponse?.customerId;
const customerCode = customerResponse?.customerCode;
if (!customerId || !customerCode) {
throw new Error("Could not locate customer record");
}
await initializeHelcimPay(customerId, customerCode, 0);
let paymentResult;
try {
paymentResult = await verifyPayment();
} catch (cancelOrFailure) {
// User cancelled or iframe failed no server call
return;
}
if (!paymentResult?.success || !paymentResult?.cardToken) {
return;
}
changeCardButtonLabel.value = "Updating…";
try {
await $fetch("/api/helcim/update-card", {
method: "POST",
body: { cardToken: paymentResult.cardToken },
});
toast.add({
title: "Card updated",
description: "Future charges will use your new card.",
color: "success",
});
} catch (err) {
console.error("[change-card] update failed", err);
toast.add({
title: "Could not update card",
description: "Please try again.",
color: "error",
actions: [
{
label: "Retry",
onClick: () => handleChangeCard(),
},
],
});
}
} catch (err) {
console.error("[change-card] flow failed", err);
toast.add({
title: "Could not update card",
description: "Please try again.",
color: "error",
});
} finally {
cleanupHelcimPay();
isChangingCard.value = false;
changeCardButtonLabel.value = "Change card";
}
};
const showCancelConfirm = ref(false); const showCancelConfirm = ref(false);
const handleCancelMembership = () => { const handleCancelMembership = () => {
@ -724,13 +359,13 @@ const confirmCancelMembership = async () => {
color: "neutral", color: "neutral",
}); });
} else { } else {
toast.add({ title: "Membership cancelled", color: "warning" }); toast.add({ title: "Membership cancelled", color: "orange" });
} }
} catch (err) { } catch (err) {
toast.add({ toast.add({
title: "Cancellation failed", title: "Cancellation failed",
description: err.data?.statusMessage || "Please try again.", description: err.data?.statusMessage || "Please try again.",
color: "error", color: "red",
}); });
} finally { } finally {
isCancelling.value = false; isCancelling.value = false;
@ -888,136 +523,9 @@ const confirmCancelMembership = async () => {
margin-bottom: 12px; margin-bottom: 12px;
} }
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
.contribution-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.contribution-currency {
font-weight: 600;
}
.contribution-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
outline: none;
border-color: var(--candle);
}
.contribution-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.contribution-preset-chip {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
.contribution-preset-chip:hover {
border-style: solid;
border-color: var(--candle);
}
.contribution-guidance {
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--ink-soft, currentColor);
}
.btn-section { .btn-section {
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.billing-link {
display: inline-block;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint);
text-decoration: none;
}
.billing-link:hover {
color: var(--candle);
text-decoration: underline;
}
/* ---- NEXT CHARGE ---- */
.next-charge {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0 12px;
align-items: center;
padding: 10px 20px;
font-size: 12px;
border: 1px dashed var(--candle);
margin-bottom: 12px;
}
.next-charge-label {
color: var(--text-faint);
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 11px;
}
.next-charge-value {
color: var(--text);
}
/* ---- PAYMENT HISTORY ---- */
.history-card {
border: 1px dashed var(--border);
padding: 0;
margin-bottom: 12px;
}
.history-row {
display: grid;
grid-template-columns: 120px 1fr auto;
gap: 0 12px;
align-items: center;
padding: 10px 20px;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.history-row:last-child {
border-bottom: none;
}
.history-date {
color: var(--text-faint);
}
.history-amount {
color: var(--text);
}
.history-status {
color: var(--text-faint);
font-size: 11px;
letter-spacing: 0.04em;
}
.history-status.status-failed {
color: var(--ember);
}
.history-state {
color: var(--text-faint);
font-style: italic;
display: block;
grid-template-columns: 1fr;
}
/* ---- CHANGE CARD ---- */
.change-card-hint {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 12px;
line-height: 1.6;
}
</style> </style>

View file

@ -0,0 +1,332 @@
<template>
<PageShell
title="Activity Log"
subtitle="Your recent activity"
>
<ColumnsLayout cols="events-sidebar" :limit="5">
<ClientOnly>
<!-- Loading State -->
<div v-if="loading && !entries.length" class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading activity...</p>
</div>
<!-- Timeline -->
<div v-else-if="entries.length" class="timeline-wrap">
<div class="timeline">
<div v-for="entry in entries" :key="entry._id" class="tl-item">
<div class="tl-dot">
<UIcon :name="getActivity(entry).icon" class="tl-icon" />
</div>
<div class="tl-time">{{ formatDate(entry.timestamp) }}</div>
<div class="tl-text">
<template v-if="getActivity(entry).link">
<span>{{ getActivity(entry).text.split(getActivity(entry).linkText)[0] }}</span>
<NuxtLink :to="getActivity(entry).link" class="tl-link">{{ getActivity(entry).linkText }}</NuxtLink>
</template>
<span v-else>{{ getActivity(entry).text }}</span>
<span v-if="entry.performedBy" class="tl-admin-badge">admin</span>
</div>
<!-- Email body expandable -->
<div v-if="entry.type === 'email_sent' && getActivity(entry).emailBody" class="tl-email">
<button class="tl-email-toggle" @click="toggleEmail(entry._id)">
{{ expandedEmails[entry._id] ? 'Hide email' : 'View email' }}
</button>
<div v-if="expandedEmails[entry._id]" class="dashed-box tl-email-body">
<pre>{{ getActivity(entry).emailBody }}</pre>
</div>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button class="btn" :disabled="loadingMore" @click="loadMore">
{{ loadingMore ? 'Loading...' : 'Load More' }}
</button>
</div>
</div>
<!-- Empty State -->
<div v-else class="state-box">
<div class="state-icon">
<UIcon name="i-lucide-activity" />
</div>
<h2 class="state-heading">No activity yet</h2>
<p class="state-text">Your activity will appear here as you use the Guild</p>
</div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading activity...</p>
</div>
</template>
</ClientOnly>
</ColumnsLayout>
</PageShell>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
const entries = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const nextCursor = ref(null)
const expandedEmails = ref({})
const getActivity = (entry) => formatActivity(entry)
const toggleEmail = (id) => {
expandedEmails.value[id] = !expandedEmails.value[id]
}
const formatDate = (date) => {
const now = new Date()
const d = new Date(date)
const diffInSeconds = Math.floor((now - d) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
const loadEntries = async () => {
loading.value = true
try {
const data = await $fetch('/api/members/me/activity', {
params: { limit: 20 }
})
entries.value = data.entries
hasMore.value = data.hasMore
nextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
loading.value = false
}
}
const loadMore = async () => {
if (!nextCursor.value) return
loadingMore.value = true
try {
const data = await $fetch('/api/members/me/activity', {
params: { limit: 20, before: nextCursor.value }
})
entries.value.push(...data.entries)
hasMore.value = data.hasMore
nextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
loadingMore.value = false
}
}
onMounted(loadEntries)
useHead({ title: 'Activity Log - Ghost Guild' })
</script>
<style scoped>
/* ---- STATE BOXES ---- */
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 32px;
text-align: center;
}
.state-icon {
width: 48px;
height: 48px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-faint);
font-size: 20px;
}
.state-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 6px;
}
.state-text {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 20px;
max-width: 320px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- TIMELINE ---- */
.timeline-wrap {
padding: 24px 32px 48px;
}
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 1px;
border-left: 1px dashed var(--border);
}
.tl-item {
position: relative;
padding: 0 0 24px;
}
.tl-item:last-child {
padding-bottom: 0;
}
.tl-dot {
position: absolute;
left: -32px;
top: 2px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px dashed var(--border);
font-size: 12px;
color: var(--text-dim);
}
.tl-icon {
width: 12px;
height: 12px;
}
.tl-time {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 2px;
}
.tl-text {
font-size: 13px;
color: var(--text);
line-height: 1.5;
display: flex;
align-items: baseline;
gap: 4px;
flex-wrap: wrap;
}
.tl-link {
color: var(--candle);
text-decoration: none;
font-weight: 500;
}
.tl-link:hover {
text-decoration: underline;
}
.tl-admin-badge {
font-size: 10px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 5px;
margin-left: 4px;
}
/* ---- EMAIL EXPANDABLE ---- */
.tl-email {
margin-top: 4px;
}
.tl-email-toggle {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
}
.tl-email-toggle:hover {
color: var(--candle);
}
.tl-email-body {
margin-top: 6px;
padding: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
max-height: 300px;
overflow-y: auto;
}
.tl-email-body pre {
white-space: pre-wrap;
word-break: break-word;
font-family: 'Commit Mono', monospace;
font-size: 12px;
margin: 0;
}
/* ---- LOAD MORE ---- */
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.timeline-wrap {
padding: 20px 20px 40px;
}
.state-box {
padding: 48px 20px;
}
}
</style>

View file

@ -33,15 +33,11 @@
<MemberStatusBanner /> <MemberStatusBanner />
<!-- Welcome Header --> <!-- Welcome Header -->
<PageHeader :title="welcomeTitle"> <PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
<div class="dashboard-meta"> <div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" /> <CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span> <span>${{ memberData?.contributionTier }} CAD/mo</span>
</div> </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> </PageHeader>
<!-- Upcoming Events + Quick Actions --> <!-- Upcoming Events + Quick Actions -->
@ -94,8 +90,8 @@
<strong>How to Subscribe to Your Calendar</strong> <strong>How to Subscribe to Your Calendar</strong>
<button <button
type="button" type="button"
class="ci-close"
@click="showCalendarInstructions = false" @click="showCalendarInstructions = false"
class="ci-close"
> >
&times; &times;
</button> </button>
@ -173,7 +169,7 @@
<div class="membership-row"> <div class="membership-row">
<span class="key">Contribution</span> <span class="key">Contribution</span>
<span class="val" <span class="val"
>${{ memberData?.contributionAmount ?? 0 }} CAD/month</span >${{ memberData?.contributionTier }} CAD/month</span
> >
</div> </div>
<div class="membership-row"> <div class="membership-row">
@ -196,14 +192,14 @@
</div> </div>
<div class="content-block"> <div class="content-block">
<div class="section-label">Bulletin Board</div> <div class="section-label">Community</div>
<DashedBox> <DashedBox>
<p class="peer-text"> <p class="peer-text">
Make offers and requests related to shared interests and Connect with other members through shared interests and
cooperative topics. cooperative topics.
</p> </p>
<NuxtLink to="/board" class="section-link"> <NuxtLink to="/board" class="section-link">
Browse the Bulletin Board &rarr; Browse the board &rarr;
</NuxtLink> </NuxtLink>
</DashedBox> </DashedBox>
</div> </div>
@ -225,19 +221,6 @@
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
useMemberStatus(); useMemberStatus();
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
? `Welcome to Ghost Guild, ${name}`
: `Welcome back, ${name}`;
});
const { completePayment, isProcessingPayment } = useMemberPayment(); const { completePayment, isProcessingPayment } = useMemberPayment();
const { trackGoal, isComplete: onboardingComplete } = useOnboarding(); const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
@ -476,13 +459,6 @@ useHead({
margin-top: 8px; margin-top: 8px;
} }
.slack-coming-note {
margin-top: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
}
.content-row { .content-row {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View file

@ -1,224 +0,0 @@
<template>
<PageShell>
<ClientOnly>
<PageHeader
title="Set Up Payment"
:subtitle="targetAmount != null ? `Upgrading to $${targetAmount}/month` : 'Payment setup'"
/>
<PageSection>
<div v-if="step === 'loading'" class="status-block">
<p>Preparing payment setup</p>
</div>
<div v-else-if="step === 'error'" class="status-block">
<div class="error-box">{{ errorMessage }}</div>
<div class="button-row">
<button class="btn" @click="initialize">Try again</button>
<NuxtLink to="/member/account" class="btn">Back to account</NuxtLink>
</div>
</div>
<div v-else-if="step === 'ready'" class="status-block">
<p>
To upgrade to <strong>${{ targetAmount }}/month</strong>, we need a
payment method on file. Click below to open the secure payment
form we'll verify your card with a $0 authorization and then
activate your new tier.
</p>
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
<div class="button-row">
<button
class="btn btn-primary"
:disabled="isProcessing"
@click="openModal"
>
{{ isProcessing ? 'Processing…' : 'Enter payment details' }}
</button>
<NuxtLink to="/member/account" class="btn">Cancel</NuxtLink>
</div>
</div>
<div v-else-if="step === 'success'" class="status-block">
<p>Payment setup complete. Redirecting to your account</p>
</div>
</PageSection>
</ClientOnly>
</PageShell>
</template>
<script setup>
definePageMeta({ middleware: 'auth' });
const route = useRoute();
const router = useRouter();
const toast = useToast();
const { memberData, checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcim } = useHelcimPay();
const VALID_CIRCLES = ['community', 'founder', 'practitioner'];
const targetAmount = computed(() => {
const n = Number(route.query.tier);
return Number.isInteger(n) && n > 0 ? n : null;
});
const targetCircle = computed(() => {
const c = String(route.query.circle || '');
return VALID_CIRCLES.includes(c) ? c : null;
});
const step = ref('loading'); // loading | ready | success | error
const errorMessage = ref('');
const isProcessing = ref(false);
const customerId = ref('');
const customerCode = ref('');
const hasExistingCard = ref(false);
const initialize = async () => {
errorMessage.value = '';
step.value = 'loading';
if (targetAmount.value == null) {
errorMessage.value = 'Missing or invalid target amount.';
step.value = 'error';
return;
}
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;
});
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);
errorMessage.value =
err.data?.statusMessage || err.message || 'Failed to initialize payment.';
step.value = 'error';
}
};
const openModal = async () => {
if (isProcessing.value) return;
isProcessing.value = true;
errorMessage.value = '';
try {
if (!hasExistingCard.value) {
const result = await verifyPayment();
if (!result?.success) throw new Error('Payment was not completed.');
await $fetch('/api/helcim/verify-payment', {
method: 'POST',
body: {
cardToken: result.cardToken,
customerId: customerId.value,
},
});
}
// Update circle first if it changed update-contribution only touches tier.
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
await $fetch('/api/members/update-circle', {
method: 'POST',
body: { circle: targetCircle.value },
});
}
await $fetch('/api/members/update-contribution', {
method: 'POST',
// cadence: annual upgrades go through /join; this page is monthly-only
body: { contributionAmount: targetAmount.value, cadence: 'monthly' },
});
await checkMemberStatus();
step.value = 'success';
toast.add({ title: 'Payment method saved', color: 'success' });
setTimeout(() => router.push('/member/account'), 1500);
} catch (err) {
console.error('Payment setup error:', err);
errorMessage.value =
err.data?.statusMessage || err.message || 'Payment setup failed.';
// Re-initialize Helcim session so the user can try again.
cleanupHelcim();
await initialize();
} finally {
isProcessing.value = false;
}
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupHelcim();
});
useHead({ title: 'Set Up Payment - Ghost Guild' });
</script>
<style scoped>
.status-block {
padding: 12px 0;
font-size: 13px;
line-height: 1.6;
color: var(--text);
}
.status-block p {
margin-bottom: 16px;
}
.error-box {
padding: 12px 14px;
border: 1px dashed var(--ember);
color: var(--ember);
font-size: 12px;
margin-bottom: 16px;
}
.button-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
</style>

View file

@ -25,13 +25,12 @@
<template v-else> <template v-else>
<!-- PAGE HEADER --> <!-- PAGE HEADER -->
<PageHeader title="Edit Profile"> <PageHeader
title="Edit Profile"
subtitle="How you appear to other members"
>
<NuxtLink <NuxtLink
v-if=" v-if="memberId && memberData?.status === MEMBER_STATUSES.ACTIVE && formData.showInDirectory"
memberId &&
memberData?.status === MEMBER_STATUSES.ACTIVE &&
formData.showInDirectory
"
:to="`/members/${memberId}`" :to="`/members/${memberId}`"
class="view-profile-link" class="view-profile-link"
> >
@ -51,33 +50,28 @@
type="text" type="text"
placeholder="Your name" placeholder="Your name"
required required
> />
</div> </div>
<div class="row-2">
<div class="field"> <div class="field">
<label>Pronouns</label> <label>Pronouns</label>
<input <input
v-model="formData.pronouns" v-model="formData.pronouns"
type="text" type="text"
placeholder="e.g., she/her, they/them" placeholder="e.g., she/her, they/them"
> />
<PrivacyToggle v-model="formData.pronounsPrivacy" />
</div> </div>
<div class="field"> <div class="field">
<label>Timezone</label> <label>Timezone</label>
<USelectMenu <input
v-model="formData.timeZone" v-model="formData.timeZone"
:items="timezoneItems" type="text"
value-key="value" placeholder="e.g., America/Toronto"
searchable
searchable-placeholder="Search timezones..."
placeholder="Select a timezone"
class="timezone-select"
:ui="{
content: 'tz-content',
item: 'tz-item',
input: 'tz-input',
}"
/> />
<PrivacyToggle v-model="formData.timeZonePrivacy" />
</div>
</div> </div>
<div class="field"> <div class="field">
@ -92,9 +86,10 @@
:title="ghost.label" :title="ghost.label"
@click="formData.avatar = ghost.value" @click="formData.avatar = ghost.value"
> >
<img :src="ghost.image" :alt="ghost.label" > <img :src="ghost.image" :alt="ghost.label" />
</button> </button>
</div> </div>
<PrivacyToggle v-model="formData.avatarPrivacy" />
</div> </div>
</PageSection> </PageSection>
@ -108,7 +103,8 @@
v-model="formData.studio" v-model="formData.studio"
type="text" type="text"
placeholder="Studio name" placeholder="Studio name"
> />
<PrivacyToggle v-model="formData.studioPrivacy" />
</div> </div>
<div class="field"> <div class="field">
<label>Location</label> <label>Location</label>
@ -116,7 +112,8 @@
v-model="formData.location" v-model="formData.location"
type="text" type="text"
placeholder="Toronto, ON" placeholder="Toronto, ON"
> />
<PrivacyToggle v-model="formData.locationPrivacy" />
</div> </div>
</div> </div>
@ -127,10 +124,11 @@
rows="4" rows="4"
placeholder="Share your background, interests, and experience..." placeholder="Share your background, interests, and experience..."
maxlength="300" maxlength="300"
/> ></textarea>
<div class="char-count"> <div class="char-count">
{{ formData.bio?.length || 0 }} / 300 {{ formData.bio?.length || 0 }} / 300
</div> </div>
<PrivacyToggle v-model="formData.bioPrivacy" />
</div> </div>
<div class="field"> <div class="field">
@ -140,6 +138,7 @@
:tags="craftTags" :tags="craftTags"
@suggest="openTagSuggest('craft')" @suggest="openTagSuggest('craft')"
/> />
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
</div> </div>
</PageSection> </PageSection>
@ -154,8 +153,7 @@
<div class="toggle-label"> <div class="toggle-label">
Show in Member Directory Show in Member Directory
<span class="toggle-sub" <span class="toggle-sub"
>Your profile will appear in the private member >Your profile will appear in the public member listing</span
directory.</span
> >
</div> </div>
</div> </div>
@ -167,12 +165,12 @@
<div class="section-label">Board</div> <div class="section-label">Board</div>
<div class="field"> <div class="field">
<label>Gamma Space Slack Handle</label> <label>Slack Handle</label>
<input <input
v-model="formData.boardSlackHandle" v-model="formData.boardSlackHandle"
type="text" type="text"
placeholder="@yourslackname" placeholder="@yourslackname"
> />
<div class="field-help"> <div class="field-help">
Shown on your board posts so other members can reach out. Shown on your board posts so other members can reach out.
</div> </div>
@ -180,9 +178,7 @@
<div class="posts-header"> <div class="posts-header">
<div class="posts-heading">Your Posts</div> <div class="posts-heading">Your Posts</div>
<NuxtLink to="/board" class="posts-new-link" <NuxtLink to="/board" class="posts-new-link">+ New Post</NuxtLink>
>+ New Post</NuxtLink
>
</div> </div>
<div v-if="myPosts.length === 0" class="posts-empty"> <div v-if="myPosts.length === 0" class="posts-empty">
@ -231,44 +227,6 @@
</div> </div>
</div> </div>
</PageSection> </PageSection>
<PageSection divider="top">
<div class="section-label">Recent Activity</div>
<div v-if="activityLoading" class="activity-empty">
Loading activity
</div>
<ul v-else-if="recentActivity.length" class="activity-list">
<li
v-for="entry in recentActivity"
:key="entry._id"
class="activity-item"
>
<div class="activity-time">
{{ formatActivityTime(entry.timestamp) }}
</div>
<div class="activity-text">
<template v-if="formatActivity(entry).link">
<span>{{
formatActivity(entry).text.split(
formatActivity(entry).linkText,
)[0]
}}</span>
<NuxtLink
:to="formatActivity(entry).link"
class="activity-link"
>
{{ formatActivity(entry).linkText }}
</NuxtLink>
</template>
<span v-else>{{ formatActivity(entry).text }}</span>
</div>
</li>
</ul>
<div v-else class="activity-empty">
Your activity will appear here as you use the Guild.
</div>
</PageSection>
</template> </template>
</ColumnsLayout> </ColumnsLayout>
@ -284,6 +242,12 @@
<button type="button" class="btn" @click="resetForm"> <button type="button" class="btn" @click="resetForm">
Reset Changes Reset Changes
</button> </button>
<span v-if="saveSuccess" class="save-msg save-msg-ok"
>Profile updated.</span
>
<span v-if="saveError" class="save-msg save-msg-err">{{
saveError
}}</span>
</div> </div>
</template> </template>
@ -294,20 +258,15 @@
</template> </template>
</ClientOnly> </ClientOnly>
<TagSuggestModal <TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
v-model:open="showTagSuggestModal"
:pool="tagSuggestPool"
/>
</PageShell> </PageShell>
</template> </template>
<script setup> <script setup>
import { MEMBER_STATUSES } from "~/composables/useMemberStatus"; import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { formatActivity } from "~/utils/activityText";
definePageMeta({ definePageMeta({
middleware: "auth", middleware: 'auth',
}); });
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
@ -316,62 +275,17 @@ const { posts: myPosts, fetchPosts, deletePost } = useBoardPosts();
const toast = useToast(); const toast = useToast();
const availableGhosts = [ const availableGhosts = [
{ { value: "disbelieving", label: "Disbelieving", image: "/ghosties/Ghost-Disbelieving.png" },
value: "disbelieving", { value: "double-take", label: "Double Take", image: "/ghosties/Ghost-Double-Take.png" },
label: "Disbelieving", { value: "exasperated", label: "Exasperated", image: "/ghosties/Ghost-Exasperated.png" },
image: "/ghosties/Ghost-Disbelieving.png",
},
{
value: "double-take",
label: "Double Take",
image: "/ghosties/Ghost-Double-Take.png",
},
{
value: "exasperated",
label: "Exasperated",
image: "/ghosties/Ghost-Exasperated.png",
},
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" }, { value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" }, { value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" }, { value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
]; ];
// Compute current UTC offset for an IANA timezone (DST-aware).
const utcOffset = (tz) => {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts(new Date());
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
// "GMT-05:00" "UTC-05:00"; "GMT" "UTC+00:00"
if (name === "GMT") return "UTC+00:00";
return name.replace("GMT", "UTC");
} catch {
return "";
}
};
// Include the saved timezone as a custom option if it's not in the curated list.
const timezoneItems = computed(() => {
const saved = formData.timeZone;
const list = TIMEZONE_OPTIONS.map((t) => {
const off = utcOffset(t.value);
return { ...t, label: off ? `${t.label} (${off})` : t.label };
});
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
const off = utcOffset(saved);
list.unshift({ label: off ? `${saved} (${off})` : saved, value: saved });
}
return list;
});
const notificationToggles = [ const notificationToggles = [
{ { key: "events", label: "Event reminders", sub: "Get notified about upcoming events" },
key: "events", { key: "updates", label: "Community updates", sub: "New posts from members you follow" },
label: "Registration & cancellation emails",
sub: "Confirmation when you register for an event, and notice if it's cancelled",
},
]; ];
const { data: tagsData } = await useFetch("/api/tags"); const { data: tagsData } = await useFetch("/api/tags");
@ -398,48 +312,26 @@ const formData = reactive({
location: "", location: "",
showInDirectory: true, showInDirectory: true,
craftTags: [], craftTags: [],
craftTagsPrivacy: "members",
boardSlackHandle: "", boardSlackHandle: "",
pronounsPrivacy: "members",
timeZonePrivacy: "members",
avatarPrivacy: "members",
studioPrivacy: "members",
bioPrivacy: "members",
locationPrivacy: "members",
notifications: { notifications: {
events: true, events: true,
updates: true,
}, },
}); });
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null); const initialData = ref(null);
let saveSuccessTimer = null;
const recentActivity = ref([]);
const activityLoading = ref(false);
const formatActivityTime = (date) => {
const now = new Date();
const d = new Date(date);
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
const loadRecentActivity = async () => {
activityLoading.value = true;
try {
const data = await $fetch("/api/members/me/activity", {
params: { limit: 5 },
});
recentActivity.value = data.entries || [];
} catch (err) {
console.error("Failed to load activity:", err);
recentActivity.value = [];
} finally {
activityLoading.value = false;
}
};
const memberId = computed(() => memberData.value?._id || memberData.value?.id); const memberId = computed(() => memberData.value?._id || memberData.value?.id);
@ -466,14 +358,26 @@ const loadProfile = () => {
const board = memberData.value.board || {}; const board = memberData.value.board || {};
formData.boardSlackHandle = board.slackHandle || ""; formData.boardSlackHandle = board.slackHandle || "";
const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members";
formData.avatarPrivacy = privacy.avatar || "members";
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.locationPrivacy = privacy.location || "members";
formData.craftTagsPrivacy = privacy.craftTags || "members";
const notifs = memberData.value.notifications || {}; const notifs = memberData.value.notifications || {};
formData.notifications.events = notifs.events ?? true; formData.notifications.events = notifs.events ?? true;
formData.notifications.updates = notifs.updates ?? true;
initialData.value = JSON.parse(JSON.stringify(formData)); initialData.value = JSON.parse(JSON.stringify(formData));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
saving.value = true; saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try { try {
await $fetch("/api/members/profile", { await $fetch("/api/members/profile", {
@ -481,18 +385,20 @@ const handleSubmit = async () => {
body: { ...formData }, body: { ...formData },
}); });
saveSuccess.value = true;
await checkMemberStatus(); await checkMemberStatus();
loadProfile(); loadProfile();
toast.add({ title: "Profile updated", color: "success" }); if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
saveSuccessTimer = setTimeout(() => {
saveSuccess.value = false;
saveSuccessTimer = null;
}, 3000);
} catch (error) { } catch (error) {
console.error("Profile save error:", error); console.error("Profile save error:", error);
toast.add({ saveError.value =
title: "Update failed", error.data?.message || "Failed to save profile. Please try again.";
description:
error.data?.statusMessage || error.data?.message || "Please try again.",
color: "error",
});
} finally { } finally {
saving.value = false; saving.value = false;
} }
@ -500,6 +406,8 @@ const handleSubmit = async () => {
const resetForm = () => { const resetForm = () => {
loadProfile(); loadProfile();
saveSuccess.value = false;
saveError.value = null;
}; };
onMounted(async () => { onMounted(async () => {
@ -520,10 +428,7 @@ onMounted(async () => {
loadProfile(); loadProfile();
if (memberId.value) { if (memberId.value) {
await Promise.allSettled([ await fetchPosts({ author: memberId.value });
fetchPosts({ author: memberId.value }),
loadRecentActivity(),
]);
} }
}); });
@ -547,6 +452,10 @@ const handleDeletePost = async (post) => {
} }
}; };
onBeforeUnmount(() => {
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
});
useHead({ useHead({
title: "Edit Profile - Ghost Guild", title: "Edit Profile - Ghost Guild",
}); });
@ -570,6 +479,11 @@ useHead({
gap: 12px; gap: 12px;
} }
/* ---- PRIVACY TOGGLE SPACING ---- */
.field :deep(.priv) {
margin-top: 4px;
}
/* ---- FIELD LABELS (distinct from .section-label) ---- */ /* ---- FIELD LABELS (distinct from .section-label) ---- */
.field label { .field label {
font-size: 11px; font-size: 11px;
@ -712,6 +626,10 @@ useHead({
.posts-empty-link { .posts-empty-link {
color: var(--candle); color: var(--candle);
text-decoration: none;
}
.posts-empty-link:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -773,41 +691,6 @@ useHead({
color: var(--ember); color: var(--ember);
} }
/* ---- RECENT ACTIVITY ---- */
.activity-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px dashed var(--border);
}
.activity-item {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.activity-time {
font-size: 10px;
color: var(--text-faint);
margin-bottom: 2px;
}
.activity-text {
color: var(--text);
}
.activity-link {
color: var(--candle);
text-decoration: none;
}
.activity-link:hover {
text-decoration: underline;
}
.activity-empty {
font-size: 12px;
color: var(--text-faint);
padding: 10px 0;
}
/* ---- DISABLED BUTTON ---- */ /* ---- DISABLED BUTTON ---- */
.btn:disabled { .btn:disabled {
opacity: 0.4; opacity: 0.4;
@ -825,6 +708,19 @@ useHead({
gap: 12px; gap: 12px;
} }
.save-msg {
font-size: 11px;
margin-left: auto;
}
.save-msg-ok {
color: var(--green);
}
.save-msg-err {
color: var(--ember);
}
/* ---- RESPONSIVE ---- */ /* ---- RESPONSIVE ---- */
@media (max-width: 768px) { @media (max-width: 768px) {
.row-2 { .row-2 {
@ -838,4 +734,3 @@ useHead({
} }
} }
</style> </style>

View file

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

View file

@ -1,5 +1,8 @@
<template> <template>
<PageShell title="Members"> <PageShell
title="Members"
:subtitle="pageSubtitle"
>
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="filter-bar"> <div class="filter-bar">
<input <input
@ -8,25 +11,34 @@
class="filter-search" class="filter-search"
placeholder="Search members..." placeholder="Search members..."
@input="debouncedSearch" @input="debouncedSearch"
>
<USelectMenu
v-model="selectedCircle"
:items="circleOptions"
value-key="value"
:search-input="false"
class="zine-select circle-select"
:ui="{
content: 'tz-content',
item: 'tz-item',
}"
@update:model-value="loadMembers"
/> />
<button <select
v-if="craftTagOptions.length > 0" v-model="selectedCircle"
type="button" class="filter-select"
class="drawer-btn" @change="loadMembers"
@click="showTagsDrawer = !showTagsDrawer"
> >
<option
v-for="opt in circleOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<label class="filter-toggle">
<input
type="checkbox"
:checked="peerSupportFilter === 'true'"
@change="togglePeerSupport"
/>
Offering support
</label>
<span class="filter-count">Showing {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }} across 3 circles</span>
</div>
<!-- Tags Drawer Toggle -->
<div v-if="craftTagOptions.length > 0" class="tags-drawer-toggle">
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
Tags... Tags...
<span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span> <span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span>
</button> </button>
@ -64,6 +76,10 @@
{{ circleLabels[selectedCircle] }} {{ circleLabels[selectedCircle] }}
<button type="button" @click="clearCircleFilter">&times;</button> <button type="button" @click="clearCircleFilter">&times;</button>
</span> </span>
<span v-if="peerSupportFilter === 'true'" class="af-tag">
Offering Support
<button type="button" @click="clearPeerSupportFilter">&times;</button>
</span>
<span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag"> <span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag">
{{ craftTagLabel(slug) }} {{ craftTagLabel(slug) }}
<button type="button" @click="toggleDirectoryCraftTag(slug)">&times;</button> <button type="button" @click="toggleDirectoryCraftTag(slug)">&times;</button>
@ -92,7 +108,7 @@
:src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`" :src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`"
:alt="member.name" :alt="member.name"
class="mc-avatar-img" class="mc-avatar-img"
> />
<span v-else>{{ getInitials(member.name) }}</span> <span v-else>{{ getInitials(member.name) }}</span>
</div> </div>
<div class="mc-info"> <div class="mc-info">
@ -114,16 +130,16 @@
v-if="member.bio" v-if="member.bio"
class="mc-bio" class="mc-bio"
v-html="renderMarkdown(member.bio)" v-html="renderMarkdown(member.bio)"
/> ></div>
<div v-if="member.craftTags?.length > 0" class="mc-tags"> <div v-if="member.craftTags?.length > 0" class="mc-tags">
<span class="tag-label">Craft:</span> <span class="tag-label">Craft:</span>
<span <span
v-for="tag in member.craftTags.slice(0, 3)" v-for="tag in member.craftTags.slice(0, 5)"
:key="tag" :key="tag"
class="skill-tag" class="skill-tag"
>{{ craftTagLabel(tag) }}</span> >{{ craftTagLabel(tag) }}</span>
<span v-if="member.craftTags.length > 3" class="tag-overflow">+{{ member.craftTags.length - 3 }} more</span> <span v-if="member.craftTags.length > 5" class="tag-overflow">+{{ member.craftTags.length - 5 }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -143,6 +159,7 @@
<script setup> <script setup>
definePageMeta({ middleware: ['members-auth'] }) definePageMeta({ middleware: ['members-auth'] })
const route = useRoute()
const { render: renderMarkdown } = useMarkdown() const { render: renderMarkdown } = useMarkdown()
// ---- Directory state ---- // ---- Directory state ----
@ -151,6 +168,7 @@ const totalCount = ref(0)
const loading = ref(true) const loading = ref(true)
const searchQuery = ref('') const searchQuery = ref('')
const selectedCircle = ref('all') const selectedCircle = ref('all')
const peerSupportFilter = ref('all')
const directoryCraftTags = ref([]) const directoryCraftTags = ref([])
const craftTagOptions = ref([]) const craftTagOptions = ref([])
const showAllTags = ref(false) const showAllTags = ref(false)
@ -200,9 +218,14 @@ const visibleTagOptions = computed(() =>
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
(selectedCircle.value && selectedCircle.value !== 'all') || (selectedCircle.value && selectedCircle.value !== 'all') ||
peerSupportFilter.value === 'true' ||
directoryCraftTags.value.length > 0 directoryCraftTags.value.length > 0
) )
const pageSubtitle = computed(() =>
`${totalCount.value} member${totalCount.value === 1 ? '' : 's'} across 3 circles`
)
// ---- Load members ---- // ---- Load members ----
const loadMembers = async () => { const loadMembers = async () => {
loading.value = true loading.value = true
@ -210,6 +233,7 @@ const loadMembers = async () => {
const params = {} const params = {}
if (searchQuery.value) params.search = searchQuery.value if (searchQuery.value) params.search = searchQuery.value
if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value
if (peerSupportFilter.value === 'true') params.peerSupport = 'true'
if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0] if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0]
const data = await $fetch('/api/members/directory', { params }) const data = await $fetch('/api/members/directory', { params })
@ -242,6 +266,11 @@ const loadTagOptions = async () => {
} }
// ---- Filter helpers ---- // ---- Filter helpers ----
const togglePeerSupport = (e) => {
peerSupportFilter.value = e.target.checked ? 'true' : 'all'
loadMembers()
}
let searchTimeout let searchTimeout
const debouncedSearch = () => { const debouncedSearch = () => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
@ -265,9 +294,15 @@ const clearCircleFilter = () => {
loadMembers() loadMembers()
} }
const clearPeerSupportFilter = () => {
peerSupportFilter.value = 'all'
loadMembers()
}
const clearAllFilters = () => { const clearAllFilters = () => {
searchQuery.value = '' searchQuery.value = ''
selectedCircle.value = 'all' selectedCircle.value = 'all'
peerSupportFilter.value = 'all'
directoryCraftTags.value = [] directoryCraftTags.value = []
showTagsDrawer.value = false showTagsDrawer.value = false
loadMembers() loadMembers()
@ -290,6 +325,10 @@ useHead({
// ---- Init ---- // ---- Init ----
onMounted(async () => { onMounted(async () => {
if (route.query.peerSupport === 'true') {
peerSupportFilter.value = 'true'
}
await loadTagOptions() await loadTagOptions()
await loadMembers() await loadMembers()
}) })
@ -324,15 +363,51 @@ onMounted(async () => {
border-color: var(--candle-faint); border-color: var(--candle-faint);
} }
/* Constrain the circle USelectMenu button width so it doesn't stretch. */ .filter-select {
:deep(.circle-select) { font-family: "Commit Mono", monospace;
width: auto !important; font-size: 11px;
min-width: 150px; padding: 5px 10px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
outline: none;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 26px;
}
.filter-select:focus {
border-color: var(--candle-faint);
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.filter-toggle input {
accent-color: var(--candle-dim);
}
.filter-count {
margin-left: auto;
font-size: 11px;
color: var(--text-faint);
} }
/* ---- TAGS DRAWER ---- */ /* ---- TAGS DRAWER ---- */
.tags-drawer-toggle {
padding: 8px 24px;
border-bottom: 1px dashed var(--border);
}
.drawer-btn { .drawer-btn {
margin-left: auto;
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-dim);
@ -504,7 +579,7 @@ onMounted(async () => {
.mc-avatar { .mc-avatar {
width: 32px; width: 32px;
height: 32px; height: 32px;
background: transparent; background: var(--surface);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -645,12 +720,15 @@ onMounted(async () => {
align-items: stretch; align-items: stretch;
padding: 14px 20px; padding: 14px 20px;
} }
.drawer-btn { .filter-count {
margin-left: 0; margin-left: 0;
} }
.skills-bar { .skills-bar {
padding: 10px 20px; padding: 10px 20px;
} }
.tags-drawer-toggle {
padding: 8px 20px;
}
.active-filters { .active-filters {
padding: 8px 20px; padding: 8px 20px;
} }
@ -670,5 +748,14 @@ onMounted(async () => {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.filter-select {
flex: 1 1 45%;
}
.filter-toggle {
flex: 1 1 45%;
}
.filter-count {
flex-basis: 100%;
}
} }
</style> </style>

View file

@ -1,79 +0,0 @@
<template>
<PageShell :title="policy.title" subtitle="Ghost Guild Policy">
<div class="policy-prose">
<p class="policy-status">This policy is being finalized.</p>
<p>
{{ policy.description }}
</p>
<p>
The full text will be published here ahead of launch. In the meantime,
the expectations that apply are summarized in our
<NuxtLink to="/community-guidelines">Community Guidelines</NuxtLink>.
</p>
<p>
Questions? Contact the Membership Committee at
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>.
</p>
</div>
</PageShell>
</template>
<script setup>
// Note: /policies/code-of-conduct and /policies/conflict-resolution are
// handled by routeRules in nuxt.config.ts and redirect to external Obsidian
// pages. /policies/privacy and /policies/terms have dedicated pages
// (privacy.vue, terms.vue) that take precedence over this dynamic route.
const POLICIES = {
'by-laws': {
title: 'By-Laws',
description:
"Ghost Guild's governing by-laws, including membership classes, voting rights, and the structure of the Membership Committee.",
},
}
const route = useRoute()
const slug = String(route.params.slug || '')
const policy = POLICIES[slug]
if (!policy) {
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
}
useHead({
title: `${policy.title} · Ghost Guild`,
})
</script>
<style scoped>
.policy-prose {
max-width: 640px;
padding: 32px;
}
.policy-status {
font-family: "Commit Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--candle);
margin-bottom: 16px;
}
.policy-prose p {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 14px;
}
.policy-prose a {
color: var(--candle);
}
@media (max-width: 640px) {
.policy-prose {
padding: 20px 16px;
}
}
</style>

View file

@ -1,328 +0,0 @@
<template>
<PageShell title="Privacy Policy" subtitle="How Ghost Guild handles your data">
<div class="policy-prose">
<p class="policy-updated">Last updated: April 18, 2026</p>
<section class="policy-section">
<p>
Ghost Guild is a program of Baby Ghosts, a Canadian non-profit. This
policy explains what information we collect when you use
ghostguild.org and wiki.ghostguild.org, what we do with it, and what
choices you have.
</p>
<p>
We treat your data as something you've trusted us with, not something
we own. If anything here is unclear or feels off, email us at
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>.
</p>
</section>
<section class="policy-section">
<h2>What we collect</h2>
<p>
<strong>When you apply for membership:</strong> your name, pronouns,
location, email, the answers you give in your application, and which
membership circle you're applying for.
</p>
<p>
<strong>When you build a profile:</strong> anything you choose to add
(bio, studio affiliation, skills, social links, availability for peer
support, Slack handle). Your profile is visible to other Ghost Guild
members. It isn't made public or visible to search engines. If you
don't want something seen by other members, don't put it in your
profile.
</p>
<p>
<strong>When you contribute to the wiki:</strong> your edits,
comments, and authorship are recorded so members can see who wrote
what and so we can roll back if needed.
</p>
<p>
<strong>When you post on the bulletin board:</strong> your posts
(needs you have, offers you can make), your name as the poster, and
the timestamps. The actual connecting between members happens in
Slack, not on our platform.
</p>
<p>
<strong>When you pay dues:</strong> payment is handled by Helcim. We
see the amount, date, and that the payment came from you. We don't
see or store card numbers or banking details.
</p>
<p>
<strong>When you get emails from us:</strong> Resend delivers them.
They process your email address and basic delivery metadata.
</p>
<p>
<strong>For site analytics:</strong> we use Plausible. Plausible
doesn't use tracking cookies, doesn't store IP addresses, and doesn't
follow you across sites. It tells us aggregate things like which
pages get visited and roughly how many people use the site. It
doesn't tell us who you are.
</p>
<p>
We don't use Google Analytics, advertising pixels, or any other
tracking. The site uses cookies needed to keep you logged in. That's
it.
</p>
</section>
<section class="policy-section">
<h2>Why we collect it</h2>
<p>
<strong>To run the membership program:</strong> review applications,
give you access, support your participation, send you things you've
signed up for.
</p>
<p>
<strong>To help members find each other:</strong> making your profile
and bulletin board posts visible to the rest of the community.
</p>
<p>
<strong>To report on impact:</strong> anonymized, aggregated numbers
for funders and the community (how many members, what topics come up
on the bulletin board, where members are based).
</p>
<p>
<strong>To meet our obligations</strong> as a non-profit corporation
under Canadian law.
</p>
</section>
<section class="policy-section">
<h2>Who else sees it</h2>
<p>The services that store or process your data on our behalf:</p>
<ul>
<li>Helcim, payment processing, Canada</li>
<li>Resend, transactional email, US</li>
<li>Hetzner, server hosting, Germany</li>
<li>Outline, wiki software running on our Hetzner server</li>
<li>Plausible, anonymous site analytics, EU</li>
<li>
Slack, if you accept an invitation to the shared Gamma Space
workspace
</li>
</ul>
<p>
<strong>Other Ghost Guild members:</strong> they can see your profile
and any bulletin board posts you make. None of this is public or
visible to search engines.
</p>
<p>
<strong>Wiki contributions:</strong> anything you post on
wiki.ghostguild.org is part of a public knowledge commons (see the
<NuxtLink to="/policies/terms">Terms of Service</NuxtLink> for the
specifics) and may be visible to anyone on the internet.
</p>
<p>
<strong>Baby Ghosts staff and the Membership Committee:</strong> for
purposes related to running the program.
</p>
</section>
<section class="policy-section">
<h2>What we don't do</h2>
<p>We don't sell your data.</p>
<p>We don't share it with third parties for marketing.</p>
<p>
We don't feed your messages, profile, applications, bulletin board
posts, or other community content into generative AI tools, and we
don't allow members to do that with each other's content either. This
is a community commitment, not just a policy line.
</p>
<p>
If we ever want to do something different than what's in this policy,
we'll ask first.
</p>
</section>
<section class="policy-section">
<h2>How long we keep it</h2>
<p>
<strong>While you're a member:</strong> as long as you have an
account.
</p>
<p>
<strong>After you leave:</strong> we keep basic account records for
one year for administrative reasons (renewals, returning members,
financial records we're required to keep). Your wiki contributions
remain in the commons under their license; that's how a commons works
(see <NuxtLink to="/policies/terms">Terms of Service</NuxtLink>).
Your bulletin board posts are removed when you close your account.
Aggregate impact data has no expiry and doesn't identify you.
</p>
<p>
<strong>Financial records:</strong> we keep what we have to keep
under tax law (currently six years).
</p>
<p>
You can ask us to delete your account and personal data at any time.
We'll do it unless we're legally required to retain something
specific, in which case we'll tell you what and why.
</p>
</section>
<section class="policy-section">
<h2>Your rights</h2>
<p>You can:</p>
<ul>
<li>See what we have about you</li>
<li>Correct anything that's wrong</li>
<li>Download your data</li>
<li>Delete your account</li>
<li>Withdraw consent for anything you previously agreed to</li>
</ul>
<p>
To do any of these, email
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>. We'll
respond within 30 days.
</p>
<p>
If you live in a province with its own privacy law (Quebec, British
Columbia, Alberta), you may have additional rights under that law.
PIPEDA may also apply to some of what we do. We'll respect those
rights regardless of which framework technically covers your
situation.
</p>
</section>
<section class="policy-section">
<h2>Security</h2>
<p>
We host on a private server, encrypt data in transit, and limit
access to staff who need it. Perfect security doesn't exist. If
something happens that affects your data, we'll tell you within a
reasonable time and explain what we're doing about it.
</p>
</section>
<section class="policy-section">
<h2>Children</h2>
<p>
Ghost Guild is for adults. We don't knowingly collect data from
anyone under 18.
</p>
</section>
<section class="policy-section">
<h2>Changes</h2>
<p>
If we change this policy in a way that affects how we handle your
data, we'll email members and post the change with a date stamp at
least 30 days before it takes effect. Continued use after that means
you accept the changes. If you don't, close your account first.
</p>
</section>
<section class="policy-section">
<h2>Contact</h2>
<p>
Questions, requests, complaints:
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>
</p>
<p class="policy-address">
Baby Ghosts<br>
3230 Yonge Street #4052<br>
Toronto ON M4N 3P6<br>
Canada
</p>
</section>
</div>
</PageShell>
</template>
<script setup>
useHead({
title: 'Privacy 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 ul {
list-style: none;
padding: 0;
margin: 8px 0 14px;
}
.policy-section ul li {
position: relative;
padding: 2px 0 2px 18px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 4px;
}
.policy-section ul li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.policy-section a {
color: var(--candle);
}
.policy-section strong {
color: var(--text-bright);
font-weight: 600;
}
.policy-address {
font-family: "Commit Mono", monospace;
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
margin-top: 16px;
}
@media (max-width: 640px) {
.policy-prose {
padding: 20px 16px;
}
}
</style>

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,357 +0,0 @@
<template>
<PageShell title="Terms of Service" subtitle="Using ghostguild.org and wiki.ghostguild.org">
<div class="policy-prose">
<p class="policy-updated">Last updated: April 18, 2026</p>
<section class="policy-section">
<p>
These terms apply to your use of ghostguild.org and
wiki.ghostguild.org, run by Baby Ghosts (a Canadian non-profit). The
<NuxtLink to="/community-guidelines">Member Agreement</NuxtLink> and
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
also apply if you're a member; they sit alongside these terms, and
the more specific document wins if there's ever a conflict.
</p>
</section>
<section class="policy-section">
<h2>Who can use Ghost Guild</h2>
<p>You can browse public pages without an account.</p>
<p>
To become a member, you have to be 18 or older, complete an
application, and be accepted by the Membership Committee. Membership
is reviewed against our values and processes; we can decline
applications, and we don't always provide detailed reasons.
</p>
</section>
<section class="policy-section">
<h2>Your account</h2>
<p>
You're responsible for what happens under your account. Don't share
login credentials. If you suspect unauthorized access, tell us at
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>.
</p>
<p>
We may suspend or close accounts that violate these terms, the
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>,
or the
<NuxtLink to="/community-guidelines">Member Agreement</NuxtLink>. The
process for that lives in our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
</p>
<p>
We don't suspend or close accounts because a payment didn't go
through. If there's a problem with your contribution, we'll reach
out.
</p>
</section>
<section class="policy-section">
<h2>Membership and dues</h2>
<p>
Membership runs annually and renews unless you cancel. You can pay
your annual dues all at once or in monthly installments; both result
in the same annual membership.
</p>
<p>
Dues are pay-what-you-can: you choose your contribution, and you can
change it any time as your situation changes. The full pricing
structure is on the membership page.
</p>
<p>
We don't refund dues. 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>Acceptable use</h2>
<p>
The
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
governs how members behave in Ghost Guild spaces. Some basics that
also apply here:
</p>
<ul>
<li>Don't harass, harm, or discriminate against other members</li>
<li>
Don't share other members' content (Slack messages, profile
information, bulletin board posts, application materials, private
comments) outside the community without their consent
</li>
<li>
Don't feed community content, including other members'
contributions, into generative AI tools
</li>
<li>
Don't use Ghost Guild for illegal activity, spam, or attempts to
compromise the platform
</li>
<li>
Don't impersonate others or misrepresent your identity or
affiliations
</li>
<li>
Don't scrape the site or use automated tools to extract member data
</li>
</ul>
<p>
If you violate these, we follow the
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
Outcomes range from a conversation through to removal from the
community.
</p>
</section>
<section class="policy-section">
<h2>Your content</h2>
<p>
There are two different things happening with content on Ghost Guild,
and they work differently.
</p>
<h3>Profiles, bulletin board posts, comments, and other member-only content</h3>
<p>
You keep ownership of profile information, bulletin board posts,
comments, messages, and anything else you post in member-only spaces.
By posting these, you give Ghost Guild a non-exclusive license to
display them within the platform to other members.
</p>
<p>
If you delete your account, we remove this content from member-facing
areas. Backups may persist for a reasonable period before being
purged.
</p>
<h3>Wiki contributions</h3>
<p>
The wiki at
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
knowledge commons. When you contribute to it (creating pages, editing
existing ones, adding examples, leaving comments on wiki pages), your
contribution is automatically and irrevocably licensed under the
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>
(CC-BY-SA 4.0).
</p>
<p>In plain terms:</p>
<ul>
<li>You still hold the copyright to what you wrote</li>
<li>
Anyone (members, the public, other cooperatives, organizations
adapting the material) can use, share, adapt, and build on your
contribution, including for commercial purposes, as long as they
credit you and license their derivatives under the same terms
</li>
<li>
You can't pull your contribution back out of the commons later,
even if you leave Ghost Guild
</li>
<li>
If we publish wiki material in other places (like
<a href="https://coop.love">coop.love</a>), it stays under
CC-BY-SA 4.0 and you stay credited
</li>
</ul>
<p>
This is how a knowledge commons works, and it's central to what Ghost
Guild is doing. We're explicit about it because we want you to
contribute knowing exactly what's happening to your work. If you have
something you'd rather keep private or under a more restrictive
license, don't put it in the wiki.
</p>
</section>
<section class="policy-section">
<h2>Our content</h2>
<p>
Educational materials, templates, and resources we publish on
ghostguild.org are for member use within the spirit of the
cooperative movement. Where a specific license is attached (like
Creative Commons), follow that license. We ask for attribution to
Ghost Guild or Baby Ghosts.
</p>
</section>
<section class="policy-section">
<h2>Third-party services</h2>
<p>
Ghost Guild relies on Helcim (payments), Resend (email), Hetzner
(hosting), Outline (wiki), Plausible (analytics), and Slack (via the
shared Gamma Space workspace). Their terms apply when you interact
with their services. We've picked them carefully but we can't
guarantee they'll never have outages, security issues, or policy
changes.
</p>
</section>
<section class="policy-section">
<h2>Disclaimers</h2>
<p>
Ghost Guild is provided as-is. We work to keep things running and
resources accurate, but we don't guarantee uptime, completeness, or
that information on the site is right for your specific situation.
Treat our templates and educational materials as starting points, not
legal, financial, or business advice. Talk to qualified professionals
before you make decisions that need them.
</p>
</section>
<section class="policy-section">
<h2>Limitation of liability</h2>
<p>
To the extent the law allows, Baby Ghosts isn't liable for indirect,
incidental, or consequential damages arising from your use of the
site. Our total liability is capped at the amount of dues you've paid
in the past 12 months (which may well be zero, given how our pricing
works).
</p>
</section>
<section class="policy-section">
<h2>Disputes</h2>
<p>
For disputes between members, we use the
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
</p>
<p>
For disputes with Baby Ghosts about these terms or your account,
please reach out first. Most things we can sort out by talking. If we
can't, the dispute will be governed by the laws of Ontario, Canada.
</p>
</section>
<section class="policy-section">
<h2>Changes</h2>
<p>
We'll update these terms from time to time. For changes that
materially affect your rights or our obligations, we'll email members
at least 30 days before the new terms take effect. Continued use
after that means you accept the changes. If you don't, close your
account before they kick in.
</p>
</section>
<section class="policy-section">
<h2>Contact</h2>
<p>
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>
</p>
<p class="policy-address">
Baby Ghosts<br>
3230 Yonge Street #4052<br>
Toronto ON M4N 3P6<br>
Canada
</p>
</section>
</div>
</PageShell>
</template>
<script setup>
useHead({
title: 'Terms of Service · 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 h3 {
font-family: "Commit Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-bright);
margin: 20px 0 10px;
}
.policy-section p {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 12px;
}
.policy-section ul {
list-style: none;
padding: 0;
margin: 8px 0 14px;
}
.policy-section ul li {
position: relative;
padding: 2px 0 2px 18px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 4px;
}
.policy-section ul li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.policy-section a {
color: var(--candle);
}
.policy-section strong {
color: var(--text-bright);
font-weight: 600;
}
.policy-address {
font-family: "Commit Mono", monospace;
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
margin-top: 16px;
}
@media (max-width: 640px) {
.policy-prose {
padding: 20px 16px;
}
}
</style>

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="page-fill"> <div>
<div v-if="pending" class="loading">Loading series details...</div> <div v-if="pending" class="loading">Loading series details...</div>
<div v-else-if="error" class="loading"> <div v-else-if="error" class="loading">
@ -8,7 +8,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink> <NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div> </div>
<div v-else class="page-fill"> <div v-else>
<!-- BACK LINK --> <!-- BACK LINK -->
<div class="back-link"> <div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink> <NuxtLink to="/events">&larr; Back to Events</NuxtLink>
@ -26,44 +26,31 @@
</div> </div>
</div> </div>
<!-- TWO-COLUMN BODY --> <!-- DESCRIPTION -->
<div class="series-body" :class="{ 'has-aside': series.tickets?.enabled }"> <div v-if="series.description" class="section">
<!-- LEFT: MAIN CONTENT -->
<div class="series-main">
<div v-if="series.description" class="section description">
<p>{{ series.description }}</p> <p>{{ series.description }}</p>
</div> </div>
<div class="section" :class="{ 'section-flush': series.events?.length }"> <!-- EVENT LIST -->
<div class="section">
<div class="section-label">Sessions</div> <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"> <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-num">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span> <span class="event-date">{{ formatDate(event.startDate) }}</span>
<div class="event-info"> <div class="event-info">
<div class="event-info-head">
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link"> <NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
{{ event.title }} {{ event.title }}
</NuxtLink> </NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span> <span class="event-status">{{ getEventStatus(event) }}</span>
</div> </div>
<p v-if="event.description" class="event-description">{{ event.description }}</p>
</div>
</div> </div>
</div> </div>
<p v-else class="empty">No sessions scheduled yet.</p> <p v-else class="empty">No sessions scheduled yet.</p>
</div> </div>
<!-- Questions (inline when no sidebar) --> <!-- PASS PURCHASE -->
<div v-if="!series.tickets?.enabled" class="section"> <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">
<SeriesPassPurchase <SeriesPassPurchase
:series-id="series.id" :series-id="series.id"
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }" :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" :user-name="memberData?.name"
@purchase-success="handlePurchaseSuccess" @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> </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> </div>
</div> </div>
@ -150,105 +137,28 @@ useHead(() => ({
} }
.meta-text { color: var(--text-faint); } .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 { .section {
padding: 24px 32px; padding: 24px 32px;
border-bottom: 1px dashed var(--border); 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 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 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 { .event-row {
display: grid; display: grid;
grid-template-columns: 32px auto 1fr; grid-template-columns: 32px 80px 1fr;
gap: 12px; gap: 12px;
align-items: baseline; align-items: baseline;
padding: 10px 32px; padding: 10px 0;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
font-size: 12px; font-size: 12px;
} }
.event-row:last-child { border-bottom: none; } .event-row:last-child { border-bottom: none; }
.event-num { color: var(--text-faint); font-size: 11px; } .event-num { color: var(--text-faint); font-size: 11px; }
.event-date { color: var(--text-faint); white-space: nowrap; } .event-date { color: var(--text-faint); }
.event-info { min-width: 0; }
.event-title-link { color: var(--text); text-decoration: none; font-size: 13px; } .event-title-link { color: var(--text); text-decoration: none; font-size: 13px; }
.event-title-link:hover { color: var(--candle); } .event-title-link:hover { color: var(--candle); }
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; } .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); } .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> </style>

View file

@ -1,77 +1,197 @@
<template> <template>
<div> <div>
<!-- Page Header -->
<PageHeader <PageHeader
title="Event Series" title="Event Series"
subtitle="Multi-session events on cooperative topics" subtitle="Multi-session events on cooperative topics"
/> />
<div v-if="pending" class="state-msg">Loading series...</div> <!-- Series Grid -->
<section class="py-20 bg-[--ui-bg]">
<div v-else-if="!filteredSeries.length" class="state-msg"> <UContainer>
<p> <div v-if="pending" class="text-center py-12">
No series right now. Check back later or browse <div
<NuxtLink to="/events">upcoming events</NuxtLink>. class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
</p> ></div>
<p class="text-[--ui-text-muted]">Loading series...</p>
</div> </div>
<div v-else> <div
<section v-else-if="filteredSeries.length > 0"
class="max-w-4xl mx-auto space-y-6"
>
<div
v-for="series in filteredSeries" v-for="series in filteredSeries"
:key="series.id" :key="series.id"
class="series-section" class="border border-[--ui-border] rounded overflow-hidden hover:border-primary transition-colors"
> >
<div class="series-head"> <!-- Series Header -->
<h2>{{ series.title }}</h2> <div class="p-6 border-b border-[--ui-border]">
<div class="series-meta-row"> <div
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span> class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
<span class="meta-text"> >
{{ series.eventCount }} sessions<template v-if="series.totalEvents"> of {{ series.totalEvents }} planned</template> <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>
<span v-if="series.startDate && series.endDate" class="meta-text"> <span
{{ formatDateRange(series.startDate, series.endDate) }} :class="[
</span> 'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
<span v-if="series.totalRegistrations" class="meta-text"> series.status === 'active'
{{ series.totalRegistrations }} registered ? '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> </span>
</div> </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 }} {{ series.description }}
</p> </p>
</div> </div>
<div class="text-center md:text-right flex-shrink-0">
<div v-if="series.events?.length" class="sessions"> <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 <div
v-for="(event, index) in series.events" v-if="series.totalEvents"
:key="event.id" class="text-xs text-[--ui-text-muted] mt-1"
class="event-row"
> >
<span class="event-num"> of {{ series.totalEvents }} planned
{{ String(event.series?.position || index + 1).padStart(2, '0') }} </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>
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
<div class="event-info">
<NuxtLink <NuxtLink
:to="`/events/${event.slug || event.id}`" :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> </NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="series-foot"> <!-- Series Footer -->
<NuxtLink :to="`/series/${series.id}`" class="view-link"> <div
View series &rarr; 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> </NuxtLink>
</div> </div>
</section>
</div> </div>
</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> </template>
<script setup> <script setup>
// SEO
useHead({ useHead({
title: "Event Series - Ghost Guild", title: "Event Series - Ghost Guild",
meta: [ meta: [
@ -83,10 +203,12 @@ useHead({
], ],
}); });
// Fetch series data
const { data: seriesData, pending } = await useFetch("/api/series", { const { data: seriesData, pending } = await useFetch("/api/series", {
query: { includeHidden: false }, query: { includeHidden: false },
}); });
// Filter for active and upcoming series only
const filteredSeries = computed(() => { const filteredSeries = computed(() => {
if (!seriesData.value) return []; if (!seriesData.value) return [];
return seriesData.value.filter( return seriesData.value.filter(
@ -94,6 +216,7 @@ const filteredSeries = computed(() => {
); );
}); });
// Helper functions
const formatSeriesType = (type) => { const formatSeriesType = (type) => {
const types = { const types = {
workshop_series: "Workshop Series", workshop_series: "Workshop Series",
@ -105,6 +228,25 @@ const formatSeriesType = (type) => {
return types[type] || 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) => { const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", { return new Date(date).toLocaleDateString("en-US", {
month: "short", 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 formatDateRange = (startDate, endDate) => {
const start = new Date(startDate); const start = new Date(startDate);
const end = new Date(endDate); const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat("en-US", { const formatter = new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); });
return `${formatter.format(start)} ${formatter.format(end)}`;
return `${formatter.format(start)} to ${formatter.format(end)}`;
}; };
const getEventStatus = (event) => { const getEventStatus = (event) => {
const now = new Date(); const now = new Date();
const startDate = new Date(event.startDate); const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate); const endDate = new Date(event.endDate);
if (now < startDate) return "Upcoming"; if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing"; if (now >= startDate && now <= endDate) return "Ongoing";
return "Completed"; 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> </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,3 +1,3 @@
<script setup> <script setup>
await navigateTo('/member/dashboard?welcome=1', { redirectCode: 301 }) await navigateTo('/member/dashboard', { redirectCode: 301 })
</script> </script>

View file

@ -42,7 +42,7 @@ const formatters = {
icon: 'i-lucide-user-pen' icon: 'i-lucide-user-pen'
}), }),
subscription_created: (m) => ({ subscription_created: (m) => ({
text: m.amount != null ? `Started $${m.amount}/mo subscription` : 'Started subscription', text: m.tier ? `Started $${m.tier}/mo subscription` : 'Started subscription',
icon: 'i-lucide-credit-card' icon: 'i-lucide-credit-card'
}), }),
subscription_cancelled: () => ({ subscription_cancelled: () => ({

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,112 +0,0 @@
# 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.
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`.
---
## 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).
---
## P0 — Must fix before launch
None outstanding.
---
## P1 — Strongly preferred before launch
None outstanding.
---
## 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
- [ ] 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.
- [ ] **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.
**Env vars required in Dokploy (reference):**
- `NODE_ENV=production`
- `BASE_URL` (exact public origin, no trailing slash)
- `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)
- `SLACK_BOT_TOKEN`
- `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`
---
## 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.
---
## Post-launch & deferred work
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).**

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

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