Compare commits

..

No commits in common. "main" and "worktree-agent-a0328c91" have entirely different histories.

327 changed files with 8781 additions and 27754 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-test-helcim-api-token
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)
RESEND_API_KEY=your-resend-api-key
@ -16,8 +14,6 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org
# Slack Integration
SLACK_WEBHOOK_URL=your-slack-webhook-url
SLACK_OAUTH_TOKEN=your-slack-oauth-token
# AdminGhost bot token — used for admin-only channel creation. Falls back to SLACK_BOT_TOKEN if unset.
SLACK_ADMIN_BOT_TOKEN=xoxb-adminghost-token
# JWT Secret for authentication
JWT_SECRET=your-jwt-secret-key-change-this-in-production
@ -31,7 +27,4 @@ BASE_URL=http://localhost:3000
# OIDC Provider (for Outline Wiki SSO)
OIDC_CLIENT_ID=outline-wiki
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
# Outline Wiki Integration
OUTLINE_API_KEY=
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>

View file

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

8
.gitignore vendored
View file

@ -26,9 +26,6 @@ logs
!.env.example
scripts/*.js
# Migration backup files
.migration-backup-*.json
# Playwright
e2e/test-results/
playwright-report/
@ -36,8 +33,3 @@ e2e/.auth/
# Worktrees
.worktrees/
.claude/worktrees/
.superpowers/
.claude
scripts/dump-babyghosts-preregistrations.mjs

0
.husky/pre-push Executable file → Normal file
View file

View file

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

View file

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

View file

@ -27,16 +27,11 @@
--text: #2a2015;
--text-bright: #1a1008;
--text-dim: #5a5040;
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
--text-dim (5.80:1) while meeting AA for small text. */
--text-faint: #665c4b;
--text-faint: #746a58;
--parch: #2a2015;
--parch-hover: #3a3025;
--parch-text: #ede4d0;
--parch-text-dim: #b8ae98;
--parch-accent: #c4a448;
--parch-border: #b8a880;
--c-community: #7a4838;
--c-founder: #8a4420;
--c-practitioner: #2a4650;
@ -63,9 +58,10 @@
--text-bright: #d0c8b0;
--text-dim: #958774;
--text-faint: #8b7b62;
/* Parch family intentionally stays pinned to light-mode values
inverted blocks are a consistent zine/terminal inset in both themes.
See: --parch-accent and --parch-border for on-parch accents/borders. */
--parch: #ede4d0;
--parch-hover: #d4c8a8;
--parch-text: #2a2015;
--parch-text-dim: #5a5040;
--c-community: #a06850;
--c-founder: #c06030;
--c-practitioner: #4a7080;
@ -178,12 +174,6 @@ p a, blockquote a {
background: var(--surface-hover);
border-color: var(--border-d);
}
/* WCAG 2.4.7 keyboard focus must be visibly indicated. Dashed outline
echoes the design system's zine/dashed aesthetic. */
.btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.btn-primary {
background: var(--candle);
color: var(--bg);
@ -276,98 +266,6 @@ p a, blockquote a {
min-width: 0;
}
/* ---- Nuxt UI placeholder contrast ----
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
AA on cream and white backgrounds (2.4:1). Override globally to --text-dim
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
[data-slot="placeholder"] {
color: var(--text-dim);
}
/* ---- SHARED USelectMenu STYLES ----
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
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 ---- */
@media (max-width: 1023px) {
body {

View file

@ -25,23 +25,17 @@
/>
</NuxtLink>
</li>
<li>
<a href="#" class="sign-out" @click.prevent="handleLogout"
>Sign out</a
>
</li>
</ul>
<div class="sidebar-section">Explore</div>
<ul class="sidebar-nav">
<li v-for="item in exploreItems" :key="item.path">
<a
v-if="item.external"
:href="item.path"
target="_blank"
rel="noopener"
@click="handleNavigate"
>
{{ item.label
}}<span class="external-hint" aria-hidden="true">ext</span>
</a>
<NuxtLink
v-else
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
@ -56,18 +50,7 @@
<div class="sidebar-section">Navigate</div>
<ul class="sidebar-nav">
<li v-for="item in publicItems" :key="item.path">
<a
v-if="item.external"
:href="item.path"
target="_blank"
rel="noopener"
@click="handleNavigate"
>
{{ item.label
}}<span class="external-hint" aria-hidden="true">ext</span>
</a>
<NuxtLink
v-else
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
@ -94,18 +77,7 @@
<div class="sidebar-section">Navigate</div>
<ul class="sidebar-nav">
<li v-for="item in publicItems" :key="item.path">
<a
v-if="item.external"
:href="item.path"
target="_blank"
rel="noopener"
@click="handleNavigate"
>
{{ item.label
}}<span class="external-hint" aria-hidden="true">ext</span>
</a>
<NuxtLink
v-else
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
@ -133,11 +105,12 @@
<div class="sidebar-meta">
<ClientOnly>
Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br />
A Canadian nonprofit
<template #fallback>
Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a
><br />
A Canadian nonprofit
</template>
</ClientOnly>
@ -163,8 +136,8 @@ const route = useRoute();
const { isAuthenticated, memberData, logout } = useAuth();
const isDev = import.meta.dev;
const showOnboardingDot = computed(
() => isAuthenticated.value && !memberData.value?.onboarding?.completedAt,
const showOnboardingDot = computed(() =>
isAuthenticated.value && !memberData.value?.onboarding?.completedAt
);
const handleNavigate = () => {
@ -189,23 +162,28 @@ const publicItems = [
{ label: "Home", path: "/" },
{ label: "About", path: "/about" },
{ label: "Events", path: "/events" },
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
{ label: "Members", path: "/members" },
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
];
const joinItems = [{ label: "Become a member", path: "/join" }];
const joinItems = [
{ label: "Become a member", path: "/join" },
{ label: "Propose an event", path: "/events" },
];
// Logged-in nav items
const youItems = [
{ label: "Dashboard", path: "/member/dashboard" },
{ label: "Profile", path: "/member/profile" },
{ label: "Account", path: "/member/account" },
{ label: "Activity Log", path: "/member/activity" },
];
const exploreItems = [
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Board", path: "/board" },
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
{ label: "Ecology", path: "/ecology" },
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
{ label: "About", path: "/about" },
];
</script>
@ -311,28 +289,13 @@ const exploreItems = [
color: var(--candle-dim);
}
.external-hint {
font-size: 10px;
letter-spacing: 0.05em;
margin-left: 4px;
position: relative;
top: -0.5px;
}
.external-hint::before {
content: "[";
}
.external-hint::after {
content: "]";
}
.onboarding-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
margin-left: 0px;
background: var(--candle);
margin-left: 6px;
vertical-align: middle;
transform: translateY(-1px);
}
</style>

View file

@ -1,386 +0,0 @@
<template>
<article class="board-post">
<header class="post-header">
<span class="post-meta">{{ typeLabel }}</span>
<div v-if="editable && !pendingDelete" class="post-actions">
<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>
</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>
<h2 class="post-title">{{ post.title }}</h2>
<div v-if="post.seeking" class="post-block">
<div class="block-label">Seeking</div>
<p class="block-text">{{ post.seeking }}</p>
</div>
<div v-if="post.offering" class="post-block">
<div class="block-label">Offering</div>
<p class="block-text">{{ post.offering }}</p>
</div>
<p v-if="post.note" class="post-note">{{ post.note }}</p>
<div v-if="post.tags && post.tags.length" class="post-tags">
<span v-for="slug in post.tags" :key="slug" class="tag-pill">{{ tagLabel(slug) }}</span>
</div>
<footer class="post-footer">
<div class="author">
<img
v-if="authorAvatar"
:src="authorAvatar"
:alt="post.author.name"
class="author-avatar"
>
<span v-else class="author-avatar avatar-placeholder" aria-hidden="true">{{ authorInitial }}</span>
<span class="author-name">{{ post.author ? post.author.name : 'Unknown' }}</span>
<span v-if="slackHandle" class="slack-handle-wrap">
<button
type="button"
class="slack-handle"
:title="copied ? 'Copied!' : 'Click to copy Slack handle'"
@click="copySlackHandle"
>@{{ slackHandle }}</button>
<button
type="button"
class="copy-link"
:class="{ copied }"
@click="copySlackHandle"
>{{ copied ? 'Copied!' : 'Copy' }}</button>
</span>
</div>
<a
v-if="slackLinks.length === 1"
:href="slackLinks[0].url"
target="_blank"
rel="noopener"
class="slack-link"
>Discuss in #{{ slackLinks[0].name }} &rarr;</a>
<details v-else-if="slackLinks.length > 1" class="slack-menu">
<summary class="slack-link">Discuss on Slack &#9662;</summary>
<ul class="slack-menu-list">
<li v-for="link in slackLinks" :key="link.id">
<a :href="link.url" target="_blank" rel="noopener" class="slack-link">#{{ link.name }}</a>
</li>
</ul>
</details>
</footer>
</article>
</template>
<script setup>
const props = defineProps({
post: { type: Object, required: true },
channels: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
editable: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false },
})
defineEmits(['edit', 'delete', 'confirm-delete', 'cancel-delete'])
const { slackUrl } = useBoardChannels()
const capitalizeAvatar = (str) => {
if (str.toLowerCase() === 'wtf') return 'WTF'
return str
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
const authorAvatar = computed(() => {
const a = props.post.author?.avatar
if (!a) return null
return `/ghosties/Ghost-${capitalizeAvatar(a)}.png`
})
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 copySlackHandle = async () => {
if (!slackHandle.value) return
try {
await navigator.clipboard.writeText(`@${slackHandle.value}`)
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
} catch {
// clipboard unavailable
}
}
const tagLabelMap = computed(() => {
const map = {}
for (const t of props.tags) map[t.slug] = t.label || t.name || t.slug
return map
})
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
const hasSeeking = computed(() => !!(props.post.seeking && props.post.seeking.trim()))
const hasOffering = computed(() => !!(props.post.offering && props.post.offering.trim()))
const typeLabel = computed(() => {
if (hasSeeking.value && hasOffering.value) return 'SEEKING + OFFERING'
if (hasSeeking.value) return 'SEEKING'
if (hasOffering.value) return 'OFFERING'
return ''
})
const slackLinks = computed(() => {
const postTags = props.post.tags || []
if (!postTags.length) return []
return props.channels
.filter((c) => {
if (!c.slackChannelId) return false
const slugs = c.tagSlugs || []
return slugs.some((s) => postTags.includes(s))
})
.map((c) => ({
id: c.slackChannelId,
name: c.slackChannelName || c.name || c.slackChannelId,
url: slackUrl(c.slackChannelId),
}))
})
</script>
<style scoped>
.board-post {
border: 1px dashed var(--border);
padding: 20px 24px;
background: var(--surface);
break-inside: avoid;
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
}
.post-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 6px;
}
.post-meta {
font-family: "Commit Mono", monospace;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
}
.post-actions {
display: flex;
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 {
font-family: "Commit Mono", monospace;
font-size: 10px;
letter-spacing: 0.04em;
padding: 3px 9px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: all 0.12s;
}
.action-btn:hover {
color: var(--text-bright);
border-color: var(--border-d);
}
.action-btn.danger:hover {
color: var(--ember);
border-color: var(--ember);
}
.action-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.post-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 12px;
line-height: 1.2;
}
.post-block {
margin-bottom: 10px;
}
.block-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
margin-bottom: 2px;
}
.block-text {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
}
.post-note {
font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-style: italic;
margin: 8px 0;
white-space: pre-wrap;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 10px 0;
}
.tag-pill {
display: inline-block;
font-size: 10px;
font-family: "Commit Mono", monospace;
color: var(--text-dim);
padding: 2px 8px;
border: 1px dashed var(--border);
}
.post-footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.author {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
}
.author-avatar {
width: 20px;
height: 20px;
object-fit: cover;
}
.avatar-placeholder {
background: transparent;
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 {
font-size: 11px;
color: var(--text-dim);
font-family: "Commit Mono", monospace;
}
.slack-handle-wrap {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.slack-handle {
font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-family: "Commit Mono", monospace;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
.slack-handle:hover {
color: var(--candle);
}
.slack-handle:focus-visible,
.copy-link:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.copy-link {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--candle);
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.copy-link:hover {
color: var(--candle-dim);
}
.copy-link.copied {
color: var(--candle);
text-decoration: none;
}
.slack-menu {
position: relative;
}
.slack-menu > summary {
list-style: none;
cursor: pointer;
}
.slack-menu > summary::-webkit-details-marker {
display: none;
}
.slack-menu-list {
position: absolute;
right: 0;
top: 100%;
margin-top: 6px;
padding: 6px 10px;
list-style: none;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
flex-direction: column;
gap: 4px;
white-space: nowrap;
z-index: 10;
}
.slack-link {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--candle);
text-decoration: none;
border-bottom: 1px dashed var(--candle-faint);
}
.slack-link:hover {
color: var(--candle-dim);
text-decoration: none;
border-bottom-style: solid;
}
</style>

View file

@ -1,265 +0,0 @@
<template>
<form class="post-form" @submit.prevent="handleSubmit">
<div class="form-header">
<h2 class="form-title">{{ isEdit ? 'Edit post' : 'New post' }}</h2>
<p class="form-hint">Fill in <em>Seeking</em> or <em>Offering</em> (or both).</p>
</div>
<div class="field">
<label for="post-title">Title</label>
<input
id="post-title"
v-model="form.title"
type="text"
maxlength="120"
placeholder="Short summary"
>
</div>
<div class="field-row">
<div class="field">
<label for="post-seeking">Seeking <span class="opt">(optional)</span></label>
<textarea
id="post-seeking"
v-model="form.seeking"
rows="2"
maxlength="500"
placeholder="What are you looking for?"
/>
</div>
<div class="field">
<label for="post-offering">Offering <span class="opt">(optional)</span></label>
<textarea
id="post-offering"
v-model="form.offering"
rows="2"
maxlength="500"
placeholder="What can you offer?"
/>
</div>
</div>
<div class="field">
<label for="post-note">Note <span class="opt">(optional)</span></label>
<textarea
id="post-note"
v-model="form.note"
rows="2"
maxlength="300"
placeholder="Anything else to add?"
/>
</div>
<div v-if="tags.length" class="field">
<label>Tags</label>
<div class="pill-grid">
<button
v-for="tag in tags"
:key="tag.slug"
type="button"
class="pill"
:class="{ selected: form.tags.includes(tag.slug) }"
@click="toggleTag(tag.slug)"
>{{ tag.label || tag.name || tag.slug }}</button>
</div>
</div>
<p v-if="error" class="form-error">{{ error }}</p>
<div class="form-actions">
<button type="button" class="btn" @click="$emit('cancel')">Cancel</button>
<button type="submit" class="btn btn-primary">
{{ isEdit ? 'Save changes' : 'Post' }}
</button>
</div>
</form>
</template>
<script setup>
const props = defineProps({
post: { type: Object, default: null },
tags: { type: Array, default: () => [] },
})
const emit = defineEmits(['submit', 'cancel'])
const isEdit = computed(() => !!props.post)
const form = reactive({
title: props.post?.title || '',
seeking: props.post?.seeking || '',
offering: props.post?.offering || '',
note: props.post?.note || '',
tags: Array.isArray(props.post?.tags) ? [...props.post.tags] : [],
})
const error = ref('')
watch(() => props.post, (p) => {
form.title = p?.title || ''
form.seeking = p?.seeking || ''
form.offering = p?.offering || ''
form.note = p?.note || ''
form.tags = Array.isArray(p?.tags) ? [...p.tags] : []
}, { immediate: false })
function toggleTag(slug) {
const idx = form.tags.indexOf(slug)
if (idx === -1) form.tags.push(slug)
else form.tags.splice(idx, 1)
}
function handleSubmit() {
error.value = ''
const title = form.title.trim()
const seeking = form.seeking.trim()
const offering = form.offering.trim()
if (!title) {
error.value = 'Title is required.'
return
}
if (!seeking && !offering) {
error.value = 'Add at least one of Seeking or Offering.'
return
}
emit('submit', {
title,
seeking,
offering,
note: form.note.trim(),
tags: [...form.tags],
})
}
</script>
<style scoped>
.post-form {
border: 1px dashed var(--border);
padding: 16px 16px;
background: transparent;
}
.form-header {
margin-bottom: 10px;
}
.form-title {
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
}
.form-hint {
font-size: 11px;
color: var(--text-faint);
font-family: "Commit Mono", monospace;
margin-top: 2px;
}
.form-hint em {
color: var(--text-dim);
font-style: normal;
}
.field {
margin-bottom: 8px;
flex: 1;
min-width: 0;
}
.field-row {
display: flex;
gap: 12px;
}
.field label {
display: block;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 3px;
}
.field label .opt {
color: var(--text-faint);
text-transform: none;
letter-spacing: 0;
font-size: 10px;
margin-left: 4px;
opacity: 0.7;
}
.field input,
.field textarea {
width: 100%;
padding: 4px 8px;
font-family: "Commit Mono", monospace;
font-size: 12px;
color: var(--text-bright);
background: var(--input-bg);
border: 1px solid var(--border);
outline: none;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
border-color: var(--candle);
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-faint);
font-size: 10px;
font-family: "Commit Mono", monospace;
cursor: pointer;
user-select: none;
transition: all 0.12s;
white-space: nowrap;
}
.pill:hover {
color: var(--text-dim);
border-color: var(--border-d);
}
.pill.selected {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
}
.pill:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.form-error {
font-size: 11px;
color: var(--ember);
margin: 8px 0;
padding: 6px 10px;
border: 1px dashed var(--ember);
background: var(--ember-bg);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--border);
}
@media (max-width: 640px) {
.field-row {
flex-direction: column;
gap: 0;
}
}
</style>

View file

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

View file

@ -29,14 +29,13 @@ const props = defineProps({
limit: { type: Number, default: 3 },
})
let upcomingEvents = ref([])
const upcomingEvents = ref([])
if (props.cols === 'events-sidebar') {
const { data } = await useFetch('/api/events', {
query: { upcoming: true, limit: props.limit },
default: () => [],
server: false,
})
upcomingEvents = computed(() => data.value || [])
upcomingEvents.value = data.value || []
}
</script>
@ -54,7 +53,6 @@ if (props.cols === 'events-sidebar') {
/* cols="events-sidebar" */
.columns-events-sidebar {
grid-template-columns: 1fr 200px;
flex: 1;
}
/* Ensure grid children don't overflow */
@ -62,14 +60,11 @@ if (props.cols === 'events-sidebar') {
min-width: 0;
}
/* Dashed divider: right border on the first column child (except events-sidebar, which owns its own border-left) */
/* Dashed divider: right border on the first column child */
.divider-dashed .col:first-child,
.divider-dashed .col-main {
border-right: 1px dashed var(--border);
}
.divider-dashed.columns-events-sidebar .col-main {
border-right: none;
}
/* Responsive collapse at 1024px (default) */
.collapse-1024 {

View file

@ -1,17 +1,23 @@
<template>
<div class="coop-tag-selector">
<div class="pill-grid">
<button
v-for="tag in tags"
:key="tag.slug"
type="button"
class="pill"
:class="{ selected: modelValue.includes(tag.slug) }"
@click="toggle(tag.slug)"
>{{ tag.label || tag.name || tag.slug }}</button>
<div
v-for="tag in tags"
:key="tag.slug"
class="coop-row"
>
<span class="tag-label">{{ tag.label }}</span>
<div class="segmented">
<span
v-for="opt in options"
:key="opt.value"
class="seg-option"
:class="{ on: getState(tag.slug) === opt.value }"
@click="toggleState(tag.slug, opt.value)"
>{{ opt.label }}</span>
</div>
</div>
<div class="suggest-link">
<button type="button" class="suggest-btn" @click="$emit('suggest')">Don't see what you're looking for?</button>
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
</div>
</div>
</template>
@ -24,15 +30,32 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue", "suggest"]);
function toggle(slug) {
const options = [
{ label: "Can help", value: "help" },
{ label: "Interested", value: "interested" },
{ label: "Need help", value: "seeking" },
];
function getState(slug) {
const entry = props.modelValue.find((e) => e.tagSlug === slug);
return entry ? entry.state : null;
}
function toggleState(slug, value) {
const current = [...props.modelValue];
const idx = current.indexOf(slug);
if (idx === -1) {
emit("update:modelValue", [...current, slug]);
const idx = current.findIndex((e) => e.tagSlug === slug);
const existingState = idx !== -1 ? current[idx].state : null;
if (existingState === value) {
// clicking active state deselects it
if (idx !== -1) current.splice(idx, 1);
} else if (idx !== -1) {
current[idx] = { tagSlug: slug, state: value };
} else {
current.splice(idx, 1);
emit("update:modelValue", current);
current.push({ tagSlug: slug, state: value });
}
emit("update:modelValue", current);
}
</script>
@ -40,60 +63,87 @@ function toggle(slug) {
.coop-tag-selector {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0;
}
.pill-grid {
.coop-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 0;
border-bottom: 1px dashed var(--border);
}
.pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-faint);
.coop-row:first-child {
border-top: 1px dashed var(--border);
}
.tag-label {
font-size: 11px;
font-family: "Commit Mono", monospace;
cursor: pointer;
user-select: none;
transition: all 0.12s;
white-space: nowrap;
}
.pill:hover {
color: var(--text-dim);
border-color: var(--border-d);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill.selected {
.segmented {
display: inline-flex;
gap: 0;
flex-shrink: 0;
}
.seg-option {
padding: 2px 7px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
color: var(--text-faint);
font-size: 9px;
font-family: "Commit Mono", monospace;
letter-spacing: 0.02em;
cursor: pointer;
transition: all 0.12s;
user-select: none;
white-space: nowrap;
position: relative;
}
.seg-option + .seg-option {
margin-left: -1px;
}
.seg-option:hover {
color: var(--text-dim);
}
.seg-option.on {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
z-index: 1;
}
.suggest-link {
margin-top: 2px;
}
.suggest-btn {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 10px;
font-family: "Commit Mono", monospace;
color: var(--text-faint);
margin-top: 8px;
}
.suggest-link span {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.suggest-btn:hover {
.suggest-link span:hover {
color: var(--text-dim);
}
</style>

View file

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

View file

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

View file

@ -1,42 +1,72 @@
<template>
<div
class="ticket-card"
:class="{
'is-selected': isSelected,
'is-unavailable': !isAvailable || alreadyRegistered,
}"
class="ticket-card rounded-xl border p-6 transition-all duration-200"
:class="[
isSelected
? 'border-primary bg-primary/5'
: 'border-guild-600 bg-guild-800/50',
isAvailable && !alreadyRegistered
? 'hover:border-primary/50 cursor-pointer'
: 'opacity-60 cursor-not-allowed',
]"
@click="handleClick"
>
<!-- Ticket Header -->
<div class="ticket-header">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
<p v-if="ticketInfo.description" class="ticket-desc">
<h3 class="text-lg font-semibold text-guild-100">
{{ ticketInfo.name }}
</h3>
<p v-if="ticketInfo.description" class="text-sm text-guild-300 mt-1">
{{ ticketInfo.description }}
</p>
</div>
<span v-if="ticketInfo.isMember" class="badge">Members Only</span>
<!-- Badge -->
<div v-if="ticketInfo.isMember" class="flex-shrink-0 ml-4">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-500 dark:bg-candlelight-900/30 dark:text-candlelight-400"
>
Members Only
</span>
</div>
</div>
<!-- Price Display -->
<div class="ticket-price-block">
<div class="ticket-price-row">
<div class="mb-4">
<div class="flex items-baseline gap-2">
<span
class="ticket-price"
:class="{ 'is-free': ticketInfo.isFree }"
class="text-3xl font-bold text-ui-mono"
:class="ticketInfo.isFree ? 'text-candlelight-400' : 'text-guild-100'"
>
{{ ticketInfo.formattedPrice }}
</span>
<span v-if="ticketInfo.isEarlyBird" class="badge early-bird">
<!-- Early Bird Badge -->
<span
v-if="ticketInfo.isEarlyBird"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-600 dark:bg-candlelight-900/35 dark:text-candlelight-400"
>
Early Bird
</span>
</div>
<div v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice" class="ticket-regular-price">
Regular: {{ ticketInfo.formattedRegularPrice }}
<!-- Regular Price (if early bird) -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
class="mt-1"
>
<span class="text-sm text-guild-400 line-through">
Regular: {{ ticketInfo.formattedRegularPrice }}
</span>
</div>
<div v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" class="ticket-deadline">
<!-- Early Bird Countdown -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
class="mt-2 text-xs text-candlelight-500 dark:text-candlelight-400"
>
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
</div>
</div>
@ -44,38 +74,59 @@
<!-- Member Savings -->
<div
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
class="ticket-savings"
class="mb-4 p-3 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
>
<p>You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!</p>
<p class="ticket-savings-detail">
<p class="text-sm text-candlelight-400">
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
</p>
<p class="text-xs text-guild-400 mt-1">
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
</p>
</div>
<!-- Availability -->
<div class="ticket-availability">
<span v-if="alreadyRegistered" class="status-registered">
You're registered
</span>
<span v-else-if="!isAvailable" class="status-sold-out">
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="status-remaining">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="status-remaining">
Unlimited availability
</span>
<div class="flex items-center justify-between text-sm">
<div>
<span
v-if="alreadyRegistered"
class="text-candlelight-400 flex items-center gap-1"
>
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
You're registered
</span>
<span
v-else-if="!isAvailable"
class="text-ember-400 flex items-center gap-1"
>
<Icon name="heroicons:x-circle-solid" class="w-4 h-4" />
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="text-guild-300">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="text-guild-300"> Unlimited availability </span>
</div>
<!-- Selection Indicator -->
<div v-if="isSelected && isAvailable && !alreadyRegistered">
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
</div>
</div>
<!-- Waitlist Option -->
<div
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
class="ticket-waitlist"
class="mt-4 pt-4 border-t border-guild-600"
>
<button class="btn" @click.stop="$emit('join-waitlist')">
<UButton
color="gray"
size="sm"
block
@click.stop="$emit('join-waitlist')"
>
Join Waitlist
</button>
</UButton>
</div>
</div>
</template>
@ -113,11 +164,13 @@ const formatDeadline = (deadline) => {
const now = new Date();
const diff = date - now;
// If less than 24 hours, show hours
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
}
// Otherwise show date
return `on ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@ -134,103 +187,6 @@ const formatPrice = (amount) => {
<style scoped>
.ticket-card {
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
transition: border-color 0.15s;
cursor: default;
}
.ticket-card.is-selected {
border-color: var(--candle-faint);
}
.ticket-card.is-unavailable {
opacity: 0.6;
cursor: not-allowed;
}
.ticket-card:not(.is-unavailable) {
cursor: pointer;
}
.ticket-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.ticket-name {
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
}
.ticket-desc {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.ticket-price-block {
margin-bottom: 10px;
}
.ticket-price-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.ticket-price {
font-size: 22px;
font-weight: 600;
color: var(--text-bright);
}
.ticket-price.is-free {
color: var(--candle);
}
.ticket-regular-price {
font-size: 11px;
color: var(--text-faint);
text-decoration: line-through;
margin-top: 2px;
}
.ticket-deadline {
font-size: 10px;
color: var(--candle-dim);
margin-top: 4px;
}
.early-bird {
color: var(--candle-dim);
border-color: var(--candle-faint);
}
.ticket-savings {
border: 1px dashed var(--candle-faint);
padding: 8px 12px;
margin-bottom: 10px;
font-size: 11px;
color: var(--candle);
}
.ticket-savings-detail {
font-size: 10px;
color: var(--text-faint);
margin-top: 2px;
}
.ticket-availability {
font-size: 11px;
}
.status-registered {
color: var(--green);
}
.status-sold-out {
color: var(--ember);
}
.status-remaining {
color: var(--text-dim);
}
.ticket-waitlist {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
position: relative;
}
</style>

View file

@ -1,47 +1,65 @@
<template>
<div class="event-ticket-purchase">
<!-- Loading State -->
<div v-if="loading" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status">Loading ticket information...</p>
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-guild-300">Loading ticket information...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)">
<div
v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
Unable to Load Tickets
</p>
<p class="ticket-detail">{{ error }}</p>
</h3>
<p class="text-ember-400">{{ error }}</p>
</div>
<!-- Series Pass Required -->
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--candle)">
<div
v-else-if="ticketInfo?.requiresSeriesPass"
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
>
<h3
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:ticket" class="w-6 h-6" />
Series Pass Required
</p>
<p class="ticket-detail">
</h3>
<p class="text-candlelight-400 mb-4">
This event is part of
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
pass to attend.
</p>
<p class="ticket-hint">
<p class="text-sm text-guild-300 mb-6">
Purchase a series pass to get access to all events in this series.
</p>
<NuxtLink
<UButton
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
color="primary"
size="lg"
block
>
<button class="btn btn-primary">View Series &amp; Purchase Pass</button>
</NuxtLink>
View Series & Purchase Pass
</UButton>
</div>
<!-- Already Registered -->
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
<p class="ticket-status" style="color: var(--green)">
<div
v-else-if="ticketInfo?.alreadyRegistered"
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
>
<h3
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
You're Registered!
</p>
<p class="ticket-detail">
</h3>
<p class="text-candlelight-400 mb-4">
<template v-if="ticketInfo.viaSeriesPass">
You have access to this event via your series pass for
<strong>{{ ticketInfo.series?.title }}</strong
@ -52,7 +70,7 @@
details.
</template>
</p>
<p class="ticket-hint">
<p class="text-sm text-guild-300">
See you on {{ formatEventDate(eventStartDate) }}!
</p>
</div>
@ -65,145 +83,128 @@
:is-selected="true"
:is-available="ticketInfo.available"
:already-registered="ticketInfo.alreadyRegistered"
class="mb-6"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration (logged-in member) -->
<div
v-if="
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
"
class="ticket-panel"
>
<p
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
This event is free for Ghost Guild members
</p>
<p
v-if="!ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
Payment of {{ ticketInfo.formattedPrice }} will be processed securely
</p>
<button
class="btn btn-primary"
:disabled="processing"
@click="handleSubmit"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Register for this event"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
</div>
<!-- Registration Form (guest) -->
<div
v-else-if="ticketInfo.available && !ticketInfo.alreadyRegistered"
class="ticket-panel"
>
<div class="box-title">
<!-- Registration Form -->
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
<h3 class="text-xl font-bold text-guild-100 mb-4">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</div>
</h3>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="ticket-name">Full Name</label>
<input
id="ticket-name"
v-model="form.name"
name="name"
type="text"
autocomplete="name"
required
:disabled="processing"
/>
</div>
<div class="field">
<label for="ticket-email">Email Address</label>
<input
id="ticket-email"
v-model="form.email"
name="email"
type="email"
autocomplete="email"
required
:disabled="processing"
/>
</div>
<p
v-if="!ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</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
>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-guild-200 mb-2"
>
Full Name
</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.
<UInput
id="name"
v-model="form.name"
type="text"
required
placeholder="Enter your full name"
:disabled="processing"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-guild-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="form.email"
type="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
/>
<p v-if="isLoggedIn" class="text-xs text-guild-400 mt-1">
Using your member email
</p>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="processing || !form.name || !form.email"
<!-- Member Benefits Notice -->
<div
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
<p class="text-sm text-candlelight-300 flex items-center gap-2">
<Icon name="heroicons:sparkles" class="w-4 h-4" />
This event is free for Ghost Guild members
</p>
</div>
<!-- Payment Required Notice -->
<div
v-if="!ticketInfo.isFree"
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
>
<p class="text-sm text-candlelight-300 flex items-center gap-2">
<Icon name="heroicons:credit-card" class="w-4 h-4" />
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
</div>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="processing"
:disabled="!form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</UButton>
</div>
</form>
</div>
<!-- Sold Out with Waitlist -->
<div
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
class="ticket-panel"
class="text-center py-8"
>
<div class="box-title">Waitlist</div>
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
<p class="ticket-detail">
<Icon
name="heroicons:ticket"
class="w-16 h-16 text-guild-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
<p class="text-guild-300 mb-6">
This event is currently at capacity. Join the waitlist to be notified
if spots become available.
</p>
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
Join Waitlist
</UButton>
</div>
<!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
<p class="ticket-detail">
<div v-else-if="!ticketInfo.available" class="text-center py-8">
<Icon
name="heroicons:x-circle"
class="w-16 h-16 text-ember-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
<p class="text-guild-300">
Unfortunately, this event is at capacity and no longer accepting
registrations.
</p>
@ -219,25 +220,17 @@ const props = defineProps({
required: true,
},
eventStartDate: {
type: [String, Date],
type: Date,
required: true,
},
eventTitle: {
type: String,
required: true,
},
eventTimezone: {
type: String,
default: "America/Toronto",
},
userEmail: {
type: String,
default: null,
},
userName: {
type: String,
default: null,
},
});
const emit = defineEmits(["success", "error"]);
@ -252,9 +245,8 @@ const error = ref(null);
const ticketInfo = ref(null);
const form = ref({
name: props.userName || "",
name: "",
email: props.userEmail || "",
createAccount: true,
});
const isLoggedIn = computed(() => !!props.userEmail);
@ -264,13 +256,11 @@ onMounted(async () => {
await fetchTicketInfo();
});
const fetchTicketInfo = async (emailOverride = null) => {
const fetchTicketInfo = async () => {
loading.value = true;
error.value = null;
try {
const effectiveEmail = emailOverride || props.userEmail;
// First check if this event requires a series pass
if (props.userEmail) {
try {
@ -280,6 +270,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
if (seriesAccess.requiresSeriesPass) {
if (seriesAccess.hasSeriesPass) {
// User has series pass - show as already registered
ticketInfo.value = {
available: true,
alreadyRegistered: true,
@ -290,6 +281,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
loading.value = false;
return;
} else {
// User needs to buy series pass
ticketInfo.value = {
available: false,
requiresSeriesPass: true,
@ -301,14 +293,13 @@ const fetchTicketInfo = async (emailOverride = null) => {
}
}
} catch (seriesErr) {
// If series check fails, continue with regular ticket check
console.warn("Series access check failed:", seriesErr);
}
}
// Regular ticket availability check
const params = effectiveEmail
? `?email=${encodeURIComponent(effectiveEmail)}`
: "";
const params = props.userEmail ? `?email=${props.userEmail}` : "";
const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`,
);
@ -329,19 +320,24 @@ const handleSubmit = async () => {
try {
let transactionId = null;
// If payment is required, initialize Helcim and process payment
if (!ticketInfo.value.isFree) {
// Initialize Helcim payment
await initializeTicketPayment(
props.eventId,
form.value.email,
ticketInfo.value.price,
props.eventTitle,
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
// For purchase transactions, we get a transactionId
transactionId = paymentResult.transactionId;
if (!transactionId) {
@ -349,38 +345,32 @@ const handleSubmit = async () => {
}
}
const body = {
name: form.value.name,
email: form.value.email,
createAccount: form.value.createAccount,
};
if (transactionId) body.transactionId = transactionId;
// Purchase ticket
const response = await $fetch(
`/api/events/${props.eventId}/tickets/purchase`,
{
method: "POST",
body,
body: {
name: form.value.name,
email: form.value.email,
transactionId,
},
},
);
// Success!
toast.add({
title: "Success!",
description: ticketInfo.value.isFree
? "You're registered for this event"
: "Ticket purchased successfully!",
color: "success",
color: "green",
});
emit("success", response);
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);
// Refresh ticket info to show registered state
await fetchTicketInfo();
} catch (err) {
console.error("Error purchasing ticket:", err);
@ -392,7 +382,7 @@ const handleSubmit = async () => {
toast.add({
title: "Registration Failed",
description: errorMessage,
color: "error",
color: "red",
});
emit("error", err);
@ -403,10 +393,11 @@ const handleSubmit = async () => {
};
const handleJoinWaitlist = () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist",
description: "Waitlist functionality coming soon!",
color: "info",
color: "blue",
});
};
@ -416,64 +407,6 @@ const formatEventDate = (date) => {
month: "long",
day: "numeric",
year: "numeric",
timeZone: props.eventTimezone || "America/Toronto",
});
};
</script>
<style scoped>
.ticket-panel {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.ticket-status {
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.ticket-detail {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 10px;
line-height: 1.6;
}
.ticket-hint {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 10px;
}
.ticket-notice {
font-size: 11px;
margin-bottom: 10px;
}
.field-hint {
font-size: 10px;
color: var(--text-faint);
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>

View file

@ -6,7 +6,7 @@
<div v-if="events?.length" class="em-rows">
<div v-for="event in events" :key="event._id" class="em-item">
<div class="em-inset em-item-body">
<span class="em-date">{{ formatDate(event) }}</span>
<span class="em-date">{{ formatDate(event.startDate) }}</span>
<NuxtLink
:to="`/events/${event.slug || event._id}`"
class="em-title"
@ -37,13 +37,10 @@ defineProps({
events: { type: Array, default: () => [] },
});
const formatDate = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
</script>
@ -107,7 +104,7 @@ const formatDate = (event) => {
}
.em-circle {
font-size: 10px;
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 2px;

View file

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

View file

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

View file

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

View file

@ -1,40 +1,67 @@
<template>
<div class="natural-date-input">
<UInput
:model-value="rawInput"
:placeholder="placeholder"
:color="trailingState"
@update:model-value="onInputChange"
<div class="space-y-2">
<div class="relative">
<UInput
v-model="naturalInput"
:placeholder="placeholder"
:color="
hasError && naturalInput.trim()
? 'error'
: isValidParse && naturalInput.trim()
? 'success'
: undefined
"
@input="parseNaturalInput"
@blur="onBlur"
>
<template #trailing>
<Icon
v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5 text-candlelight-500"
/>
<Icon
v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5 text-ember-500"
/>
</template>
</UInput>
</div>
<div
v-if="parsedDate && isValidParse"
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800"
>
<template #trailing>
<Icon
v-if="isValid && rawInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5"
style="color: var(--candle)"
<div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" />
<span>{{ formatParsedDate(parsedDate) }}</span>
</div>
</div>
<div
v-if="hasError && naturalInput.trim()"
class="text-sm text-ember-400 bg-ember-900/20 px-3 py-2 rounded-lg border border-ember-800"
>
<div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer text-guild-400 hover:text-guild-100">
Use traditional date picker
</summary>
<div class="mt-2">
<UInput
v-model="datetimeValue"
type="datetime-local"
@change="onDatetimeChange"
/>
<Icon
v-else-if="hasError && rawInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5"
style="color: var(--ember)"
/>
</template>
</UInput>
<p
v-if="rawInput.trim() && isValid"
class="preview-line"
style="color: var(--candle)"
>
&rarr; {{ previewText }}
</p>
<p
v-else-if="rawInput.trim() && hasError"
class="preview-line"
style="color: var(--ember)"
>
{{ errorMessage }}
</p>
</div>
</details>
</div>
</template>
@ -42,197 +69,176 @@
import * as chrono from "chrono-node";
const props = defineProps({
modelValue: { type: String, default: "" },
modelValue: {
type: String,
default: "",
},
placeholder: {
type: String,
default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
},
inputClass: {
type: [String, Object],
default: "",
},
required: {
type: Boolean,
default: false,
},
displayTimezone: { type: String, default: "" },
required: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const rawInput = ref("");
const isValid = ref(false);
const naturalInput = ref("");
const parsedDate = ref(null);
const isValidParse = ref(false);
const hasError = ref(false);
const errorMessage = ref("");
// previewDate holds the parsed value as a UTC Date so we can format it in
// arbitrary timezones without re-parsing. Source of truth for the preview.
const previewDate = ref(null);
const datetimeValue = ref("");
const trailingState = computed(() => {
if (!rawInput.value.trim()) return undefined;
if (hasError.value) return "error";
if (isValid.value) return "success";
return undefined;
});
const previewText = computed(() => {
if (!previewDate.value) return "";
const tz = activeTZ();
const date = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(previewDate.value);
const abbr = shortTimezoneName(previewDate.value, tz);
return abbr ? `${date} ${abbr}` : date;
});
const activeTZ = () =>
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Seed the input from modelValue without triggering chrono. The parent's
// value is canonical we just render it as a chrono-friendly readable
// string so the user can backspace and tweak in place.
const seedFromModelValue = () => {
if (!props.modelValue) {
rawInput.value = "";
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
return;
// Initialize with current value
onMounted(() => {
if (props.modelValue) {
const date = new Date(props.modelValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
}
}
const tz = activeTZ();
const utc = zonedLocalToUTC(props.modelValue, tz);
if (!utc) return;
previewDate.value = utc;
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
rawInput.value = readableSeed(utc, tz);
};
onMounted(seedFromModelValue);
});
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(next) => {
const tz = activeTZ();
const expected = previewDate.value
? utcToZonedLocal(previewDate.value, tz)
: "";
if (next === expected) return;
seedFromModelValue();
(newValue) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
const date = new Date(newValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
naturalInput.value = ""; // Clear natural input when set externally
}
} else if (!newValue) {
reset();
}
},
);
watch(
() => props.displayTimezone,
() => {
// Re-interpret the current input under the new TZ so the preview and
// emitted value reflect the new timezone semantics.
if (rawInput.value.trim()) parse(rawInput.value);
},
);
const parseNaturalInput = () => {
const input = naturalInput.value.trim();
const onInputChange = (value) => {
rawInput.value = value;
parse(value);
if (!input) {
reset();
return;
}
try {
// Parse with chrono-node
const results = chrono.parse(input);
if (results.length > 0) {
const result = results[0];
const date = result.date();
// Validate the parsed date
if (date && !isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
datetimeValue.value = formatForDatetimeLocal(date);
emit("update:modelValue", formatForDatetimeLocal(date));
} else {
setError("Could not parse this date format");
}
} else {
setError(
'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"',
);
}
} catch (error) {
setError("Error parsing date");
}
};
const parse = (input) => {
const trimmed = input.trim();
if (!trimmed) {
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
emit("update:modelValue", "");
return;
const onBlur = () => {
// If we have a valid parse but the input changed, try to parse again
if (naturalInput.value.trim() && !isValidParse.value) {
parseNaturalInput();
}
const tz = activeTZ();
let results;
try {
results = chrono.parse(trimmed, referenceNowInTZ(tz));
} catch {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
};
const onDatetimeChange = () => {
if (datetimeValue.value) {
const date = new Date(datetimeValue.value);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
naturalInput.value = ""; // Clear natural input when using traditional picker
emit("update:modelValue", datetimeValue.value);
}
} else {
reset();
}
if (!results.length) {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
const date = results[0].date();
if (!date || Number.isNaN(date.getTime())) {
setError("Couldn't read that date");
return;
}
// chrono returned a Date whose browser-local components match what the
// user typed in the event timezone (because we shifted the reference).
// Read those components as wall-clock in displayTimezone.
const localStr = browserComponentsToString(date);
const utc = zonedLocalToUTC(localStr, tz);
if (!utc) {
setError("Couldn't parse this date");
return;
}
isValid.value = true;
};
const reset = () => {
parsedDate.value = null;
isValidParse.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = utc;
emit("update:modelValue", localStr);
};
const setError = (msg) => {
isValid.value = false;
hasError.value = true;
errorMessage.value = msg;
previewDate.value = null;
emit("update:modelValue", "");
};
// Build a Date object whose browser-local components equal the current
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
// Friday" anchor to the event TZ rather than the editor's browser TZ.
const referenceNowInTZ = (tz) => {
const nowStr = utcToZonedLocal(new Date(), tz);
if (!nowStr) return new Date();
const [d, t] = nowStr.split("T");
const [y, mo, day] = d.split("-").map(Number);
const [h, mi] = t.split(":").map(Number);
return new Date(y, mo - 1, day, h, mi);
const setError = (message) => {
isValidParse.value = false;
hasError.value = true;
errorMessage.value = message;
parsedDate.value = null;
};
const browserComponentsToString = (date) => {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${d}T${h}:${mi}`;
const formatForDatetimeLocal = (date) => {
if (!date) return "";
// Format as YYYY-MM-DDTHH:MM for datetime-local input
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const readableSeed = (utc, tz) => {
// Format chosen to round-trip cleanly through chrono.parse.
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
month: "short",
day: "numeric",
year: "numeric",
const formatParsedDate = (date) => {
if (!date) return "";
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const timeStr = date.toLocaleString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(utc);
});
if (isToday) {
return `Today at ${timeStr}`;
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`;
} else {
return date.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
};
</script>
<style scoped>
.natural-date-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
font-size: 12px;
margin: 0;
}
</style>

View file

@ -1,176 +1,215 @@
<template>
<ClientOnly>
<div v-if="!loading" class="onboarding-widget">
<div v-if="!loading" class="onboarding-widget dashed-box no-hover">
<!-- Welcome mode: onboarding in progress -->
<template v-if="!isComplete">
<div class="ow-prompt">&gt; welcome</div>
<div class="ow-message">You are in the <strong>Ghost Guild</strong>. A few passages remain unexplored.</div>
<div class="ow-hint">Next: {{ currentSuggestion.text }}</div>
<NuxtLink
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
:to="currentSuggestion.action"
class="ow-action"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
<a
v-else-if="currentSuggestion.isExternal"
:href="currentSuggestion.action"
target="_blank"
rel="noopener"
class="ow-action"
@click="trackGoal('wikiClicked')"
>
{{ currentSuggestion.actionText }} &rarr;
</a>
<div class="ow-header">
<h3 class="ow-title">Welcome to Ghost Guild</h3>
<p class="ow-intro">Get oriented here are a few things to explore as a new member.</p>
</div>
<div class="ow-suggestion">
<span class="ow-suggestion-text">{{ currentSuggestion.text }}</span>
<NuxtLink
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
:to="currentSuggestion.action"
class="btn btn-primary ow-action"
>
{{ currentSuggestion.actionText }}
</NuxtLink>
<a
v-else-if="currentSuggestion.isExternal"
href="https://wiki.ghostguild.org"
target="_blank"
rel="noopener"
class="btn btn-primary ow-action"
@click="trackGoal('wikiClicked')"
>
{{ currentSuggestion.actionText }}
</a>
</div>
<div class="ow-progress">
<span class="ow-bar"><span class="ow-bar-fill">{{ barFill }}</span><span class="ow-bar-empty">{{ barEmpty }}</span></span>
{{ completedCount }} of 4 explored
<button
v-if="currentSuggestion.key"
type="button"
class="ow-skip"
@click="handleSkip"
>Skip this</button>
<span class="ow-progress-label">{{ completedCount }} of 4</span>
<span class="ow-dots">
<span
v-for="i in 4"
:key="i"
class="ow-dot"
:class="{ 'ow-dot--done': i <= completedCount }"
/>
</span>
</div>
</template>
<!-- Suggestion mode: onboarding complete -->
<template v-else>
<!-- Empty state -->
<div v-if="currentSuggestion.key === 'empty'" class="ow-prompt">&gt; look</div>
<div v-if="currentSuggestion.key === 'empty'" class="ow-message ow-message--dim">{{ currentSuggestion.text }}</div>
<div v-if="currentSuggestion.key === 'empty'" class="ow-empty">
{{ currentSuggestion.text }}
</div>
<!-- Recommendation (event, board, or wiki) -->
<template v-if="currentSuggestion.key !== 'empty'">
<div class="ow-prompt">&gt; look</div>
<div class="ow-message">{{ currentSuggestion.text }}</div>
<a
v-if="currentSuggestion.isExternal && currentSuggestion.action"
:href="currentSuggestion.action"
target="_blank"
rel="noopener"
class="ow-action"
>
{{ currentSuggestion.actionText }} &rarr;
</a>
<!-- Event recommendation -->
<div v-else-if="currentSuggestion.key === 'event'" class="ow-rec">
<div class="section-label">Suggested</div>
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
<NuxtLink
v-else-if="currentSuggestion.action"
:to="currentSuggestion.action"
class="ow-action"
class="ow-rec-link"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
</template>
</div>
<!-- Ecology recommendation -->
<div v-else-if="currentSuggestion.key === 'ecology'" class="ow-rec">
<div class="section-label">Suggested</div>
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
<NuxtLink
:to="currentSuggestion.action"
class="ow-rec-link"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
</div>
<!-- Wiki recommendation -->
<div v-else-if="currentSuggestion.key === 'wiki'" class="ow-rec">
<div class="section-label">Suggested</div>
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
<a
v-if="currentSuggestion.action"
:href="currentSuggestion.action"
target="_blank"
rel="noopener"
class="ow-rec-link"
@click="trackGoal('wikiClicked')"
>
{{ currentSuggestion.actionText }} &rarr;
</a>
</div>
</template>
</div>
</ClientOnly>
</template>
<script setup>
const { goals, isComplete, currentSuggestion, trackGoal, skipSuggestion, loading } = useOnboarding()
const handleSkip = () => {
const key = currentSuggestion.value?.key
if (key) skipSuggestion(key)
}
const { goals, isComplete, currentSuggestion, trackGoal, loading } = useOnboarding()
const completedCount = computed(() => {
const g = goals.value
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedBoard, g.hasClickedWiki]
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedEcology, g.hasClickedWiki]
.filter(Boolean).length
})
const barFill = computed(() => '[' + '#'.repeat(completedCount.value * 2))
const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']')
</script>
<style scoped>
.onboarding-widget {
padding: 16px 20px;
border-bottom: 1px dashed var(--parch-border);
background: var(--parch);
color: var(--parch-text);
}
/* Welcome mode */
.ow-header {
margin-bottom: 12px;
}
.ow-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text-bright);
margin: 0 0 4px;
}
.ow-intro {
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
margin: 0;
line-height: 1.5;
}
.ow-prompt {
color: var(--parch-accent);
margin-bottom: 6px;
.ow-suggestion {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-top: 1px dashed var(--border);
flex-wrap: wrap;
}
.ow-message {
color: var(--parch-text);
margin-bottom: 2px;
}
.ow-message--dim {
color: var(--parch-text-dim);
}
.ow-hint {
color: var(--parch-text-dim);
font-size: 11px;
.ow-suggestion-text {
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.ow-action {
display: inline-block;
margin-top: 8px;
padding: 4px 12px;
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
color: var(--parch-accent);
flex-shrink: 0;
font-size: 11px;
text-decoration: none;
letter-spacing: 0.04em;
}
.ow-action:hover {
border-color: var(--parch-accent);
border-style: solid;
text-decoration: none;
padding: 5px 14px;
}
.ow-progress {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
font-size: 11px;
color: var(--parch-text-dim);
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.ow-bar {
display: inline-flex;
gap: 0;
letter-spacing: 0;
.ow-progress-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.ow-bar-fill {
color: var(--parch-accent);
.ow-dots {
display: flex;
gap: 4px;
}
.ow-bar-empty {
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
.ow-dot {
width: 6px;
height: 6px;
border: 1px dashed var(--border);
display: inline-block;
}
.ow-skip {
margin-left: auto;
background: none;
border: none;
color: var(--parch-text-dim);
font-family: inherit;
.ow-dot--done {
background: var(--candle);
border-color: var(--candle);
border-style: solid;
}
/* Suggestion mode (graduated) */
.ow-rec {
display: flex;
flex-direction: column;
gap: 4px;
}
.ow-rec .section-label {
margin-bottom: 2px;
}
.ow-rec-text {
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.ow-rec-link {
font-size: 11px;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
color: var(--candle);
margin-top: 2px;
display: inline-block;
}
.ow-skip:hover {
color: var(--parch-accent);
.ow-empty {
font-size: 12px;
color: var(--text-faint);
line-height: 1.5;
}
</style>

View file

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

View file

@ -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

@ -12,17 +12,12 @@
class="breadcrumb-link"
>{{ crumb.label }}</NuxtLink
>
<ClientOnly v-else>
<span class="breadcrumb-current">{{ crumb.label }}</span>
<template #fallback>
<span class="breadcrumb-current">&nbsp;</span>
</template>
</ClientOnly>
<span v-else class="breadcrumb-current">{{ crumb.label }}</span>
</template>
</span>
</slot>
</span>
<span class="right">
<span>
<slot name="right">
<ClientOnly>
<template v-if="memberData">
@ -32,7 +27,7 @@
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
:alt="memberData.name"
class="member-avatar"
>
/>
<svg
v-else
class="member-avatar default-ghost"
@ -61,13 +56,9 @@
</svg>
{{ memberData.name }}
</NuxtLink>
<span class="sep" aria-hidden="true">/</span>
<a href="#" class="sign-out" @click.prevent="handleLogout"
>sign out</a
>
</template>
<template v-else> The Baby Ghosts member program </template>
<template #fallback> The Baby Ghosts member program </template>
<template v-else> A cooperative for game developers </template>
<template #fallback> A cooperative for game developers </template>
</ClientOnly>
</slot>
</span>
@ -79,12 +70,7 @@ const props = defineProps({
pagePath: { type: String, default: "" },
});
const { memberData, logout } = useAuth();
const handleLogout = async () => {
await logout();
navigateTo("/");
};
const { memberData } = useAuth();
const capitalize = (str) => {
if (!str) return "";
@ -126,9 +112,6 @@ const breadcrumbs = computed(() => {
gap: 6px;
text-decoration: none;
}
.member-link:hover {
text-decoration: underline;
}
.member-avatar {
width: 18px;
height: 18px;
@ -137,23 +120,6 @@ const breadcrumbs = computed(() => {
.default-ghost {
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 {
display: inline;

View file

@ -1,19 +0,0 @@
export function useBoardChannels() {
const channels = useState('board.channels', () => [])
async function fetchChannels() {
const result = await $fetch('/api/board/channels')
channels.value = result?.channels || []
return channels.value
}
function slackUrl(channelId) {
return `https://gammaspace.slack.com/archives/${channelId}`
}
return {
channels: readonly(channels),
fetchChannels,
slackUrl,
}
}

View file

@ -1,50 +0,0 @@
export function useBoardPosts() {
const posts = useState('board.posts', () => [])
const loading = useState('board.loading', () => false)
async function fetchPosts(params = {}) {
loading.value = true
try {
const result = await $fetch('/api/board/posts', { params })
posts.value = result?.posts || []
return posts.value
} finally {
loading.value = false
}
}
async function createPost(body) {
const created = await $fetch('/api/board/posts', {
method: 'POST',
body,
})
await fetchPosts()
return created
}
async function updatePost(id, body) {
const updated = await $fetch(`/api/board/posts/${id}`, {
method: 'PATCH',
body,
})
await fetchPosts()
return updated
}
async function deletePost(id) {
const result = await $fetch(`/api/board/posts/${id}`, {
method: 'DELETE',
})
await fetchPosts()
return result
}
return {
posts: readonly(posts),
loading: readonly(loading),
fetchPosts,
createPost,
updatePost,
deletePost,
}
}

View file

@ -0,0 +1,6 @@
export const useEcology = () => {
const getSuggestions = (params = {}) =>
$fetch('/api/ecology/suggestions', { params })
return { getSuggestions }
}

View file

@ -1,98 +1,85 @@
// Utility composable for event date handling with timezone support.
// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ.
// Utility composable for event date handling with timezone support
export const useEventDateUtils = () => {
const DEFAULT_TIMEZONE = "America/Toronto";
const TIMEZONE = "America/Toronto";
// Format a date to a specific format
const formatDate = (date, options = {}) => {
if (!date) return "";
const dateObj = date instanceof Date ? date : new Date(date);
if (isNaN(dateObj.getTime())) return "";
const {
month = "short",
day = "numeric",
year = "numeric",
weekday,
timeZone,
} = options;
const { month = "short", day = "numeric", year = "numeric" } = options;
return new Intl.DateTimeFormat("en-US", {
...(weekday && { weekday }),
month,
day,
year,
...(timeZone && { timeZone }),
}).format(dateObj);
};
const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
// Format event date range
const formatDateRange = (startDate, endDate, compact = false) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const tzOpts = timeZone ? { timeZone } : {};
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
const startDay = Number(
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
);
const endDay = Number(
end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
);
const year = Number(
end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
);
const startMonthIdx = startMonth; // compared as label string
const endMonthIdx = endMonth;
const startYear = Number(
start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
if (compact) {
if (startMonthIdx === endMonthIdx && startYear === year) {
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}`;
}
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
}
if (startMonthIdx === endMonthIdx && startYear === year) {
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (startYear === year) {
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`;
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
}
};
// Check if a date is in the past
const isPastDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
return dateObj < new Date();
const now = new Date();
return dateObj < now;
};
const isToday = (date, timeZone) => {
// Check if a date is today
const isToday = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
const today = new Date();
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
return (
dateObj.toLocaleDateString("en-US", opts) ===
today.toLocaleDateString("en-US", opts)
dateObj.getDate() === today.getDate() &&
dateObj.getMonth() === today.getMonth() &&
dateObj.getFullYear() === today.getFullYear()
);
};
const formatTime = (date, includeSeconds = false, timeZone) => {
// Get a readable time string
const formatTime = (date, includeSeconds = false) => {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat("en-US", {
const options = {
hour: "2-digit",
minute: "2-digit",
...(includeSeconds && { second: "2-digit" }),
...(timeZone && { timeZone }),
}).format(dateObj);
};
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
};
return {
DEFAULT_TIMEZONE,
// Legacy alias for callers that hard-coded the constant.
TIMEZONE: DEFAULT_TIMEZONE,
TIMEZONE,
formatDate,
formatDateRange,
isPastDate,

View file

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

View file

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

View file

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

View file

@ -4,146 +4,137 @@
*/
export const MEMBER_STATUSES = {
PENDING_PAYMENT: "pending_payment",
ACTIVE: "active",
SUSPENDED: "suspended",
CANCELLED: "cancelled",
};
PENDING_PAYMENT: 'pending_payment',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
}
export const MEMBER_STATUS_CONFIG = {
pending_payment: {
label: "Setting up payment",
color: "orange",
bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/30",
textColor: "text-orange-300",
icon: "heroicons:exclamation-triangle",
severity: "warning",
canRSVP: true,
label: 'Payment Pending',
color: 'orange',
bgColor: 'bg-orange-500/10',
borderColor: 'border-orange-500/30',
textColor: 'text-orange-300',
icon: 'heroicons:exclamation-triangle',
severity: 'warning',
canRSVP: false,
canAccessMembers: true,
canPeerSupport: true,
canPeerSupport: false,
},
active: {
label: "Active Member",
color: "green",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/30",
textColor: "text-green-300",
icon: "heroicons:check-circle",
severity: "success",
label: 'Active Member',
color: 'green',
bgColor: 'bg-green-500/10',
borderColor: 'border-green-500/30',
textColor: 'text-green-300',
icon: 'heroicons:check-circle',
severity: 'success',
canRSVP: true,
canAccessMembers: true,
canPeerSupport: true,
},
suspended: {
label: "Membership Suspended",
color: "red",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/30",
textColor: "text-red-300",
icon: "heroicons:no-symbol",
severity: "error",
label: 'Membership Suspended',
color: 'red',
bgColor: 'bg-red-500/10',
borderColor: 'border-red-500/30',
textColor: 'text-red-300',
icon: 'heroicons:no-symbol',
severity: 'error',
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
cancelled: {
label: "Membership Cancelled",
color: "gray",
bgColor: "bg-gray-500/10",
borderColor: "border-gray-500/30",
textColor: "text-gray-300",
icon: "heroicons:x-circle",
severity: "error",
label: 'Membership Cancelled',
color: 'gray',
bgColor: 'bg-gray-500/10',
borderColor: 'border-gray-500/30',
textColor: 'text-gray-300',
icon: 'heroicons:x-circle',
severity: 'error',
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
};
}
export const useMemberStatus = () => {
const { memberData } = useAuth();
const { memberData } = useAuth()
// Get current member status
const status = computed(
() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT,
);
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT)
// Get status configuration
const statusConfig = computed(
() =>
MEMBER_STATUS_CONFIG[status.value] ||
MEMBER_STATUS_CONFIG.pending_payment,
);
const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment)
// Helper methods
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
const isPendingPayment = computed(
() => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
);
const isSuspended = computed(
() => status.value === MEMBER_STATUSES.SUSPENDED,
);
const isCancelled = computed(
() => status.value === MEMBER_STATUSES.CANCELLED,
);
const isInactive = computed(() => !isActive.value);
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
const isInactive = computed(() => !isActive.value)
// Check if member can perform action
const canRSVP = computed(() => statusConfig.value.canRSVP);
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
const canRSVP = computed(() => statusConfig.value.canRSVP)
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
// Get action button text and link based on status
const getNextAction = () => {
if (isPendingPayment.value) {
return {
label: "Complete Payment",
link: "/member/account",
icon: "heroicons:credit-card",
color: "orange",
};
label: 'Complete Payment',
link: '/member/profile#account',
icon: 'heroicons:credit-card',
color: 'orange',
}
}
if (isCancelled.value) {
return {
label: "Reactivate Membership",
link: "/member/account",
icon: "heroicons:arrow-path",
color: "blue",
};
label: 'Reactivate Membership',
link: '/member/profile#account',
icon: 'heroicons:arrow-path',
color: 'blue',
}
}
if (isSuspended.value) {
return {
label: "Contact Support",
link: "mailto:support@ghostguild.org",
icon: "heroicons:envelope",
color: "gray",
};
label: 'Contact Support',
link: 'mailto:support@ghostguild.org',
icon: 'heroicons:envelope',
color: 'gray',
}
}
return null;
};
return null
}
// Get banner message based on status
const getBannerMessage = () => {
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) {
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) {
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
const getRSVPMessage = () => {
if (isSuspended.value || isCancelled.value) {
return "Your account isn't active right now. Reach out if you have questions.";
if (isPendingPayment.value) {
return 'Complete your payment to register for events'
}
return null;
};
if (isSuspended.value || isCancelled.value) {
return 'Your membership status prevents RSVP. Please reactivate your account.'
}
return null
}
return {
status,
@ -160,5 +151,5 @@ export const useMemberStatus = () => {
getBannerMessage,
getRSVPMessage,
MEMBER_STATUSES,
};
};
}
}

View file

@ -6,42 +6,26 @@ export function useOnboarding(options = {}) {
const goals = useState('onboarding.goals', () => ({
hasProfileTags: false,
hasVisitedEvent: false,
hasEngagedBoard: false,
hasEngagedEcology: false,
hasClickedWiki: false,
}))
const skipped = useState('onboarding.skipped', () => ({
profileTags: false,
visitEvent: false,
board: false,
wiki: false,
}))
const completedAt = useState('onboarding.completedAt', () => null)
const loading = useState('onboarding.loading', () => false)
const recommendations = useState('onboarding.recommendations', () => ({
events: [],
ecology: [],
wiki: [],
}))
// Track whether we've already fetched status this session
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(() =>
!!completedAt.value ||
(effectiveGoals.value.hasProfileTags &&
effectiveGoals.value.hasVisitedEvent &&
effectiveGoals.value.hasEngagedBoard &&
effectiveGoals.value.hasClickedWiki)
goals.value.hasProfileTags &&
goals.value.hasVisitedEvent &&
goals.value.hasEngagedEcology &&
goals.value.hasClickedWiki
)
const pickCategory = options.pickCategory || ((categories) => {
@ -49,9 +33,9 @@ export function useOnboarding(options = {}) {
})
const currentSuggestion = computed(() => {
// Not graduated — return highest-priority incomplete, non-skipped goal
// Not graduated — return highest-priority incomplete goal
if (!isComplete.value) {
if (!effectiveGoals.value.hasProfileTags) {
if (!goals.value.hasProfileTags) {
return {
key: 'profileTags',
text: 'Complete your profile by adding your craft and community tags',
@ -59,7 +43,7 @@ export function useOnboarding(options = {}) {
actionText: 'Set up tags',
}
}
if (!effectiveGoals.value.hasVisitedEvent) {
if (!goals.value.hasVisitedEvent) {
return {
key: 'visitEvent',
text: 'Check out upcoming events',
@ -67,19 +51,19 @@ export function useOnboarding(options = {}) {
actionText: 'Browse events',
}
}
if (!effectiveGoals.value.hasEngagedBoard) {
if (!goals.value.hasEngagedEcology) {
return {
key: 'board',
text: 'Explore the board to find collaborators',
action: '/board',
actionText: 'Explore board',
key: 'ecology',
text: 'Explore the community ecology to find collaborators',
action: '/ecology',
actionText: 'Explore ecology',
}
}
if (!effectiveGoals.value.hasClickedWiki) {
if (!goals.value.hasClickedWiki) {
return {
key: 'wiki',
text: 'Browse the wiki for resources and guides',
action: 'https://wiki.ghostguild.org',
action: null,
actionText: 'Browse wiki',
isExternal: true,
}
@ -87,7 +71,7 @@ export function useOnboarding(options = {}) {
}
// Graduated — suggestion mode
const cats = ['events', 'wiki'].filter(
const cats = ['events', 'ecology', 'wiki'].filter(
(c) => recommendations.value[c]?.length > 0
)
@ -102,6 +86,13 @@ export function useOnboarding(options = {}) {
return buildRecommendation(selected, items[0])
}
// Fallback to first non-empty category (shouldn't happen since we filtered)
for (const cat of cats) {
if (recommendations.value[cat]?.length > 0) {
return buildRecommendation(cat, recommendations.value[cat][0])
}
}
return { key: 'empty', text: 'No suggestions right now' }
})
@ -110,10 +101,18 @@ export function useOnboarding(options = {}) {
return {
key: 'event',
text: `Upcoming event: ${item.title}`,
action: `/events/${item.slug}`,
action: `/events/${item._id}`,
actionText: 'View event',
}
}
if (category === 'ecology') {
return {
key: 'ecology',
text: `Connect with ${item.name || 'a member'} in the ecology`,
action: '/ecology',
actionText: 'Explore ecology',
}
}
if (category === 'wiki') {
return {
key: 'wiki',
@ -134,16 +133,13 @@ export function useOnboarding(options = {}) {
if (data?.goals) {
goals.value = { ...goals.value, ...data.goals }
}
if (data?.skipped) {
skipped.value = { ...skipped.value, ...data.skipped }
}
if (data?.completedAt) {
completedAt.value = data.completedAt
}
_fetched.value = true
// If graduated, fetch recommendations
if (completedAt.value) {
if (isComplete.value) {
await fetchRecommendations()
}
} catch {
@ -154,12 +150,14 @@ export function useOnboarding(options = {}) {
}
async function fetchRecommendations() {
const [events, wiki] = await Promise.allSettled([
const [events, ecology, wiki] = await Promise.allSettled([
$fetch('/api/events/recommended'),
$fetch('/api/ecology/suggestions'),
$fetch('/api/wiki/recommended'),
])
recommendations.value = {
events: events.status === 'fulfilled' ? (events.value || []) : [],
ecology: ecology.status === 'fulfilled' ? (ecology.value?.suggestions || []) : [],
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
}
}
@ -176,21 +174,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
fetchStatus()
@ -200,8 +183,6 @@ export function useOnboarding(options = {}) {
completedAt: readonly(completedAt),
currentSuggestion,
trackGoal,
skipSuggestion,
skipped: readonly(skipped),
recommendations: readonly(recommendations),
loading: readonly(loading),
}

View file

@ -1,58 +0,0 @@
/**
* useSiteMeta set page-level SEO + social meta with site defaults baked in.
*
* Builds absolute URLs from runtimeConfig.public.appUrl so og:image and og:url
* resolve for crawlers. Defaults og:type=website, twitter:card=summary_large_image,
* og:site_name=Ghost Guild. Set noindex:true to emit robots="noindex, nofollow".
*
* Pass a function (or refs in fields) to keep tags reactive when content loads
* asynchronously via useFetch.
*/
export function useSiteMeta(input) {
const runtimeConfig = useRuntimeConfig()
const route = useRoute()
const appUrl = (runtimeConfig.public.appUrl || '').replace(/\/$/, '')
const resolve = () => (typeof input === 'function' ? input() : input) || {}
const buildAbsolute = (path) => {
if (!path) return undefined
if (/^https?:\/\//i.test(path)) return path
return `${appUrl}${path.startsWith('/') ? '' : '/'}${path}`
}
const titleGetter = () => resolve().title || 'Ghost Guild'
const descGetter = () => resolve().description || undefined
const isBareTitle = () => Boolean(resolve().bareTitle)
const imageGetter = () => buildAbsolute(resolve().image || '/og/default.png')
const typeGetter = () => resolve().type || 'website'
const robotsGetter = () =>
resolve().noindex ? 'noindex, nofollow' : undefined
const canonicalGetter = () => buildAbsolute(route.path)
useSeoMeta({
title: titleGetter,
description: descGetter,
ogSiteName: 'Ghost Guild',
ogTitle: titleGetter,
ogDescription: descGetter,
ogType: typeGetter,
ogUrl: canonicalGetter,
ogImage: imageGetter,
ogImageWidth: 1200,
ogImageHeight: 630,
twitterCard: 'summary_large_image',
twitterTitle: titleGetter,
twitterDescription: descGetter,
twitterImage: imageGetter,
robots: robotsGetter,
})
useHead({
link: [{ rel: 'canonical', href: canonicalGetter }],
})
if (isBareTitle()) {
useHead({ titleTemplate: null })
}
}

View file

@ -21,7 +21,7 @@ export const CIRCLES = {
shortDescription: "Building your studio",
description: "For those actively establishing or growing their coop",
features: [
"Teams working toward applying for Cooperative Foundations",
"Teams working toward applying for the Peer Accelerator",
"Early-stage coop studios",
"Studios transitioning to coop model",
],
@ -33,7 +33,7 @@ export const CIRCLES = {
value: "practitioner",
label: "Practitioners",
shortDescription: "Leading and mentoring",
description: "For alumni and experienced studio founders",
description: "For Peer Accelerator alumni and experienced studio founders",
features: [
"Those implementing cooperative models",
"Industry mentors and advisors",

View file

@ -1,22 +1,82 @@
// Guidance presets for the contribution amount input.
// These are NOT tiers — just suggested amounts with matching guidance copy.
export const CONTRIBUTION_PRESETS = [
{ amount: 0, label: "I need support right now" },
{ amount: 5, label: "I can contribute" },
{ amount: 15, label: "I can sustain the community" },
{ amount: 30, label: "I can support others too" },
{ amount: 50, label: "I want to sponsor multiple members" },
]
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
export const CONTRIBUTION_TIERS = {
FREE: {
value: "0",
amount: 0,
label: "$0 - I need support right now",
tier: "free",
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) =>
Number.isInteger(amount) && amount >= 0
// Get valid contribution values for validation
export const getValidContributionValues = () => {
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
};
export const getGuidanceLabel = (amount) => {
if (amount === null || amount === undefined) return null
const n = Number(amount)
if (!Number.isFinite(n) || n < 0) return null
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
return match?.label ?? null
}
// Get contribution tier by value
export const getContributionTierByValue = (value) => {
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
};
// 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,21 +0,0 @@
// Central configuration for Ghost Guild event types.
// Keep values in sync with the `eventType` enum in server/models/event.js.
export const EVENT_TYPES = [
{ value: "talk", label: "Talk / Presentation" },
{ value: "workshop", label: "Workshop" },
{ value: "community-meetup", label: "Community Meetup" },
{ value: "coworking", label: "Co-working Session" },
{ value: "peer-session", label: "Peer Session" },
{ value: "skills-share", label: "Skills Share" },
{ value: "info-session", label: "Info Session" },
];
export const EVENT_TYPE_VALUES = EVENT_TYPES.map((t) => t.value);
const labelLookup = Object.fromEntries(
EVENT_TYPES.map((t) => [t.value, t.label]),
);
export function eventTypeLabel(value) {
return labelLookup[value] || value || "";
}

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

@ -50,30 +50,6 @@
Series
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/wiki"
:class="{ active: route.path.startsWith('/admin/wiki') }"
>
Wiki
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/board-channels"
:class="{ active: route.path.startsWith('/admin/board-channels') }"
>
Board Channels
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/site-content"
:class="{ active: route.path.startsWith('/admin/site-content') }"
>
Site Content
</NuxtLink>
</li>
</ul>
<div class="sidebar-section">Site</div>
@ -84,7 +60,7 @@
</div>
<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>
</div>
</aside>
@ -160,33 +136,6 @@
Series
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/wiki"
:class="{ active: route.path.startsWith('/admin/wiki') }"
@click="isMobileMenuOpen = false"
>
Wiki
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/board-channels"
:class="{ active: route.path.startsWith('/admin/board-channels') }"
@click="isMobileMenuOpen = false"
>
Board Channels
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/site-content"
:class="{ active: route.path.startsWith('/admin/site-content') }"
@click="isMobileMenuOpen = false"
>
Site Content
</NuxtLink>
</li>
</ul>
<div class="sidebar-section">Site</div>
@ -207,7 +156,7 @@
</div>
<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>
</div>
</aside>
@ -217,8 +166,6 @@
</template>
<script setup>
useSiteMeta({ title: "Admin", noindex: true });
const route = useRoute();
const isMobileMenuOpen = ref(false);
const { logout } = useAuth();

View file

@ -1,12 +1,5 @@
<template>
<div class="coming-soon-layout">
<div class="min-h-screen bg-guild-900">
<slot />
</div>
</template>
<style scoped>
.coming-soon-layout {
min-height: 100vh;
background: var(--bg);
}
</style>

View file

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

View file

@ -1,12 +0,0 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
if (process.server) return;
const { memberData, checkMemberStatus } = useAuth();
if (!memberData.value) {
const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) {
return navigateTo("/join");
}
}
});

View file

@ -6,27 +6,26 @@
<h1>About Ghost Guild</h1>
<p>
A membership community for game developers exploring cooperative
models.
business models.
</p>
</div>
<div class="about-hero-right">
<div class="section-label">Our Story</div>
<p>
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
advancing cooperative and worker-centric models in the game industry
since 2023.
supporting indie game developers since 2018. We noticed a gap: game
developers interested in cooperative models had nowhere to learn,
practice, and connect with others doing the same work.
</p>
<p>
Developers interested in co-op practice had few places to learn,
connect, and figure things out alongside others doing the same work.
Ghost Guild is that place: a membership community for developers at
every stage of cooperative practice, with resources, events, and peers
to learn from.
Ghost Guild is the response &mdash; a membership program where
developers at every stage of cooperative practice can find resources,
events, mentorship, and community.
</p>
<p>
We don't prescribe a single model. We're here to explore the options,
learn from people who've tried them, and build something that works
for your team.
We don't prescribe a single model. We're a place to explore the
options, learn from people who've tried them, and build something that
works for your team.
</p>
</div>
</div>
@ -38,17 +37,28 @@
<div class="section-label">The Circles</div>
<div class="circles-grid">
<div id="community" class="circle-cell">
<h2 style="color: var(--c-community)">Community</h2>
<p>For anyone exploring cooperative models.</p>
<h3 style="color: var(--c-community)">Community</h3>
<div class="circle-subtitle">"The open hall"</div>
<p>
For anyone exploring cooperative models. Wiki access, public
events, Slack community, monthly meetings.
</p>
</div>
<div id="founder" class="circle-cell">
<h2 style="color: var(--c-founder)">Founder</h2>
<p>For people actively building cooperatives.</p>
<h3 style="color: var(--c-founder)">Founder</h3>
<div class="circle-subtitle">"The workshop"</div>
<p>
For people actively building cooperatives. Peer accelerator,
mentorship, governance templates.
</p>
</div>
<div id="practitioner" class="circle-cell">
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
<p>For experienced practitioners sharing what they know.</p>
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
<div class="circle-subtitle">"The alcove"</div>
<p>
For experienced practitioners. Mentoring, teaching, shaping the
program direction.
</p>
</div>
</div>
</div>
@ -91,12 +101,13 @@
<div class="about-section">
<div class="section-label">About Baby Ghosts</div>
<p>
Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing
cooperative models in game development.
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit
advancing cooperative models in game development. No tracking. No ads.
No venture capital.
</p>
<p>
<a href="https://babyghosts.org" target="_blank"
>babyghosts.org &rarr;</a
<a href="https://babyghosts.fund" target="_blank"
>babyghosts.fund &rarr;</a
>
</p>
</div>
@ -104,13 +115,7 @@
</PageShell>
</template>
<script setup>
useSiteMeta({
title: 'About',
description:
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
})
</script>
<script setup></script>
<style scoped>
/* ---- ABOUT HERO ---- */

View file

@ -32,7 +32,7 @@
class="form-input"
type="text"
required
>
/>
</div>
<div class="form-group">
<label class="form-label" for="accept-email">Email</label>
@ -42,7 +42,7 @@
class="form-input"
type="email"
disabled
>
/>
<p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p>
</div>
<div class="form-group">
@ -53,7 +53,7 @@
class="form-input"
type="text"
placeholder="e.g. they/them, she/her"
>
/>
</div>
<div class="form-group">
<label class="form-label" for="accept-location">City / Region</label>
@ -63,7 +63,7 @@
class="form-input"
type="text"
placeholder="e.g. Vancouver, BC"
>
/>
</div>
<div class="form-group full-width">
@ -77,7 +77,7 @@
type="radio"
name="circle"
value="community"
>
/>
<label for="circle-community">
<span class="circle-label-name" style="color: var(--c-community);">Community</span>
<span class="circle-label-desc">Learning about co-ops</span>
@ -90,7 +90,7 @@
type="radio"
name="circle"
value="founder"
>
/>
<label for="circle-founder">
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span>
<span class="circle-label-desc">Building your studio</span>
@ -103,7 +103,7 @@
type="radio"
name="circle"
value="practitioner"
>
/>
<label for="circle-practitioner">
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span>
<span class="circle-label-desc">Leading and mentoring</span>
@ -120,90 +120,36 @@
class="form-input"
rows="3"
placeholder="2-3 sentences about what you're looking for"
/>
></textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Billing Cadence</label>
<div class="cadence-radios">
<div class="circle-radio">
<input
id="accept-cadence-annual"
v-model="cadence"
type="radio"
name="cadence"
value="annual"
>
<label for="accept-cadence-annual">
<span class="circle-label-name">Per Year</span>
</label>
</div>
<div class="circle-radio">
<input
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>
</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>
<label class="form-label" for="accept-tier">Monthly Contribution</label>
<select
id="accept-tier"
v-model="form.contributionTier"
class="form-select"
>
<option value="0">$0/mo -- Access is a right</option>
<option value="5">$5/mo -- A small gesture</option>
<option value="15">$15/mo -- Sustaining (suggested)</option>
<option value="30">$30/mo -- Supporting</option>
<option value="50">$50/mo -- Solidarity</option>
</select>
<p class="field-note">Every dollar above $0 goes to the Solidarity Fund. Your contribution is never a gate -- it is a gift.</p>
</div>
<div class="form-group full-width">
<label class="checkbox-label">
<input
v-model="form.agreedToGuidelines"
type="checkbox"
>
v-model="form.agreedToTerms"
/>
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>.
I've read and agree to the
<NuxtLink to="/agreement" target="_blank">Member Agreement</NuxtLink>
and
<NuxtLink to="/guidelines" target="_blank">Code of Conduct</NuxtLink>
</span>
</label>
</div>
@ -223,29 +169,43 @@
</form>
</div>
<!-- Flow overlay: covers the page through payment + redirect. -->
<SignupFlowOverlay
:state="flowState"
:summary="flowSummary"
:error-message="errorMessage"
dashboard-href="/member/dashboard?welcome=1"
@close="closeFlowOverlay"
/>
<!-- Payment Step -->
<div v-else-if="step === 'payment'" class="form-container">
<h1>Payment Information</h1>
<p class="form-intro">
You're signing up for ${{ form.contributionTier }} CAD / month.
</p>
<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>
</template>
<script setup>
import {
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
import { requiresPayment } from "~/config/contributions";
definePageMeta({ layout: false });
useSiteMeta({ title: "Accept Invitation", noindex: true });
const { checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
const step = ref("verifying");
const errorMessage = ref("");
@ -253,10 +213,6 @@ const isSubmitting = ref(false);
const preRegId = ref(null);
const preRegEmail = 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({
name: "",
@ -264,49 +220,22 @@ const form = reactive({
location: "",
circle: "community",
motivation: "",
contributionAmount: 15,
agreedToGuidelines: false,
contributionTier: "15",
agreedToTerms: false,
});
const isFormValid = computed(() => {
return (
form.name &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
return form.name && form.circle && form.contributionTier && form.agreedToTerms;
});
const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount);
return requiresPayment(form.contributionTier);
});
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
const firstCharge = computed(() => {
const amount = form.contributionAmount || 0;
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 = "";
};
// Helcim state for paid tiers
const memberId = ref(null);
const customerId = ref(null);
const customerCode = ref(null);
// On mount: extract token from fragment, verify
onMounted(async () => {
@ -342,10 +271,9 @@ const handleAccept = async () => {
isSubmitting.value = true;
errorMessage.value = "";
flowState.value = "creating-customer";
try {
const accepted = await $fetch("/api/invite/accept", {
const result = await $fetch("/api/invite/accept", {
method: "POST",
body: {
preRegistrationId: preRegId.value,
@ -354,59 +282,96 @@ const handleAccept = async () => {
location: form.location || undefined,
circle: form.circle,
motivation: form.motivation || undefined,
contributionAmount: form.contributionAmount,
agreedToGuidelines: form.agreedToGuidelines,
contributionTier: form.contributionTier,
agreedToTerms: form.agreedToTerms,
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
await checkMemberStatus();
flowState.value = "success";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
return;
step.value = "confirmation";
setTimeout(() => navigateTo("/welcome"), 3000);
}
// Paid tier: initialize HelcimPay session, auto-open modal
flowState.value = "opening-payment";
await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0);
const paymentResult = await verifyPayment();
if (!paymentResult?.success) {
throw new Error("Payment was not completed.");
}
flowState.value = "processing-payment";
await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: accepted.customerId,
},
});
flowState.value = "creating-subscription";
await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: accepted.customerId,
customerCode: accepted.customerCode,
contributionAmount: form.contributionAmount,
cadence: cadence.value,
cardToken: paymentResult.cardToken,
},
});
await checkMemberStatus();
flowState.value = "success";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
} catch (err) {
errorMessage.value =
err.data?.statusMessage ||
err.message ||
"Failed to accept invitation. Please try again.";
flowState.value = "error";
err.data?.statusMessage || "Failed to accept invitation. Please try again.";
} finally {
isSubmitting.value = false;
}
};
const setupPayment = async (member) => {
try {
// 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();
if (paymentResult.success) {
// Verify payment on server
await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
});
// Create subscription
await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionTier: form.contributionTier,
cardToken: paymentResult.cardToken,
},
});
await checkMemberStatus();
step.value = "confirmation";
setTimeout(() => navigateTo("/welcome"), 3000);
}
} catch (err) {
errorMessage.value =
err.message || "Payment verification failed. Please try again.";
} finally {
isSubmitting.value = false;
}
@ -524,72 +489,6 @@ textarea.form-input {
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 {
display: grid;
@ -597,12 +496,6 @@ textarea.form-input {
gap: 8px;
}
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.circle-radio {
position: relative;
}
@ -720,9 +613,5 @@ textarea.form-input {
.circle-radios {
grid-template-columns: 1fr;
}
.cadence-radios {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -1,596 +0,0 @@
<template>
<div class="admin-board-channels">
<!-- Page Header -->
<div class="page-header">
<div class="header-row">
<div>
<h1>Board Channels</h1>
<p>Create Slack channels for cooperative tags. New channels are created in Slack when you click Create Channel.</p>
</div>
<div class="header-actions">
<button class="btn btn-primary" @click="openCreateModal">+ New Channel</button>
</div>
</div>
</div>
<!-- Unmapped Tags Indicator -->
<div v-if="unmappedTags.length > 0" class="unmapped-block">
<div class="section-label">Unmapped Cooperative Tags</div>
<p class="unmapped-hint">These cooperative tags are not yet mapped to any board channel:</p>
<div class="tag-pills">
<span v-for="tag in unmappedTags" :key="tag.slug" class="tag-pill tag-pill-warning">
{{ tag.label }}
</span>
</div>
</div>
<!-- Channels List -->
<div class="channels-list">
<div v-if="!channels.length" class="empty-state">
<p>No board channels configured yet.</p>
<p class="empty-hint">Click "+ New Channel" to create your first board channel in Slack.</p>
</div>
<table v-else class="channels-table">
<thead>
<tr>
<th>Channel</th>
<th>Mapped Tags</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="channel in channels" :key="channel._id">
<td class="name-cell">
<div class="channel-name">{{ channel.name }}</div>
<div class="channel-id">{{ channel.slackChannelId }}</div>
</td>
<td>
<div class="tag-pills">
<span
v-for="slug in channel.tagSlugs || []"
:key="slug"
class="tag-pill"
>
{{ tagLabel(slug) }}
</span>
<span v-if="!(channel.tagSlugs || []).length" class="tag-empty"></span>
</div>
</td>
<td class="actions-cell">
<button class="link-btn" @click="openEditModal(channel)">Edit</button>
<button class="link-btn link-btn-danger" @click="deleteChannel(channel)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create / Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingId ? 'Edit Channel' : 'New Channel' }}</h2>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="formData.name" type="text" placeholder="e.g., coop-formation" />
<p v-if="!editingId" class="help-text">A new Slack channel will be created with this name. Lowercase, letters/numbers/dashes only.</p>
</div>
<div v-if="editingId" class="field">
<label>Slack Channel ID</label>
<input v-model="formData.slackChannelId" type="text" placeholder="C0123456789" />
<p class="help-text">The Slack channel ID (starts with C).</p>
</div>
<div class="field">
<label>Mapped Tags</label>
<p class="help-text">Cooperative tags that route posts to this channel.</p>
<div class="pill-grid">
<button
v-for="tag in cooperativeTags"
:key="tag.slug"
type="button"
class="pill"
:class="{
selected: formData.tagSlugs.includes(tag.slug),
disabled: tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug),
}"
:disabled="!!(tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug))"
:title="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)
? `Already mapped to ${tagOwner(tag.slug)}`
: ''"
@click="toggleTag(tag.slug)"
>{{ tag.label }}<span
v-if="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)"
class="pill-owner"
> · {{ tagOwner(tag.slug) }}</span></button>
<p v-if="!cooperativeTags.length" class="help-text">No cooperative tags available.</p>
</div>
<p class="help-text">Each tag can only be mapped to one channel.</p>
</div>
</div>
<div class="modal-actions">
<button class="btn" @click="closeModal">Cancel</button>
<button class="btn btn-primary" :disabled="saving" @click="saveChannel">
{{ saving ? 'Saving...' : (editingId ? 'Save Changes' : 'Create Channel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const toast = useToast()
const { channels, fetchChannels } = useBoardChannels()
const { data: tagsData } = await useFetch('/api/tags')
const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === 'cooperative'),
)
const tagLabelMap = computed(() => {
const map = {}
for (const tag of tagsData.value?.tags || []) {
map[tag.slug] = tag.label
}
return map
})
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
const mappedSlugs = computed(() => {
const set = new Set()
for (const ch of channels.value) {
for (const slug of ch.tagSlugs || []) set.add(slug)
}
return set
})
// Map of slug -> channel name, EXCLUDING the channel currently being edited.
const otherChannelTagMap = computed(() => {
const map = {}
for (const ch of channels.value) {
if (editingId.value && String(ch._id) === String(editingId.value)) continue
for (const slug of ch.tagSlugs || []) map[slug] = ch.name
}
return map
})
const tagOwner = (slug) => otherChannelTagMap.value[slug] || ''
const unmappedTags = computed(() =>
cooperativeTags.value.filter((t) => !mappedSlugs.value.has(t.slug)),
)
// ---- Modal State ----
const showModal = ref(false)
const editingId = ref(null)
const saving = ref(false)
const formData = reactive({
name: '',
slackChannelId: '',
tagSlugs: [],
})
const resetForm = () => {
formData.name = ''
formData.slackChannelId = ''
formData.tagSlugs = []
}
const openCreateModal = () => {
editingId.value = null
resetForm()
showModal.value = true
}
const openEditModal = (channel) => {
editingId.value = channel._id
formData.name = channel.name || ''
formData.slackChannelId = channel.slackChannelId || ''
formData.tagSlugs = [...(channel.tagSlugs || [])]
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingId.value = null
resetForm()
}
const toggleTag = (slug) => {
const idx = formData.tagSlugs.indexOf(slug)
if (idx === -1) formData.tagSlugs.push(slug)
else formData.tagSlugs.splice(idx, 1)
}
const saveChannel = async () => {
if (!formData.name.trim()) {
toast.add({
title: 'Missing fields',
description: 'Name is required.',
color: 'red',
})
return
}
if (editingId.value && !formData.slackChannelId.trim()) {
toast.add({
title: 'Missing fields',
description: 'Slack channel ID is required.',
color: 'red',
})
return
}
saving.value = true
try {
const body = {
name: formData.name.trim(),
tagSlugs: formData.tagSlugs,
}
if (formData.slackChannelId.trim()) {
body.slackChannelId = formData.slackChannelId.trim()
}
if (editingId.value) {
await $fetch(`/api/admin/board-channels/${editingId.value}`, {
method: 'PATCH',
body,
})
toast.add({ title: 'Channel updated', color: 'green' })
} else {
await $fetch('/api/admin/board-channels', {
method: 'POST',
body,
})
toast.add({ title: 'Channel created', color: 'green' })
}
await fetchChannels()
closeModal()
} catch (err) {
toast.add({
title: 'Save failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
saving.value = false
}
}
const deleteChannel = async (channel) => {
if (!window.confirm(`Delete channel "${channel.name}"? This cannot be undone.`)) return
try {
await $fetch(`/api/admin/board-channels/${channel._id}`, { method: 'DELETE' })
toast.add({ title: 'Channel deleted', color: 'green' })
await fetchChannels()
} catch (err) {
toast.add({
title: 'Delete failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
onMounted(() => {
fetchChannels()
})
</script>
<style scoped>
.admin-board-channels {
padding: 24px;
max-width: 1100px;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--border);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
margin-bottom: 4px;
}
.page-header p {
color: var(--text-dim);
font-size: 13px;
}
.header-actions {
display: flex;
gap: 8px;
}
/* ---- Unmapped Indicator ---- */
.unmapped-block {
border: 1px dashed var(--border);
padding: 16px;
margin-bottom: 24px;
background: var(--surface);
}
.unmapped-hint {
font-size: 12px;
color: var(--text-dim);
margin: 4px 0 12px;
}
.section-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
/* ---- Tag Pills ---- */
.tag-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
display: inline-block;
padding: 2px 9px;
font-size: 11px;
font-family: "Commit Mono", monospace;
background: transparent;
border: 1px dashed var(--border);
color: var(--text-dim);
}
.tag-pill-warning {
border-color: var(--ember);
color: var(--ember);
}
.tag-empty {
color: var(--text-faint);
font-size: 12px;
}
/* ---- Table ---- */
.channels-list {
border: 1px dashed var(--border);
background: var(--bg);
}
.channels-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.channels-table th,
.channels-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px dashed var(--border);
vertical-align: top;
}
.channels-table thead th {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: normal;
background: var(--surface);
}
.channels-table tbody tr:last-child td {
border-bottom: none;
}
.channel-name {
font-weight: 600;
}
.channel-id {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.actions-col {
width: 160px;
}
.actions-cell {
white-space: nowrap;
}
.link-btn {
background: none;
border: none;
color: var(--candle-dim);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
text-decoration: underline;
}
.link-btn:hover {
color: var(--candle);
}
.link-btn-danger {
color: var(--ember);
}
.link-btn-danger:hover {
color: var(--ember);
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-dim);
}
.empty-hint {
font-size: 12px;
color: var(--text-faint);
margin-top: 4px;
}
/* ---- Modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal {
background: var(--bg);
border: 1px dashed var(--border);
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px dashed var(--border);
}
.modal-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 20px;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-dim);
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px dashed var(--border);
}
.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 {
width: 100%;
padding: 8px 10px;
background: var(--input-bg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus {
outline: none;
border-color: var(--candle);
}
.help-text {
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 240px;
overflow-y: auto;
}
.pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-faint);
font-size: 11px;
font-family: "Commit Mono", monospace;
cursor: pointer;
user-select: none;
transition: all 0.12s;
white-space: nowrap;
}
.pill:hover {
color: var(--text-dim);
border-color: var(--border-d);
}
.pill.selected {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
}
.pill.disabled,
.pill:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pill.disabled:hover {
color: var(--text-faint);
border-color: var(--border);
}
.pill-owner {
font-size: 10px;
color: var(--text-faint);
margin-left: 2px;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -16,12 +16,15 @@
<!-- Filters -->
<div class="filter-bar">
<div class="field" style="margin-bottom: 0; flex: 1;">
<input v-model="searchQuery" placeholder="Search events..." >
<input v-model="searchQuery" placeholder="Search events..." />
</div>
<div class="field" style="margin-bottom: 0;">
<select v-model="typeFilter">
<option value="all">All Types</option>
<option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option value="community">Community</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select>
</div>
<div class="field" style="margin-bottom: 0;">
@ -68,7 +71,7 @@
<td class="col-title">
<div class="event-title-cell">
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
</div>
<div>
<span class="event-name">{{ event.title }}</span>
@ -86,11 +89,11 @@
</div>
</td>
<td>
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td>
<td class="col-date">
<span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event) }}</span>
<span class="date-main">{{ formatDate(event.startDate) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span>
</td>
<td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -125,9 +128,9 @@
</td>
<td class="col-actions">
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
</td>
</tr>
</tbody>
@ -166,7 +169,7 @@
<td class="col-title">
<div class="event-title-cell">
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
</div>
<div>
<span class="event-name">{{ event.title }}</span>
@ -184,11 +187,11 @@
</div>
</td>
<td>
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td>
<td class="col-date">
<span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event) }}</span>
<span class="date-main">{{ formatDate(event.startDate) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span>
</td>
<td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -223,9 +226,9 @@
</td>
<td class="col-actions">
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
</td>
</tr>
</tbody>
@ -264,8 +267,6 @@
</template>
<script setup>
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
definePageMeta({
layout: 'admin',
middleware: 'admin',
@ -348,23 +349,19 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
pastPage.value = 1
})
const formatDate = (event) => {
if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleDateString('en-US', {
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: event.displayTimezone || 'America/Toronto',
})
}
const formatTime = (event) => {
if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleTimeString('en-US', {
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: event.displayTimezone || 'America/Toronto',
})
}
@ -573,7 +570,7 @@ tbody td {
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c-founder);
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
border: 1px dashed rgba(138, 68, 32, 0.3);
padding: 2px 8px;
}
@ -586,7 +583,7 @@ tbody td {
font-size: 10px;
font-weight: 600;
color: var(--c-founder);
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border: 1px dashed rgba(138, 68, 32, 0.4);
border-radius: 50%;
}
@ -635,12 +632,12 @@ tbody td {
.status-upcoming {
color: var(--candle);
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
border-color: rgba(122, 90, 16, 0.3);
}
.status-ongoing {
color: var(--green);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
border-color: rgba(74, 106, 56, 0.3);
}
.status-past {
@ -650,7 +647,7 @@ tbody td {
.status-cancelled {
color: var(--ember);
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
border-color: rgba(138, 68, 32, 0.3);
margin-top: 4px;
}

View file

@ -65,7 +65,7 @@
<span class="item-sub">{{ member.email }}</span>
</div>
<div class="item-meta">
<CircleBadge :circle="member.circle" />
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
</div>
</div>
@ -91,7 +91,7 @@
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
</div>
<div class="item-meta">
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
<span class="item-date">{{ event.location || 'Online' }}</span>
</div>
</div>
@ -106,8 +106,6 @@
</template>
<script setup>
import { eventTypeLabel } from '~/config/eventTypes'
definePageMeta({
layout: 'admin',
middleware: 'admin',

View file

@ -16,7 +16,7 @@
<p v-if="member" class="member-email">{{ member.email }}</p>
</div>
<div v-if="member" class="header-badges">
<CircleBadge :circle="member.circle" />
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>
@ -39,11 +39,11 @@
<form class="edit-form" @submit.prevent="submitEdit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" required >
<input v-model="form.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" required >
<input v-model="form.email" type="email" required />
</div>
<div class="field">
<label>Circle</label>
@ -54,20 +54,22 @@
</select>
</div>
<div class="field">
<label>Contribution ($/mo)</label>
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
<p class="field-hint field-hint--warn">
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard this form does not sync.
</p>
<label>Contribution tier ($/mo)</label>
<select v-model="form.contributionTier">
<option value="0">$0</option>
<option value="5">$5</option>
<option value="15">$15</option>
<option value="30">$30</option>
<option value="50">$50</option>
</select>
</div>
<div class="field">
<label>Status</label>
<select v-model="form.status">
<option
v-for="(label, value) in STATUS_LABELS"
:key="value"
:value="value"
>{{ label }}</option>
<option value="pending_payment">pending_payment</option>
<option value="active">active</option>
<option value="suspended">suspended</option>
<option value="cancelled">cancelled</option>
</select>
</div>
<div class="field">
@ -110,19 +112,8 @@
</div>
<div class="meta-row">
<dt>Slack invite</dt>
<dd v-if="member.slackInvited" class="status-ok">
Invited {{ formatDate(member.slackInvitedAt) }}
</dd>
<dd v-else class="meta-action">
<span class="status-dim">Not yet invited</span>
<button
type="button"
class="link-btn"
:disabled="markingSlackInvited"
@click="markSlackInvited"
>
{{ markingSlackInvited ? "Marking…" : "Mark as Slack invited" }}
</button>
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
{{ member.slackInvited ? "Invited" : "Pending" }}
</dd>
</div>
<div v-if="member.helcimCustomerId" class="meta-row">
@ -136,43 +127,6 @@
</dl>
</section>
<!-- Onboarding -->
<section class="detail-section">
<div class="section-label">Onboarding</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Profile Tags</dt>
<dd :class="hasProfileTags ? 'status-ok' : 'status-dim'">
{{ hasProfileTags ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Event Page Visited</dt>
<dd :class="member.onboarding?.eventPageVisited ? 'status-ok' : 'status-dim'">
{{ member.onboarding?.eventPageVisited ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Board Engaged</dt>
<dd :class="hasBoardEngaged ? 'status-ok' : 'status-dim'">
{{ hasBoardEngaged ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Wiki Clicked</dt>
<dd :class="member.onboarding?.wikiClicked ? 'status-ok' : 'status-dim'">
{{ member.onboarding?.wikiClicked ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Completed</dt>
<dd :class="member.onboarding?.completedAt ? 'status-ok' : 'status-dim'">
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
</dd>
</div>
</dl>
</section>
<!-- Notification preferences -->
<section class="detail-section">
<div class="section-label">Notification preferences</div>
@ -243,7 +197,6 @@
<script setup>
import { formatActivity } from '~/utils/activityText'
import { STATUS_LABELS } from '~/config/memberStatus'
definePageMeta({
layout: "admin",
@ -274,7 +227,7 @@ const form = reactive({
name: "",
email: "",
circle: "",
contributionAmount: 0,
contributionTier: "",
status: "",
role: "",
});
@ -286,7 +239,7 @@ function populateForm(m) {
form.name = m.name;
form.email = m.email;
form.circle = m.circle;
form.contributionAmount = m.contributionAmount ?? 0;
form.contributionTier = String(m.contributionTier);
form.status = m.status || "pending_payment";
form.role = m.role || "member";
}
@ -308,7 +261,7 @@ async function submitEdit() {
name: form.name,
email: form.email,
circle: form.circle,
contributionAmount: form.contributionAmount,
contributionTier: form.contributionTier,
status: form.status,
},
});
@ -351,47 +304,6 @@ function statusClass(status) {
return "status-dim";
}
// Onboarding computed states
const hasProfileTags = computed(() => {
const m = member.value
if (!m) return false
return m.craftTags?.length > 0 && m.board?.topics?.length > 0
})
const hasBoardEngaged = computed(() => {
const m = member.value
if (!m) return false
return m.onboarding?.boardPageVisited && m.board?.topics?.some(
t => ['help', 'interested', 'seeking'].includes(t.state)
)
})
const markingSlackInvited = ref(false)
async function markSlackInvited() {
if (!member.value || markingSlackInvited.value) return
markingSlackInvited.value = true
try {
const res = await $fetch(
`/api/admin/members/${route.params.id}/slack-status`,
{
method: "PATCH",
body: { slackInvited: true },
},
)
member.value = { ...member.value, ...res.member }
toast.add({ title: "Marked as Slack invited", color: "success" })
} catch (err) {
toast.add({
title: "Failed to mark Slack invited",
description: err.data?.statusMessage || err.message,
color: "error",
})
} finally {
markingSlackInvited.value = false
}
}
// Activity log
const activityEntries = ref([])
const activityLoading = ref(false)
@ -539,24 +451,6 @@ onMounted(loadActivity)
margin-top: 12px;
}
.field-hint {
font-size: 11px;
color: var(--text-faint);
margin: 6px 0 0;
line-height: 1.4;
}
.field-hint--warn {
color: var(--ember);
border-left: 2px solid var(--ember);
padding: 4px 0 4px 8px;
}
.field-hint code {
font-family: "Commit Mono", monospace;
font-size: 10px;
}
.form-actions {
display: flex;
gap: 8px;
@ -600,32 +494,6 @@ onMounted(loadActivity)
word-break: break-all;
}
.meta-action {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.link-btn {
background: none;
border: none;
color: var(--candle);
cursor: pointer;
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 2px 6px;
}
.link-btn:hover {
text-decoration: underline;
}
.link-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mono {
font-family: "Commit Mono", monospace;
font-size: 11px;

View file

@ -28,7 +28,7 @@
<!-- Search / Filter -->
<div class="filter-bar">
<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 class="field" style="margin-bottom: 0">
<select v-model="circleFilter" aria-label="Filter by circle">
@ -38,16 +38,6 @@
<option value="practitioner">Practitioner</option>
</select>
</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>
<!-- Members Table -->
@ -71,18 +61,17 @@
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
>
/>
<span class="check-mark" />
</label>
</th>
<th class="sortable" @click="toggleSort('name')">Name <span class="sort-ind">{{ sortIndicator('name') }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span class="sort-ind">{{ sortIndicator('email') }}</span></th>
<th class="sortable" @click="toggleSort('circle')">Circle <span class="sort-ind">{{ sortIndicator('circle') }}</span></th>
<th class="sortable" @click="toggleSort('contributionAmount')">Contribution <span class="sort-ind">{{ sortIndicator('contributionAmount') }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span class="sort-ind">{{ sortIndicator('status') }}</span></th>
<th>Name</th>
<th>Email</th>
<th>Circle</th>
<th>Tier</th>
<th>Invite</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>
</tr>
</thead>
@ -100,7 +89,7 @@
type="checkbox"
:checked="selectedMemberIds.includes(member._id)"
@change="toggleSelect(member._id)"
>
/>
<span class="check-mark" />
</label>
</td>
@ -109,12 +98,11 @@
</td>
<td class="col-email">{{ member.email }}</td>
<td>
<CircleBadge :circle="member.circle" />
</td>
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
<td>
<span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span>
<span class="badge" :class="member.circle">{{
member.circle
}}</span>
</td>
<td class="col-mono">${{ member.contributionTier }}/mo</td>
<td>
<span
:class="member.inviteEmailSent ? 'status-ok' : 'status-dim'"
@ -123,11 +111,8 @@
</span>
</td>
<td>
<span v-if="member.slackInvited" class="status-ok">
Invited {{ formatDate(member.slackInvitedAt) }}
</span>
<span v-else class="status-dim">
Not yet invited
<span :class="member.slackInvited ? 'status-ok' : 'status-dim'">
{{ member.slackInvited ? "Invited" : "Pending" }}
</span>
</td>
<td class="col-mono col-date">
@ -137,14 +122,10 @@
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
>View</NuxtLink
>
<button
v-if="!member.slackInvited"
class="link-btn"
@click.stop="markSlackInvited(member)"
>
Mark as Slack invited
<button @click.stop="sendSlackInvite(member)" class="link-btn">
Slack
</button>
<button class="link-btn" @click.stop="editMember(member)">Edit</button>
<button @click.stop="editMember(member)" class="link-btn">Edit</button>
</td>
</tr>
</tbody>
@ -169,10 +150,10 @@
</button>
</div>
<form class="modal-body" @submit.prevent="createMember">
<form @submit.prevent="createMember" class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="newMember.name" placeholder="Full name" required >
<input v-model="newMember.name" placeholder="Full name" required />
</div>
<div class="field">
<label>Email</label>
@ -181,7 +162,7 @@
type="email"
placeholder="email@example.com"
required
>
/>
</div>
<div class="field">
<label>Circle</label>
@ -192,8 +173,14 @@
</select>
</div>
<div class="field">
<label>Contribution ($/mo)</label>
<input v-model.number="newMember.contributionAmount" type="number" min="0" step="1">
<label>Contribution Tier</label>
<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 class="modal-actions">
<button type="button" class="btn" @click="showCreateModal = false">
@ -223,18 +210,19 @@
<div v-if="!csvRows.length">
<p class="help-text">
Upload a CSV with columns:
<code>name,email,circle,contributionAmount</code>
<code>name,email,circle,contributionTier</code>
</p>
<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>
<input
ref="csvFileInput"
type="file"
accept=".csv"
class="file-input"
@change="handleCsvFile"
>
class="file-input"
/>
</div>
<div v-if="csvParseError" class="error-box">
@ -255,7 +243,7 @@
>
{{ csvRows.length - csvValidRows.length }} with errors.
</span>
<button class="link-btn" @click="resetCsvImport">
<button @click="resetCsvImport" class="link-btn">
Choose different file
</button>
</div>
@ -268,7 +256,7 @@
<th>Name</th>
<th>Email</th>
<th>Circle</th>
<th>Contribution</th>
<th>Tier</th>
</tr>
</thead>
<tbody>
@ -286,7 +274,7 @@
<td>{{ row.name }}</td>
<td class="col-email">{{ row.email }}</td>
<td>{{ row.circle }}</td>
<td>${{ row.contributionAmount }}/mo</td>
<td>${{ row.contributionTier }}/mo</td>
</tr>
</tbody>
</table>
@ -315,14 +303,14 @@
</div>
<div class="modal-actions">
<button class="btn" @click="closeImportModal">
<button @click="closeImportModal" class="btn">
{{ importResults ? "Done" : "Cancel" }}
</button>
<button
v-if="csvValidRows.length && !importResults"
:disabled="importing"
class="btn btn-primary"
@click="submitImport"
class="btn btn-primary"
>
{{
importing
@ -348,14 +336,14 @@
</button>
</div>
<form class="modal-body" @submit.prevent="submitEditMember">
<form @submit.prevent="submitEditMember" class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="editingMember.name" required >
<input v-model="editingMember.name" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="editingMember.email" type="email" required >
<input v-model="editingMember.email" type="email" required />
</div>
<div class="field">
<label>Circle</label>
@ -366,17 +354,22 @@
</select>
</div>
<div class="field">
<label>Contribution ($/mo)</label>
<input v-model.number="editingMember.contributionAmount" type="number" min="0" step="1">
<label>Contribution Tier</label>
<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 class="field">
<label>Status</label>
<select v-model="editingMember.status">
<option
v-for="(label, value) in STATUS_LABELS"
:key="value"
:value="value"
>{{ label }}</option>
<option value="pending_payment">Pending Payment</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="modal-actions">
@ -414,7 +407,7 @@
<div class="field">
<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">
Tokens: <code>{name}</code>, <code>{loginLink}</code>,
<code>{circle}</code>
@ -446,14 +439,14 @@
</div>
<div class="modal-actions">
<button class="btn" @click="showInviteModal = false">
<button @click="showInviteModal = false" class="btn">
{{ inviteResults ? "Done" : "Cancel" }}
</button>
<button
v-if="!inviteResults"
:disabled="sendingInvites"
class="btn btn-primary"
@click="submitInvites"
class="btn btn-primary"
>
{{
sendingInvites
@ -468,8 +461,6 @@
</template>
<script setup>
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
definePageMeta({
layout: "admin",
middleware: "admin",
@ -486,22 +477,6 @@ const {
const searchQuery = 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 creating = ref(false);
@ -523,14 +498,14 @@ const inviteResults = ref(null);
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:
{loginLink}
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);
@ -538,13 +513,13 @@ const newMember = reactive({
name: "",
email: "",
circle: "community",
contributionAmount: 0,
contributionTier: "0",
});
const filteredMembers = computed(() => {
if (!members.value) return [];
const filtered = members.value.filter((member) => {
return members.value.filter((member) => {
const matchesSearch =
!searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
@ -553,33 +528,7 @@ const filteredMembers = computed(() => {
const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value;
const matchesStatus =
!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;
return matchesSearch && matchesCircle;
});
});
@ -656,7 +605,7 @@ const createMember = async () => {
name: "",
email: "",
circle: "community",
contributionAmount: 0,
contributionTier: "0",
});
await refresh();
@ -675,6 +624,7 @@ const createMember = async () => {
// --- CSV Import ---
const VALID_CIRCLES = ["community", "founder", "practitioner"];
const VALID_TIERS = ["0", "5", "15", "30", "50"];
const handleCsvFile = (event) => {
const file = event.target.files[0];
@ -703,10 +653,10 @@ const parseCsv = (text) => {
const nameIdx = header.indexOf("name");
const emailIdx = header.indexOf("email");
const circleIdx = header.indexOf("circle");
const amountIdx = header.indexOf("contributionamount");
const tierIdx = header.indexOf("contributiontier");
if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || amountIdx === -1) {
csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionAmount`;
if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || tierIdx === -1) {
csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionTier`;
return;
}
@ -718,21 +668,20 @@ const parseCsv = (text) => {
const name = cols[nameIdx] || "";
const email = (cols[emailIdx] || "").toLowerCase();
const circle = (cols[circleIdx] || "").toLowerCase();
const rawAmount = cols[amountIdx] || "";
const contributionAmount = Number(rawAmount);
const contributionTier = cols[tierIdx] || "";
let error = null;
if (!name) error = "Missing name";
else if (!email || !email.includes("@")) error = "Invalid email";
else if (!VALID_CIRCLES.includes(circle))
error = `Invalid circle: ${circle}`;
else if (!Number.isInteger(contributionAmount) || contributionAmount < 0)
error = `Invalid contribution: ${rawAmount}`;
else if (!VALID_TIERS.includes(contributionTier))
error = `Invalid tier: ${contributionTier}`;
else if (seenEmails.has(email)) error = "Duplicate email in CSV";
if (!error) seenEmails.add(email);
rows.push({ name, email, circle, contributionAmount, error });
rows.push({ name, email, circle, contributionTier, error });
}
csvRows.value = rows;
@ -759,11 +708,11 @@ const submitImport = async () => {
importing.value = true;
try {
const payload = csvValidRows.value.map(
({ name, email, circle, contributionAmount }) => ({
({ name, email, circle, contributionTier }) => ({
name,
email,
circle,
contributionAmount,
contributionTier,
}),
);
@ -830,25 +779,8 @@ const submitInvites = async () => {
};
// --- Existing actions ---
const markSlackInvited = async (member) => {
try {
const res = await $fetch(
`/api/admin/members/${member._id}/slack-status`,
{
method: "PATCH",
body: { slackInvited: true },
},
);
const idx = members.value.findIndex((m) => m._id === member._id);
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
toast.add({ title: "Marked as Slack invited", color: "success" });
} catch (err) {
toast.add({
title: "Failed to mark Slack invited",
description: err.data?.statusMessage || err.message,
color: "error",
});
}
const sendSlackInvite = (member) => {
console.log("Send Slack invite to:", member.email);
};
// --- Edit Member ---
@ -859,7 +791,7 @@ const editingMember = reactive({
name: "",
email: "",
circle: "community",
contributionAmount: 0,
contributionTier: "0",
status: "pending_payment",
});
@ -869,7 +801,7 @@ const editMember = (member) => {
name: member.name,
email: member.email,
circle: member.circle,
contributionAmount: member.contributionAmount ?? 0,
contributionTier: String(member.contributionTier),
status: member.status || "pending_payment",
});
showEditModal.value = true;
@ -884,7 +816,7 @@ const submitEditMember = async () => {
name: editingMember.name,
email: editingMember.email,
circle: editingMember.circle,
contributionAmount: editingMember.contributionAmount,
contributionTier: editingMember.contributionTier,
status: editingMember.status,
},
});
@ -1123,44 +1055,6 @@ tbody td {
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 ---- */
.modal-overlay {
position: fixed;
@ -1301,7 +1195,7 @@ th.sortable:hover {
}
.row-error {
background: color-mix(in srgb, var(--ember) 4%, transparent);
background: rgba(138, 68, 32, 0.04);
}
/* ---- PREVIEW BOX ---- */

View file

@ -247,11 +247,9 @@ Click below to accept your invitation, choose your circle, and set your contribu
{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!
Ghost Guild`;
See you inside.`;
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
@ -643,8 +641,8 @@ tbody td {
}
.status-accepted {
color: var(--green);
border-color: var(--green);
color: var(--green, #4a7);
border-color: var(--green, #4a7);
}
.status-expired {
@ -671,7 +669,7 @@ tbody td {
/* ---- STATUS INDICATORS ---- */
.status-ok {
color: var(--green);
color: var(--green, #4a7);
font-size: 11px;
}

View file

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

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -1,121 +0,0 @@
<script setup lang="ts">
definePageMeta({ layout: false });
useSiteMeta({ title: "Sign Out", noindex: true });
// The xsrf token comes from a short-lived httpOnly cookie set by
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).
// We consume it during SSR, persist it into useState so the form input
// hydrates correctly on the client, and clear the cookie immediately so the
// token is strictly one-time use.
const xsrf = useState<string>("oidc-logout-xsrf", () => "");
if (import.meta.server && !xsrf.value) {
const cookie = useCookie("oidc_logout_xsrf");
if (cookie.value) {
xsrf.value = cookie.value;
cookie.value = null;
} else {
// No active logout flow somebody hit this page directly. Send them
// back to the wiki rather than render a dead form.
await navigateTo("https://wiki.ghostguild.org", {
external: true,
replace: true,
});
}
}
</script>
<template>
<main class="auth-shell">
<div class="dashed-box auth-box">
<header class="auth-header">
<p class="section-label">Ghost Guild</p>
<h1 class="auth-title">Sign Out</h1>
</header>
<hr class="section-divider" />
<p class="auth-body">
Do you want to sign out of your Ghost Guild session?
</p>
<p class="auth-sub">
This will sign you out of the wiki and any other connected services.
</p>
<form
method="post"
action="/oidc/session/end/confirm"
class="auth-form"
>
<input type="hidden" name="xsrf" :value="xsrf" />
<input type="hidden" name="logout" value="yes" />
<button type="submit" class="btn btn-primary auth-btn">
Yes, sign me out
</button>
<a href="https://wiki.ghostguild.org" class="btn auth-btn auth-btn-secondary">
Stay signed in
</a>
</form>
</div>
</main>
</template>
<style scoped>
.auth-shell {
display: grid;
place-items: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--page-pad-y) var(--page-pad-x);
}
.auth-box {
width: 100%;
max-width: 420px;
padding: 24px 28px;
}
.auth-header {
text-align: center;
}
.auth-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
margin: 0;
}
.auth-body {
font-size: 14px;
color: var(--text);
line-height: 1.55;
text-align: center;
margin: 0;
}
.auth-sub {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
text-align: center;
margin: 0;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
}
.auth-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>

View file

@ -1,71 +0,0 @@
<script setup lang="ts">
definePageMeta({ layout: false });
useSiteMeta({ title: "Signed Out", noindex: true });
</script>
<template>
<main class="auth-shell">
<div class="dashed-box auth-box">
<header class="auth-header">
<p class="section-label">Ghost Guild</p>
<h1 class="auth-title">Signed Out</h1>
</header>
<hr class="section-divider" />
<p class="auth-body" role="status">
You've been signed out.
</p>
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">
Return to Wiki
</a>
</div>
</main>
</template>
<style scoped>
.auth-shell {
display: grid;
place-items: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--page-pad-y) var(--page-pad-x);
}
.auth-box {
width: 100%;
max-width: 360px;
padding: 24px 28px;
}
.auth-header {
text-align: center;
}
.auth-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
margin: 0;
}
.auth-body {
font-size: 14px;
color: var(--text);
line-height: 1.55;
text-align: center;
margin: 0;
}
.auth-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 4px;
}
</style>

View file

@ -1,115 +0,0 @@
<script setup lang="ts">
definePageMeta({ layout: false });
useSiteMeta({ title: "Sign-In Error", noindex: true });
const route = useRoute();
// Vue's default {{ }} interpolation escapes HTML on render, so these
// values from the query string can never execute as markup fixing the
// XSS that existed in the old guildPageShell renderError implementation.
const errorCode = computed(() =>
typeof route.query.error === "string" ? route.query.error : "",
);
const errorDescription = computed(() =>
typeof route.query.error_description === "string"
? route.query.error_description
: "",
);
const hasDetail = computed(
() => Boolean(errorCode.value) || Boolean(errorDescription.value),
);
</script>
<template>
<main class="auth-shell">
<div class="dashed-box auth-box">
<header class="auth-header">
<p class="section-label">Ghost Guild</p>
<h1 class="auth-title">Something went wrong</h1>
</header>
<hr class="section-divider" />
<p class="auth-body">
An error occurred during authentication. Please try again.
</p>
<div v-if="hasDetail" class="auth-detail" role="status">
<p v-if="errorCode" class="auth-detail-code">{{ errorCode }}</p>
<p v-if="errorDescription" class="auth-detail-desc">
{{ errorDescription }}
</p>
</div>
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">
Return to Wiki
</a>
</div>
</main>
</template>
<style scoped>
.auth-shell {
display: grid;
place-items: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--page-pad-y) var(--page-pad-x);
}
.auth-box {
width: 100%;
max-width: 420px;
padding: 24px 28px;
}
.auth-header {
text-align: center;
}
.auth-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
margin: 0;
}
.auth-body {
font-size: 14px;
color: var(--text);
line-height: 1.55;
text-align: center;
margin: 0;
}
.auth-detail {
border: 1px dashed var(--border);
padding: 12px 14px;
font-family: "Commit Mono", monospace;
font-size: 12px;
color: var(--text-dim);
text-align: left;
word-break: break-word;
}
.auth-detail-code {
color: var(--ember);
font-weight: 600;
margin: 0 0 4px;
}
.auth-detail-desc {
margin: 0;
}
.auth-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 4px;
}
</style>

View file

@ -2,14 +2,12 @@
definePageMeta({
layout: false,
});
useSiteMeta({ title: "Wiki Sign In", noindex: true });
const route = useRoute();
const uid = route.query.uid as string;
const email = ref("");
const sent = ref(false);
const notRegistered = ref(false);
const loading = ref(false);
const error = ref("");
@ -17,85 +15,53 @@ async function sendMagicLink() {
if (!email.value || !uid) return;
loading.value = true;
error.value = "";
notRegistered.value = false;
try {
const response = await $fetch<{ success: boolean; registered: boolean }>(
"/oidc/interaction/login",
{
method: "POST",
body: { email: email.value, uid },
}
);
if (response.registered === false) {
notRegistered.value = true;
} else {
sent.value = true;
}
await $fetch("/oidc/interaction/login", {
method: "POST",
body: { email: email.value, uid },
});
sent.value = true;
} catch (e: any) {
error.value =
e?.data?.statusMessage || "Something went wrong. Please try again.";
error.value = e?.data?.statusMessage || "Something went wrong. Please try again.";
} finally {
loading.value = false;
}
}
function resetForm() {
sent.value = false;
notRegistered.value = false;
email.value = "";
}
</script>
<template>
<main class="wiki-login">
<div class="dashed-box wiki-login-box">
<header class="wiki-login-header">
<p class="section-label">Ghost Guild</p>
<div class="wiki-login">
<div class="wiki-login-card">
<div class="wiki-login-header">
<span class="wiki-login-overline">Ghost Guild</span>
<h1 class="wiki-login-title">Wiki</h1>
</header>
</div>
<hr class="section-divider" >
<div class="wiki-login-divider" />
<Transition name="wiki-fade" mode="out-in">
<form
v-if="!sent && !notRegistered"
key="form"
class="wiki-login-form"
@submit.prevent="sendMagicLink"
>
<div class="field">
<label for="email">Email address</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="you@example.com"
:disabled="loading"
>
</div>
<form v-if="!sent" key="form" @submit.prevent="sendMagicLink" class="wiki-login-form">
<label for="email" class="wiki-login-label">Email address</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="you@example.com"
class="wiki-login-input"
:disabled="loading"
/>
<p
v-if="error"
class="wiki-login-error"
role="alert"
aria-live="assertive"
>
{{ error }}
</p>
<p v-if="error" class="wiki-login-error">{{ error }}</p>
<button
type="submit"
class="btn btn-primary wiki-login-submit"
:disabled="loading || !email"
class="wiki-login-button"
>
<span
v-if="loading"
class="wiki-login-spinner"
aria-hidden="true"
/>
<span v-if="loading" class="wiki-login-spinner" />
{{ loading ? "Sending" : "Continue" }}
</button>
@ -104,130 +70,187 @@ function resetForm() {
</p>
</form>
<div
v-else-if="sent"
key="sent"
class="wiki-login-sent"
role="status"
aria-live="polite"
>
<h2 class="wiki-login-sent-heading">Check your inbox</h2>
<div v-else key="sent" class="wiki-login-sent">
<p class="wiki-login-sent-heading">Check your inbox</p>
<p class="wiki-login-sent-detail">
A sign-in link was sent to <strong>{{ email }}</strong>
</p>
<button class="wiki-login-reset" @click="resetForm">
Try a different email
</button>
</div>
<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">
<button
@click="sent = false; email = '';"
class="wiki-login-link"
>
Try a different email
</button>
</div>
</Transition>
</div>
</main>
</div>
</template>
<style scoped>
.wiki-login {
display: grid;
place-items: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--page-pad-y) var(--page-pad-x);
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse at 30% 70%, rgba(184, 135, 58, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 70% 30%, rgba(178, 104, 64, 0.04) 0%, transparent 60%),
var(--color-guild-900);
}
.wiki-login-box {
.dark .wiki-login {
background:
radial-gradient(ellipse at 30% 70%, rgba(224, 184, 110, 0.05) 0%, transparent 60%),
radial-gradient(ellipse at 70% 30%, rgba(218, 154, 114, 0.03) 0%, transparent 60%),
var(--color-guild-900);
}
.wiki-login-card {
width: 100%;
max-width: 360px;
padding: 24px 28px;
padding: 2.5rem 2rem 2rem;
background: var(--color-guild-800);
border: 1px solid var(--color-guild-700);
border-radius: 12px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 8px 24px rgba(0, 0, 0, 0.08);
}
.dark .wiki-login-card {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 8px 32px rgba(0, 0, 0, 0.3);
}
.wiki-login-header {
text-align: center;
}
.wiki-login-overline {
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-guild-400);
}
.wiki-login-title {
font-family: var(--font-display);
font-size: 36px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
margin: 0;
font-family: var(--font-sans);
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
color: var(--color-candlelight-400);
margin-top: 0.25rem;
}
.wiki-login-divider {
height: 1px;
background: linear-gradient(
to right,
transparent,
var(--color-guild-600),
transparent
);
margin: 1.5rem 0;
}
.wiki-login-form {
display: flex;
flex-direction: column;
gap: 12px;
gap: 0.75rem;
}
.wiki-login-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-guild-300);
}
.wiki-login-input {
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
color: var(--color-guild-100);
background: var(--color-guild-900);
border: 1px solid var(--color-guild-600);
border-radius: 8px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.wiki-login-input::placeholder {
color: var(--color-guild-500);
}
.wiki-login-input:focus {
border-color: var(--color-candlelight-500);
box-shadow: 0 0 0 3px rgba(184, 135, 58, 0.15);
}
.wiki-login-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.wiki-login-error {
font-size: 13px;
color: var(--ember);
font-size: 0.8125rem;
color: var(--color-ember-400);
margin: 0;
}
.wiki-login-submit {
width: 100%;
display: inline-flex;
.wiki-login-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
margin-top: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-guild-50);
background: var(--color-candlelight-500);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.wiki-login-submit:disabled {
.wiki-login-button:hover:not(:disabled) {
background: var(--color-candlelight-400);
}
.wiki-login-button:active:not(:disabled) {
transform: scale(0.98);
}
.wiki-login-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.wiki-login-spinner {
display: inline-block;
width: 10px;
height: 10px;
border: 1.5px solid color-mix(in srgb, var(--bg) 35%, transparent);
border-top-color: var(--bg);
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: white;
border-radius: 50%;
animation: wiki-spin 0.7s linear infinite;
animation: wiki-spin 0.6s linear infinite;
}
@keyframes wiki-spin {
to {
transform: rotate(360deg);
}
to { transform: rotate(360deg); }
}
.wiki-login-hint {
font-size: 11px;
color: var(--text-faint);
font-size: 0.75rem;
color: var(--color-guild-500);
text-align: center;
margin: 4px 0 0;
margin: 0;
}
.wiki-login-sent {
@ -235,58 +258,45 @@ function resetForm() {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 0.5rem;
}
.wiki-login-sent-heading {
font-family: var(--font-display);
font-size: 20px;
font-family: var(--font-sans);
font-size: 1.25rem;
font-weight: 600;
color: var(--text-bright);
color: var(--color-guild-100);
margin: 0;
}
.wiki-login-sent-detail {
font-size: 13px;
color: var(--text-dim);
font-size: 0.8125rem;
color: var(--color-guild-400);
line-height: 1.5;
margin: 0;
}
.wiki-login-sent-detail strong {
color: var(--text-bright);
color: var(--color-guild-200);
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 {
font-family: "Commit Mono", monospace;
font-size: 12px;
color: var(--candle);
font-size: 0.8125rem;
color: var(--color-candlelight-500);
background: none;
border: none;
padding: 0;
margin-top: 4px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
padding: 0;
margin-top: 0.5rem;
transition: color 0.15s;
}
.wiki-login-reset:hover {
color: var(--candle-dim);
.wiki-login-link:hover {
color: var(--color-candlelight-400);
}
/* State transition */
/* Transition */
.wiki-fade-enter-active,
.wiki-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;

View file

@ -1,395 +0,0 @@
<template>
<PageShell title="Bulletin Board" :subtitle="pageSubtitle">
<p class="page-intro">
Make offers and requests related to shared interests and cooperative
topics.
</p>
<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">
+ New Post
</button>
</div>
<div v-if="showTagsDrawer && cooperativeTags.length > 0" class="tags-drawer">
<div class="skills-bar">
<span class="tag-label">Filter:</span>
<button
v-for="tag in visibleTagOptions"
:key="tag.slug"
type="button"
class="skill-tag"
:class="{ active: activeTagFilter === tag.slug }"
@click="toggleTagFilter(tag.slug)"
>
{{ tag.label || tag.name }}
</button>
<button
v-if="cooperativeTags.length > 10"
type="button"
class="more-btn"
@click="showAllTags = !showAllTags"
>
{{ showAllTags ? 'Show less' : `+${cooperativeTags.length - 10} more` }}
</button>
</div>
</div>
<div v-if="showForm" class="form-wrapper">
<BoardPostForm
:post="editingPost"
:tags="cooperativeTags"
@submit="handleSubmit"
@cancel="closeForm"
/>
</div>
<ClientOnly>
<div v-if="loading" class="loading-state">
<p>Loading board...</p>
</div>
<template v-else>
<div v-if="posts.length === 0" class="empty-state">
<p class="empty-title">No posts yet.</p>
<p class="empty-sub">Be the first to post.</p>
<button type="button" class="new-post-btn" @click="openNewForm">
+ New Post
</button>
</div>
<div v-else class="post-grid">
<BoardPostCard
v-for="post in posts"
:key="post._id"
:post="post"
:channels="channels"
:tags="cooperativeTags"
:editable="isAuthor(post)"
:pending-delete="pendingDeleteId === post._id"
@edit="handleEdit"
@delete="requestDelete"
@confirm-delete="confirmDelete"
@cancel-delete="cancelDelete"
/>
</div>
</template>
<template #fallback>
<div class="loading-state">
<p>Loading board...</p>
</div>
</template>
</ClientOnly>
</PageShell>
</template>
<script setup>
definePageMeta({ middleware: ['members-auth'] })
const { memberData } = useAuth()
const { posts, loading, fetchPosts, createPost, updatePost, deletePost } = useBoardPosts()
const { channels, fetchChannels } = useBoardChannels()
const toast = useToast()
const cooperativeTags = ref([])
const showTagsDrawer = ref(false)
const showAllTags = ref(false)
const activeTagFilter = ref(null)
const showForm = ref(false)
const editingPost = ref(null)
const pendingDeleteId = ref(null)
const currentMemberId = computed(() => memberData.value?._id || null)
const pageSubtitle = computed(() => {
const count = posts.value.length
return `${count} post${count === 1 ? '' : 's'}`
})
const visibleTagOptions = computed(() =>
showAllTags.value ? cooperativeTags.value : cooperativeTags.value.slice(0, 10)
)
const isAuthor = (post) => {
if (!currentMemberId.value || !post.author) return false
const authorId = typeof post.author === 'object' ? post.author._id : post.author
return String(authorId) === String(currentMemberId.value)
}
const toggleTagFilter = async (slug) => {
activeTagFilter.value = activeTagFilter.value === slug ? null : slug
await fetchPosts(activeTagFilter.value ? { tag: activeTagFilter.value } : {})
}
const openNewForm = () => {
editingPost.value = null
showForm.value = true
}
const closeForm = () => {
showForm.value = false
editingPost.value = null
}
const handleEdit = (post) => {
editingPost.value = post
showForm.value = true
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const requestDelete = (post) => {
pendingDeleteId.value = post._id
}
const cancelDelete = () => {
pendingDeleteId.value = null
}
const confirmDelete = async (post) => {
try {
await deletePost(post._id)
pendingDeleteId.value = null
} catch (err) {
toast.add({
title: 'Failed to delete post',
description: err?.data?.message || err?.message || 'Please try again.',
color: 'red',
})
}
}
const handleSubmit = async (body) => {
try {
if (editingPost.value) {
await updatePost(editingPost.value._id, body)
} else {
await createPost(body)
}
closeForm()
} catch (err) {
toast.add({
title: editingPost.value ? 'Failed to update post' : 'Failed to create post',
description: err?.data?.message || err?.message || 'Please try again.',
color: 'red',
})
}
}
const loadTags = async () => {
const data = await $fetch('/api/tags')
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
}
useSiteMeta({
title: 'Bulletin Board',
description:
'The Ghost Guild bulletin board. Members post offers and requests around shared interests and cooperative topics.',
})
onMounted(async () => {
await Promise.allSettled([loadTags(), fetchPosts(), fetchChannels()])
})
</script>
<style scoped>
.page-intro {
padding: 12px 24px 0;
color: var(--text-dim);
font-size: 13px;
line-height: 1.65;
max-width: 640px;
}
.action-bar {
padding: 12px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.new-post-btn {
font-family: "Commit Mono", monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--candle);
background: transparent;
border: 1px dashed var(--candle-faint);
padding: 4px 12px;
cursor: pointer;
transition: all 0.15s;
}
.new-post-btn:hover {
border-style: solid;
background: rgba(154, 116, 32, 0.08);
}
.new-post-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
/* ---- TAGS DRAWER ---- */
.drawer-btn {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-dim);
background: none;
border: 1px dashed var(--border);
padding: 3px 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s;
}
.drawer-btn:hover {
border-color: var(--candle-faint);
color: var(--text);
}
.drawer-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.tag-count-badge {
font-size: 9px;
background: var(--candle-faint);
color: var(--candle);
padding: 0 4px;
min-width: 14px;
text-align: center;
}
.tags-drawer {
border-bottom: 1px dashed var(--border);
}
.skills-bar {
padding: 12px 24px;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.skills-bar .tag-label {
font-size: 10px;
color: var(--text-faint);
margin-right: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.skills-bar .skill-tag {
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--text-dim);
padding: 2px 8px;
border: 1px dashed var(--border);
background: transparent;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.skills-bar .skill-tag:hover {
border-color: var(--candle-faint);
color: var(--text);
}
.skills-bar .skill-tag.active {
border-color: var(--candle-dim);
border-style: solid;
color: var(--candle);
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 {
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--candle);
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
}
.more-btn:hover {
text-decoration: underline;
}
/* ---- FORM WRAPPER ---- */
.form-wrapper {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
max-width: 640px;
}
/* ---- POST GRID (masonry via CSS columns) ---- */
.post-grid {
column-count: 2;
column-gap: 16px;
padding: 20px 24px;
}
.post-grid > * {
display: block;
width: 100%;
margin: 0 0 16px;
}
@media (min-width: 1400px) {
.post-grid {
column-count: 3;
}
}
/* ---- LOADING / EMPTY ---- */
.loading-state {
padding: 64px 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
.empty-state {
padding: 64px 24px;
text-align: center;
}
.empty-title {
font-family: "Brygada 1918", serif;
font-size: 20px;
color: var(--text-dim);
margin-bottom: 6px;
}
.empty-sub {
font-size: 12px;
color: var(--text-faint);
margin-bottom: 16px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.post-grid {
column-count: 1;
}
}
@media (max-width: 768px) {
.action-bar {
padding: 12px 16px;
}
.skills-bar {
padding: 10px 16px;
}
.post-grid,
.form-wrapper {
padding: 16px;
}
}
</style>

View file

@ -1,30 +1,44 @@
<template>
<div class="coming-soon">
<h1 class="coming-soon-title">Ghost Guild</h1>
<p v-if="!isAuthenticated" class="coming-soon-subtitle">Coming Soon</p>
<div class="min-h-screen w-full flex flex-col items-center justify-center px-4">
<h1 class="text-display-xl font-bold mb-2 uppercase font-sans!">Ghost Guild</h1>
<p
v-if="!isAuthenticated"
class="text-display-sm text-guild-400 mb-10 uppercase py-4 text-center font-sans!">
Coming Soon
</p>
<!-- Logged-in state -->
<div v-if="isAuthenticated" class="coming-soon-auth">
<p>
Welcome, <strong>{{ memberData.name || memberData.email }}</strong>
<div v-if="isAuthenticated" class="w-full max-w-sm flex flex-col items-center space-y-4 text-center mt-8">
<p class="text-guild-200 font-sans py-4 text-center">
Welcome, <strong class="text-guild-100">{{ memberData.name || memberData.email }}</strong>
</p>
<a href="https://wiki.ghostguild.org" class="coming-soon-btn">
<a
href="https://wiki.ghostguild.org"
class="block w-full py-3 px-6 bg-candlelight-500 hover:bg-candlelight-600 text-guild-900 font-semibold rounded-full uppercase tracking-wide transition-colors font-sans text-center">
Go to Wiki
</a>
<button class="coming-soon-signout" @click="handleLogout">
<button
class="block w-full text-sm text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide transition-colors"
@click="handleLogout">
Sign out
</button>
</div>
<!-- Login form -->
<div v-else class="coming-soon-form">
<div v-else class="w-full max-w-sm">
<!-- Success state -->
<div v-if="loginSuccess" class="coming-soon-success">
<h3>Check your email</h3>
<p>
<div v-if="loginSuccess" class="text-center py-4">
<div
class="w-16 h-16 bg-candlelight-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<UIcon name="i-heroicons-check-circle" class="w-10 h-10 text-candlelight-400" />
</div>
<h3 class="text-lg font-semibold text-guild-100 mb-2">
Check your email
</h3>
<p class="text-guild-300">
We've sent a magic link to
<strong>{{ email }}</strong
>. Click the link to sign in.
<strong class="text-guild-100">{{ email }}</strong>.
Click the link to sign in.
</p>
</div>
@ -36,28 +50,32 @@
type="email"
size="lg"
class="w-full"
placeholder="your.email@example.com"
/>
placeholder="your.email@example.com" />
</UFormField>
<div v-if="loginError" class="coming-soon-error">
<p>{{ loginError }}</p>
<div v-if="loginError" class="mb-4 p-3 bg-ember-500/10 border border-ember-500/30 rounded-lg">
<p class="text-ember-400 text-sm">{{ loginError }}</p>
</div>
<div class="coming-soon-actions">
<div class="flex justify-center">
<UButton
type="submit"
:loading="isLoggingIn"
:disabled="!isFormValid"
size="lg"
class="uppercase tracking-wide font-semibold whitespace-nowrap"
>
class="rounded-full uppercase tracking-wide font-semibold whitespace-nowrap">
Send Magic Link
</UButton>
</div>
<div class="coming-soon-preregister">
<a href="https://babyghosts.org/ghost-guild/">Pre-Register</a>
<div class="text-center pt-6 border-t border-guild-700 mt-6">
<p class="text-guild-400 text-sm">
<a
href="https://babyghosts.fund/ghost-guild/"
class="text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide">
Pre-Register
</a>
</p>
</div>
</UForm>
</div>
@ -109,138 +127,3 @@ const handleLogout = async () => {
await logout();
};
</script>
<style scoped>
.coming-soon {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
}
.coming-soon-title {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.coming-soon-subtitle {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 40px;
padding: 16px 0;
}
.coming-soon-auth {
width: 100%;
max-width: 24rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
margin-top: 32px;
color: var(--text-dim);
}
.coming-soon-auth strong {
color: var(--text-bright);
}
.coming-soon-btn {
display: block;
width: 100%;
padding: 12px 24px;
background: var(--parch);
color: var(--parch-text);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
transition: background 0.15s;
}
.coming-soon-btn:hover {
background: var(--parch-hover);
text-decoration: none;
}
.coming-soon-signout {
font-size: 12px;
color: var(--candle);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.15s;
cursor: pointer;
}
.coming-soon-signout:hover {
color: var(--candle-dim);
}
.coming-soon-form {
width: 100%;
max-width: 24rem;
}
.coming-soon-success {
text-align: center;
padding: 16px 0;
}
.coming-soon-success h3 {
font-family: var(--font-display);
font-size: 1.125rem;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.coming-soon-success p {
color: var(--text-dim);
}
.coming-soon-success strong {
color: var(--text-bright);
}
.coming-soon-error {
margin-bottom: 16px;
padding: 12px;
background: var(--ember-bg);
border: 1px dashed var(--ember);
}
.coming-soon-error p {
color: var(--ember);
font-size: 12px;
}
.coming-soon-actions {
display: flex;
justify-content: center;
}
.coming-soon-preregister {
text-align: center;
padding-top: 24px;
border-top: 1px dashed var(--border);
margin-top: 24px;
font-size: 12px;
}
.coming-soon-preregister a {
color: var(--candle);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>

View file

@ -1,397 +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>
useSiteMeta({
title: "Community Guidelines",
description:
"What you're agreeing to when you join Ghost Guild — community values, member commitments, and the policies that govern participation.",
});
</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

@ -1,3 +1,4 @@
<script setup>
await navigateTo("/board", { replace: true });
definePageMeta({ middleware: "auth" });
await navigateTo("/ecology", { replace: true });
</script>

View file

@ -1,3 +1,471 @@
<template>
<PageShell
title="Community Ecology"
subtitle="Find members who share your cooperative interests"
>
<ClientOnly>
<div v-if="loading" class="loading-state">
<p>Loading ecology...</p>
</div>
<template v-else>
<div v-if="tagOptions.length > 0" class="filter-bar">
<select
v-model="filterTag"
class="filter-select"
@change="loadSuggestions"
>
<option value="">All topics</option>
<option v-for="tag in tagOptions" :key="tag.slug" :value="tag.slug">
{{ tag.label }}
</option>
</select>
</div>
<div class="connections-section">
<div class="section-label">Suggested Matches</div>
<div v-if="suggestions.length > 0" class="connection-grid">
<div
v-for="suggestion in suggestions"
:key="suggestion.member._id"
class="connection-card"
>
<div class="cc-head">
<div class="cc-avatar">
<img
v-if="suggestion.member.avatar"
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
:alt="suggestion.member.name"
class="cc-avatar-img"
/>
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
</div>
<div class="cc-info">
<div class="cc-name">
<NuxtLink :to="`/members/${suggestion.member._id}`">
{{ suggestion.member.name }}
</NuxtLink>
</div>
<div class="cc-meta">
<CircleBadge :circle="suggestion.member.circle || 'community'" />
</div>
</div>
</div>
<div
v-if="suggestion.member.craftTags?.length"
class="cc-craft-tags"
>
<span
v-for="tag in suggestion.member.craftTags.slice(0, 5)"
:key="tag"
class="craft-pill"
>{{ craftTagLabel(tag) }}</span>
</div>
<div class="cc-matches">
<div
v-for="match in suggestion.matchingTags"
:key="match.tagSlug"
class="match-row"
>
<span class="match-tag">{{ cooperativeTagLabel(match.tagSlug) }}</span>
<span class="match-states">
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
<span class="match-sep">&middot;</span>
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
</span>
</div>
</div>
<div v-if="suggestion.member.slackHandle" class="cc-contact">
<span class="cc-slack">@{{ suggestion.member.slackHandle }}</span>
<button
type="button"
class="text-action"
@click="copyHandle(suggestion.member.slackHandle)"
>
{{ copiedHandle === suggestion.member.slackHandle ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<p class="empty-title">No matches yet</p>
<p class="empty-sub">
Add cooperative topics to your
<NuxtLink to="/member/profile">profile</NuxtLink>
to find members with shared interests.
</p>
</div>
</div>
</template>
<template #fallback>
<div class="loading-state">
<p>Loading ecology...</p>
</div>
</template>
</ClientOnly>
</PageShell>
</template>
<script setup>
await navigateTo("/board", { replace: true });
definePageMeta({ middleware: 'auth' })
const { getSuggestions } = useEcology()
const loading = ref(true)
const suggestions = ref([])
const filterTag = ref('')
const tagOptions = ref([])
const craftTagOptions = ref([])
const copiedHandle = ref(null)
const stateLabels = {
help: 'Can help',
interested: 'Interested',
seeking: 'Need help',
}
const stateLabel = (state) => stateLabels[state] || state || ''
const cooperativeTagLabel = (slug) => {
const found = tagOptions.value.find((t) => t.slug === slug)
return found ? found.label : slug
}
const craftTagLabel = (slug) => {
const found = craftTagOptions.value.find((t) => t.slug === slug)
return found ? found.label : slug
}
const getInitials = (name) => {
if (!name) return '?'
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
const capitalize = (str) => {
if (!str) return ''
return str
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
const loadSuggestions = async () => {
try {
const params = {}
if (filterTag.value) params.tag = filterTag.value
const data = await getSuggestions(params)
suggestions.value = data.suggestions || []
} catch (error) {
console.error('Failed to load suggestions:', error)
suggestions.value = []
}
}
const { memberData } = useAuth()
const loadTags = async () => {
try {
const data = await $fetch('/api/tags')
const tags = data.tags || []
const cooperativeTags = tags
.filter((t) => t.pool === 'cooperative')
.map((t) => ({ slug: t.slug, label: t.label }))
craftTagOptions.value = tags
.filter((t) => t.pool === 'craft')
.map((t) => ({ slug: t.slug, label: t.label }))
const myTopicSlugs = (memberData.value?.communityEcology?.topics || []).map(
(t) => t.tagSlug,
)
tagOptions.value = myTopicSlugs.length
? cooperativeTags.filter((t) => myTopicSlugs.includes(t.slug))
: cooperativeTags
} catch (error) {
console.error('Failed to load tags:', error)
}
}
let copyTimer = null
const copyHandle = async (handle) => {
try {
await navigator.clipboard.writeText(`@${handle}`)
copiedHandle.value = handle
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => {
copiedHandle.value = null
copyTimer = null
}, 1500)
} catch (error) {
console.error('Clipboard write failed:', error)
}
}
onBeforeUnmount(() => {
if (copyTimer) clearTimeout(copyTimer)
})
onMounted(async () => {
loading.value = true
try {
await Promise.all([loadTags(), loadSuggestions()])
} finally {
loading.value = false
}
})
useHead({
title: 'Community Ecology - Ghost Guild',
meta: [
{
name: 'description',
content:
'Find Ghost Guild members who share your cooperative interests and reach out on Slack.',
},
],
})
</script>
<style scoped>
.loading-state {
padding: 60px 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
.filter-bar {
padding: 16px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-select {
font-family: "Commit Mono", monospace;
font-size: 11px;
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);
}
.connections-section {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.connection-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.connection-card {
padding: 16px 20px;
border: 1px dashed var(--border);
margin: -1px 0 0 -1px;
transition: background 0.15s;
}
.connection-card:hover {
background: var(--surface);
}
.cc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.cc-avatar {
width: 32px;
height: 32px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
}
.cc-avatar-img {
width: 28px;
height: 28px;
object-fit: contain;
}
.cc-info {
min-width: 0;
}
.cc-name {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.cc-name a {
color: var(--text-bright);
text-decoration: none;
}
.cc-name a:hover {
color: var(--candle);
text-decoration: underline;
}
.cc-meta {
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 1px;
}
.cc-craft-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin: 6px 0;
}
.craft-pill {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
}
.cc-matches {
margin: 8px 0;
}
.match-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
font-size: 11px;
}
.match-tag {
color: var(--text);
font-weight: 600;
min-width: 0;
}
.match-states {
color: var(--text-faint);
font-size: 10px;
display: flex;
gap: 4px;
align-items: baseline;
flex-wrap: wrap;
}
.match-sep {
color: var(--border);
}
.match-you,
.match-them {
white-space: nowrap;
}
.cc-contact {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.cc-slack {
font-size: 11px;
color: var(--candle-dim);
font-family: "Commit Mono", monospace;
}
.text-action {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.text-action:hover {
color: var(--text);
text-decoration: underline;
}
.text-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.empty-state {
padding: 32px 0;
text-align: center;
}
.empty-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
color: var(--text-dim);
margin-bottom: 6px;
}
.empty-sub {
font-size: 12px;
color: var(--text-faint);
}
.empty-sub a {
color: var(--candle);
}
@media (max-width: 1024px) {
.connection-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
padding: 14px 20px;
}
.connections-section {
padding: 16px 20px;
}
.connection-card {
padding: 14px 16px;
}
}
</style>

View file

@ -7,7 +7,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<div v-else>
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
@ -22,14 +22,15 @@
</div>
<div class="event-meta-item">
<span class="meta-label">Location</span>
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
Platform TBD
</span>
<template v-else>{{ event.location }}</template>
{{ event.location }}
</div>
<div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" />
</div>
<div v-if="event.maxAttendees" class="event-meta-item">
<span class="meta-label">Capacity</span>
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</div>
</div>
</div>
@ -47,7 +48,7 @@
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
>
/>
</div>
<!-- TWO-COLUMN BODY -->
@ -81,7 +82,7 @@
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<div class="prose" v-html="renderMarkdown(event.description)" />
<p>{{ event.description }}</p>
</div>
<!-- Series Description -->
@ -90,23 +91,17 @@
class="section"
>
<h2>About the {{ event.series.title }} Series</h2>
<div class="prose" v-html="renderMarkdown(event.series.description)" />
</div>
<!-- Additional Information -->
<div v-if="event.content" class="section">
<h2>Additional Information</h2>
<div class="prose" v-html="renderMarkdown(event.content)" />
<p>{{ event.series.description }}</p>
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ul class="agenda-list">
<ol class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">
{{ item }}
</li>
</ul>
</ol>
</div>
<!-- Speakers -->
@ -130,22 +125,158 @@
<div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System -->
<EventTicketPurchase
v-if="event.tickets?.enabled"
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:event-timezone="eventTimeZone"
:user-email="memberData?.email"
:user-name="memberData?.name"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Legacy Registration -->
<template v-else>
<!-- Already Registered -->
<div v-if="registrationStatus === 'registered'" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--green)">
You're registered!
</p>
<p class="reg-price">Confirmation sent to your email</p>
<button
class="btn btn-danger"
:disabled="isCancelling"
@click="handleCancelRegistration"
>
{{ isCancelling ? "Cancelling..." : "Cancel Registration" }}
</button>
</div>
<!-- Member Status Issues -->
<div v-else-if="memberData && !canRSVP" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember)">
{{ statusConfig.label }}
</p>
<p class="reg-price">{{ getRSVPMessage() }}</p>
<NuxtLink
v-if="isPendingPayment"
to="#"
@click.prevent="completePayment"
>
<button class="btn btn-primary" :disabled="isProcessingPayment">
{{ isProcessingPayment ? "Processing..." : "Complete Payment" }}
</button>
</NuxtLink>
</div>
<!-- Members-Only Gate -->
<div
v-else-if="event.membersOnly && memberData && !isMember"
class="dashed-box"
>
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember)">
Membership Required
</p>
<p class="reg-price">This event is exclusive to members.</p>
<NuxtLink to="/join"
><button class="btn btn-primary">
Become a Member
</button></NuxtLink
>
</div>
<!-- Can Register (logged in) -->
<div
v-else-if="memberData && (!event.membersOnly || isMember)"
class="dashed-box"
>
<div class="box-title">Registration</div>
<div v-if="event.maxAttendees" class="reg-status">
{{ event.maxAttendees - (event.registeredCount || 0) }} spots
remaining
</div>
<div class="reg-price">Free for members</div>
<button
class="btn btn-primary"
:disabled="isRegistering"
@click="handleRegistration"
>
{{ isRegistering ? "Registering..." : "Register for this event" }}
</button>
<a
:href="`/api/events/${route.params.slug}/calendar`"
download
class="cal-link"
>Add to calendar</a
>
</div>
<!-- Not Logged In -->
<div v-else class="dashed-box">
<div class="box-title">Registration</div>
<form @submit.prevent="handleRegistration">
<div class="field">
<label>Name</label>
<input v-model="registrationForm.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="registrationForm.email" type="email" required />
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="isRegistering"
>
{{ isRegistering ? "Registering..." : "Register for Event" }}
</button>
</form>
</div>
<!-- Waitlist -->
<div
v-if="event.tickets?.waitlist?.enabled && isEventFull"
class="dashed-box"
>
<div class="box-title">Waitlist</div>
<div v-if="isOnWaitlist">
<p class="reg-status">
You're on the waitlist (#{{ waitlistPosition }})
</p>
<button
class="btn"
@click="handleLeaveWaitlist"
:disabled="isJoiningWaitlist"
>
Leave Waitlist
</button>
</div>
<div v-else>
<p class="reg-status" style="color: var(--ember)">
This event is full
</p>
<form @submit.prevent="handleJoinWaitlist">
<div v-if="!memberData" class="field">
<label>Email</label>
<input v-model="waitlistForm.email" type="email" required />
</div>
<button type="submit" class="btn" :disabled="isJoiningWaitlist">
{{ isJoiningWaitlist ? "Joining..." : "Join Waitlist" }}
</button>
</form>
</div>
</div>
</template>
<!-- Event Details Box -->
<div class="dashed-box">
<div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span>
<span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
<span class="detail-val">{{ event.eventType }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
@ -171,8 +302,6 @@
</template>
<script setup>
import { eventTypeLabel } from "~/config/eventTypes";
const route = useRoute();
const toast = useToast();
@ -192,44 +321,192 @@ if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: "Event not found" });
}
const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding();
const { render: renderMarkdown } = useMarkdown();
const { isMember, memberData, checkMemberStatus } = useAuth();
const {
isPendingPayment,
isSuspended,
isCancelled,
canRSVP,
statusConfig,
getRSVPMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
onMounted(async () => {
await checkMemberStatus();
if (memberData.value && !isComplete.value) {
trackGoal('eventPageVisited');
if (memberData.value) {
registrationForm.value.name = memberData.value.name;
registrationForm.value.email = memberData.value.email;
registrationForm.value.membershipLevel =
memberData.value.membershipLevel || "non-member";
await checkRegistrationStatus();
checkWaitlistStatus();
}
});
const eventTimeZone = computed(
() => event.value?.displayTimezone || "America/Toronto",
);
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return;
try {
const response = await $fetch(
`/api/events/${route.params.slug}/check-registration`,
{
method: "POST",
body: { email: memberData.value.email },
},
);
if (response.isRegistered) registrationStatus.value = "registered";
} catch (err) {
console.error("Failed to check registration status:", err);
}
};
const registrationForm = ref({
name: "",
email: "",
membershipLevel: "non-member",
});
const isRegistering = ref(false);
const isCancelling = ref(false);
const registrationStatus = ref("not-registered");
const isJoiningWaitlist = ref(false);
const isOnWaitlist = ref(false);
const waitlistPosition = ref(0);
const waitlistForm = ref({ email: "" });
const isEventFull = computed(() => {
if (!event.value?.maxAttendees) return false;
return (event.value.registeredCount || 0) >= event.value.maxAttendees;
});
const checkWaitlistStatus = () => {
const email = memberData.value?.email || waitlistForm.value.email;
if (!email || !event.value?.tickets?.waitlist?.enabled) return;
const entries = event.value.tickets.waitlist.entries || [];
const idx = entries.findIndex(
(e) => e.email.toLowerCase() === email.toLowerCase(),
);
if (idx !== -1) {
isOnWaitlist.value = true;
waitlistPosition.value = idx + 1;
}
};
const handleJoinWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
const name = memberData.value?.name || "Guest";
const response = await $fetch(`/api/events/${route.params.slug}/waitlist`, {
method: "POST",
body: { email, name },
});
isOnWaitlist.value = true;
waitlistPosition.value = response.position;
toast.add({
title: "Added to Waitlist",
description: `You're #${response.position} on the waitlist.`,
color: "orange",
});
} catch (err) {
toast.add({
title: "Couldn't Join Waitlist",
description: err.data?.statusMessage || "Please try again.",
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
const handleLeaveWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
await $fetch(`/api/events/${route.params.slug}/waitlist`, {
method: "DELETE",
body: { email },
});
isOnWaitlist.value = false;
waitlistPosition.value = 0;
toast.add({ title: "Removed from Waitlist", color: "blue" });
} catch (err) {
toast.add({
title: "Error",
description: "Failed to leave waitlist.",
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: eventTimeZone.value,
}).format(d);
};
const formatTime = (start, end) => {
if (!start || !end) return "";
const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: eventTimeZone.value,
});
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`;
};
const handleRegistration = async () => {
isRegistering.value = true;
try {
await $fetch(`/api/events/${route.params.slug}/register`, {
method: "POST",
body: registrationForm.value,
});
registrationStatus.value = "registered";
toast.add({
title: "Registered!",
description: `You're registered for ${event.value.title}.`,
color: "green",
});
if (event.value.registeredCount !== undefined)
event.value.registeredCount++;
} catch (err) {
toast.add({
title: "Registration Failed",
description: err.data?.statusMessage || "Please try again.",
color: "red",
});
} finally {
isRegistering.value = false;
}
};
const handleCancelRegistration = async () => {
isCancelling.value = true;
try {
await $fetch(`/api/events/${route.params.slug}/cancel-registration`, {
method: "POST",
body: { email: registrationForm.value.email || memberData.value?.email },
});
registrationStatus.value = "not-registered";
toast.add({ title: "Registration Cancelled", color: "blue" });
if (event.value.registeredCount !== undefined)
event.value.registeredCount--;
} catch (err) {
toast.add({
title: "Cancellation Failed",
description: err.data?.statusMessage || "Please try again.",
color: "red",
});
} finally {
isCancelling.value = false;
}
};
const handleTicketSuccess = () => {
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
};
@ -237,12 +514,16 @@ const handleTicketError = (err) => {
console.error("Ticket purchase failed:", err);
};
useSiteMeta(() => ({
title: event.value ? `${event.value.title} · Events` : "Event",
description:
event.value?.description || "View event details and register.",
type: "article",
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
useHead(() => ({
title: event.value
? `${event.value.title} - Ghost Guild Events`
: "Event - Ghost Guild",
meta: [
{
name: "description",
content: event.value?.description || "View event details and register",
},
],
}));
</script>
@ -307,19 +588,10 @@ useSiteMeta(() => ({
margin-bottom: 4px;
}
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
flex: 1;
}
.event-main {
min-width: 0;
@ -350,79 +622,12 @@ useSiteMeta(() => ({
margin-bottom: 8px;
}
.section p {
font-size: 14px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.prose {
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.prose :deep(p) {
margin-bottom: 12px;
}
.prose :deep(p:last-child) {
margin-bottom: 0;
}
.prose :deep(a) {
color: var(--ember);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :deep(strong) {
color: var(--text-bright);
}
.prose :deep(ul),
.prose :deep(ol) {
list-style: none;
padding: 0;
margin: 8px 0 12px;
}
.prose :deep(ul li),
.prose :deep(ol li) {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.prose :deep(ul li::before) {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.prose :deep(ol) {
counter-reset: prose-item;
}
.prose :deep(ol li) {
counter-increment: prose-item;
padding-left: 28px;
}
.prose :deep(ol li::before) {
content: counter(prose-item) ".";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
}
.prose :deep(blockquote) {
border-left: 2px solid var(--candle-faint);
padding-left: 12px;
margin: 12px 0;
color: var(--text-faint);
}
.prose :deep(code) {
font-family: "Commit Mono", monospace;
background: var(--input-bg);
padding: 0 4px;
}
.circle-badges {
display: flex;
gap: 6px;
@ -435,27 +640,10 @@ useSiteMeta(() => ({
}
.agenda-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
font-size: 14px;
padding-left: 20px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.agenda-list li {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.agenda-list li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
line-height: 2;
}
.speaker {
@ -487,6 +675,23 @@ useSiteMeta(() => ({
color: var(--text-faint);
margin-bottom: 8px;
}
.reg-status {
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.reg-price {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 10px;
}
.cal-link {
display: block;
margin-top: 8px;
font-size: 11px;
color: var(--candle);
}
.detail-row {
display: flex;
justify-content: space-between;

View file

@ -5,24 +5,15 @@
<h1>Events</h1>
<p>
Workshops, meetups, and gatherings for game developers practicing
cooperative models. Some events are open to the public.
cooperative models.
</p>
</div>
<!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions">
<button
type="button"
class="past-toggle"
: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>
<label class="filter-toggle">
<input v-model="includePastEvents" type="checkbox" /> Show past events
</label>
</FilterBar>
<!-- EVENT LIST -->
@ -34,8 +25,8 @@
:class="{ 'is-cancelled': event.isCancelled }"
>
<div class="event-date-col">
<span class="event-date">{{ formatDate(event) }}</span>
<span class="event-time">{{ formatTime(event) }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<span class="event-time">{{ formatTime(event.startDate) }}</span>
</div>
<div class="event-info">
<div class="event-title">
@ -45,25 +36,30 @@
<span v-if="event.isCancelled" class="cancelled-tag"
>cancelled</span
>
<span v-if="event.isRegistered" class="registered-tag"
>Registered</span
>
</div>
<div v-if="event.tagline" class="event-tagline">
{{ event.tagline }}
</div>
<div class="event-sub">
<span v-if="event.eventType" class="event-type-tag">{{
eventTypeLabel(event.eventType)
event.eventType
}}</span>
<span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span>
</div>
</div>
<span class="event-capacity">
<template v-if="event.maxAttendees">
<span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span>
</template>
<template v-else>Open</template>
</span>
<div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
<span v-else class="badge all">Public</span>
<span v-else class="badge all">All</span>
</div>
</div>
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
@ -75,11 +71,11 @@
<div class="series-grid">
<NuxtLink
v-for="series in activeSeries"
:key="series.id"
:to="`/series/${series.id}`"
:key="series._id"
:to="`/series/${series._id}`"
class="series-box"
>
<h2>{{ series.title }}</h2>
<h3>{{ series.title }}</h3>
<p class="series-desc">{{ series.description }}</p>
<div class="series-meta">
<span
@ -94,39 +90,55 @@
>
</div>
</NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div>
</div>
<!-- PROPOSE AN EVENT -->
<!-- TODO: Build /events/propose page + form for members to submit event ideas.
Think through before building:
- Who can propose? Members only, or any circle?
- Required fields: title, description, proposed date/time, target circle,
format (workshop/social/talk/etc.), estimated attendance
- Approval workflow: does an admin review and publish, or does it auto-post
as a draft?
- Interest threshold mechanic: can other members +1 a proposal to signal
demand before it gets formally scheduled?
- Notifications: proposer gets notified when approved/declined
See CLAUDE.md product spec for additional context. -->
<div class="full-section">
<div class="section-label">Have an idea?</div>
<DashedBox>
<h3>Propose an Event</h3>
<p>
Members can propose events for any circle. Workshops, social hangs,
talks, or anything else that serves the community.
</p>
<span class="cta cta-soon"
>Propose an event &rarr; <em>coming soon</em></span
>
</DashedBox>
</div>
</div>
</template>
<script setup>
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
useSiteMeta({
title: "Events",
description:
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
});
const activeFilter = ref("all");
const includePastEvents = ref(false);
const filterOptions = [
{ label: "All", value: "all" },
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
{ label: "Workshops", value: "workshop" },
{ label: "Community", value: "community" },
{ label: "Social", value: "social" },
{ label: "Showcase", value: "showcase" },
];
const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series");
const now = new Date();
const filteredEvents = computed(() => {
const now = new Date();
if (!eventsData.value) return [];
return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now)
@ -144,25 +156,18 @@ const activeSeries = computed(() => {
);
});
const formatDate = (event) => {
if (!event?.startDate) return "";
const tz = event.displayTimezone || "America/Toronto";
const d = new Date(event.startDate);
const opts = { month: "short", day: "numeric", timeZone: tz };
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
if (dYear !== nowYear) opts.year = "numeric";
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
const opts = { month: "short", day: "numeric" };
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
return d.toLocaleDateString("en-US", opts);
};
const formatTime = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: event.displayTimezone || "America/Toronto",
});
const formatTime = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
};
const formatLocation = (event) => {
@ -174,15 +179,19 @@ const formatLocation = (event) => {
return event.location;
};
const isAlmostFull = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) / event.maxAttendees > 0.8;
};
</script>
<style scoped>
.hero {
padding: 32px 28px 24px;
padding: 32px 32px 24px;
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: var(--font-display);
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
@ -198,13 +207,13 @@ const formatLocation = (event) => {
/* ---- EVENT LIST ---- */
.event-list-full {
padding: 0 28px;
padding: 0 32px;
border-bottom: 1px dashed var(--border);
}
.event-row {
display: grid;
grid-template-columns: 90px 1fr auto;
grid-template-columns: 90px 1fr auto auto;
gap: 16px;
align-items: start;
padding: 14px 0;
@ -221,12 +230,8 @@ const formatLocation = (event) => {
.event-row:hover {
padding-left: 4px;
}
.event-row.is-cancelled .event-title a {
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
.event-row.is-cancelled {
opacity: 0.5;
}
.event-date-col {
@ -267,7 +272,7 @@ const formatLocation = (event) => {
}
.cancelled-tag {
font-size: 10px;
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--ember);
@ -276,16 +281,6 @@ const formatLocation = (event) => {
line-height: 1.5;
flex-shrink: 0;
}
.registered-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--candle);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.event-tagline {
font-size: 11px;
@ -314,6 +309,16 @@ const formatLocation = (event) => {
color: var(--text-faint);
}
.event-capacity {
font-size: 11px;
color: var(--text-faint);
white-space: nowrap;
padding-top: 2px;
}
.seats-warn {
color: var(--ember);
}
.event-badges {
display: flex;
flex-direction: column;
@ -321,7 +326,7 @@ const formatLocation = (event) => {
gap: 4px;
}
.members-badge {
font-size: 10px;
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
@ -333,7 +338,7 @@ const formatLocation = (event) => {
/* ---- FULL SECTION ---- */
.full-section {
padding: 32px 28px;
padding: 32px;
border-bottom: 1px dashed var(--border);
}
@ -345,26 +350,19 @@ const formatLocation = (event) => {
border: 1px dashed var(--border);
}
.series-box {
padding: 20px 24px;
padding: 20px;
border-right: 1px dashed var(--border);
text-decoration: none;
transition: background 0.15s;
border-right: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
.series-box:last-child {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: none;
}
.series-box-filler {
pointer-events: none;
}
.series-box:not(.series-box-filler):hover {
.series-box:hover {
background: var(--surface-hover);
}
.series-box h2 {
font-family: var(--font-display);
.series-box h3 {
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
@ -384,49 +382,47 @@ const formatLocation = (event) => {
align-items: center;
}
.past-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-family: "Commit Mono", monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: transparent;
border: 1px dashed var(--border);
padding: 4px 10px;
cursor: pointer;
transition: all 0.15s;
/* ---- PROPOSE ---- */
.full-section h3 {
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 4px;
}
.past-toggle:hover {
border-color: var(--candle-faint);
color: var(--text-dim);
}
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.past-toggle.active {
border-color: var(--candle);
border-style: solid;
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 {
.full-section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.cta {
display: inline-block;
margin-top: 8px;
font-size: 12px;
line-height: 1;
color: var(--candle);
}
.cta-soon {
color: var(--text-dim);
cursor: default;
}
.cta-soon em {
font-style: normal;
font-size: 10px;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 11px;
color: var(--text-faint);
cursor: pointer;
}
.filter-toggle input {
accent-color: var(--candle-dim);
}
.empty {
padding: 24px 0;
@ -435,16 +431,11 @@ const formatLocation = (event) => {
}
@media (max-width: 768px) {
.hero,
.event-list-full,
.full-section {
padding-left: 20px;
padding-right: 20px;
}
.event-row {
grid-template-columns: 70px 1fr;
gap: 8px;
}
.event-capacity,
.event-badges {
display: none;
}
@ -455,17 +446,8 @@ const formatLocation = (event) => {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
border-bottom: none;
}
.series-box-filler {
display: none;
}
}
</style>

View file

@ -2,19 +2,20 @@
<div>
<!-- HERO -->
<div class="hero">
<h1>Ghost Guild is where game developers explore cooperative models.</h1>
<h1>
Ghost Guild is where game developers practice cooperative business
models.
</h1>
<p>
Resources, events, and a community of people figuring it out. Three
circles, pay what you can.
circles, no hierarchy. $050/mo, pay what you can.
</p>
<div class="hero-links">
<NuxtLink to="/join" class="hero-link primary"
>Become a member</NuxtLink
>
<a href="https://wiki.ghostguild.org" class="hero-link"
>Read the wiki</a
>
<NuxtLink to="/about" class="hero-link">About the Guild</NuxtLink>
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
</div>
</div>
@ -30,6 +31,10 @@
</div>
<h2>{{ circle.metaphor }}</h2>
<p>{{ circle.blurb }}</p>
<details>
<summary>What's included?</summary>
<p>{{ circle.included }}</p>
</details>
</div>
</div>
@ -42,7 +47,7 @@
<div v-if="events?.length" class="event-list">
<div v-for="event in events" :key="event._id" class="event-item">
<div class="block-inset event-item-inner">
<span class="event-date">{{ formatDate(event) }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<span class="event-title">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title
@ -60,21 +65,27 @@
<div class="block-inset">
<div class="label">Recently in the Wiki</div>
</div>
<div v-if="wikiArticles?.length" class="wiki-list">
<div
v-for="article in wikiArticles"
:key="article._id"
class="wiki-item"
>
<div class="wiki-list">
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a :href="article.url" target="_blank">{{ article.title }}</a>
<a href="/wiki">Revenue sharing models</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">What is a cooperative studio?</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">Governance structures</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">Legal incorporation guide</a>
</div>
</div>
</div>
<div v-else class="block-inset">
<p class="empty">
<a href="https://wiki.ghostguild.org">Browse the wiki &rarr;</a>
</p>
</div>
</div>
</div>
@ -87,27 +98,18 @@
>
From the Wiki
</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>
<p>
A cooperative studio is a game development company owned and governed
by the people who work there. Decisions are made collectively. Profits
are shared according to contribution, not ownership stake.
</p>
<p>
The games industry is full of stories about crunch, layoffs, and
studios that extract value from workers. Cooperatives are one
alternative not the only one, but one worth
<a href="https://wiki.ghostguild.org">practicing together</a>.
</p>
</template>
<h2>What is a cooperative studio?</h2>
<p>
<a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a>
A cooperative studio is a game development company owned and governed by
the people who work there. Decisions are made collectively. Profits are
shared according to contribution, not ownership stake.
</p>
<p>
The games industry is full of stories about crunch, layoffs, and studios
that extract value from workers. Cooperatives are one alternative not
the only one, but one worth <a href="/wiki">practicing together</a>.
</p>
<p><a href="/wiki">Read more in the wiki &rarr;</a></p>
</ParchmentInset>
</div>
</template>
@ -117,91 +119,45 @@ definePageMeta({
layout: "default",
});
const runtimeConfig = useRuntimeConfig();
const siteUrl = (runtimeConfig.public.appUrl || "").replace(/\/$/, "");
useSiteMeta({
title: "Ghost Guild",
bareTitle: true,
description:
"Ghost Guild is where game developers explore cooperative models. Membership, events, and resources for people figuring it out together. Pay what you can.",
});
useHead({
script: [
{
type: "application/ld+json",
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "Ghost Guild",
url: siteUrl || "https://ghostguild.org",
logo: `${siteUrl || "https://ghostguild.org"}/og/default.png`,
description:
"A membership community for game developers exploring cooperative models. A program of Baby Ghosts, a Canadian non-profit.",
}),
},
],
});
const { data: events } = await useFetch("/api/events", {
query: { limit: 4, upcoming: true },
default: () => [],
});
const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
query: { limit: 4 },
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 = [
{
value: "community",
label: "Community",
metaphor: "The open hall",
blurb:
"For anyone exploring cooperative models in game development. Solo devs, researchers, students, people who just heard about this and want to know more.",
"Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.",
included:
"Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.",
},
{
value: "founder",
label: "Founder",
metaphor: "The workshop",
blurb:
"For people actively building cooperative studios. You're working through governance, legal structure, revenue sharing, and all the hard parts.",
"For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.",
included:
"Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.",
},
{
value: "practitioner",
label: "Practitioner",
metaphor: "The alcove",
blurb:
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
"Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.",
included:
"Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.",
},
];
const formatDate = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
</script>
@ -302,6 +258,26 @@ const formatDate = (event) => {
margin-bottom: 8px;
}
/* ---- DETAILS ---- */
details {
margin-top: 12px;
}
details summary {
font-size: 12px;
color: var(--candle-dim);
cursor: pointer;
list-style: none;
}
details summary::before {
content: "+ ";
}
details[open] summary::before {
content: " ";
}
details p {
margin-top: 8px;
}
/* ---- EVENT LIST ---- */
.event-item {
border-bottom: 1px dashed var(--border);

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,7 @@
<span
class="status-dot"
:class="memberData.status || 'active'"
/>
></span>
<span>{{
formatStatus(memberData.status || "active")
}}</span>
@ -57,11 +57,9 @@
</div>
<div class="membership-row">
<span class="membership-k">Contribution</span>
<span class="membership-v">{{ currentContributionLabel }}</span>
</div>
<div v-if="nextPaymentDate" class="membership-row">
<span class="membership-k">Next payment</span>
<span class="membership-v">{{ formatNextPaymentDate(nextPaymentDate) }}</span>
<span class="membership-v"
>${{ memberData.contributionTier || 0 }} / month</span
>
</div>
<div class="membership-row">
<span class="membership-k">Member since</span>
@ -72,89 +70,6 @@
</div>
</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">
<div class="section-label">Email</div>
@ -169,26 +84,26 @@
<div class="field">
<label>New email address</label>
<input
v-model="newEmail"
type="email"
v-model="newEmail"
placeholder="you@example.com"
autofocus
@keydown.enter="handleUpdateEmail"
@keydown.escape="cancelEmailEdit"
>
autofocus
/>
</div>
<div class="email-edit-actions">
<button
class="btn btn-primary"
:disabled="isUpdatingEmail || !newEmail.trim()"
@click="handleUpdateEmail"
:disabled="isUpdatingEmail || !newEmail.trim()"
>
{{ isUpdatingEmail ? "Saving…" : "Save" }}
</button>
<button
class="btn"
:disabled="isUpdatingEmail"
@click="cancelEmailEdit"
:disabled="isUpdatingEmail"
>
Cancel
</button>
@ -204,12 +119,9 @@
<div class="section-label danger">Danger Zone</div>
<div class="danger-zone">
<p>
Cancelling closes your account and ends access to member-only
spaces, including Slack.<template v-if="(memberData.contributionAmount || 0) > 0"> If you're cancelling because of a
money issue, the
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
and the $0 tier are always available reach out before you
go.</template>
Cancelling your membership will immediately revoke access to
member-only resources, events, and the Slack workspace.
<strong>This action cannot be easily undone.</strong>
</p>
<div v-if="showCancelConfirm" class="cancel-confirm">
<p class="cancel-confirm-prompt">
@ -218,8 +130,8 @@
<div class="cancel-confirm-actions">
<button
class="btn btn-danger"
:disabled="isCancelling"
@click="confirmCancelMembership"
:disabled="isCancelling"
>
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
</button>
@ -231,8 +143,8 @@
<button
v-else
class="btn btn-danger"
:disabled="isCancelling"
@click="handleCancelMembership"
:disabled="isCancelling"
>
Cancel Membership
</button>
@ -245,45 +157,17 @@
<PageSection>
<div class="section-label">Change Contribution</div>
<div class="form-group">
<label class="form-label" for="account-contribution">
Monthly Contribution
</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 }}
<TierPicker v-model="selectedTier" :tiers="tiers" />
<div class="tier-hint">
Changes take effect on your next billing cycle
</div>
<button
class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled="
form.contributionAmount === Number(memberData.contributionAmount || 0) ||
selectedTier === Number(memberData.contributionTier || 0) ||
isUpdating
"
@click="handleUpdateContribution"
>
{{ isUpdating ? "Updating…" : "Update Contribution" }}
</button>
@ -294,13 +178,12 @@
<CirclePicker
v-model="selectedCircle"
:saved-value="memberData.circle"
:circles="circleOptions"
/>
<button
class="btn btn-primary btn-section"
:disabled="selectedCircle === memberData.circle || isUpdating"
@click="handleUpdateCircle"
:disabled="selectedCircle === memberData.circle || isUpdating"
>
{{ isUpdating ? "Updating…" : "Update Circle" }}
</button>
@ -314,22 +197,15 @@
</template>
<script setup>
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
import { STATUS_LABELS } from '~/config/memberStatus';
useSiteMeta({ title: 'Account', noindex: true });
definePageMeta({
middleware: "auth",
});
const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay();
const toast = useToast();
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
const form = reactive({ contributionAmount: 0 });
const selectedTier = ref(0);
const selectedCircle = ref("");
const isUpdating = ref(false);
const isCancelling = ref(false);
@ -339,87 +215,42 @@ const showEmailEdit = ref(false);
const newEmail = ref("");
const isUpdatingEmail = ref(false);
// Payment history state
const paymentHistory = ref([]);
const paymentHistoryLoading = ref(false);
const paymentHistoryError = ref(false);
const paymentHistoryLoaded = ref(false);
// 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 tiers = [
{ amount: 0, display: "$0", label: "Solidarity" },
{ amount: 5, display: "$5", label: "Supporter" },
{ amount: 15, display: "$15", label: "Sustainer" },
{ amount: 30, display: "$30", label: "Builder" },
{ amount: 50, display: "$50", label: "Champion" },
];
const circleOptions = [
{
value: "community",
label: "Community",
description: "Exploring cooperative ideas",
description:
"For anyone interested in cooperative game dev. Access discussions, events, and resources.",
},
{
value: "founder",
label: "Founder",
description: "Building a cooperative studio",
description:
"For those actively building or running a cooperative studio. Peer support and deep dives.",
},
{
value: "practitioner",
label: "Practitioner",
description: "Experienced in cooperative practice",
description:
"For professionals advising co-ops: lawyers, accountants, facilitators, consultants.",
},
];
const STATUS_LABELS = {
active: "Active",
pending_payment: "Pending",
suspended: "Suspended",
cancelled: "Cancelled",
};
const formatStatus = (s) => STATUS_LABELS[s] || s;
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
@ -427,7 +258,7 @@ const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
// Initialize from member data
watchEffect(() => {
if (memberData.value) {
form.contributionAmount = Number(memberData.value.contributionAmount || 0);
selectedTier.value = Number(memberData.value.contributionTier || 0);
selectedCircle.value = memberData.value.circle || "community";
}
});
@ -440,71 +271,21 @@ const formatMemberSince = (dateStr) => {
});
};
const formatNextPaymentDate = (dateStr) => {
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 () => {
const handleUpdateTier = async () => {
isUpdating.value = true;
try {
await $fetch("/api/members/update-contribution", {
method: "POST",
body: {
contributionAmount: form.contributionAmount,
cadence: cadence.value,
},
body: { contributionTier: String(selectedTier.value) },
});
await checkMemberStatus();
toast.add({ title: "Contribution updated", color: "success" });
toast.add({ title: "Contribution updated", color: "green" });
} catch (err) {
// Paid upgrade without a saved card route to payment setup instead of erroring.
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);
selectedTier.value = Number(memberData.value?.contributionTier || 0);
toast.add({
title: "Update failed",
description: err.data?.statusMessage || "Please try again.",
color: "error",
color: "red",
});
} finally {
isUpdating.value = false;
@ -519,13 +300,13 @@ const handleUpdateCircle = async () => {
body: { circle: selectedCircle.value },
});
await checkMemberStatus();
toast.add({ title: "Circle updated", color: "success" });
toast.add({ title: "Circle updated", color: "green" });
} catch (err) {
selectedCircle.value = memberData.value?.circle || "community";
toast.add({
title: "Update failed",
description: err.data?.statusMessage || "Please try again.",
color: "error",
color: "red",
});
} finally {
isUpdating.value = false;
@ -548,163 +329,18 @@ const handleUpdateEmail = async () => {
});
await checkMemberStatus();
cancelEmailEdit();
toast.add({ title: "Email updated", color: "success" });
toast.add({ title: "Email updated", color: "green" });
} catch (err) {
toast.add({
title: "Update failed",
description: err.data?.statusMessage || "Please try again.",
color: "error",
color: "red",
});
} finally {
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 handleCancelMembership = () => {
@ -726,13 +362,13 @@ const confirmCancelMembership = async () => {
color: "neutral",
});
} else {
toast.add({ title: "Membership cancelled", color: "warning" });
toast.add({ title: "Membership cancelled", color: "orange" });
}
} catch (err) {
toast.add({
title: "Cancellation failed",
description: err.data?.statusMessage || "Please try again.",
color: "error",
color: "red",
});
} finally {
isCancelling.value = false;
@ -890,136 +526,9 @@ const confirmCancelMembership = async () => {
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 {
width: 100%;
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>

View file

@ -0,0 +1,332 @@
<template>
<PageShell
title="Activity Log"
subtitle="Your activity and milestones in the Guild"
>
<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

@ -32,180 +32,176 @@
<!-- Member Status Banner -->
<MemberStatusBanner />
<!-- Welcome Header -->
<PageHeader :title="welcomeTitle">
<div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
</div>
<p v-if="showSlackComingNote" class="slack-coming-note">
Slack workspace access is part of your membership. Invitations are
sent in monthly onboarding waves &mdash; we'll be in touch.
</p>
</PageHeader>
<!-- Upcoming Events + Quick Actions -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Upcoming Events</div>
<div v-if="loadingEvents" class="loading-inline">
<div class="spinner spinner-sm" />
<!-- Welcome Header -->
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
<div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionTier }} CAD/mo</span>
</div>
</PageHeader>
<div v-else-if="registeredEvents.length" class="event-list">
<NuxtLink
v-for="evt in registeredEvents"
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="event-item"
>
<span class="event-date">{{ formatEventDate(evt) }}</span>
<span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink>
<!-- Upcoming Events + Quick Actions -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Upcoming Events</div>
<!-- Calendar subscription -->
<button class="calendar-btn" @click="copyCalendarLink">
{{
calendarLinkCopied
? "Link copied!"
: "Subscribe to calendar"
}}
</button>
</div>
<div v-if="loadingEvents" class="loading-inline">
<div class="spinner spinner-sm" />
</div>
<div v-else class="empty-state">
<p>You haven't registered for any upcoming events</p>
</div>
<NuxtLink to="/events" class="section-link"
>Browse all events &rarr;</NuxtLink
>
<!-- Calendar subscription instructions -->
<div
v-if="registeredEvents.length > 0 && showCalendarInstructions"
class="calendar-instructions"
>
<div class="ci-header">
<strong>How to Subscribe to Your Calendar</strong>
<button
type="button"
class="ci-close"
@click="showCalendarInstructions = false"
<div v-else-if="registeredEvents.length" class="event-list">
<NuxtLink
v-for="evt in registeredEvents"
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="event-item"
>
&times;
<span class="event-date">{{
formatEventDate(evt.startDate)
}}</span>
<span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink>
<!-- Calendar subscription -->
<button class="calendar-btn" @click="copyCalendarLink">
{{
calendarLinkCopied
? "Link copied!"
: "Subscribe to calendar"
}}
</button>
</div>
<ul>
<li>
<strong>Google Calendar:</strong> Click "+" then "From URL"
then paste the link
</li>
<li>
<strong>Apple Calendar:</strong> File then New Calendar
Subscription then paste the link
</li>
<li>
<strong>Outlook:</strong> Add Calendar then Subscribe from
web then paste the link
</li>
</ul>
<p class="ci-note">
Your calendar will automatically update when you register or
unregister from events.
</p>
</div>
</div>
<div class="content-block">
<div class="section-label">Quick Actions</div>
<NuxtLink
to="/board"
class="quick-action"
:class="{ disabled: !canPeerSupport }"
:title="
!canPeerSupport
? 'Complete your membership to access the board'
: ''
"
>
Board<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile" class="quick-action">
Update your profile<span class="arrow">&rarr;</span>
</NuxtLink>
<a
href="https://wiki.ghostguild.org"
target="_blank"
class="quick-action"
@click="handleWikiClick"
>
Browse the wiki<span class="arrow">&rarr;</span>
</a>
<NuxtLink to="/members" class="quick-action">
Browse members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/account" class="quick-action">
Manage account<span class="arrow">&rarr;</span>
</NuxtLink>
</div>
</div>
<div v-else class="empty-state">
<p>You haven't registered for any upcoming events</p>
</div>
<!-- Membership Summary + Peer Support -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Membership</div>
<div class="membership-row">
<span class="key">Circle</span>
<span
class="val"
:style="{
color: `var(--c-${memberData?.circle || 'community'})`,
}"
<NuxtLink to="/events" class="section-link"
>Browse all events &rarr;</NuxtLink
>
{{ memberData?.circle }}
</span>
</div>
<div class="membership-row">
<span class="key">Contribution</span>
<span class="val"
>${{ memberData?.contributionAmount ?? 0 }} CAD/month</span
>
</div>
<div class="membership-row">
<span class="key">Status</span>
<span class="val">
<span :class="isActive ? 'status-active' : ''">
{{ isActive ? "Active" : statusConfig.label }}
</span>
</span>
</div>
<div v-if="memberData?.createdAt" class="membership-row">
<span class="key">Member since</span>
<span class="val">{{
formatMemberSince(memberData.createdAt)
}}</span>
</div>
<NuxtLink to="/member/account" class="section-link">
Change circle or contribution &rarr;
</NuxtLink>
</div>
<div class="content-block">
<div class="section-label">Bulletin Board</div>
<DashedBox>
<p class="peer-text">
Make offers and requests related to shared interests and
cooperative topics.
</p>
<NuxtLink to="/board" class="section-link">
Browse the Bulletin Board &rarr;
<!-- Calendar subscription instructions -->
<div
v-if="registeredEvents.length > 0 && showCalendarInstructions"
class="calendar-instructions"
>
<div class="ci-header">
<strong>How to Subscribe to Your Calendar</strong>
<button
@click="showCalendarInstructions = false"
class="ci-close"
>
&times;
</button>
</div>
<ul>
<li>
<strong>Google Calendar:</strong> Click "+" then "From
URL" then paste the link
</li>
<li>
<strong>Apple Calendar:</strong> File then New Calendar
Subscription then paste the link
</li>
<li>
<strong>Outlook:</strong> Add Calendar then Subscribe from
web then paste the link
</li>
</ul>
<p class="ci-note">
Your calendar will automatically update when you register or
unregister from events.
</p>
</div>
</div>
<div class="content-block">
<div class="section-label">Quick Actions</div>
<NuxtLink
to="/ecology"
class="quick-action"
:class="{ disabled: !canPeerSupport }"
:title="
!canPeerSupport
? 'Complete your membership to access community ecology'
: ''
"
>
Community ecology<span class="arrow">&rarr;</span>
</NuxtLink>
</DashedBox>
<NuxtLink to="/member/profile" class="quick-action">
Update your profile<span class="arrow">&rarr;</span>
</NuxtLink>
<a
href="https://wiki.ghostguild.org"
target="_blank"
class="quick-action"
>
Browse the wiki<span class="arrow">&rarr;</span>
</a>
<NuxtLink to="/members" class="quick-action">
Browse members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile#account" class="quick-action">
Manage account<span class="arrow">&rarr;</span>
</NuxtLink>
</div>
</div>
<!-- Membership Summary + Peer Support -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Membership</div>
<div class="membership-row">
<span class="key">Circle</span>
<span
class="val"
:style="{
color: `var(--c-${memberData?.circle || 'community'})`,
}"
>
{{ memberData?.circle }}
</span>
</div>
<div class="membership-row">
<span class="key">Contribution</span>
<span class="val"
>${{ memberData?.contributionTier }} CAD/month</span
>
</div>
<div class="membership-row">
<span class="key">Status</span>
<span class="val">
<span :class="isActive ? 'status-active' : ''">
{{ isActive ? "Active" : statusConfig.label }}
</span>
</span>
</div>
<div v-if="memberData?.createdAt" class="membership-row">
<span class="key">Member since</span>
<span class="val">{{
formatMemberSince(memberData.createdAt)
}}</span>
</div>
<NuxtLink to="/member/profile#account" class="section-link">
Change circle or contribution &rarr;
</NuxtLink>
</div>
<div class="content-block">
<div class="section-label">Community</div>
<DashedBox>
<p class="peer-text">
Connect with other members through shared interests and
cooperative topics.
</p>
<NuxtLink to="/ecology" class="section-link">
Browse community ecology &rarr;
</NuxtLink>
</DashedBox>
</div>
</div>
</div>
</ColumnsLayout>
</template>
@ -220,32 +216,10 @@
</template>
<script setup>
useSiteMeta({ title: 'Dashboard', noindex: true });
const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
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 { trackGoal, isComplete: onboardingComplete } = useOnboarding();
const handleWikiClick = () => {
if (!onboardingComplete.value) {
trackGoal("wikiClicked");
}
};
const registeredEvents = ref([]);
const loadingEvents = ref(false);
@ -365,22 +339,20 @@ const getEventImageUrl = (featureImage) => {
return "";
};
const formatEventDate = (event) => {
if (!event?.startDate) return "";
const formatEventDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
}).format(date);
};
const formatEventTime = (event) => {
if (!event?.startDate) return "";
const formatEventTime = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
}).format(date);
};
const formatMemberSince = (dateString) => {
@ -455,7 +427,7 @@ useHead({
}
.unauth-state h2 {
font-family: var(--font-display);
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
@ -478,13 +450,6 @@ useHead({
margin-top: 8px;
}
.slack-coming-note {
margin-top: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
}
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -546,7 +511,7 @@ useHead({
/* ---- CALENDAR BUTTON ---- */
.calendar-btn {
font-family: inherit;
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--candle-dim);
background: none;
@ -630,7 +595,7 @@ useHead({
/* ---- QUICK ACTIONS ---- */
.quick-action {
border: 1px dashed var(--border);
padding: 12px 20px;
padding: 14px 20px;
margin-bottom: 8px;
transition: border-color 0.2s;
display: flex;
@ -724,7 +689,7 @@ useHead({
}
.content-block {
padding: 20px 24px;
padding: 20px;
}
.event-item {

View file

@ -1,226 +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' });
useSiteMeta({ title: 'Payment Setup', noindex: true });
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>

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,9 @@
<span class="profile-pronouns">{{ member.pronouns }}</span>
</div>
<div class="profile-meta">
<CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[member.circle]" />
<span v-if="member.circle" class="badge" :class="member.circle">
{{ circleLabels[member.circle] }}
</span>
<template v-if="member.studio">
<span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span>
@ -106,39 +108,56 @@
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
</div>
<!-- Craft Tags -->
<div v-if="craftTagsDisplay.length > 0" class="profile-section">
<div class="section-label">What I Do</div>
<div class="tag-list">
<span
v-for="tag in craftTagsDisplay"
:key="tag"
class="tag-pill"
>{{ tagLabel('craft', tag) }}</span>
<!-- Two-column: Craft Tags + Community Ecology -->
<div
v-if="craftTagsDisplay.length > 0 || ecologyTopics.length > 0 || member.communityEcology?.details"
class="profile-two-col"
>
<!-- Left: What I Do -->
<div class="profile-section">
<div class="section-label">What I Do</div>
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
<span
v-for="tag in craftTagsDisplay"
:key="tag"
class="tag-pill"
>{{ tagLabel('craft', tag) }}</span>
</div>
</div>
<!-- Right: Community Ecology -->
<div class="profile-section">
<div class="section-label">Community Ecology</div>
<div v-if="ecologyTopics.length > 0" class="tag-list">
<span
v-for="topic in ecologyTopics"
:key="topic.tagSlug"
class="tag-pill connection-pill"
>
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug) }}
</span>
</div>
<p v-if="member.communityEcology?.details" class="profile-detail connection-details">
{{ member.communityEcology.details }}
</p>
</div>
</div>
<!-- Board Posts -->
<div class="profile-section">
<div class="section-label">Board Posts</div>
<p v-if="memberPosts.length === 0" class="profile-detail posts-empty">
No posts yet.
</p>
<ul v-else class="posts-list">
<li v-for="post in memberPosts" :key="post._id" class="post-item">
<NuxtLink to="/board" class="post-link">
<div class="post-title">{{ post.title }}</div>
<div class="post-excerpt">{{ postExcerpt(post) }}</div>
<div v-if="post.tags && post.tags.length" class="tag-list post-tags">
<span
v-for="tag in post.tags"
:key="tag"
class="tag-pill"
>{{ tagLabel('cooperative', tag) }}</span>
</div>
</NuxtLink>
</li>
</ul>
<!-- Peer Support -->
<div v-if="member.communityEcology?.offerPeerSupport" class="profile-section">
<div class="section-label">Peer Support</div>
<div class="dashed-box no-hover">
<p v-if="member.communityEcology?.personalMessage" class="profile-detail">
{{ member.communityEcology.personalMessage }}
</p>
<p v-if="member.communityEcology?.availability" class="profile-detail peer-availability">
{{ member.communityEcology.availability }}
</p>
<p v-if="member.communityEcology?.slackHandle" class="profile-detail peer-availability">
Reach out on Slack: <span class="slack-handle">@{{ member.communityEcology.slackHandle }}</span>
</p>
</div>
</div>
<!-- Recent Activity -->
@ -181,8 +200,6 @@
</template>
<script setup>
definePageMeta({ middleware: ["members-auth"] });
import { formatActivity } from '~/utils/activityText'
const route = useRoute();
@ -198,6 +215,15 @@ const circleLabels = {
practitioner: "Practitioner",
};
// State display text mapping
const stateLabels = {
help: "Can help",
interested: "Interested",
seeking: "Need help",
};
const stateLabel = (state) => stateLabels[state] || state || "";
const getInitials = (name) => {
if (!name) return "?";
return name
@ -246,18 +272,9 @@ const tagLabel = (pool, slug) => {
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
// Board posts authored by this member
const { data: postsData } = useFetch(`/api/board/posts`, {
params: { author: id },
default: () => ({ posts: [] }),
})
const memberPosts = computed(() => postsData.value?.posts || [])
const postExcerpt = (post) => {
const text = post.seeking || post.offering || "";
if (text.length <= 80) return text;
return text.slice(0, 80).trimEnd() + "...";
};
const ecologyTopics = computed(
() => member.value?.communityEcology?.topics || [],
);
// Whether the member has any social links (for hero layout)
const hasSocialLinks = computed(() =>
@ -276,10 +293,14 @@ onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
useSiteMeta(() => ({
title: member.value ? member.value.name : "Member Profile",
noindex: true,
}));
// Page head
useHead({
title: computed(() =>
member.value
? `${member.value.name} — Ghost Guild`
: "Member Profile — Ghost Guild",
),
});
</script>
<style scoped>
@ -342,6 +363,7 @@ useSiteMeta(() => ({
width: 96px;
height: 96px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
@ -366,7 +388,7 @@ useSiteMeta(() => ({
}
.profile-name {
font-family: "Brygada 1918", serif;
font-size: 36px;
font-size: 42px;
font-weight: 600;
color: var(--text-bright);
margin: 0;
@ -446,7 +468,6 @@ useSiteMeta(() => ({
/* Bio: parch (inverted) block */
.profile-section--parch {
background: var(--parch);
border-bottom-color: var(--parch-border);
}
.profile-section--parch .section-label {
color: var(--parch-text-dim);
@ -455,7 +476,7 @@ useSiteMeta(() => ({
color: var(--parch-text);
}
.profile-section--parch .profile-bio :deep(a) {
color: var(--parch-accent);
color: var(--candle-faint);
text-decoration: underline;
text-underline-offset: 2px;
}
@ -483,6 +504,22 @@ useSiteMeta(() => ({
color: var(--ember);
}
/* ====================================================
TWO-COLUMN: Craft Tags + Community Ecology
==================================================== */
.profile-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px dashed var(--border);
}
.profile-two-col .profile-section {
border-bottom: none;
}
.profile-two-col .profile-section:first-child {
border-right: 1px dashed var(--border);
}
/* ====================================================
SHARED SECTION ELEMENTS
==================================================== */
@ -493,6 +530,9 @@ useSiteMeta(() => ({
line-height: 1.6;
margin: 0;
}
.connection-details {
margin-top: 10px;
}
/* Tags */
.tag-list {
@ -508,47 +548,30 @@ useSiteMeta(() => ({
border: 1px dashed var(--border);
white-space: nowrap;
}
.connection-pill {
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
/* ====================================================
BOARD POSTS
PEER SUPPORT
==================================================== */
.posts-empty {
color: var(--text-faint);
}
.posts-list {
list-style: none;
margin: 0;
padding: 0;
}
.post-item {
.peer-availability {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.post-item:last-child {
border-bottom: 1px dashed var(--border);
}
.post-link {
display: block;
padding: 10px 0;
text-decoration: none;
color: inherit;
}
.post-link:hover .post-title {
color: var(--candle);
}
.post-title {
font-size: 13px;
color: var(--text);
margin-bottom: 2px;
transition: color 0.15s;
}
.post-excerpt {
font-size: 11px;
color: var(--text-faint);
line-height: 1.4;
}
.post-tags {
margin-top: 6px;
.slack-handle {
font-family: "Commit Mono", monospace;
color: var(--candle-dim);
}
/* ====================================================
@ -640,6 +663,17 @@ useSiteMeta(() => ({
RESPONSIVE
==================================================== */
@media (max-width: 1024px) {
/* ColumnsLayout events-sidebar hides itself at ≤1024px */
.profile-two-col {
grid-template-columns: 1fr;
}
.profile-two-col .profile-section:first-child {
border-right: none;
border-bottom: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.profile-hero,
.profile-hero--with-links {

View file

@ -1,5 +1,8 @@
<template>
<PageShell title="Members">
<PageShell
title="Members"
:subtitle="`${totalCount} member${totalCount === 1 ? '' : 's'} across 3 circles`"
>
<!-- Filter Bar -->
<div class="filter-bar">
<input
@ -8,53 +11,93 @@
class="filter-search"
placeholder="Search members..."
@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
v-if="craftTagOptions.length > 0"
type="button"
class="drawer-btn"
@click="showTagsDrawer = !showTagsDrawer"
<select
v-model="selectedCircle"
class="filter-select"
@change="loadMembers"
>
Tags...
<span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span>
<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" }}</span
>
</div>
<!-- Craft Tags Filter -->
<div
v-if="craftTagOptions.length > 0"
class="skills-bar"
>
<span class="tag-label">Craft:</span>
<button
v-for="tag in craftTagOptions.slice(
0,
showAllCraftTags ? undefined : 10,
)"
:key="tag.slug"
type="button"
class="skill-tag"
:class="{ active: selectedCraftTags.includes(tag.slug) }"
@click="toggleCraftTag(tag.slug)"
>
{{ tag.label }}
</button>
<button
v-if="craftTagOptions.length > 10"
type="button"
class="more-btn"
@click="showAllCraftTags = !showAllCraftTags"
>
{{
showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
}}
</button>
</div>
<!-- Tags Drawer -->
<div v-if="showTagsDrawer && craftTagOptions.length > 0" class="tags-drawer">
<div class="skills-bar">
<span class="tag-label">Craft:</span>
<button
v-for="tag in visibleTagOptions"
:key="tag.slug"
type="button"
class="skill-tag"
:class="{ active: directoryCraftTags.includes(tag.slug) }"
@click="toggleDirectoryCraftTag(tag.slug)"
>
{{ tag.label }}
</button>
<button
v-if="craftTagOptions.length > 10"
type="button"
class="more-btn"
@click="showAllTags = !showAllTags"
>
{{ showAllTags ? 'Show less' : `+${craftTagOptions.length - 10} more` }}
</button>
</div>
<!-- Connection Tags Filter -->
<div
v-if="connectionTagOptions.length > 0"
class="skills-bar"
>
<span class="tag-label">Topics:</span>
<button
v-for="tag in connectionTagOptions.slice(
0,
showAllConnectionTags ? undefined : 10,
)"
:key="tag.slug"
type="button"
class="skill-tag"
:class="{ active: selectedConnectionTags.includes(tag.slug) }"
@click="toggleConnectionTag(tag.slug)"
>
{{ tag.label }}
</button>
<button
v-if="connectionTagOptions.length > 10"
type="button"
class="more-btn"
@click="showAllConnectionTags = !showAllConnectionTags"
>
{{
showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
}}
</button>
</div>
<!-- Active Filters -->
@ -64,12 +107,23 @@
{{ circleLabels[selectedCircle] }}
<button type="button" @click="clearCircleFilter">&times;</button>
</span>
<span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag">
<span
v-if="peerSupportFilter && peerSupportFilter !== 'all'"
class="af-tag"
>
Offering Support
<button type="button" @click="clearPeerSupportFilter">&times;</button>
</span>
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
{{ craftTagLabel(slug) }}
<button type="button" @click="toggleDirectoryCraftTag(slug)">&times;</button>
<button type="button" @click="toggleCraftTag(slug)">&times;</button>
</span>
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
{{ connectionTagLabel(slug) }}
<button type="button" @click="toggleConnectionTag(slug)">&times;</button>
</span>
<button
v-if="hasActiveFilters"
v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
type="button"
class="clear-all-btn"
@click="clearAllFilters"
@ -78,30 +132,37 @@
</button>
</div>
<!-- DIRECTORY -->
<!-- Loading State -->
<div v-if="loading && !members.length" class="loading-state">
<p>Loading members...</p>
</div>
<!-- Member Grid -->
<div v-else-if="members.length > 0" class="member-grid">
<div v-for="member in members" :key="member._id" class="member-card">
<div class="mc-head">
<div class="mc-avatar">
<img
v-if="member.avatar"
:src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`"
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
:alt="member.name"
class="mc-avatar-img"
>
/>
<span v-else>{{ getInitials(member.name) }}</span>
</div>
<div class="mc-info">
<div class="mc-name">
<NuxtLink :to="`/members/${member._id}`">{{ member.name }}</NuxtLink>
<span v-if="member.pronouns" class="mc-pronouns">{{ member.pronouns }}</span>
<NuxtLink :to="`/members/${member._id}`">{{
member.name
}}</NuxtLink>
<span v-if="member.pronouns" class="mc-pronouns">{{
member.pronouns
}}</span>
</div>
<div class="mc-meta">
<CircleBadge :circle="member.circle" :label="circleLabels[member.circle]" />
<span class="badge" :class="member.circle">{{
circleLabels[member.circle]
}}</span>
<template v-if="member.studio">
<span class="sep">&middot;</span>
{{ member.studio }}
@ -114,176 +175,322 @@
v-if="member.bio"
class="mc-bio"
v-html="renderMarkdown(member.bio)"
/>
></div>
<div v-if="member.craftTags?.length > 0" class="mc-tags">
<div v-if="member.location || member.timeZone" class="mc-location">
{{
[member.location, member.timeZone].filter(Boolean).join(" \u00b7 ")
}}
</div>
<div
v-if="member.craftTags?.length > 0"
class="mc-tags"
>
<span class="tag-label">Craft:</span>
<span
v-for="tag in member.craftTags.slice(0, 3)"
v-for="tag in member.craftTags"
:key="tag"
class="skill-tag"
>{{ craftTagLabel(tag) }}</span>
<span v-if="member.craftTags.length > 3" class="tag-overflow">+{{ member.craftTags.length - 3 }} more</span>
>{{ craftTagLabel(tag) }}</span
>
</div>
<div
v-if="member.communityEcology?.topics?.length > 0"
class="mc-looking"
>
<span
v-for="topic in member.communityEcology.topics"
:key="topic.tagSlug"
class="connection-topic"
>
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ connectionTagLabel(topic.tagSlug) }}
</span>
</div>
<!-- Peer support session link -->
<a
v-if="showPeerSupport(member)"
href="#"
class="mc-session"
@click.prevent="openSlackDM(member)"
>
Book session
</a>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<p class="empty-title">No members found</p>
<p class="empty-sub">Try adjusting your search or filters</p>
<button type="button" class="btn" @click="clearAllFilters">Clear Filters</button>
<button type="button" class="btn" @click="clearAllFilters">
Clear Filters
</button>
</div>
<!-- Load more / count -->
<div v-if="members.length > 0" class="load-more">
<span>Showing {{ members.length }} of {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }}</span>
<span
>Showing {{ members.length }} of {{ totalCount }} member{{
totalCount === 1 ? "" : "s"
}}</span
>
</div>
<!-- Not Authenticated Notice -->
<div v-if="!isAuthenticated && members.length > 0" class="auth-notice">
<p>Some member information is visible to members only.</p>
<div class="auth-actions">
<button
type="button"
class="btn"
@click="
openLoginModal({
title: 'Sign in to see more',
description: 'Log in to view full member profiles',
})
"
>
Log In
</button>
<NuxtLink to="/join" class="btn btn-primary">Join Ghost Guild</NuxtLink>
</div>
</div>
</PageShell>
</template>
<script setup>
definePageMeta({ middleware: ['members-auth'] })
const { isAuthenticated } = useAuth();
const { openLoginModal } = useLoginModal();
const { render: renderMarkdown } = useMarkdown();
const { render: renderMarkdown } = useMarkdown()
// State
const members = ref([]);
const totalCount = ref(0);
const loading = ref(true);
const searchQuery = ref("");
const selectedCircle = ref("all");
const peerSupportFilter = ref("all");
const selectedCraftTags = ref([]);
const selectedConnectionTags = ref([]);
const showAllCraftTags = ref(false);
const showAllConnectionTags = ref(false);
// ---- Directory state ----
const members = ref([])
const totalCount = ref(0)
const loading = ref(true)
const searchQuery = ref('')
const selectedCircle = ref('all')
const directoryCraftTags = ref([])
const craftTagOptions = ref([])
const showAllTags = ref(false)
const showTagsDrawer = ref(false)
// Tag options from API
const craftTagOptions = ref([]);
const connectionTagOptions = ref([]);
// ---- Helpers ----
// State display text mapping
const stateLabels = {
help: "Can help",
interested: "Interested",
seeking: "Need help",
};
const stateLabel = (state) => stateLabels[state] || state || "";
// Circle options
const circleOptions = [
{ label: 'All Circles', value: 'all' },
{ label: 'Community', value: 'community' },
{ label: 'Founder', value: 'founder' },
{ label: 'Practitioner', value: 'practitioner' },
]
{ label: "All Circles", value: "all" },
{ label: "Community", value: "community" },
{ label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" },
];
const circleLabels = {
community: 'Community',
founder: 'Founder',
practitioner: 'Practitioner',
}
community: "Community",
founder: "Founder",
practitioner: "Practitioner",
};
// Tag slug-to-label lookups
const craftTagLabel = (slug) => {
const found = craftTagOptions.value.find((t) => t.slug === slug)
return found ? found.label : slug
}
const found = craftTagOptions.value.find((t) => t.slug === slug);
return found ? found.label : slug;
};
const connectionTagLabel = (slug) => {
const found = connectionTagOptions.value.find((t) => t.slug === slug);
return found ? found.label : slug;
};
const showPeerSupport = (member) =>
!!member.communityEcology?.offerPeerSupport;
// Computed: has active filters
const hasActiveFilters = computed(() => {
return (
(selectedCircle.value && selectedCircle.value !== "all") ||
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
selectedCraftTags.value.length > 0 ||
selectedConnectionTags.value.length > 0
);
});
// Get initials from name
const getInitials = (name) => {
if (!name) return '?'
if (!name) return "?";
return name
.split(' ')
.split(" ")
.map((w) => w[0])
.join('')
.join("")
.toUpperCase()
.slice(0, 2)
}
.slice(0, 2);
};
const capitalize = (str) => {
if (!str) return ''
return str
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
// ---- Computed ----
const visibleTagOptions = computed(() =>
showAllTags.value ? craftTagOptions.value : craftTagOptions.value.slice(0, 10)
)
const hasActiveFilters = computed(() =>
(selectedCircle.value && selectedCircle.value !== 'all') ||
directoryCraftTags.value.length > 0
)
// ---- Load members ----
// Load members
const loadMembers = async () => {
loading.value = true
loading.value = true;
try {
const params = {}
if (searchQuery.value) params.search = searchQuery.value
if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value
if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0]
const params = {};
if (searchQuery.value) params.search = searchQuery.value;
if (selectedCircle.value && selectedCircle.value !== "all")
params.circle = selectedCircle.value;
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
params.peerSupport = peerSupportFilter.value;
if (selectedCraftTags.value.length === 1)
params.craftTag = selectedCraftTags.value[0];
if (selectedConnectionTags.value.length === 1)
params.connectionTag = selectedConnectionTags.value[0];
const data = await $fetch('/api/members/directory', { params })
members.value = data.members || []
totalCount.value = data.totalCount || 0
const data = await $fetch("/api/members/directory", { params });
members.value = data.members || [];
totalCount.value = data.totalCount || 0;
// Update tag options from API response (only on initial load or if empty)
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
craftTagOptions.value = data.filters.craftTags
craftTagOptions.value = data.filters.craftTags;
}
if (
data.filters?.cooperativeTags &&
connectionTagOptions.value.length === 0
) {
connectionTagOptions.value = data.filters.cooperativeTags;
}
} catch (error) {
console.error('Failed to load members:', error)
members.value = []
totalCount.value = 0
console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
} finally {
loading.value = false
loading.value = false;
}
}
};
// ---- Load tag options ----
// Fetch tag options from API on mount
const loadTagOptions = async () => {
try {
const data = await $fetch('/api/tags')
const tags = data.tags || []
const data = await $fetch("/api/tags");
const tags = data.tags || [];
craftTagOptions.value = tags
.filter((t) => t.pool === 'craft')
.map((t) => ({ slug: t.slug, label: t.label }))
.filter((t) => t.pool === "craft")
.map((t) => ({ slug: t.slug, label: t.label }));
connectionTagOptions.value = tags
.filter((t) => t.pool === "cooperative")
.map((t) => ({ slug: t.slug, label: t.label }));
} catch (error) {
console.error('Failed to load tags:', error)
console.error("Failed to load tags:", error);
}
}
};
// ---- Filter helpers ----
let searchTimeout
// Toggle peer support checkbox
const togglePeerSupport = (e) => {
peerSupportFilter.value = e.target.checked ? "true" : "all";
loadMembers();
};
// Debounced search
let searchTimeout;
const debouncedSearch = () => {
clearTimeout(searchTimeout)
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadMembers()
}, 300)
}
loadMembers();
}, 300);
};
const toggleDirectoryCraftTag = (slug) => {
const idx = directoryCraftTags.value.indexOf(slug)
if (idx > -1) {
directoryCraftTags.value.splice(idx, 1)
// Toggle craft tag filter
const toggleCraftTag = (slug) => {
const index = selectedCraftTags.value.indexOf(slug);
if (index > -1) {
selectedCraftTags.value.splice(index, 1);
} else {
directoryCraftTags.value = [slug]
selectedCraftTags.value = [slug]; // single-select for API query param
}
loadMembers()
}
loadMembers();
};
// Toggle connection tag filter
const toggleConnectionTag = (slug) => {
const index = selectedConnectionTags.value.indexOf(slug);
if (index > -1) {
selectedConnectionTags.value.splice(index, 1);
} else {
selectedConnectionTags.value = [slug]; // single-select for API query param
}
loadMembers();
};
// Clear filters
const clearCircleFilter = () => {
selectedCircle.value = 'all'
loadMembers()
}
selectedCircle.value = "all";
loadMembers();
};
const clearPeerSupportFilter = () => {
peerSupportFilter.value = "all";
loadMembers();
};
const clearAllFilters = () => {
searchQuery.value = ''
selectedCircle.value = 'all'
directoryCraftTags.value = []
showTagsDrawer.value = false
loadMembers()
}
searchQuery.value = "";
selectedCircle.value = "all";
peerSupportFilter.value = "all";
selectedCraftTags.value = [];
selectedConnectionTags.value = [];
loadMembers();
};
onBeforeUnmount(() => {
clearTimeout(searchTimeout)
})
// Slack DM functionality
const openSlackDM = async (member) => {
const username = member.communityEcology?.slackHandle || member.name;
useSiteMeta({ title: 'Member Directory', noindex: true })
try {
await navigator.clipboard.writeText(username);
} catch (err) {
console.log("Could not copy to clipboard:", err);
}
// ---- Init ----
alert(
`Opening Slack...\n\nSearch for: ${username}\n\n(Username copied to clipboard)`,
);
window.open("https://gammaspace.slack.com", "_blank");
};
// Load on mount and handle query params
onMounted(async () => {
await loadTagOptions()
await loadMembers()
})
const route = useRoute();
if (route.query.peerSupport === "true") {
peerSupportFilter.value = "true";
}
await loadTagOptions();
loadMembers();
});
useHead({
title: "Member Directory - Ghost Guild",
meta: [
{
name: "description",
content:
"Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.",
},
],
});
</script>
<style scoped>
@ -315,47 +522,48 @@ onMounted(async () => {
border-color: var(--candle-faint);
}
/* Constrain the circle USelectMenu button width so it doesn't stretch. */
:deep(.circle-select) {
width: auto !important;
min-width: 150px;
}
/* ---- TAGS DRAWER ---- */
.drawer-btn {
margin-left: auto;
.filter-select {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-dim);
background: none;
padding: 5px 10px;
border: 1px dashed var(--border);
padding: 3px 10px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
display: inline-flex;
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;
transition: all 0.15s;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.drawer-btn:hover {
border-color: var(--candle-faint);
color: var(--text);
.filter-toggle input {
accent-color: var(--candle-dim);
}
.tag-count-badge {
font-size: 9px;
background: var(--candle-faint);
color: var(--candle);
padding: 0 4px;
min-width: 14px;
text-align: center;
}
.tags-drawer {
border-bottom: 1px dashed var(--border);
.filter-count {
margin-left: auto;
font-size: 11px;
color: var(--text-faint);
}
/* ---- SKILLS / TOPICS BAR ---- */
.skills-bar {
padding: 12px 24px;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 6px;
align-items: center;
@ -484,7 +692,6 @@ onMounted(async () => {
border-right: none;
}
/* ---- DIRECTORY CARD ---- */
.mc-head {
display: flex;
align-items: center;
@ -495,7 +702,8 @@ onMounted(async () => {
.mc-avatar {
width: 32px;
height: 32px;
background: transparent;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
@ -555,7 +763,7 @@ onMounted(async () => {
line-height: 1.6;
margin: 8px 0;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@ -563,6 +771,12 @@ onMounted(async () => {
margin: 0;
}
.mc-location {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.mc-tags {
display: flex;
gap: 4px;
@ -584,11 +798,52 @@ onMounted(async () => {
white-space: nowrap;
}
.tag-overflow {
.mc-looking {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.connection-topic {
font-size: 10px;
color: var(--text-dim);
padding: 1px 6px;
border: 1px dashed var(--border);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
.mc-session {
display: inline-block;
margin-top: 6px;
font-family: "Commit Mono", monospace;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 3px 10px;
border: 1px dashed var(--candle-faint);
color: var(--candle);
cursor: pointer;
transition: all 0.15s;
background: transparent;
text-decoration: none;
}
.mc-session:hover {
border-color: var(--candle);
background: rgba(122, 90, 16, 0.06);
text-decoration: none;
}
/* ---- LOAD MORE ---- */
.load-more {
padding: 20px 24px;
@ -616,8 +871,23 @@ onMounted(async () => {
color: var(--text-faint);
margin-bottom: 16px;
}
.empty-sub a {
color: var(--candle);
/* ---- AUTH NOTICE ---- */
.auth-notice {
padding: 24px;
margin: 20px 24px;
border: 1px dashed var(--candle-faint);
text-align: center;
}
.auth-notice p {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 12px;
}
.auth-actions {
display: flex;
gap: 10px;
justify-content: center;
}
/* ---- RESPONSIVE ---- */
@ -636,7 +906,7 @@ onMounted(async () => {
align-items: stretch;
padding: 14px 20px;
}
.drawer-btn {
.filter-count {
margin-left: 0;
}
.skills-bar {
@ -648,18 +918,8 @@ onMounted(async () => {
.member-card {
padding: 14px 16px;
}
}
@media (max-width: 375px) {
.filter-bar {
padding: 12px 16px;
gap: 8px;
flex-direction: row;
flex-wrap: wrap;
}
.filter-search {
flex: 1 1 auto;
min-width: 0;
.auth-notice {
margin: 16px;
}
}
</style>

View file

@ -1,80 +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 })
}
useSiteMeta({
title: policy.title,
description: policy.description,
})
</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,330 +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>
useSiteMeta({
title: 'Privacy Policy',
description:
'How Ghost Guild handles your data: what we collect, why we collect it, and who has access. No Google Analytics, no advertising pixels, no third-party tracking.',
})
</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,118 +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>
useSiteMeta({
title: 'Refund Policy',
description:
'How Ghost Guild handles refund requests for membership dues and event tickets. Pay-what-you-can, case-by-case, run as a non-profit program of Baby Ghosts.',
})
</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,359 +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>
useSiteMeta({
title: 'Terms of Service',
description:
'Terms of service for ghostguild.org and wiki.ghostguild.org, operated by Baby Ghosts. Covers accounts, membership, acceptable use, and what we expect from each other.',
})
</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>
<div class="page-fill">
<div>
<div v-if="pending" class="loading">Loading series details...</div>
<div v-else-if="error" class="loading">
@ -8,7 +8,7 @@
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<div v-else>
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
@ -26,59 +26,46 @@
</div>
</div>
<!-- TWO-COLUMN BODY -->
<div class="series-body" :class="{ 'has-aside': series.tickets?.enabled }">
<!-- LEFT: MAIN CONTENT -->
<div class="series-main">
<div v-if="series.description" class="section description">
<p>{{ series.description }}</p>
</div>
<!-- DESCRIPTION -->
<div v-if="series.description" class="section">
<p>{{ series.description }}</p>
</div>
<div class="section" :class="{ 'section-flush': series.events?.length }">
<div class="section-label">Sessions</div>
<div v-if="series.events?.length" class="sessions-box">
<div v-for="(event, index) in series.events" :key="event._id || index" class="event-row">
<span class="event-num">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<div class="event-info">
<div class="event-info-head">
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
{{ event.title }}
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
<p v-if="event.description" class="event-description">{{ event.description }}</p>
</div>
</div>
<!-- EVENT LIST -->
<div class="section">
<div class="section-label">Sessions</div>
<div v-if="series.events?.length">
<div v-for="(event, index) in series.events" :key="event._id || index" class="event-row">
<span class="event-num">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<div class="event-info">
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
{{ event.title }}
</NuxtLink>
<span class="event-status">{{ getEventStatus(event) }}</span>
</div>
<p v-else class="empty">No sessions scheduled yet.</p>
</div>
<!-- Questions (inline when no sidebar) -->
<div v-if="!series.tickets?.enabled" class="section">
<div class="section-label">Questions?</div>
<p>If you have questions about this series, reach out to us.</p>
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</div>
<p v-else class="empty">No sessions scheduled yet.</p>
</div>
<!-- RIGHT: SIDEBAR -->
<aside v-if="series.tickets?.enabled" class="series-aside">
<SeriesPassPurchase
:series-id="series.id"
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
:series-events="series.events || []"
:user-email="memberData?.email"
:user-name="memberData?.name"
@purchase-success="handlePurchaseSuccess"
/>
<!-- PASS PURCHASE -->
<div v-if="series.tickets?.enabled" class="section">
<SeriesPassPurchase
:series-id="series.id"
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
:series-events="series.events || []"
:user-email="memberData?.email"
:user-name="memberData?.name"
@purchase-success="handlePurchaseSuccess"
/>
</div>
<div class="aside-panel">
<div class="box-title">Questions?</div>
<p class="aside-detail">Drop us a line.</p>
<a class="aside-link" href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</aside>
<!-- QUESTIONS -->
<div class="section">
<div class="section-label">Questions?</div>
<p>If you have questions about this series, reach out to us.</p>
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</div>
</div>
</div>
@ -117,11 +104,9 @@ const handlePurchaseSuccess = () => {
refreshNuxtData()
}
useSiteMeta(() => ({
title: series.value ? `${series.value.title} · Event Series` : 'Event Series',
description:
series.value?.description ||
(series.value?.title ? `${series.value.title} — a Ghost Guild event series.` : undefined),
useHead(() => ({
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
}))
</script>
@ -152,105 +137,28 @@ useSiteMeta(() => ({
}
.meta-text { color: var(--text-faint); }
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.series-body {
display: grid;
grid-template-columns: 1fr;
}
.series-body.has-aside {
grid-template-columns: 1fr 280px;
flex: 1;
}
.series-main { min-width: 0; }
.series-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.series-main .section:last-child {
border-bottom: none;
}
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
.section a { font-size: 12px; color: var(--candle); }
.section.description p { font-size: 14px; color: var(--text); }
.section-flush { padding-bottom: 0; }
.sessions-box {
border-top: 1px dashed var(--border);
margin: 10px -32px 0;
}
.event-row {
display: grid;
grid-template-columns: 32px auto 1fr;
grid-template-columns: 32px 80px 1fr;
gap: 12px;
align-items: baseline;
padding: 10px 32px;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.event-row:last-child { border-bottom: none; }
.event-num { color: var(--text-faint); font-size: 11px; }
.event-date { color: var(--text-faint); white-space: nowrap; }
.event-info { min-width: 0; }
.event-date { color: var(--text-faint); }
.event-title-link { color: var(--text); text-decoration: none; font-size: 13px; }
.event-title-link:hover { color: var(--candle); }
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; }
.event-description {
font-size: 11px;
color: var(--text-dim);
line-height: 1.5;
margin: 4px 0 0;
max-width: 560px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty { font-size: 12px; color: var(--text-faint); }
/* ---- ASIDE PANELS ---- */
.aside-panel {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.aside-panel:last-child { border-bottom: none; }
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.aside-detail {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 4px;
}
.aside-link {
font-size: 12px;
color: var(--candle);
}
@media (max-width: 768px) {
.series-body.has-aside {
grid-template-columns: 1fr;
}
.series-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
}
</style>

View file

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

View file

@ -17,7 +17,6 @@
<script setup>
definePageMeta({ layout: false })
useSiteMeta({ title: 'Verifying', noindex: true })
const state = ref('verifying')
const errorMessage = ref('')

View file

@ -1,3 +1,280 @@
<template>
<div>
<PageHeader
title="Welcome to Ghost Guild"
subtitle="You're officially part of the community!"
/>
<section class="py-16 bg-guild-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Welcome Message -->
<div class="text-center mb-16">
<div class="w-24 h-24 mx-auto mb-6">
<img
v-if="memberData?.avatar"
:src="`/ghosties/Ghost-${memberData.avatar.charAt(0).toUpperCase() + memberData.avatar.slice(1)}.png`"
:alt="memberData.name"
class="w-full h-full object-contain"
/>
<img
v-else
src="/ghosties/Ghost-Sweet.png"
alt="Ghost Guild"
class="w-full h-full object-contain"
/>
</div>
<h2 class="text-display font-bold text-guild-100 mb-4">
Hey {{ memberData?.name || "there" }}!
</h2>
<p class="text-lg text-guild-300 max-w-2xl mx-auto">
You've joined a an awesome community!!👻 Welcome to Ghost guild
</p>
</div>
<!-- Getting Started Steps -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
<div class="p-6 bg-guild-800/50 rounded-xl border border-guild-700">
<div
class="w-12 h-12 bg-candlelight-500/20 rounded-lg flex items-center justify-center mb-4"
>
<Icon
name="heroicons:user-circle"
class="w-6 h-6 text-candlelight-400"
/>
</div>
<h3 class="font-semibold text-guild-100 mb-2">
<span class="text-ui-label text-candlelight-400 mr-2">1.</span>Complete Your Profile
</h3>
<p class="text-sm text-guild-400 mb-4">
Tell the community about yourself, your skills, and what you're
looking for.
</p>
<UButton to="/member/profile" variant="outline" size="sm">
Edit Profile
</UButton>
</div>
<div class="p-6 bg-guild-800/50 rounded-xl border border-guild-700">
<div
class="w-12 h-12 bg-candlelight-500/20 rounded-lg flex items-center justify-center mb-4"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-candlelight-400"
/>
</div>
<h3 class="font-semibold text-guild-100 mb-2">
<span class="text-ui-label text-candlelight-400 mr-2">2.</span>Join an Event
</h3>
<p class="text-sm text-guild-400 mb-4">
From workshops to game nights, events are the heart of our
community.
</p>
<UButton to="/events" variant="outline" size="sm">
Browse Events
</UButton>
</div>
<div class="p-6 bg-guild-800/50 rounded-xl border border-guild-700">
<div
class="w-12 h-12 bg-candlelight-500/20 rounded-lg flex items-center justify-center mb-4"
>
<Icon name="heroicons:users" class="w-6 h-6 text-candlelight-400" />
</div>
<h3 class="font-semibold text-guild-100 mb-2">
<span class="text-ui-label text-candlelight-400 mr-2">3.</span>Meet the Community
</h3>
<p class="text-sm text-guild-400 mb-4">
Connect with other members and find peers for support and
collaboration.
</p>
<UButton to="/members" variant="outline" size="sm">
View Members
</UButton>
</div>
</div>
<!-- About Circles -->
<div
class="p-8 bg-guild-800/30 rounded-2xl border border-guild-700 mb-16"
>
<h3 class="text-display-sm font-bold text-guild-100 mb-4">
Understanding Circles
</h3>
<p class="text-guild-300 mb-6">
Ghost Guild is organized into three circles based on where you are
in your journey. Your circle helps us tailor events and resources
to your needs.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class="p-4 rounded-lg"
:class="
memberData?.circle === 'community'
? 'circle-surface-community border border-[var(--color-circle-community)]/50'
: 'bg-guild-800/50'
"
>
<h4 class="font-semibold text-guild-100 mb-2">
Community Circle
<span
v-if="memberData?.circle === 'community'"
class="text-candlelight-400 text-sm ml-2"
> You're here</span
>
</h4>
<p class="text-sm text-guild-400">
For those exploring solidarity economics and alternative
studio models.
</p>
</div>
<div
class="p-4 rounded-lg"
:class="
memberData?.circle === 'founder'
? 'circle-surface-founder border border-[var(--color-circle-founder)]/50'
: 'bg-guild-800/50'
"
>
<h4 class="font-semibold text-guild-100 mb-2">
Founder Circle
<span
v-if="memberData?.circle === 'founder'"
class="text-candlelight-400 text-sm ml-2"
> You're here</span
>
</h4>
<p class="text-sm text-guild-400">
For those actively building or running a cooperative or
solidarity-based studio.
</p>
</div>
<div
class="p-4 rounded-lg"
:class="
memberData?.circle === 'practitioner'
? 'circle-surface-practitioner border border-[var(--color-circle-practitioner)]/50'
: 'bg-guild-800/50'
"
>
<h4 class="font-semibold text-guild-100 mb-2">
Practitioner Circle
<span
v-if="memberData?.circle === 'practitioner'"
class="text-candlelight-400 text-sm ml-2"
> You're here</span
>
</h4>
<p class="text-sm text-guild-400">
For consultants, advisors, and professionals supporting
cooperative game studios.
</p>
</div>
</div>
</div>
<!-- Resources -->
<div
class="p-8 bg-guild-800/30 rounded-2xl border border-guild-700 mb-16"
>
<h3 class="text-display-sm font-bold text-guild-100 mb-4">
Resources & Support
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 bg-candlelight-500/20 rounded-lg flex items-center justify-center flex-shrink-0"
>
<Icon
name="heroicons:book-open"
class="w-5 h-5 text-candlelight-400"
/>
</div>
<div>
<h4 class="font-semibold text-guild-100 mb-1">
Resource Library
</h4>
<p class="text-sm text-guild-400 mb-2">
Templates, guides, and tools for building solidarity-based
studios.
</p>
<UButton
to="https://wiki.ghostguild.org"
target="_blank"
variant="link"
size="sm"
class="p-0"
>
Browse Resources
</UButton>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="w-10 h-10 bg-ember-500/20 rounded-lg flex items-center justify-center flex-shrink-0"
>
<Icon
name="heroicons:chat-bubble-left-right"
class="w-5 h-5 text-ember-400"
/>
</div>
<div>
<h4 class="font-semibold text-guild-100 mb-1">
Community Ecology
</h4>
<p class="text-sm text-guild-400 mb-2">
Connect with community members through shared interests and
cooperative topics.
</p>
<UButton
to="/ecology"
variant="link"
size="sm"
class="p-0"
>
Browse Community
</UButton>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div class="text-center">
<UButton to="/member/dashboard" size="xl" class="px-12">
Go to Your Dashboard
</UButton>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
await navigateTo('/member/dashboard?welcome=1', { redirectCode: 301 })
const { memberData, checkMemberStatus } = useAuth();
// Ensure user is authenticated
definePageMeta({
middleware: ["auth"],
});
onMounted(async () => {
await checkMemberStatus();
});
useHead({
title: "Welcome - Ghost Guild",
meta: [
{
name: "description",
content: "Welcome to Ghost Guild! Get started with your membership.",
},
],
});
</script>

View file

@ -42,7 +42,7 @@ const formatters = {
icon: 'i-lucide-user-pen'
}),
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'
}),
subscription_cancelled: () => ({
@ -76,8 +76,8 @@ const formatters = {
text: 'Updated community connections',
icon: 'i-lucide-users'
}),
board_updated: () => ({
text: 'Updated board',
community_ecology_updated: () => ({
text: 'Updated community ecology',
icon: 'i-lucide-users'
}),
connection_requested: (m) => ({

View file

@ -1,77 +0,0 @@
// Convert a datetime-local string ("YYYY-MM-DDTHH:MM") to a UTC Date,
// interpreting the wall-clock time in the given IANA timezone.
export function zonedLocalToUTC(localStr, tz) {
if (!localStr || !tz) return null;
const [datePart, timePart] = String(localStr).split("T");
if (!datePart || !timePart) return null;
const [y, mo, d] = datePart.split("-").map(Number);
const [h, mi] = timePart.split(":").map(Number);
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
// Treat the components as if they are already UTC. The result's wall-clock
// in the target TZ will differ from what we want by exactly the TZ offset
// for that moment, so we measure that offset and subtract it.
const asUTC = new Date(Date.UTC(y, mo - 1, d, h, mi));
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(asUTC);
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
const observed = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
get("hour") % 24,
get("minute"),
get("second"),
);
const offsetMs = observed - asUTC.getTime();
return new Date(asUTC.getTime() - offsetMs);
}
// Convert a UTC Date (or ISO string) to a datetime-local string
// ("YYYY-MM-DDTHH:MM") rendered in the given IANA timezone.
export function utcToZonedLocal(utc, tz) {
if (!utc || !tz) return "";
const d = utc instanceof Date ? utc : new Date(utc);
if (Number.isNaN(d.getTime())) return "";
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (type) => parts.find((p) => p.type === type)?.value;
const year = get("year");
const month = get("month");
const day = get("day");
let hour = get("hour");
const minute = get("minute");
if (hour === "24") hour = "00";
return `${year}-${month}-${day}T${hour}:${minute}`;
}
// Short timezone label (e.g., "EDT", "PDT") for a Date in a given IANA TZ.
export function shortTimezoneName(date, tz) {
if (!date || !tz) return "";
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) return "";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "short",
}).formatToParts(d);
return parts.find((p) => p.type === "timeZoneName")?.value || "";
} catch {
return "";
}
}

View file

@ -1,124 +0,0 @@
# Ghost Guild — Open Backlog
_Last consolidated: 2026-05-18. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
**Launch shape (2026-05-18):** site live with events ASAP, applications open immediately, Slack invites delivered in waves. Entire waitlist invited to apply at launch. See `LAUNCH_READINESS.md` for the full shape, the activation steps, and the open product decisions that gate the launch comms.
---
## 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 cancel-subscription leaves status `active`.~~ Verified shipped 2026-05-18: `server/api/members/cancel-subscription.post.js:31,50` writes `status: 'active'`. Test coverage in `tests/server/api/cancel-subscription.test.js` (Fix #9 in LAUNCH_READINESS).
- ~~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,154 +0,0 @@
# Launch Readiness
**Status as of 2026-05-18. 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`.
---
## Launch shape (2026-05-18)
The launch decision: **site live with events ASAP, applications open immediately, Slack invitations sent later in waves.**
- Anyone can hit the site, see events, buy a ticket (members and guests both supported on `main`).
- Anyone can join — `/join` (anonymous) and `/accept-invite` (waitlist pre-registrants) both render the same `SignupFlowOverlay` and call the same Helcim signup path. New members become `active` immediately on payment; `slackInvited=false` until an admin marks them in a wave.
- The entire waitlist is invited to apply at launch via the pre-registrant invitation tool. They go through the same flow as anonymous signups, just with email pre-filled and a token-bound pre-reg.
Open decisions that gate the launch comms — see [Open decisions](#open-decisions-before-launch-comms) below.
---
## Current state
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign + migration script, cadence UX unification, receipts Phase 1, and `feature/guest-event-accounts` (merged in `e96d493`). Not pushed — site is not deployed 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).
- `cancel-subscription` correctly keeps status `active` per ratified bylaws (Fix #9 in this doc; the stale B1 entry in BACKLOG was marked done 2026-05-18).
---
## 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.
### Activation (after Cutover passes)
The site is deployed but not yet public. These are the steps that flip the switch.
- [ ] **Disable the coming-soon gate.** Set `NUXT_PUBLIC_COMING_SOON=false` (or remove the var) in Dokploy and redeploy. The gate lives in `app/middleware/coming-soon.global.js:4` and is purely env-driven. Verify `/`, `/about`, `/events`, `/board` all render without a redirect when logged out.
- [ ] **Publish first event(s).** Confirm at least one event or series is live and visible publicly. Walk through the guest ticket-purchase flow end-to-end (anonymous → buy ticket → registered → confirmation email).
- [ ] **Pre-flight real-money signup test on prod.** Have one trusted person (ideally outside the immediate build team) go through `/join` from scratch: choose a small contribution, pay, receive welcome email, land on dashboard, see "Slack coming" note. This catches end-to-end issues that no internal test reproduces.
- [ ] **Send waitlist invitation batch** via the pre-registrant admin tool. Decide cadence first (see [Open decisions](#open-decisions-before-launch-comms)). Smoke-test by inviting yourself or one friend first; only fan out once that round-trip is clean.
### Open decisions before launch comms
These do not block deploy but need answers before the waitlist invite goes out. Each carries a small amount of work depending on the answer.
- [ ] **Apply-framing decision.** Today's CTAs say "Join Ghost Guild" / "Become a member"; there is no "Apply" copy in the codebase. Both `/join` and `/accept-invite` use the same `SignupFlowOverlay`, so the mechanical flow is single-source. Pick one:
- **A (no code work).** Keep "Join" everywhere on-site; use "apply" only in external comms (waitlist email, social, etc.).
- **B (small code work).** Rename to "Apply" across CTAs + page copy. Touches `app/pages/index.vue:11`, `app/pages/about.vue:86`, `app/pages/join.vue:5,109,111,301`, `app/components/LoginModal.vue:66`, and at least the waitlist invite + welcome email copy. Likely ~30 min of search-and-replace + screenshot review.
- [ ] **First Slack wave date.** A publicly-stated date or cadence rule (e.g. "end of each month"). Used in three places: waitlist invite email, welcome email, dashboard "Slack coming" note. Without this, every new member emails support asking when Slack is coming.
- [ ] **Non-member event CTA — ticket-first or membership-first?** Event pages render to anonymous visitors with both paths viable. Pick which one is primary: "Buy ticket" lowers friction, "Apply for membership" protects the funnel. Write the CTA copy once and use consistently across events.
- [ ] **Receipts for guest ticket purchases.** Phase 1 receipts cover membership payments only. Guest ticket buyers will get no CRA-compliant receipt at launch. Options: (a) ship a basic transactional receipt for tickets pre-launch, (b) accept the gap until Phase 2 (build JuneOct 2026, live Jan 2027).
- [ ] **Waitlist invite cadence.** Single blast vs staggered (e.g., 50/day over 4 days). Trade-off is Day-1 support load — a stagger gives you time to catch real issues from early batches before the rest of the list hits.
### Pre-launch code cleanup (recommended, not blocking)
Items from [`BACKLOG.md`](./BACKLOG.md) that materially affect the launch-window experience. None are deploy blockers, but each shows up to real users:
- [ ] **`/api/auth/member` returns `slackInvited`.** Without this, the dashboard "Slack coming" note shows for every active member regardless of state. Highest-priority of the wave-Slack bugs because every new member sees the broken case.
- [ ] **Admin members-list row reactivity** on "Mark as Slack invited" — admin has to manually reload after clicking. Hits operators, not members, but operators are us.
- [ ] **`/board` color-contrast fix** (`.block-label`, `.slack-handle``#746a58` on `#e8dfc8` → 4.01:1, needs ≥4.5:1). Single CSS-var change, currently the only red item in `e2e/a11y.spec.js`.
- [ ] **Spec vs UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 says "no wave/cohort/batch language" but shipped copy uses "monthly onboarding waves." Pick a side and align before launch comms go out.
**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).**

View file

@ -1,198 +0,0 @@
# Board Classifieds Redesign
## Overview
Replace the Board's passive tag-matching system with an active classifieds board where members post what they're seeking and offering. Posts are the single source of truth for board presence. The UI follows a corkboard/zine card layout. All communication happens on Slack via curated topic channels.
## Goals
- Give members a reason to browse and return to the Board
- Make the Board feel like a BBS — fun, personal, alive
- Push all conversation to Slack (no in-app messaging)
- Replace the abstract tag-state system with concrete, human-readable posts
## Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Visual style | Corkboard / zine cards | Fits existing design language, gives each post personality |
| Posts vs matching | Posts replace tag-matching entirely | Single source of truth, simpler mental model |
| Post lifecycle | Evergreen until removed by author | Simple, member-managed |
| Posts per member | Unlimited | Community will self-regulate |
| Slack integration | Web URL links to curated topic channels | `gammaspace.slack.com/archives/{channelId}` — tested, works reliably |
| Slack deep links | Protocol (`slack://`) and app links do not work | Tested — only web URL format opens the correct channel |
| Channel management | Admin-managed, curated set with tag mapping | Admin UI to map cooperative tags to Slack channels |
| Unmapped tags | No Slack link shown | No fallback channel |
| Visibility | All members see all posts | Behind `members-auth` middleware |
| Migration | None needed | Pre-launch, test data only |
## Data Model
### New: `BoardPost`
```
author ObjectId, ref Member, required
title String, required, max 120
seeking String, optional, max 500
offering String, optional, max 500
note String, optional, max 300
tags [String] — slugs from cooperative tag pool
createdAt Date
updatedAt Date
```
Validation: at least one of `seeking` or `offering` is required.
### New: `BoardChannel`
```
name String, required — display name (e.g. "Structure & Governance")
slackChannelId String, required — Slack channel ID (e.g. "C09DDGZGXAP")
tagSlugs [String] — cooperative tag slugs mapped to this channel
createdAt Date
updatedAt Date
```
### Member Model Changes
**Remove:**
- `board.topics` (array of tag/state objects)
- `board.details`
- `board.offerPeerSupport`
- `board.availability`
- `board.personalMessage`
**Keep:**
- `board.slackHandle` — author's Slack identity, shown on posts
- Privacy toggle for Slack handle visibility
## Board Page
### Layout
Corkboard card grid. 2 columns on desktop, 1 column at ≤1024px. Newest posts first.
### Header
- Page title "Board" with post count subtitle
- "+ New Post" button
- Tag filter drawer (existing pattern — toggleable, filters posts by tag)
### Post Card
Each card displays:
- **Type indicator:** SEEKING (gold) / OFFERING (green) / SEEKING ↔ OFFERING (ember) — derived from which fields are filled
- **Title** — prominent, Brygada 1918
- **Seeking text** — if present
- **Offering text** — if present
- **Note** — personal touch, slightly different visual treatment
- **Tags** — dashed-border pills
- **Footer:** author avatar + name + circle badge
- **Slack link:** "Discuss on Slack →" linking to mapped channel. Only shown if post's tags map to a channel. Uses `https://gammaspace.slack.com/archives/{channelId}`. If tags map to multiple channels, use the first match.
### New Post Form
Inline at top of the Board page (not a modal). Fields:
- Title (required, 120 chars)
- Seeking (optional, 500 chars)
- Offering (optional, 500 chars)
- Note (optional, 300 chars)
- Tags (multi-select from cooperative tag pool)
Validation: at least one of seeking/offering required. Form appears on "+ New Post" click, collapses after submission.
### Empty State
Friendly prompt to be the first to post, with link to create.
## Profile Board Section
Replaces the current cooperative tag selector, details textarea, and peer support section.
### Shows
- List of member's active posts (compact card previews)
- Edit and delete actions per post
- "+ New Post" button (navigates to Board page or opens same inline form)
- Slack handle setting (identity-level, not per-post)
- Privacy toggle for Slack handle
### Removes
- `CooperativeTagSelector` three-state tag picker
- Details textarea
- Offer Peer Support toggle + conditional fields (availability, personal message)
### No Posts State
Prompt to visit the Board and post something.
## Admin: Board Channels
New admin page for managing Slack channel mappings.
### UI
- List of board channels showing: display name, Slack channel ID, mapped tags
- Add / edit / remove channels
- Tag mapping: multi-select from cooperative tags
- Unmapped tag indicator: shows cooperative tags not yet assigned to any channel
### Behavior
- Admins create channels in Slack manually, then register them here by pasting the channel ID
- Frontend uses the channel list to build Slack links on post cards
## API Routes
### New
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/board/posts` | member | List all posts, newest first. Tag filtering via query params. Populates author name/avatar/circle/slackHandle. |
| POST | `/api/board/posts` | member | Create a post. Validates at least one of seeking/offering. |
| PATCH | `/api/board/posts/:id` | member (own post) | Edit a post. |
| DELETE | `/api/board/posts/:id` | member (own post) | Delete a post. |
| GET | `/api/board/channels` | member | List channels with tag mappings (for building Slack links). |
| GET | `/api/admin/board-channels` | admin | List channels for admin UI. |
| POST | `/api/admin/board-channels` | admin | Create channel mapping. |
| PATCH | `/api/admin/board-channels/:id` | admin | Update channel mapping. |
| DELETE | `/api/admin/board-channels/:id` | admin | Remove channel mapping. |
### Remove
| Path | Reason |
|------|--------|
| `GET /api/board/suggestions` | Replaced by posts |
| `PATCH /api/members/me/board` | Board profile fields removed (slackHandle stays on existing member profile patch) |
## Components
### Stays (repurposed)
- `CooperativeTagSelector` — simplified to a plain tag picker (no three-state toggle) for use in post creation form
### Goes
- Match-card UI on Board page
- Peer support section on profile page
### New
- `BoardPostCard` — the corkboard card component
- `BoardPostForm` — inline creation/edit form
- `BoardPostList` — grid layout for post cards (used on Board page and profile)
- Admin channel management components
## Composables
### Remove
- `useBoard` (the old `getSuggestions` wrapper)
### New
- `useBoardPosts` — CRUD for posts, tag filtering
- `useBoardChannels` — fetch channel mappings, resolve tag→channel for Slack links

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

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