Compare commits

..

No commits in common. "main" and "testing-infrastructure" have entirely different histories.

382 changed files with 13671 additions and 39402 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
@ -32,6 +28,3 @@ BASE_URL=http://localhost:3000
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=

View file

@ -1,90 +0,0 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:run
playwright:
runs-on: ubuntu-latest
needs: vitest
env:
MONGODB_URI: mongodb://mongo-ci: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
with:
node-version: 22
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 &
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
if: failure()
with:
name: playwright-report
path: |
playwright-report/
e2e/test-results/
retention-days: 7
notify:
name: Notify on failure
runs-on: ubuntu-latest
needs: [vitest, playwright]
if: failure()
steps:
- name: Post to Slack
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-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\"}"

94
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,94 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:run
playwright:
runs-on: ubuntu-latest
needs: vitest
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: npx wait-on http://localhost:3000 --timeout 30000
- run: npx playwright test --ignore-snapshots
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
playwright-report/
e2e/test-results/
retention-days: 7
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: npx wait-on http://localhost:3000 --timeout 30000
- run: npx playwright test e2e/visual/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: e2e/test-results/
retention-days: 7

14
.gitignore vendored
View file

@ -18,7 +18,7 @@ logs
.fleet
.idea
/docs/
/*.md
*.md/
# Local env files
.env
@ -26,18 +26,6 @@ logs
!.env.example
scripts/*.js
# Migration backup files
.migration-backup-*.json
# Playwright
e2e/test-results/
playwright-report/
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: []

99
CLAUDE.md Normal file
View file

@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ghost Guild is a membership community platform for game developers exploring cooperative business models. Built with Nuxt 4, Vue 3, MongoDB, and Nuxt UI 4.
## Commands
```bash
npm run dev # Start dev server at http://localhost:3000
npm run build # Production build
npm run preview # Preview production build
npm run test:run # Vitest single run (pre-push hook)
npm run test:e2e # Playwright E2E (needs dev server + MongoDB)
npm run test:a11y # Accessibility scans
npm run test:all # Vitest + Playwright
```
**Dev helpers:** `GET /api/dev/test-login` — creates a test admin user and sets auth cookie (dev only, blocked in production). Navigate to this URL to access admin pages during development.
**Testing:** Vitest for unit/handler tests (`tests/`), Playwright for E2E (`e2e/`). Husky pre-push hook runs Vitest. See `TESTING.md` for details.
## Architecture
### Stack
- **Framework:** Nuxt 4 (Vue 3 + Nitro server)
- **UI:** Nuxt UI 4 (`@nuxt/ui@^4`) with Tailwind CSS
- **Database:** MongoDB via Mongoose
- **Auth:** JWT magic link (email-only, no passwords)
- **Payments:** Helcim (recurring subscriptions + ticket sales)
- **Email:** Resend
- **Slack:** `@slack/web-api` for member invitations and notifications
- **Images:** Cloudinary
- **Analytics:** Plausible (`ghostguild.org`)
### Key Directories
- `app/composables/` — State management via `useState()` (no Pinia/Vuex). Key composables: `useAuth`, `useHelcim`, `useMemberPayment`, `useMemberStatus`
- `app/config/` — Circle definitions (`circles.js`) and contribution tiers (`contributions.js`) used across frontend and forms
- `app/middleware/` — Route guards: `auth.js` (member pages), `admin.js` (admin pages), `coming-soon.global.js` (launch gate)
- `app/layouts/``default` (sidebar, member/public), `admin` (sidebar, admin pages), `landing`, `coming-soon`
- `server/api/` — Nitro API routes organized by feature: `auth/`, `events/`, `members/`, `helcim/`, `series/`, `updates/`, `admin/`, `slack/`, `dev/` (dev-only helpers)
- `server/models/` — Mongoose schemas: `Member`, `Event`, `Series`, `Update`
- `server/utils/` — Service integrations: `mongoose.js`, `helcim.js`, `resend.js`, `slack.ts`, `tickets.js`
### Domain Model
Three membership **circles**: Community, Founder, Practitioner — each with different access and context. Five **contribution tiers**: $0, $5, $15, $30, $50/month via Helcim subscriptions.
Member statuses: `pending_payment`, `active`, `suspended`, `cancelled`.
Events support ticketing with circle-specific pricing overrides and can be grouped into Series with bundled passes.
### Design System (Zine Direction)
- **Palette:** CSS custom properties in `:root` / `.dark` blocks in `app/assets/css/main.css``--bg` (cream/#f4efe4), `--surface`, `--border`, `--candle` (gold accent), `--ember` (rust accent), `--text`, `--text-bright`, `--text-dim`, `--text-faint`, `--parch` (inverted blocks), `--c-community`, `--c-founder`, `--c-practitioner`
- **Typography:** Brygada 1918 (serif, display/headings) + Commit Mono (monospace, body/UI/everything structural) — loaded via Google Fonts in `nuxt.config.ts`
- **Theme:** `primary: amber`, `neutral: stone` — configured in `app/app.config.ts`. Tailwind `@theme` maps `--font-sans` and `--font-mono` to Commit Mono, `--font-display` to Brygada 1918
- **Key classes:** `.btn` / `.btn-primary` / `.btn-danger` (buttons), `.field` (form groups), `.badge` (circle badges), `.section-label` (10px uppercase headers), `.dashed-box` (bordered containers), `.section-divider`
- **Visual language:** Dashed borders (1px dashed), cream backgrounds, no rounded corners, text-forward density, minimal decoration
- **Color mode:** `@nuxtjs/color-mode` with preference `system`, fallback `light`. Dark mode via `.dark` class on `<html>`
- **Layouts:** `default` (sidebar + main, member/public pages), `admin` (sidebar + main, admin pages), `landing` (horizontal nav, unused)
### Environment
Copy `.env.example` to `.env`. Required: `MONGODB_URI`, `JWT_SECRET`, `RESEND_API_KEY`, `HELCIM_API_TOKEN`, `SLACK_BOT_TOKEN`. Public vars are prefixed `NUXT_PUBLIC_`. The `NUXT_PUBLIC_COMING_SOON` flag gates access behind a launch page.
## Conventions
- All frontend code is plain JavaScript (not TypeScript), using Vue 3 Composition API
- Server utilities auto-imported by Nitro — no explicit imports needed in API routes
- Use `USwitch` (not `UToggle`) — this is the correct Nuxt UI 3+ component name
- No fallback/placeholder data — always use real data
- Follow Nuxt 4 file-based routing conventions for route naming
- Always check Nuxt UI 4 latest documentation on the web when implementing UI components
- Auth API responses (`/api/auth/status`, `/api/auth/member`) must include `status` in the returned member object — `useMemberStatus` defaults to `PENDING_PAYMENT` if missing
- Helcim payment testing requires ngrok: `npx nuxi dev --https` then `ngrok http https://localhost:3000` — Helcim blocks localhost origins
- The `/api/helcim/initialize-payment` endpoint skips auth for `event_ticket` type payments (public users can buy tickets)
## Product Spec
The sections below describe planned and in-progress features for reference.
### Member Features
- Profiles with privacy controls (public/members-only/private per field)
- Member updates/mini blog with rich text and images
- Peer support system with Cal.com integration for 1:1 scheduling
### Events System
- RSVP with capacity limits and waitlist management
- Calendar export (.ics), ticketing, series passes
- Member-proposed events with interest threshold
### Resources (Planned)
- Learning paths by circle, templates and tools, case studies
- Tag by circle relevance, download tracking, version control

View file

@ -1,19 +1,12 @@
# Build stage
FROM node:22-alpine AS builder
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts && npx nuxt prepare
RUN npm ci
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
WORKDIR /app
COPY --from=builder /app/.output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View file

@ -1,16 +1,8 @@
/*
* Self-hosted font declarations for Ghost Guild Zine Direction
* Font declarations for Ghost Guild Zine Direction
*
* Brygada 1918: Display/heading serif
* Commit Mono: Body/UI monospace
* Brygada 1918: Display/heading serif (Google Fonts, variable 400-700, italic)
* Commit Mono: Body/UI monospace (Google Fonts)
*
* Fonts are bundled locally via Fontsource.
* Loaded via Google Fonts link in nuxt.config.ts head.
*/
@import "@fontsource-variable/brygada-1918/wght.css";
@import "@fontsource-variable/brygada-1918/wght-italic.css";
@import "@fontsource/commit-mono/400.css";
@import "@fontsource/commit-mono/500.css";
@import "@fontsource/commit-mono/600.css";
@import "@fontsource/commit-mono/700.css";

View file

@ -15,42 +15,31 @@
:root {
--bg: #f4efe4;
--input-bg: #faf8f2;
--surface: #e8dfc8;
--surface-hover: #e0d6bc;
--border: #b8a880;
--border-d: #a89470;
--candle: #7a5a10;
--candle-dim: #866518;
--candle-dim: #9a7420;
--candle-faint: #c4a448;
--ember: #8a4420;
--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: #8a7e6a;
--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;
--green: #4a6a38;
--green-bg: rgba(74, 106, 56, 0.08);
--ember-bg: rgba(138, 68, 32, 0.1);
--page-pad-x: 28px;
--page-pad-y: 24px;
--page-collapse: 1024px;
}
.dark {
--bg: #131210;
--input-bg: #1c1a17;
--surface: #1a1815;
--surface-hover: #252220;
--border: #2a2520;
@ -58,33 +47,28 @@
--candle: #d4a03a;
--candle-dim: #b8922e;
--candle-faint: #8a7030;
--ember: #ca6a3a;
--ember: #c06030;
--text: #a89880;
--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. */
--text-dim: #8a7e6a;
--text-faint: #5a5040;
--parch: #ede4d0;
--parch-hover: #d4c8a8;
--parch-text: #2a2015;
--parch-text-dim: #5a5040;
--c-community: #a06850;
--c-founder: #c06030;
--c-practitioner: #4a7080;
--green: #6e9c52;
--green-bg: rgba(110, 156, 82, 0.12);
--ember-bg: rgba(202, 106, 58, 0.14);
--page-pad-x: 28px;
--page-pad-y: 24px;
--page-collapse: 1024px;
}
/* ---- TAILWIND @THEME MAPPING ---- */
@theme {
--font-sans: "Commit Mono", monospace;
--font-body: "Commit Mono", monospace;
--font-mono: "Commit Mono", monospace;
--font-display: "Brygada 1918", serif;
--font-serif: "Brygada 1918", serif;
--font-sans: 'Commit Mono', monospace;
--font-body: 'Commit Mono', monospace;
--font-mono: 'Commit Mono', monospace;
--font-display: 'Brygada 1918', serif;
--font-serif: 'Brygada 1918', serif;
/* Map primary to candle for Nuxt UI components */
--color-primary-500: var(--candle);
@ -97,35 +81,14 @@
body {
background: var(--bg);
color: var(--text);
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ---- NOISE TEXTURE OVERLAY ---- */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: url("~/assets/images/noise.webp") repeat;
opacity: 0.025;
}
a {
color: var(--candle);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
p a, blockquote a {
text-decoration: underline;
text-underline-offset: 2px;
}
a { color: var(--candle); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---- SECTION LABELS ---- */
.section-label {
@ -145,26 +108,14 @@ p a, blockquote a {
padding: 2px 8px;
border: 1px dashed;
}
.badge.community {
color: var(--c-community);
border-color: rgba(122, 72, 56, 0.35);
}
.badge.founder {
color: var(--c-founder);
border-color: rgba(138, 68, 32, 0.35);
}
.badge.practitioner {
color: var(--c-practitioner);
border-color: rgba(42, 70, 80, 0.35);
}
.badge.all {
color: var(--text-dim);
border-color: var(--border);
}
.badge.community { color: var(--c-community); border-color: rgba(122, 72, 56, 0.35); }
.badge.founder { color: var(--c-founder); border-color: rgba(138, 68, 32, 0.35); }
.badge.practitioner { color: var(--c-practitioner); border-color: rgba(42, 70, 80, 0.35); }
.badge.all { color: var(--text-dim); border-color: var(--border); }
/* ---- BUTTONS ---- */
.btn {
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 12px;
padding: 7px 18px;
border: 1px dashed var(--border);
@ -174,26 +125,14 @@ p a, blockquote a {
letter-spacing: 0.04em;
transition: all 0.15s;
}
.btn:hover {
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:hover { background: var(--surface-hover); border-color: var(--border-d); }
.btn-primary {
background: var(--candle);
color: var(--bg);
border-color: var(--candle);
border-style: solid;
}
.btn-primary:hover {
background: var(--candle-dim);
border-color: var(--candle-dim);
}
.btn-primary:hover { background: var(--candle-dim); border-color: var(--candle-dim); }
.btn-danger {
color: var(--ember);
border-color: var(--ember);
@ -205,9 +144,7 @@ p a, blockquote a {
}
/* ---- FORM FIELDS ---- */
.field {
margin-bottom: 12px;
}
.field { margin-bottom: 12px; }
.field label {
font-size: 10px;
letter-spacing: 0.08em;
@ -216,21 +153,17 @@ p a, blockquote a {
margin-bottom: 3px;
display: block;
}
.field input,
.field select,
.field textarea {
.field input, .field select, .field textarea {
width: 100%;
padding: 5px 8px;
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 13px;
color: var(--text-bright);
background: var(--input-bg);
background: var(--bg);
border: 1px dashed var(--border);
outline: none;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--candle);
border-style: solid;
}
@ -241,25 +174,8 @@ p a, blockquote a {
padding: 20px 24px;
transition: border-color 0.2s;
}
.dashed-box:hover {
border-color: var(--candle-faint);
}
.dashed-box.no-hover:hover {
border-color: var(--border);
}
/* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */
/* Negative-margin overlap: every item keeps all 4 borders,
siblings overlap by 1px, active item paints on top via z-index. */
.segmented {
display: flex;
}
.segmented > * {
position: relative;
}
.segmented > * + * {
margin-left: -1px;
}
.dashed-box:hover { border-color: var(--candle-faint); }
.dashed-box.no-hover:hover { border-color: var(--border); }
/* ---- SECTION DIVIDERS ---- */
.section-divider {
@ -276,98 +192,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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -17,36 +17,29 @@
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
>
{{ item.label }}
<span
v-if="item.path === '/member/dashboard' && showOnboardingDot"
class="onboarding-dot"
/>
</NuxtLink>
>{{ item.label }}</NuxtLink>
</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"
>{{ item.label }}</NuxtLink
>
>{{ item.label }}</NuxtLink>
</li>
</ul>
<div class="sidebar-section">Community</div>
<ul class="sidebar-nav">
<li v-for="item in communityItems" :key="item.path">
<NuxtLink
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
>{{ item.label }}</NuxtLink>
</li>
</ul>
</template>
@ -56,23 +49,11 @@
<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"
>{{ item.label }}</NuxtLink
>
>{{ item.label }}</NuxtLink>
</li>
</ul>
@ -83,8 +64,7 @@
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
>{{ item.label }}</NuxtLink
>
>{{ item.label }}</NuxtLink>
</li>
</ul>
</template>
@ -94,23 +74,11 @@
<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"
>{{ item.label }}</NuxtLink
>
>{{ item.label }}</NuxtLink>
</li>
</ul>
@ -121,8 +89,7 @@
:to="item.path"
:class="{ active: isActive(item.path) }"
@click="handleNavigate"
>{{ item.label }}</NuxtLink
>
>{{ item.label }}</NuxtLink>
</li>
</ul>
</template>
@ -132,17 +99,29 @@
<!-- Meta at bottom -->
<div class="sidebar-meta">
<ClientOnly>
Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
A Canadian nonprofit
<template v-if="isAuthenticated">
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
<span
v-if="memberData?.circle"
class="member-circle"
:style="{ color: `var(--c-${memberData.circle})` }"
>{{ memberData.circle }}</span>
<br v-if="memberData?.circle">
<a href="#" @click.prevent="handleLogout">Sign out</a>
</template>
<template v-else>
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
A Canadian nonprofit<br>
<a href="#" @click.prevent="openLogin">Sign in</a>
</template>
<template #fallback>
Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
A Canadian nonprofit
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
A Canadian nonprofit<br>
<a href="#" @click.prevent="openLogin">Sign in</a>
</template>
</ClientOnly>
<ClientOnly>
<DevLoginPanel v-if="isDev" />
<ColorModeToggle />
</ClientOnly>
</div>
@ -155,59 +134,68 @@ const props = defineProps({
type: Boolean,
default: false,
},
});
})
const emit = defineEmits(["navigate"]);
const emit = defineEmits(['navigate'])
const route = useRoute();
const { isAuthenticated, memberData, logout } = useAuth();
const isDev = import.meta.dev;
const showOnboardingDot = computed(
() => isAuthenticated.value && !memberData.value?.onboarding?.completedAt,
);
const route = useRoute()
const { isAuthenticated, logout, memberData } = useAuth()
const { openLoginModal } = useLoginModal()
const handleNavigate = () => {
if (props.isMobile) {
emit("navigate");
emit('navigate')
}
};
}
const handleLogout = async () => {
await logout();
handleNavigate();
navigateTo("/");
};
await logout()
handleNavigate()
}
const openLogin = () => {
openLoginModal()
handleNavigate()
}
const isActive = (path) => {
if (path === "/") return route.path === "/";
return route.path.startsWith(path);
};
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
// Public nav items
const publicItems = [
{ label: "Home", path: "/" },
{ label: "About", path: "/about" },
{ label: "Events", path: "/events" },
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
];
{ label: 'Home', path: '/' },
{ label: 'About', path: '/about' },
{ label: 'Events', path: '/events' },
{ label: 'Members', path: '/members' },
{ label: 'Wiki', path: '/wiki' },
]
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: 'Dashboard', path: '/member/dashboard' },
{ label: 'Profile', path: '/member/profile' },
{ label: 'Account', path: '/member/account' },
{ label: 'My Updates', path: '/member/my-updates' },
]
const exploreItems = [
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Board", path: "/board" },
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
{ label: "About", path: "/about" },
];
{ label: 'Events', path: '/events' },
{ label: 'Members', path: '/members' },
{ label: 'Wiki', path: '/wiki' },
{ label: 'About', path: '/about' },
]
const communityItems = [
{ label: 'Peer Support', path: '/members' },
{ label: 'Propose an Event', path: '/events' },
]
</script>
<style scoped>
@ -233,14 +221,12 @@ const exploreItems = [
}
.sidebar-brand {
display: flex;
align-items: center;
font-family: "Brygada 1918", serif;
display: block;
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);
padding: 0 24px;
height: 53px;
padding: 24px 24px 16px;
border-bottom: 1px dashed var(--border);
text-decoration: none;
}
@ -251,7 +237,6 @@ const exploreItems = [
.sidebar-body {
flex: 1;
overflow-y: auto;
padding-bottom: 16px;
}
.sidebar-section {
@ -282,11 +267,6 @@ const exploreItems = [
text-decoration: none;
}
.sidebar-nav a.sign-out {
color: var(--text-faint);
margin-top: 4px;
}
.sidebar-nav a:hover {
color: var(--text);
background: var(--surface);
@ -311,28 +291,14 @@ 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: "]";
.member-name {
color: var(--text);
font-size: 12px;
}
.onboarding-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
margin-left: 0px;
vertical-align: middle;
transform: translateY(-1px);
.member-circle {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
</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

@ -1,24 +1,22 @@
<template>
<div class="color-mode-toggle segmented">
<div class="color-mode-toggle">
<button
v-for="option in options"
:key="option.value"
:class="{ active: colorMode.preference === option.value }"
@click="colorMode.preference = option.value"
>
{{ option.label }}
</button>
>{{ option.label }}</button>
</div>
</template>
<script setup>
const colorMode = useColorMode();
const colorMode = useColorMode()
const options = [
{ label: "Light", value: "light" },
{ label: "System", value: "system" },
{ label: "Dark", value: "dark" },
];
{ label: 'Light', value: 'light' },
{ label: 'System', value: 'system' },
{ label: 'Dark', value: 'dark' },
]
</script>
<style scoped>
@ -30,7 +28,7 @@ const options = [
.color-mode-toggle button {
flex: 1;
padding: 4px 0;
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 10px;
letter-spacing: 0.04em;
background: transparent;
@ -38,12 +36,10 @@ const options = [
border: 1px dashed var(--border);
cursor: pointer;
transition: all 0.15s;
position: relative;
}
/* Overlap adjacent borders so dashed lines collapse into one */
.color-mode-toggle button + button {
margin-left: -1px;
border-left: none;
}
.color-mode-toggle button:hover {
@ -55,6 +51,13 @@ const options = [
border-color: var(--candle);
border-style: solid;
background: var(--surface);
z-index: 1;
}
/* When active button is adjacent to dashed, restore left border */
.color-mode-toggle button.active + button {
border-left: 1px dashed var(--border);
}
.color-mode-toggle button:has(+ button.active) {
border-right: none;
}
</style>

View file

@ -1,103 +0,0 @@
<template>
<div
class="columns-layout"
:class="[`columns-${cols}`, `divider-${divider}`, `collapse-${collapse}`]"
>
<template v-if="cols === 'events-sidebar'">
<div class="col col-main">
<slot />
</div>
<EventsMiniSidebar :events="upcomingEvents" />
</template>
<template v-else>
<!-- cols="2": named slots only. Use <template #left> and <template #right>. -->
<div class="col col-left">
<slot name="left" />
</div>
<div class="col col-right">
<slot name="right" />
</div>
</template>
</div>
</template>
<script setup>
const props = defineProps({
cols: { type: String, default: '2' }, // "2" | "events-sidebar"
divider: { type: String, default: 'dashed' }, // "dashed" | "none"
collapse: { type: String, default: '1024' }, // "1024" | "768"
limit: { type: Number, default: 3 },
})
let 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 || [])
}
</script>
<style scoped>
.columns-layout {
display: grid;
align-items: stretch;
}
/* cols="2" */
.columns-2 {
grid-template-columns: 1fr 1fr;
}
/* cols="events-sidebar" */
.columns-events-sidebar {
grid-template-columns: 1fr 200px;
flex: 1;
}
/* Ensure grid children don't overflow */
.col {
min-width: 0;
}
/* Dashed divider: right border on the first column child (except events-sidebar, which owns its own border-left) */
.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 {
--col-collapse: 1024px;
}
/* Responsive collapse at 768px */
.collapse-768 {
--col-collapse: 768px;
}
@media (max-width: 1024px) {
.collapse-1024 {
grid-template-columns: 1fr;
}
.collapse-1024 .col:first-child,
.collapse-1024 .col-main {
border-right: none;
}
}
@media (max-width: 768px) {
.collapse-768 {
grid-template-columns: 1fr;
}
.collapse-768 .col:first-child,
.collapse-768 .col-main {
border-right: none;
}
}
</style>

View file

@ -1,99 +0,0 @@
<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>
<div class="suggest-link">
<button type="button" class="suggest-btn" @click="$emit('suggest')">Don't see what you're looking for?</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue", "suggest"]);
function toggle(slug) {
const current = [...props.modelValue];
const idx = current.indexOf(slug);
if (idx === -1) {
emit("update:modelValue", [...current, slug]);
} else {
current.splice(idx, 1);
emit("update:modelValue", current);
}
}
</script>
<style scoped>
.coop-tag-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.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;
}
.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);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.suggest-btn:hover {
color: var(--text-dim);
}
</style>

View file

@ -1,95 +0,0 @@
<template>
<div class="craft-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 }}</button>
</div>
<div class="suggest-link">
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue", "suggest"]);
function toggle(slug) {
const current = [...props.modelValue];
const idx = current.indexOf(slug);
if (idx === -1) {
emit("update:modelValue", [...current, slug]);
} else {
current.splice(idx, 1);
emit("update:modelValue", current);
}
}
</script>
<style scoped>
.craft-tag-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.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;
}
.suggest-link {
font-size: 10px;
font-family: "Commit Mono", monospace;
color: var(--text-faint);
margin-top: 2px;
}
.suggest-link span {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.suggest-link span:hover {
color: var(--text-dim);
}
</style>

View file

@ -1,105 +0,0 @@
<template>
<div class="dev-login">
<div class="dev-label">Dev Login</div>
<div class="dev-actions">
<div class="dev-buttons">
<a href="/api/dev/test-login" class="dev-button">Admin</a>
<button class="dev-button dev-logout" @click="handleLogout">
Log out
</button>
</div>
<USelectMenu
v-model="selectedEmail"
:items="members"
value-key="value"
:filter-fields="['label', 'value']"
placeholder="Switch user..."
:search-input="{ placeholder: 'Search members...' }"
class="dev-select"
size="xs"
@update:model-value="loginAsEmail"
/>
</div>
</div>
</template>
<script setup>
const selectedEmail = ref(null);
const { logout } = useAuth();
const { data: members } = await useFetch("/api/dev/members", {
default: () => [],
});
const loginAsEmail = (email) => {
if (email) {
navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, {
external: true,
});
}
};
const handleLogout = async () => {
await logout();
};
</script>
<style scoped>
.dev-login {
margin-top: 10px;
padding: 8px;
border: 1px dashed var(--ember);
background: transparent;
}
.dev-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ember);
margin-bottom: 8px;
}
.dev-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.dev-buttons {
display: flex;
gap: 6px;
}
.dev-button {
flex: 1;
padding: 4px 8px;
font-family: "Commit Mono", monospace;
font-size: 11px;
background: var(--surface);
color: var(--ember);
border: 1px solid var(--ember);
cursor: pointer;
text-decoration: none;
text-align: center;
transition: all 0.15s;
}
.dev-button:hover {
background: var(--ember);
color: var(--bg);
}
.dev-select {
width: 100%;
}
:deep([data-slot="base"]) {
background: var(--bg);
border-color: var(--border);
}
:deep([data-slot="placeholder"]) {
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 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">
<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="status-sold-out">
<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="status-remaining">
<span v-else-if="ticketInfo.remaining !== null" class="text-guild-300">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="status-remaining">
Unlimited availability
</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"
>
<UButton
color="gray"
size="sm"
block
@click.stop="$emit('join-waitlist')"
>
<button class="btn" @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,114 +83,88 @@
:is-selected="true"
:is-available="ticketInfo.available"
:already-registered="ticketInfo.alreadyRegistered"
class="mb-6"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration (logged-in member) -->
<!-- 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" }}
</h3>
<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>
<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>
<!-- Member Benefits Notice -->
<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)"
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:sparkles" class="w-4 h-4" />
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) -->
<!-- Payment Required Notice -->
<div
v-else-if="ticketInfo.available && !ticketInfo.alreadyRegistered"
class="ticket-panel"
>
<div class="box-title">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</div>
<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)"
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 class="consent-block">
<label class="consent-field">
<input
v-model="form.createAccount"
type="checkbox"
:disabled="processing"
/>
<span
>Create a free guest account so I can manage my
registration</span
>
</label>
<p class="field-hint consent-hint">
Guest accounts let you view your tickets and register faster next
time. We won't add you to member communications.
</p>
</div>
<button
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
class="btn btn-primary"
:disabled="processing || !form.name || !form.email"
color="primary"
size="lg"
block
:loading="processing"
:disabled="!form.name || !form.email"
>
{{
processing
@ -181,29 +173,38 @@
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
</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,18 +6,13 @@
<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>
<NuxtLink
:to="`/events/${event.slug || event._id}`"
class="em-title"
>{{ event.title }}</NuxtLink
>
<span class="em-date">{{ formatDate(event.date) }}</span>
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
<span
v-if="event.circle"
class="em-circle"
:style="{ color: `var(--c-${event.circle})` }"
>{{ event.circle }}</span
>
>{{ event.circle }}</span>
</div>
</div>
</div>
@ -35,16 +30,13 @@
<script setup>
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>
<style scoped>
@ -107,7 +99,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 bg-guild-800 rounded-full h-2">
<div
class="w-full rounded-full h-2"
style="background: var(--surface)"
>
<div
class="h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%; background: var(--candle)`"
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%`"
/>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
<div v-if="errorMessage" class="text-sm text-ember-400">
{{ errorMessage }}
</div>
</div>
@ -220,16 +201,3 @@ const updateAltText = (altText) => {
});
};
</script>
<style scoped>
.alt-text-input {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.alt-text-input:focus {
outline: none;
border-color: var(--candle);
}
</style>

View file

@ -40,7 +40,7 @@
type="email"
placeholder="your.email@example.com"
required
>
/>
</div>
<div class="info-box">
@ -144,15 +144,6 @@ watch(isOpen, (newValue) => {
loginError.value = ''
}
})
const handleKeydown = (e) => {
if (e.key === 'Escape' && isOpen.value) {
resetAndClose()
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>
@ -182,7 +173,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 +209,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,31 +1,60 @@
<template>
<ClientOnly>
<div v-if="shouldShowBanner" class="status-banner">
<div class="status-banner-inner">
<div class="status-banner-text">
<strong class="status-banner-label">{{ statusConfig.label }}</strong>
<span class="status-banner-msg">{{ bannerMessage }}</span>
<div v-if="shouldShowBanner" class="w-full">
<div
:class="[
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
statusConfig.bgColor,
statusConfig.borderColor,
]"
>
<Icon
:name="statusConfig.icon"
:class="['w-5 h-5 flex-shrink-0 mt-0.5', statusConfig.textColor]"
/>
<div class="flex-1 min-w-0">
<h3 :class="['font-semibold mb-1', statusConfig.textColor]">
{{ statusConfig.label }}
</h3>
<p :class="['text-sm', statusConfig.textColor, 'opacity-90']">
{{ bannerMessage }}
</p>
</div>
<div v-if="nextAction" class="status-banner-actions">
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Payment button for pending payment status -->
<button
v-if="isPendingPayment"
:disabled="isProcessingPayment"
class="btn btn-primary"
<UButton
v-if="isPendingPayment && nextAction"
:color="getButtonColor(nextAction.color)"
size="sm"
:loading="isProcessingPayment"
@click="handleActionClick"
class="whitespace-nowrap"
>
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
</button>
</UButton>
<!-- Link button for other actions -->
<NuxtLink
v-else-if="nextAction.link"
v-else-if="nextAction && nextAction.link"
:to="nextAction.link"
class="btn"
:class="[
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
getActionButtonClass(nextAction.color),
]"
>
{{ nextAction.label }}
</NuxtLink>
<button
v-if="dismissible"
@click="isDismissed = true"
class="text-guild-400 hover:text-guild-200 transition-colors"
:aria-label="`Dismiss ${statusConfig.label} banner`"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
</div>
@ -33,6 +62,17 @@
</template>
<script setup>
const props = defineProps({
dismissible: {
type: Boolean,
default: true,
},
compact: {
type: Boolean,
default: false,
},
});
const {
isPendingPayment,
isSuspended,
@ -41,9 +81,11 @@ const {
getNextAction,
getBannerMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
const isDismissed = ref(false);
// Handle action button click
const handleActionClick = async () => {
if (isPendingPayment.value) {
try {
@ -54,57 +96,33 @@ const handleActionClick = async () => {
}
};
const shouldShowBanner = computed(
() => isPendingPayment.value || isSuspended.value || isCancelled.value,
);
// Map color names to UButton color props
const getButtonColor = (color) => {
const colorMap = {
orange: "warning",
blue: "primary",
gray: "neutral",
};
return colorMap[color] || "primary";
};
// Only show banner if status is not active
const shouldShowBanner = computed(() => {
if (isDismissed.value) return false;
return isPendingPayment.value || isSuspended.value || isCancelled.value;
});
const bannerMessage = computed(() => getBannerMessage());
const nextAction = computed(() => getNextAction());
// Button styling based on color
const getActionButtonClass = (color) => {
const baseClass = "hover:scale-105 active:scale-95";
const colorClasses = {
orange: "bg-candlelight-600 text-white hover:bg-candlelight-700",
blue: "bg-guild-600 text-white hover:bg-guild-500",
gray: "bg-guild-700 text-guild-100 hover:bg-guild-600",
};
return `${baseClass} ${colorClasses[color] || colorClasses.blue}`;
};
</script>
<style scoped>
.status-banner {
width: 100%;
background: var(--parch);
}
.status-banner-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
flex-wrap: wrap;
}
.status-banner-text {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.status-banner-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--parch-text);
white-space: nowrap;
}
.status-banner-msg {
font-size: 12px;
color: var(--parch-text-dim);
line-height: 1.5;
}
.status-banner-actions {
flex-shrink: 0;
}
/* Ensure no border-radius leaks in from global resets or UButton */
.status-banner .btn {
border-radius: 0;
}
</style>

View file

@ -1,40 +1,67 @@
<template>
<div class="natural-date-input">
<div class="space-y-2">
<div class="relative">
<UInput
:model-value="rawInput"
v-model="naturalInput"
:placeholder="placeholder"
:color="trailingState"
@update:model-value="onInputChange"
:color="
hasError && naturalInput.trim()
? 'error'
: isValidParse && naturalInput.trim()
? 'success'
: undefined
"
@input="parseNaturalInput"
@blur="onBlur"
>
<template #trailing>
<Icon
v-if="isValid && rawInput.trim()"
v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5"
style="color: var(--candle)"
class="w-5 h-5 text-candlelight-500"
/>
<Icon
v-else-if="hasError && rawInput.trim()"
v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5"
style="color: var(--ember)"
class="w-5 h-5 text-ember-500"
/>
</template>
</UInput>
<p
v-if="rawInput.trim() && isValid"
class="preview-line"
style="color: var(--candle)"
</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"
>
&rarr; {{ previewText }}
</p>
<p
v-else-if="rawInput.trim() && hasError"
class="preview-line"
style="color: var(--ember)"
<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"
>
{{ errorMessage }}
</p>
<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"
/>
</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);
};
const parse = (input) => {
const trimmed = input.trim();
if (!trimmed) {
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
emit("update:modelValue", "");
if (!input) {
reset();
return;
}
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;
}
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;
// 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;
errorMessage.value = "";
previewDate.value = utc;
emit("update:modelValue", localStr);
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 setError = (msg) => {
isValid.value = false;
hasError.value = true;
errorMessage.value = msg;
previewDate.value = null;
const onBlur = () => {
// If we have a valid parse but the input changed, try to parse again
if (naturalInput.value.trim() && !isValidParse.value) {
parseNaturalInput();
}
};
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();
}
};
const reset = () => {
parsedDate.value = null;
isValidParse.value = false;
hasError.value = false;
errorMessage.value = "";
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 +0,0 @@
<template>
<ClientOnly>
<div v-if="!loading" class="onboarding-widget">
<!-- 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-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>
</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>
<!-- 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>
<NuxtLink
v-else-if="currentSuggestion.action"
:to="currentSuggestion.action"
class="ow-action"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
</template>
</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 completedCount = computed(() => {
const g = goals.value
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedBoard, 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);
font-size: 12px;
line-height: 1.7;
}
.ow-prompt {
color: var(--parch-accent);
margin-bottom: 6px;
}
.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-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);
font-size: 11px;
text-decoration: none;
letter-spacing: 0.04em;
}
.ow-action:hover {
border-color: var(--parch-accent);
border-style: solid;
text-decoration: none;
}
.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;
}
.ow-bar {
display: inline-flex;
gap: 0;
letter-spacing: 0;
}
.ow-bar-fill {
color: var(--parch-accent);
}
.ow-bar-empty {
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
}
.ow-skip {
margin-left: auto;
background: none;
border: none;
color: var(--parch-text-dim);
font-family: inherit;
font-size: 11px;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
}
.ow-skip:hover {
color: var(--parch-accent);
}
</style>

View file

@ -15,7 +15,7 @@ defineProps({
<style scoped>
.page-header {
padding: var(--page-pad-y) var(--page-pad-x) 16px;
padding: 24px 28px 16px;
border-bottom: 1px dashed var(--border);
}
.page-header h1 {

View file

@ -1,23 +0,0 @@
<template>
<div class="page-section" :class="`divider-${divider}`">
<slot />
</div>
</template>
<script setup>
defineProps({
divider: { type: String, default: 'none' }, // "top" | "bottom" | "none"
})
</script>
<style scoped>
.page-section {
padding: var(--page-pad-x) var(--page-pad-x);
}
.page-section.divider-top {
border-top: 1px dashed var(--border);
}
.page-section.divider-bottom {
border-bottom: 1px dashed var(--border);
}
</style>

View file

@ -1,24 +0,0 @@
<template>
<component :is="as" class="page-shell">
<PageHeader v-if="title" :title="title" :subtitle="subtitle" />
<slot />
</component>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
subtitle: { type: String, default: '' },
as: { type: String, default: 'div' },
})
</script>
<style scoped>
.page-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
</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,101 @@
<template>
<!-- Corner Sticker Badge -->
<div
v-if="type === 'sticker'"
class="absolute top-2 right-2 z-10"
:title="title"
>
<div
class="relative transform rotate-3 hover:rotate-0 transition-transform"
style="width: 60px; height: 66px"
>
<!-- Shield background -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 1000"
class="absolute inset-0 w-full h-full drop-shadow-lg"
>
<path
d="M500 70 150 175.3v217.1C150 785 500 930 500 930s350-145 350-537.6V175.2L500 70Z"
class="fill-candlelight-500"
/>
</svg>
<!-- Content on top of shield -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<Icon
name="heroicons:chat-bubble-left-right-solid"
class="w-6 h-6 text-white"
/>
</div>
<!-- Sparkle effect -->
<div
class="absolute top-0 right-1 w-2 h-2 bg-candlelight-300 rounded-full animate-pulse"
></div>
</div>
</div>
<!-- Inline Badge -->
<div
v-else
:class="[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-all',
variant === 'default' &&
'bg-candlelight-900/20 text-candlelight-400 border-candlelight-500/40 hover:bg-candlelight-900/30',
variant === 'subtle' &&
'bg-candlelight-900/10 text-candlelight-500 border-candlelight-500/20',
variant === 'solid' &&
'bg-candlelight-500 text-white border-candlelight-600 hover:bg-candlelight-600',
]"
:title="title"
>
<Icon
name="heroicons:chat-bubble-left-right"
:class="[
'w-3.5 h-3.5',
variant === 'default' && 'text-candlelight-400',
variant === 'subtle' && 'text-candlelight-500',
variant === 'solid' && 'text-white',
]"
/>
<span>{{ label }}</span>
</div>
</template>
<script setup>
const props = defineProps({
/**
* Badge type - inline or corner sticker
* @values inline, sticker
*/
type: {
type: String,
default: "inline",
validator: (value) => ["inline", "sticker"].includes(value),
},
/**
* Display variant of the badge (for inline type)
* @values default, subtle, solid
*/
variant: {
type: String,
default: "default",
validator: (value) => ["default", "subtle", "solid"].includes(value),
},
/**
* Custom label text (defaults to "Offering Peer Support")
*/
label: {
type: String,
default: "Offering Peer Support",
},
/**
* Tooltip/title text
*/
title: {
type: String,
default: "This member offers 1:1 peer support sessions",
},
});
</script>

View file

@ -0,0 +1,67 @@
<template>
<div class="priv">
<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;
}
.priv span + span {
border-left: none;
}
.priv span:hover {
color: var(--text-dim);
}
.priv span.on {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
}
.priv span.on + span {
border-left-color: var(--candle);
}
</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>

104
app/components/TagInput.vue Normal file
View file

@ -0,0 +1,104 @@
<template>
<div class="tags" @click="focusInput">
<span v-for="(tag, i) in modelValue" :key="tag" class="tag">
{{ tag }}
<span class="rm" @click.stop="removeTag(i)">&times;</span>
</span>
<input
ref="input"
v-model="newTag"
@keydown.enter.prevent="addTag"
@keydown.backspace="handleBackspace"
:placeholder="modelValue?.length ? '' : placeholder"
/>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: 'Add tag...' },
})
const emit = defineEmits(['update:modelValue'])
const input = ref(null)
const newTag = ref('')
const focusInput = () => {
input.value?.focus()
}
const addTag = () => {
const tag = newTag.value.trim()
if (tag && !props.modelValue.includes(tag)) {
emit('update:modelValue', [...props.modelValue, tag])
}
newTag.value = ''
}
const removeTag = (index) => {
const tags = [...props.modelValue]
tags.splice(index, 1)
emit('update:modelValue', tags)
}
const handleBackspace = () => {
if (!newTag.value && props.modelValue.length) {
removeTag(props.modelValue.length - 1)
}
}
</script>
<style scoped>
.tags {
border: 1px dashed var(--border);
padding: 3px 5px;
display: flex;
flex-wrap: wrap;
gap: 3px;
background: var(--bg);
min-height: 30px;
align-items: center;
cursor: text;
}
.tags:focus-within {
border-color: var(--candle);
border-style: solid;
}
.tag {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border: 1px dashed var(--border-d);
font-size: 11px;
color: var(--text);
background: var(--surface);
}
.rm {
color: var(--text-faint);
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.rm:hover {
color: var(--ember);
}
.tags input {
border: none;
background: transparent;
padding: 1px 4px;
font-size: 11px;
font-family: 'Commit Mono', monospace;
color: var(--text);
flex: 1;
min-width: 80px;
outline: none;
}
</style>

View file

@ -1,106 +0,0 @@
<template>
<UModal v-model:open="open" :title="`Suggest a ${pool} tag`" :dismissible="true">
<template #body>
<div class="suggest-modal-body">
<div v-if="success" class="success-msg">
Thanks! We'll review your suggestion.
</div>
<form v-else @submit.prevent="submit" class="suggest-form">
<div class="field">
<label>Tag name</label>
<input
v-model="tagName"
type="text"
placeholder="e.g., Game Narrative Design"
required
:disabled="submitting"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting || !tagName.trim()">
{{ submitting ? "Sending..." : "Submit suggestion" }}
</button>
<button type="button" class="btn" @click="open = false">Cancel</button>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
</form>
</div>
</template>
</UModal>
</template>
<script setup>
const props = defineProps({
pool: { type: String, default: "" },
});
const emit = defineEmits(["close"]);
const open = defineModel("open", { default: false });
const tagName = ref("");
const submitting = ref(false);
const success = ref(false);
const error = ref(null);
watch(open, (val) => {
if (!val) {
// reset state when closed
tagName.value = "";
submitting.value = false;
success.value = false;
error.value = null;
}
});
async function submit() {
if (!tagName.value.trim()) return;
submitting.value = true;
error.value = null;
try {
await $fetch("/api/tags/suggest", {
method: "POST",
body: { label: tagName.value.trim(), pool: props.pool },
});
success.value = true;
} catch (e) {
error.value = e?.data?.message || "Something went wrong. Please try again.";
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.suggest-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.suggest-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-actions {
display: flex;
gap: 8px;
align-items: center;
}
.success-msg {
font-size: 12px;
font-family: "Commit Mono", monospace;
color: var(--green);
padding: 8px 0;
}
.error-msg {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--ember);
margin: 0;
}
</style>

View file

@ -0,0 +1,98 @@
<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;
}
.tier-option + .tier-option {
border-left: none;
}
.tier-option:hover {
background: var(--surface-hover);
}
.tier-option.current {
border-color: var(--candle);
border-style: solid;
background: var(--surface);
}
.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

@ -1,73 +1,23 @@
<template>
<div class="top-strip">
<span>
<slot name="left">
<span class="breadcrumb-nav">
<NuxtLink to="/" class="breadcrumb-link">ghostguild.org</NuxtLink>
<template v-for="(crumb, i) in breadcrumbs" :key="i">
<span class="breadcrumb-sep"> / </span>
<NuxtLink
v-if="i < breadcrumbs.length - 1"
:to="crumb.path"
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>
</template>
<slot name="left">ghostguild.org{{ pagePath ? ` / ${pagePath}` : '' }}</slot>
</span>
</slot>
</span>
<span class="right">
<span>
<slot name="right">
<ClientOnly>
<template v-if="memberData">
<NuxtLink to="/member/profile" class="member-link">
<img
v-if="memberData.avatar"
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
:alt="memberData.name"
class="member-avatar"
>
<svg
v-else
class="member-avatar default-ghost"
viewBox="0 0 136 129"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
fill="currentColor"
points="59.75 0 59.75 1.794 50.792 1.794 50.792 3.585 43.627 3.585 43.627 7.169 34.669 7.169 34.669 10.752 27.5 10.752 27.5 16.127 22.125 16.127 22.125 21.502 16.752 21.502 16.752 28.668 13.167 28.668 13.167 37.626 9.583 37.626 9.583 44.791 7.794 44.791 7.794 53.749 6 53.749 6 75.251 7.794 75.251 7.794 84.209 9.583 84.209 9.583 91.376 13.167 91.376 13.167 100.334 16.752 100.334 16.752 107.498 22.125 107.498 22.125 112.873 27.5 112.873 27.5 118.25 34.669 118.25 34.669 121.831 43.627 121.831 43.627 125.415 50.792 125.415 50.792 127.208 59.75 127.208 59.75 129 81.25 129 81.25 127.208 90.208 127.208 90.208 125.415 97.377 125.415 97.377 121.831 106.335 121.831 106.335 118.25 113.5 118.25 113.5 112.873 118.875 112.873 118.875 107.498 124.252 107.498 124.252 100.334 127.833 100.334 127.833 91.376 131.417 91.376 131.417 84.209 133.21 84.209 133.21 75.251 135 75.251 135 53.749 133.21 53.749 133.21 44.791 131.417 44.791 131.417 37.626 127.833 37.626 127.833 28.668 124.252 28.668 124.252 21.502 118.875 21.502 118.875 16.127 113.5 16.127 113.5 10.752 106.335 10.752 106.335 7.169 97.377 7.169 97.377 3.585 90.208 3.585 90.208 1.794 81.25 1.794 81.25 0"
/>
<polygon
fill="currentColor"
points="1.356 82 1.356 83.308 0 83.308 0 98.999 1.356 98.999 1.356 100.309 9.501 100.309 9.501 104.231 8.143 104.231 8.143 106.847 1.356 106.847 1.356 108.154 0 108.154 0 114.694 1.356 114.694 1.356 116 10.855 116 10.855 114.694 13.57 114.694 13.57 112.08 16.285 112.08 16.285 109.464 17.644 109.464 17.644 104.231 19 104.231 19 83.308 17.644 83.308 17.644 82"
/>
<g transform="translate(50, 38)" fill="#000">
<polygon
points="7.072 0.642 7.072 2.569 7.714 2.569 7.714 4.499 8.358 4.499 8.358 6.427 9 6.427 9 8.356 8.358 8.356 8.358 9 4.501 9 4.501 8.356 3.859 8.356 3.859 6.427 2.571 6.427 2.571 4.499 1.286 4.499 1.286 2.569 0 2.569 0 0.642 0.642 0.642 0.642 0 6.431 0 6.431 0.642"
/>
<polygon
points="40.395 25 40.395 25.599 41 25.599 41 30.399 40.395 30.399 40.395 31 21.605 31 21.605 30.399 21 30.399 21 25.599 21.605 25.599 21.605 25"
/>
<polygon
points="52.072 0.642 52.072 2.569 52.714 2.569 52.714 4.499 53.358 4.499 53.358 6.427 54 6.427 54 8.356 53.358 8.356 53.358 9 49.501 9 49.501 8.356 48.859 8.356 48.859 6.427 47.571 6.427 47.571 4.499 46.286 4.499 46.286 2.569 45 2.569 45 0.642 45.642 0.642 45.642 0 51.431 0 51.431 0.642"
/>
</g>
</svg>
{{ memberData.name }}
</NuxtLink>
<span class="sep" aria-hidden="true">/</span>
<a href="#" class="sign-out" @click.prevent="handleLogout"
>sign out</a
>
Signed in as {{ memberData.name }}
<template v-if="memberData.circle">
&middot; {{ memberData.circle }}
</template>
</template>
<template v-else>
A cooperative for game developers
</template>
<template #fallback>
A cooperative for game developers
</template>
<template v-else> The Baby Ghosts member program </template>
<template #fallback> The Baby Ghosts member program </template>
</ClientOnly>
</slot>
</span>
@ -75,38 +25,16 @@
</template>
<script setup>
const props = defineProps({
pagePath: { type: String, default: "" },
});
defineProps({
pagePath: { type: String, default: '' },
})
const { memberData, logout } = useAuth();
const handleLogout = async () => {
await logout();
navigateTo("/");
};
const capitalize = (str) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
};
const breadcrumbs = computed(() => {
if (!props.pagePath) return [];
const segments = props.pagePath.split(" / ");
let path = "";
return segments.map((segment) => {
path += "/" + segment.replace(/\s+/g, "-");
const label = segment.charAt(0).toUpperCase() + segment.slice(1);
return { label, path };
});
});
const { memberData } = useAuth()
</script>
<style scoped>
.top-strip {
padding: 0 32px;
min-height: 53px;
padding: 16px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
color: var(--text-dim);
@ -114,62 +42,6 @@ const breadcrumbs = computed(() => {
justify-content: space-between;
align-items: center;
}
.top-strip a {
color: var(--text-faint);
}
.top-strip a:hover {
color: var(--candle);
}
.member-link {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
}
.member-link:hover {
text-decoration: underline;
}
.member-avatar {
width: 18px;
height: 18px;
object-fit: contain;
}
.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;
}
.breadcrumb-link {
color: var(--text-faint);
text-decoration: none;
}
.breadcrumb-link:hover {
color: var(--candle);
text-decoration: none;
}
.breadcrumb-sep {
color: var(--text-faint);
}
.breadcrumb-current {
color: var(--text-dim);
}
.top-strip a { color: var(--text-faint); }
.top-strip a:hover { color: var(--candle); }
</style>

View file

@ -0,0 +1,191 @@
<template>
<UCard variant="outline" class="update-card">
<div class="flex gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
<img
v-if="update.author?.avatar"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-12 h-12 rounded-full"
@error="handleImageError"
/>
<div
v-else
class="w-12 h-12 rounded-full bg-guild-700 flex items-center justify-center text-guild-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-guild-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-guild-300 transition-colors"
>
{{ update.author.name }}
</NuxtLink>
<span v-else>Unknown Member</span>
</h3>
<div class="flex items-center gap-2 text-sm text-guild-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-guild-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
>
Private
</span>
<span
v-if="update.privacy === 'public'"
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
>
Public
</span>
</div>
</div>
<!-- Actions (for author only) -->
<div v-if="isAuthor" class="flex gap-2">
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-edit"
aria-label="Edit update"
@click="$emit('edit', update)"
/>
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-trash-2"
aria-label="Delete update"
@click="$emit('delete', update)"
/>
</div>
</div>
<!-- Content -->
<div class="text-guild-200 whitespace-pre-wrap break-words mb-3">
<template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}...
<NuxtLink
:to="`/updates/${update._id}`"
class="text-guild-400 hover:text-guild-300 ml-1"
>
Read more
</NuxtLink>
</template>
<template v-else>
{{ update.content }}
</template>
</div>
<!-- Images (if any) -->
<div v-if="update.images?.length" class="mb-3 space-y-2">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="rounded-lg max-w-full h-auto"
/>
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-guild-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-guild-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-guild-500">
Comments (coming soon)
</span>
</div>
</div>
</div>
</UCard>
</template>
<script setup>
const props = defineProps({
update: {
type: Object,
required: true,
},
showPreview: {
type: Boolean,
default: true,
},
});
defineEmits(["edit", "delete"]);
const { memberData } = useAuth();
const isAuthor = computed(() => {
return memberData.value && props.update.author?._id === memberData.value.id;
});
const isEdited = computed(() => {
const created = new Date(props.update.createdAt).getTime();
const updated = new Date(props.update.updatedAt).getTime();
return updated - created > 1000; // More than 1 second difference
});
const capitalize = (str) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
};
const handleImageError = (e) => {
e.target.src = "/ghosties/Ghost-Mild.png"; // Fallback ghost
};
const formatDate = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 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 updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
updateDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
</script>
<style scoped>
.update-card {
background-color: var(--color-guild-800);
border-color: var(--color-guild-600);
}
.update-card:hover {
border-color: var(--color-guild-500);
}
:deep(.card) {
background-color: var(--color-guild-800);
}
</style>

View file

@ -0,0 +1,184 @@
<template>
<div class="space-y-6">
<UFormField label="What's on your mind?" name="content" required>
<UTextarea
v-model="formData.content"
placeholder="Share your thoughts, updates, questions, or learnings with the community..."
:rows="8"
autoresize
:maxrows="20"
/>
</UFormField>
<!-- Privacy Settings -->
<div class="border border-guild-700 rounded-lg p-4 bg-guild-800/30">
<h3 class="text-sm font-medium text-guild-200 mb-4">Privacy Settings</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="public"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Public</div>
<div class="text-sm text-guild-400">
Visible to everyone, including non-members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Members Only</div>
<div class="text-sm text-guild-400">
Only visible to Ghost Guild members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Private</div>
<div class="text-sm text-guild-400">Only visible to you</div>
</div>
</label>
</div>
</div>
<!-- Image Upload (Future) -->
<!-- TODO: Add image upload integration with Cloudinary -->
<!-- Comments Toggle -->
<div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" />
<div>
<div class="text-guild-200 font-medium">Enable Comments</div>
<div class="text-sm text-guild-400">
Allow members to comment on this update
</div>
</div>
</div>
<!-- Actions -->
<div
class="flex justify-between items-center pt-4 border-t border-guild-700"
>
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>
<UButton
:loading="submitting"
:disabled="!formData.content.trim()"
@click="handleSubmit"
>
{{ submitLabel }}
</UButton>
</div>
<!-- Error Message -->
<div
v-if="error"
class="bg-ember-900/20 border border-ember-400/30 rounded-lg p-4"
>
<p class="text-ember-400">{{ error }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
initialData: {
type: Object,
default: () => ({
content: "",
privacy: "members",
commentsEnabled: true,
images: [],
}),
},
submitLabel: {
type: String,
default: "Post Update",
},
submitting: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
});
const emit = defineEmits(["submit", "cancel"]);
const formData = reactive({
content: props.initialData.content || "",
privacy: props.initialData.privacy || "members",
commentsEnabled: props.initialData.commentsEnabled ?? true,
images: props.initialData.images || [],
});
const handleSubmit = () => {
if (!formData.content.trim()) return;
emit("submit", { ...formData });
};
// Watch for initialData changes (for edit mode)
watch(
() => props.initialData,
(newData) => {
if (newData) {
formData.content = newData.content || "";
formData.privacy = newData.privacy || "members";
formData.commentsEnabled = newData.commentsEnabled ?? true;
formData.images = newData.images || [];
}
},
{ immediate: true },
);
</script>
<style scoped>
/* Field labels */
:deep(label) {
color: var(--color-guild-200) !important;
font-weight: 500;
}
/* Textarea styling */
:deep(textarea) {
background-color: var(--color-guild-800) !important;
color: var(--color-guild-200) !important;
border-color: var(--color-guild-600) !important;
}
:deep(textarea::placeholder) {
color: var(--color-guild-500) !important;
}
:deep(textarea:focus) {
border-color: var(--color-guild-400) !important;
background-color: var(--color-guild-700) !important;
}
/* Radio buttons */
input[type="radio"] {
accent-color: var(--color-candlelight-600);
}
</style>

View file

@ -1,340 +0,0 @@
<!-- app/components/admin/AdminAlertsPanel.vue -->
<template>
<div v-if="hasContent" class="alerts-panel">
<div class="section-label">Needs Attention</div>
<div
v-for="alert in visibleAlerts"
:key="alert.type"
class="alert-row"
:class="`severity-${alert.severity}`"
>
<div class="alert-head">
<div>
<span class="alert-title">{{ alert.title }}</span>
<span class="alert-count">{{ alert.count }}</span>
</div>
<button
type="button"
class="dismiss-btn"
:disabled="dismissing[alert.type]"
@click="dismissAlert(alert)"
>
Dismiss
</button>
</div>
<ul v-if="alert.items.length" class="alert-items">
<li v-for="(item, idx) in displayItems(alert)" :key="item.id || idx">
<NuxtLink v-if="item.href" :to="item.href">{{ item.label }}</NuxtLink>
<span v-else>{{ item.label }}</span>
<span v-if="item.sublabel" class="alert-item-sub"> {{ item.sublabel }}</span>
</li>
<li v-if="alert.items.length > maxItems" class="alert-more">
and {{ alert.items.length - maxItems }} more
</li>
</ul>
</div>
<div v-if="!visibleAlerts.length" class="empty-active">
No active alerts.
</div>
<div v-if="dismissedAlerts.length" class="restore-section">
<button
v-if="!restoreOpen"
type="button"
class="restore-toggle"
@click="restoreOpen = true"
>
Restore dismissed ({{ dismissedAlerts.length }})
</button>
<div v-else class="restore-panel">
<div class="section-label restore-label">Restore dismissed alerts</div>
<ul class="restore-list">
<li v-for="d in dismissedAlerts" :key="d.alertType">
<label class="restore-option">
<input
v-model="selectedRestore"
type="checkbox"
:value="d.alertType"
/>
<span>{{ d.title }}</span>
<span class="restore-when">dismissed {{ formatDismissedAt(d.dismissedAt) }}</span>
</label>
</li>
</ul>
<div class="restore-actions">
<button
type="button"
class="dismiss-btn"
:disabled="!selectedRestore.length || restoring"
@click="restoreSelected"
>
Restore selected
</button>
<button
type="button"
class="dismiss-btn"
@click="cancelRestore"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
const maxItems = 5
const dismissing = reactive({})
const restoreOpen = ref(false)
const selectedRestore = ref([])
const restoring = ref(false)
const { data, refresh } = await useFetch('/api/admin/alerts', {
default: () => ({ alerts: [] })
})
const { data: dismissedData, refresh: refreshDismissed } = await useFetch(
'/api/admin/alerts/dismissed',
{ default: () => ({ dismissed: [] }) }
)
const visibleAlerts = computed(() => data.value?.alerts || [])
const dismissedAlerts = computed(() => dismissedData.value?.dismissed || [])
const hasContent = computed(
() => visibleAlerts.value.length > 0 || dismissedAlerts.value.length > 0
)
function displayItems(alert) {
return alert.items.slice(0, maxItems)
}
async function dismissAlert(alert) {
dismissing[alert.type] = true
try {
await $fetch('/api/admin/alerts/dismiss', {
method: 'POST',
body: { alertType: alert.type, signature: alert.signature }
})
// Refetch both lists so the dismissed alert appears in the restore list
await Promise.all([refresh(), refreshDismissed()])
} catch (err) {
console.error('Failed to dismiss alert', err)
await refresh()
} finally {
dismissing[alert.type] = false
}
}
async function restoreSelected() {
if (!selectedRestore.value.length) return
restoring.value = true
try {
await $fetch('/api/admin/alerts/restore', {
method: 'POST',
body: { alertTypes: selectedRestore.value }
})
selectedRestore.value = []
restoreOpen.value = false
await Promise.all([refresh(), refreshDismissed()])
} catch (err) {
console.error('Failed to restore alerts', err)
} finally {
restoring.value = false
}
}
function cancelRestore() {
selectedRestore.value = []
restoreOpen.value = false
}
function formatDismissedAt(value) {
if (!value) return ''
const d = new Date(value)
const now = Date.now()
const diffMin = Math.round((now - d.getTime()) / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffH = Math.round(diffMin / 60)
if (diffH < 24) return `${diffH}h ago`
const diffD = Math.round(diffH / 24)
return `${diffD}d ago`
}
</script>
<style scoped>
.alerts-panel {
border-bottom: 1px dashed var(--border);
padding: 24px 28px;
}
.section-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
margin-bottom: 12px;
}
.alert-row {
border: 1px dashed var(--border);
border-left-width: 3px;
border-left-style: solid;
padding: 12px 16px;
margin-bottom: 8px;
}
.alert-row.severity-critical {
border-left-color: var(--ember);
}
.alert-row.severity-attention {
border-left-color: var(--candle);
}
.alert-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.alert-title {
font-size: 13px;
color: var(--text-bright);
font-weight: 600;
}
.alert-count {
display: inline-block;
margin-left: 8px;
font-size: 11px;
color: var(--text-faint);
}
.dismiss-btn {
background: none;
border: 1px dashed var(--border);
padding: 4px 10px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
}
.dismiss-btn:hover:not(:disabled) {
border-color: var(--candle);
color: var(--candle);
}
.dismiss-btn:disabled {
opacity: 0.5;
cursor: default;
}
.alert-items {
list-style: none;
padding: 0;
margin: 6px 0 0;
}
.alert-items li {
font-size: 12px;
color: var(--text);
padding: 3px 0;
}
.alert-items a {
color: var(--text);
text-decoration: none;
}
.alert-items a:hover {
color: var(--candle);
text-decoration: underline;
}
.alert-item-sub {
color: var(--text-faint);
margin-left: 4px;
}
.alert-more {
color: var(--text-faint);
font-style: italic;
}
.empty-active {
font-size: 12px;
color: var(--text-faint);
font-style: italic;
padding: 4px 0 8px;
}
.restore-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.restore-toggle {
background: none;
border: none;
padding: 0;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
text-decoration: underline dashed;
text-underline-offset: 3px;
}
.restore-toggle:hover {
color: var(--candle);
}
.restore-panel {
padding: 4px 0;
}
.restore-label {
margin-bottom: 8px;
}
.restore-list {
list-style: none;
padding: 0;
margin: 0 0 10px;
}
.restore-list li {
padding: 4px 0;
}
.restore-option {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.restore-option input[type='checkbox'] {
accent-color: var(--candle);
cursor: pointer;
}
.restore-when {
color: var(--text-faint);
font-size: 11px;
margin-left: auto;
}
.restore-actions {
display: flex;
gap: 8px;
}
</style>

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

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

View file

@ -1,208 +0,0 @@
/**
* Onboarding Composable
* Tracks new member onboarding goals and provides post-graduation suggestions.
*/
export function useOnboarding(options = {}) {
const goals = useState('onboarding.goals', () => ({
hasProfileTags: false,
hasVisitedEvent: false,
hasEngagedBoard: 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: [],
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)
)
const pickCategory = options.pickCategory || ((categories) => {
return categories[Math.floor(Math.random() * categories.length)]
})
const currentSuggestion = computed(() => {
// Not graduated — return highest-priority incomplete, non-skipped goal
if (!isComplete.value) {
if (!effectiveGoals.value.hasProfileTags) {
return {
key: 'profileTags',
text: 'Complete your profile by adding your craft and community tags',
action: '/member/profile',
actionText: 'Set up tags',
}
}
if (!effectiveGoals.value.hasVisitedEvent) {
return {
key: 'visitEvent',
text: 'Check out upcoming events',
action: '/events',
actionText: 'Browse events',
}
}
if (!effectiveGoals.value.hasEngagedBoard) {
return {
key: 'board',
text: 'Explore the board to find collaborators',
action: '/board',
actionText: 'Explore board',
}
}
if (!effectiveGoals.value.hasClickedWiki) {
return {
key: 'wiki',
text: 'Browse the wiki for resources and guides',
action: 'https://wiki.ghostguild.org',
actionText: 'Browse wiki',
isExternal: true,
}
}
}
// Graduated — suggestion mode
const cats = ['events', 'wiki'].filter(
(c) => recommendations.value[c]?.length > 0
)
if (cats.length === 0) {
return { key: 'empty', text: 'No suggestions right now' }
}
const selected = pickCategory(cats)
const items = recommendations.value[selected]
if (items?.length > 0) {
return buildRecommendation(selected, items[0])
}
return { key: 'empty', text: 'No suggestions right now' }
})
function buildRecommendation(category, item) {
if (category === 'events') {
return {
key: 'event',
text: `Upcoming event: ${item.title}`,
action: `/events/${item.slug}`,
actionText: 'View event',
}
}
if (category === 'wiki') {
return {
key: 'wiki',
text: `Recommended: ${item.title}`,
action: item.url || null,
actionText: 'Read article',
isExternal: true,
}
}
return { key: 'empty', text: 'No suggestions right now' }
}
async function fetchStatus() {
if (_fetched.value) return
loading.value = true
try {
const data = await $fetch('/api/onboarding/status')
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) {
await fetchRecommendations()
}
} catch {
// Silently fail — goals stay at defaults
} finally {
loading.value = false
}
}
async function fetchRecommendations() {
const [events, wiki] = await Promise.allSettled([
$fetch('/api/events/recommended'),
$fetch('/api/wiki/recommended'),
])
recommendations.value = {
events: events.status === 'fulfilled' ? (events.value || []) : [],
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
}
}
async function trackGoal(goalName) {
if (isComplete.value) return
try {
await $fetch('/api/onboarding/track', {
method: 'POST',
body: { goal: goalName },
})
} catch {
// Fire-and-forget
}
}
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()
return {
goals: readonly(goals),
isComplete: readonly(isComplete),
completedAt: readonly(completedAt),
currentSuggestion,
trackGoal,
skipSuggestion,
skipped: readonly(skipped),
recommendations: readonly(recommendations),
loading: readonly(loading),
}
}

View file

@ -0,0 +1,16 @@
export const usePeerSupport = () => {
const updateSettings = async (settings) => {
return await $fetch('/api/members/me/peer-support', {
method: 'PATCH',
body: settings
});
};
const getSupporters = async (topic) => {
return await $fetch('/api/peer-support', {
query: topic ? { topic } : {}
});
};
return { updateSettings, getSupporters };
};

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

@ -1,11 +1,6 @@
<template>
<div class="site">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:z-100 focus:p-3 focus:bg-(--bg) focus:text-(--text)"
>Skip to content</a
>
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]">Skip to content</a>
<!-- Desktop Sidebar -->
<aside class="sidebar sidebar-desktop">
<NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>
@ -19,61 +14,20 @@
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/pre-registrants"
:class="{ active: route.path.startsWith('/admin/pre-registrants') }"
>
Pre-Registrants
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/members"
:class="{ active: route.path.startsWith('/admin/members') }"
>
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }">
Members
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/events"
:class="{ active: route.path.startsWith('/admin/events') }"
>
<NuxtLink to="/admin/events" :class="{ active: route.path.startsWith('/admin/events') }">
Events
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/series-management"
:class="{ active: route.path.includes('/admin/series') }"
>
<NuxtLink to="/admin/series-management" :class="{ active: route.path.includes('/admin/series') }">
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 +38,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>
@ -105,109 +59,42 @@
<USlideover v-model:open="isMobileMenuOpen" side="left">
<template #body>
<aside class="sidebar sidebar-mobile">
<NuxtLink
to="/"
class="sidebar-brand"
@click="isMobileMenuOpen = false"
>Ghost Guild</NuxtLink
>
<NuxtLink to="/" class="sidebar-brand" @click="isMobileMenuOpen = false">Ghost Guild</NuxtLink>
<div class="sidebar-body">
<div class="sidebar-section">Admin</div>
<ul class="sidebar-nav">
<li>
<NuxtLink
to="/admin"
:class="{ active: route.path === '/admin' }"
@click="isMobileMenuOpen = false"
>
<NuxtLink to="/admin" :class="{ active: route.path === '/admin' }" @click="isMobileMenuOpen = false">
Dashboard
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/pre-registrants"
:class="{ active: route.path.startsWith('/admin/pre-registrants') }"
@click="isMobileMenuOpen = false"
>
Pre-Registrants
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/members"
:class="{ active: route.path.startsWith('/admin/members') }"
@click="isMobileMenuOpen = false"
>
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }" @click="isMobileMenuOpen = false">
Members
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/events"
:class="{ active: route.path.startsWith('/admin/events') }"
@click="isMobileMenuOpen = false"
>
<NuxtLink to="/admin/events" :class="{ active: route.path.startsWith('/admin/events') }" @click="isMobileMenuOpen = false">
Events
</NuxtLink>
</li>
<li>
<NuxtLink
to="/admin/series-management"
:class="{ active: route.path.includes('/admin/series') }"
@click="isMobileMenuOpen = false"
>
<NuxtLink to="/admin/series-management" :class="{ active: route.path.includes('/admin/series') }" @click="isMobileMenuOpen = false">
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>
<ul class="sidebar-nav">
<li>
<NuxtLink
to="/member/dashboard"
@click="isMobileMenuOpen = false"
>Your Dashboard</NuxtLink
>
</li>
<li>
<NuxtLink to="/" @click="isMobileMenuOpen = false"
>Public Site</NuxtLink
>
</li>
<li><NuxtLink to="/member/dashboard" @click="isMobileMenuOpen = false">Your Dashboard</NuxtLink></li>
<li><NuxtLink to="/" @click="isMobileMenuOpen = false">Public Site</NuxtLink></li>
</ul>
</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,23 +104,23 @@
</template>
<script setup>
useSiteMeta({ title: "Admin", noindex: true });
const route = useRoute();
const isMobileMenuOpen = ref(false);
const { logout } = useAuth();
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
const route = useRoute()
const isMobileMenuOpen = ref(false)
const currentPageName = computed(() => {
const path = route.path;
if (path === "/admin") return "admin";
const segments = path.slice(1).split("/");
if (pageBreadcrumbTitle.value && segments.length > 1) {
return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / ");
const path = route.path
if (path === '/admin') return 'admin'
return path.slice(1).replace(/\//g, ' / ')
})
const logout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/')
} catch (error) {
console.error('Logout failed:', error)
}
return segments.join(" / ");
});
}
</script>
<style scoped>
@ -267,20 +154,16 @@ const currentPageName = computed(() => {
}
.sidebar-brand {
display: flex;
align-items: center;
font-family: "Brygada 1918", serif;
display: block;
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);
padding: 0 24px;
height: 53px;
padding: 24px 24px 16px;
border-bottom: 1px dashed var(--border);
text-decoration: none;
}
.sidebar-brand:hover {
text-decoration: none;
}
.sidebar-brand:hover { text-decoration: none; }
.sidebar-body {
flex: 1;
@ -356,7 +239,7 @@ const currentPageName = computed(() => {
}
.brand {
font-family: "Brygada 1918", serif;
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);

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

@ -1,10 +1,6 @@
<template>
<div class="site">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]"
>Skip to content</a
>
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]">Skip to content</a>
<!-- Desktop Sidebar -->
<AppNavigation class="sidebar-desktop" />
@ -28,24 +24,20 @@
<AppNavigation :is-mobile="true" @navigate="isMobileMenuOpen = false" />
</template>
</USlideover>
</div>
</template>
<script setup>
const isMobileMenuOpen = ref(false);
const route = useRoute();
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
const isMobileMenuOpen = ref(false)
const route = useRoute()
const currentPageName = computed(() => {
const path = route.path;
if (path === "/") return "";
const segments = path.slice(1).split("/");
if (pageBreadcrumbTitle.value && segments.length > 1) {
return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / ");
}
return segments.join(" / ");
});
const path = route.path
if (path === '/') return ''
// Convert /member/dashboard member / dashboard
return path.slice(1).replace(/\//g, ' / ')
})
</script>
<style scoped>
@ -79,7 +71,7 @@ const currentPageName = computed(() => {
}
.brand {
font-family: "Brygada 1918", serif;
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);

View file

@ -1,38 +1,36 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip on server-side rendering
if (process.server) {
console.log("🛡️ Auth middleware - skipping on server");
return;
console.log('🛡️ Auth middleware - skipping on server')
return
}
const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
const { memberData, checkMemberStatus } = useAuth()
const { openLoginModal } = useLoginModal()
console.log("🛡️ Auth middleware (CLIENT) - route:", to.path);
console.log(" - memberData exists:", !!memberData.value);
console.log(" - Running on:", process.server ? "SERVER" : "CLIENT");
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
console.log(' - memberData exists:', !!memberData.value)
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT')
// If no member data, try to check authentication
if (!memberData.value) {
console.log(" - No member data, checking authentication...");
const isAuthenticated = await checkMemberStatus();
console.log(" - Authentication result:", isAuthenticated);
console.log(' - No member data, checking authentication...')
const isAuthenticated = await checkMemberStatus()
console.log(' - Authentication result:', isAuthenticated)
if (!isAuthenticated) {
console.log(" - ❌ Authentication failed, showing login modal");
console.log(' - ❌ Authentication failed, showing login modal')
// Open login modal instead of redirecting
openLoginModal({
title: "Sign in to continue",
description: "You need to be signed in to access this page",
title: 'Sign in to continue',
description: 'You need to be signed in to access this page',
dismissible: true,
redirectTo: to.fullPath,
});
// Let navigation proceed — the page renders its own unauthenticated
// fallback, and the modal opens on top. abortNavigation() on an initial
// page load resets client state, which closes the modal before it shows.
return;
})
// Abort navigation - stay on current page with modal open
return abortNavigation()
}
}
console.log(" - ✅ Authentication successful for:", memberData.value?.email);
});
console.log(' - ✅ Authentication successful for:', memberData.value?.email)
})

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

@ -1,118 +1,95 @@
<template>
<PageShell>
<div class="about-page">
<!-- ABOUT HERO (side by side) -->
<div class="about-hero">
<div class="about-hero-left">
<h1>About Ghost Guild</h1>
<p>
A membership community for game developers exploring cooperative
models.
</p>
<p>A membership community for game developers exploring cooperative 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.
</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.
</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.
</p>
<p>Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been 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>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 a place to explore the options, learn from people who've tried them, and build something that works for your team.</p>
</div>
</div>
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
<ColumnsLayout cols="events-sidebar" :limit="3">
<div class="content-area">
<div class="content-main">
<!-- THE CIRCLES -->
<div class="about-section" id="circles">
<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>
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
<div class="two-col-row">
<!-- HOW CONTRIBUTION WORKS -->
<div class="about-section">
<div class="section-label">How Contribution Works</div>
<p>
Membership is $0&ndash;50/month, pay what you can. Nobody is
excluded for lack of funds. Your contribution supports
infrastructure, events, and community resources.
</p>
<p>Membership is $0&ndash;50/month, pay what you can. Nobody is excluded for lack of funds. Your contribution supports infrastructure, events, and community resources.</p>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">$5</span> I can contribute</li>
<li>
<span class="tier-amt">$15</span> I can sustain the community
</li>
<li><span class="tier-amt">$15</span> I can sustain the community</li>
<li><span class="tier-amt">$30</span> I can support others too</li>
<li>
<span class="tier-amt">$50</span> I want to sponsor multiple
members
</li>
<li><span class="tier-amt">$50</span> I want to sponsor multiple members</li>
</ul>
</div>
<!-- COMMUNITY -->
<div class="about-section">
<div class="section-label">Community</div>
<p>
We gather in Slack, at monthly meetings, and through peer support
sessions. The wiki is our shared knowledge base &mdash; growing as
members contribute. Events range from workshops to social hangs to
deep-dive series.
</p>
<p>We gather in Slack, at monthly meetings, and through peer support sessions. The wiki is our shared knowledge base &mdash; growing as members contribute. Events range from workshops to social hangs to deep-dive series.</p>
<NuxtLink to="/join" class="cta">Join the Guild &rarr;</NuxtLink>
</div>
</div>
<!-- ABOUT BABY GHOSTS -->
<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.
</p>
<p>
<a href="https://babyghosts.org" target="_blank"
>babyghosts.org &rarr;</a
>
</p>
<p>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.fund" target="_blank">babyghosts.fund &rarr;</a></p>
</div>
</div>
<!-- EVENTS MINI SIDEBAR -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</div>
</ColumnsLayout>
</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.',
const { data: upcomingEvents } = await useFetch('/api/events', {
query: { limit: 3, upcoming: true },
default: () => [],
})
</script>
<style scoped>
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
.about-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- ABOUT HERO ---- */
.about-hero {
display: grid;
@ -127,7 +104,7 @@ useSiteMeta({
align-self: stretch;
}
.about-hero-left h1 {
font-family: "Brygada 1918", serif;
font-family: 'Brygada 1918', serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
@ -151,6 +128,24 @@ useSiteMeta({
margin-bottom: 10px;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
padding: 0;
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- SECTIONS ---- */
.about-section {
padding: 28px 32px;
@ -181,11 +176,9 @@ useSiteMeta({
padding: 20px;
border-right: 1px dashed var(--border);
}
.circle-cell:last-child {
border-right: none;
}
.circle-cell:last-child { border-right: none; }
.circle-cell h3 {
font-family: "Brygada 1918", serif;
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 500;
line-height: 1.2;
@ -203,19 +196,6 @@ useSiteMeta({
line-height: 1.65;
}
/* ---- TWO-COL ROW ---- */
.two-col-row {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px dashed var(--border);
}
.two-col-row .about-section {
border-bottom: none;
}
.two-col-row .about-section:first-child {
border-right: 1px dashed var(--border);
}
/* ---- TIER LIST ---- */
.tier-list {
list-style: none;
@ -229,9 +209,7 @@ useSiteMeta({
display: flex;
gap: 12px;
}
.tier-list li:last-child {
border-bottom: none;
}
.tier-list li:last-child { border-bottom: none; }
.tier-amt {
color: var(--text-bright);
font-weight: 600;
@ -240,23 +218,13 @@ useSiteMeta({
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.circles-grid {
grid-template-columns: 1fr;
}
.content-area { grid-template-columns: 1fr; }
.circles-grid { grid-template-columns: 1fr; }
.circle-cell {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.circle-cell:last-child {
border-bottom: none;
}
.two-col-row {
grid-template-columns: 1fr;
}
.two-col-row .about-section:first-child {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.circle-cell:last-child { border-bottom: none; }
}
@media (max-width: 768px) {
.about-hero {

View file

@ -1,728 +0,0 @@
<template>
<div class="accept-invite">
<!-- Verifying -->
<div v-if="step === 'verifying'" class="center-box">
<div class="spinner" />
<p>Verifying your invitation...</p>
</div>
<!-- Error -->
<div v-else-if="step === 'error'" class="center-box">
<h1>Invitation Error</h1>
<div class="error-box">{{ errorMessage }}</div>
<NuxtLink to="/" class="btn" style="margin-top: 16px">Go to Ghost Guild</NuxtLink>
</div>
<!-- Accept Form -->
<div v-else-if="step === 'form'" class="form-container">
<h1>Accept Your Invitation</h1>
<p class="form-intro">
Welcome to Ghost Guild. Review your info below, choose your circle and contribution, and you're in.
</p>
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
<form @submit.prevent="handleAccept">
<div class="form-grid">
<div class="form-group">
<label class="form-label" for="accept-name">Name</label>
<input
id="accept-name"
v-model="form.name"
class="form-input"
type="text"
required
>
</div>
<div class="form-group">
<label class="form-label" for="accept-email">Email</label>
<input
id="accept-email"
:value="preRegEmail"
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">
<label class="form-label" for="accept-pronouns">Pronouns</label>
<input
id="accept-pronouns"
v-model="form.pronouns"
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>
<input
id="accept-location"
v-model="form.location"
class="form-input"
type="text"
placeholder="e.g. Vancouver, BC"
>
</div>
<div class="form-group full-width">
<label class="form-label">Circle</label>
<p class="field-note" style="margin-bottom: 8px">Which circle fits where you are right now?</p>
<div class="circle-radios">
<div class="circle-radio community">
<input
id="circle-community"
v-model="form.circle"
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>
</label>
</div>
<div class="circle-radio founder">
<input
id="circle-founder"
v-model="form.circle"
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>
</label>
</div>
<div class="circle-radio practitioner">
<input
id="circle-practitioner"
v-model="form.circle"
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>
</label>
</div>
</div>
</div>
<div class="form-group full-width">
<label class="form-label" for="accept-motivation">What brings you to Ghost Guild?</label>
<textarea
id="accept-motivation"
v-model="form.motivation"
class="form-input"
rows="3"
placeholder="2-3 sentences about what you're looking for"
/>
</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>
</div>
<div class="form-group full-width">
<label class="checkbox-label">
<input
v-model="form.agreedToGuidelines"
type="checkbox"
>
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>.
</span>
</label>
</div>
<div class="form-group">
<button
class="form-submit"
type="submit"
:disabled="!isFormValid || isSubmitting"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else-if="needsPayment">Continue to Payment</span>
<span v-else>Accept Invitation</span>
</button>
</div>
</div>
</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"
/>
</div>
</template>
<script setup>
import {
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} 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("");
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: "",
pronouns: "",
location: "",
circle: "community",
motivation: "",
contributionAmount: 15,
agreedToGuidelines: false,
});
const isFormValid = computed(() => {
return (
form.name &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
});
const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount);
});
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 = "";
};
// On mount: extract token from fragment, verify
onMounted(async () => {
const hash = window.location.hash?.slice(1);
if (!hash) {
step.value = "error";
errorMessage.value = "No invitation token found. Please check your email link.";
return;
}
token.value = hash;
try {
const result = await $fetch("/api/invite/verify", {
method: "POST",
body: { token: hash },
});
preRegId.value = result.preRegistrationId;
preRegEmail.value = result.email;
form.name = result.name || "";
form.location = result.city || "";
step.value = "form";
} catch (err) {
step.value = "error";
errorMessage.value =
err.data?.statusMessage || "This invitation link is invalid or has expired.";
}
});
const handleAccept = async () => {
if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true;
errorMessage.value = "";
flowState.value = "creating-customer";
try {
const accepted = await $fetch("/api/invite/accept", {
method: "POST",
body: {
preRegistrationId: preRegId.value,
name: form.name,
pronouns: form.pronouns || undefined,
location: form.location || undefined,
circle: form.circle,
motivation: form.motivation || undefined,
contributionAmount: form.contributionAmount,
agreedToGuidelines: form.agreedToGuidelines,
token: token.value,
},
});
if (!accepted.requiresPayment) {
// Free tier session cookie already set by accept endpoint
await checkMemberStatus();
flowState.value = "success";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
return;
}
// 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";
} finally {
isSubmitting.value = false;
}
};
</script>
<style scoped>
.accept-invite {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Commit Mono", monospace;
}
.center-box {
max-width: 480px;
margin: 0 auto;
padding: 80px 24px;
text-align: center;
}
.center-box h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 12px;
}
.center-box p {
font-size: 13px;
color: var(--text-dim);
}
.form-container {
max-width: 560px;
margin: 0 auto;
padding: 48px 24px 80px;
}
.form-container h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.form-intro {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 28px;
line-height: 1.5;
}
.error-box {
padding: 12px 16px;
border: 1px dashed var(--ember);
color: var(--ember);
font-size: 12px;
margin-bottom: 20px;
}
/* ---- FORM ---- */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 4px;
}
.form-input,
.form-select {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 8px 10px;
}
.form-input:focus,
.form-select:focus {
border-color: var(--candle);
outline: none;
}
.form-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
textarea.form-input {
resize: vertical;
}
.field-note {
font-size: 10px;
color: var(--text-faint);
margin-top: 4px;
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;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.circle-radio {
position: relative;
}
.circle-radio input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.circle-radio label {
display: block;
padding: 12px;
border: 1px dashed var(--border);
cursor: pointer;
text-align: center;
transition: border-color 0.15s;
}
.circle-radio input:checked + label {
border-color: var(--candle);
border-style: solid;
background: var(--surface);
}
.circle-label-name {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.circle-label-desc {
display: block;
font-size: 10px;
color: var(--text-faint);
}
/* ---- CHECKBOX ---- */
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
line-height: 1.5;
}
.checkbox-label input {
margin-top: 3px;
flex-shrink: 0;
}
.checkbox-label a {
color: var(--candle);
}
/* ---- SUBMIT BUTTON ---- */
.form-submit {
display: inline-block;
padding: 10px 24px;
background: var(--candle);
color: var(--bg);
border: none;
font-family: "Commit Mono", monospace;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.form-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-row {
display: flex;
gap: 12px;
align-items: center;
}
.payment-instruction {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
/* ---- SPINNER ---- */
.spinner {
width: 24px;
height: 24px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---- RESPONSIVE ---- */
@media (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
}
.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,23 @@
<!-- 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;">
<select v-model="statusFilter">
<option value="all">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
</div>
<div class="field" style="margin-bottom: 0;">
@ -33,7 +44,8 @@
</div>
</div>
<!-- Loading / Error -->
<!-- Events Table -->
<div class="table-wrap">
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading events...</span>
@ -43,15 +55,7 @@
Error loading events: {{ error }}
</div>
<template v-else>
<!-- Upcoming Events -->
<div class="section-divider">
<span class="section-label">Upcoming Events</span>
<span class="event-count">{{ upcomingFiltered.length }}</span>
</div>
<div class="table-wrap">
<table v-if="upcomingPaged.length">
<table v-else-if="filteredEvents.length">
<thead>
<tr>
<th class="col-title">Title</th>
@ -64,11 +68,16 @@
</tr>
</thead>
<tbody>
<tr v-for="event in upcomingPaged" :key="event._id">
<tr v-for="event in filteredEvents" :key="event._id">
<!-- Title -->
<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>
@ -85,19 +94,27 @@
</div>
</div>
</td>
<!-- Type -->
<td>
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td>
<!-- Date -->
<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>
<!-- Status -->
<td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
{{ getEventStatus(event) }}
</span>
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
</td>
<!-- Registration -->
<td>
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
@ -105,6 +122,8 @@
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
</td>
<!-- Tickets -->
<td class="col-tickets">
<template v-if="event.tickets?.enabled">
<span class="ticket-on">Ticketing On</span>
@ -123,124 +142,26 @@
</template>
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
</td>
<!-- Actions -->
<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>
<NuxtLink
:to="`/events/${event.slug || String(event._id)}`"
class="link-btn"
title="View"
>View</NuxtLink>
<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>
</table>
<div v-else class="empty-state">No upcoming events matching your filters</div>
<div v-if="upcomingPageCount > 1" class="pagination">
<button class="page-btn" :disabled="upcomingPage === 1" @click="upcomingPage--"></button>
<span class="page-info">{{ upcomingPage }} / {{ upcomingPageCount }}</span>
<button class="page-btn" :disabled="upcomingPage === upcomingPageCount" @click="upcomingPage++"></button>
<div v-else class="empty-state">
No events found matching your criteria
</div>
</div>
<!-- Past Events -->
<div class="section-divider">
<span class="section-label">Past Events</span>
<span class="event-count">{{ pastFiltered.length }}</span>
</div>
<div class="table-wrap">
<table v-if="pastPaged.length">
<thead>
<tr>
<th class="col-title">Title</th>
<th>Type</th>
<th>Date</th>
<th>Status</th>
<th>Registration</th>
<th>Tickets</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="event in pastPaged" :key="event._id">
<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)" >
</div>
<div>
<span class="event-name">{{ event.title }}</span>
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
<div v-if="event.series?.isSeriesEvent" class="series-tag">
<span class="series-pos">{{ event.series.position }}</span>
{{ event.series.title }}
</div>
<div class="event-flags">
<span v-if="event.membersOnly" class="flag">Members Only</span>
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
</div>
</div>
</div>
</td>
<td>
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
</td>
<td class="col-date">
<span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event) }}</span>
</td>
<td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
{{ getEventStatus(event) }}
</span>
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
</td>
<td>
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
<span v-if="event.maxAttendees" class="reg-count">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
</td>
<td class="col-tickets">
<template v-if="event.tickets?.enabled">
<span class="ticket-on">Ticketing On</span>
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
<template v-else>
<span v-if="event.tickets.member?.available" class="ticket-detail">
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
</span>
<span v-if="event.tickets.public?.available" class="ticket-detail">
Public: ${{ event.tickets.public.price || 0 }}
<template v-if="event.tickets.public.quantity">
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
</template>
</span>
</template>
</template>
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
</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>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">No past events matching your filters</div>
<div v-if="pastPageCount > 1" class="pagination">
<button class="page-btn" :disabled="pastPage === 1" @click="pastPage--"></button>
<span class="page-info">{{ pastPage }} / {{ pastPageCount }}</span>
<button class="page-btn" :disabled="pastPage === pastPageCount" @click="pastPage++"></button>
</div>
</div>
</template>
<!-- Confirm Delete Modal -->
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
<div class="modal">
@ -264,8 +185,6 @@
</template>
<script setup>
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
definePageMeta({
layout: 'admin',
middleware: 'admin',
@ -280,12 +199,33 @@ const {
const searchQuery = ref('')
const typeFilter = ref('all')
const statusFilter = ref('all')
const seriesFilter = ref('all')
const upcomingPage = ref(1)
const pastPage = ref(1)
const UPCOMING_PAGE_SIZE = 10
const PAST_PAGE_SIZE = 5
const filteredEvents = computed(() => {
if (!events.value) return []
return events.value.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesType =
typeFilter.value === 'all' || event.eventType === typeFilter.value
const eventStatus = getEventStatus(event)
const matchesStatus =
statusFilter.value === 'all' || eventStatus.toLowerCase() === statusFilter.value
const matchesSeries =
seriesFilter.value === 'all' ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesStatus && matchesSeries
})
})
const getEventStatus = (event) => {
const now = new Date()
@ -297,74 +237,19 @@ const getEventStatus = (event) => {
return 'Past'
}
const applyBaseFilters = (list) => {
if (!list) return []
return list.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesType =
typeFilter.value === 'all' || event.eventType === typeFilter.value
const matchesSeries =
seriesFilter.value === 'all' ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesSeries
})
}
const upcomingFiltered = computed(() => {
return applyBaseFilters(events.value)
.filter((e) => getEventStatus(e) !== 'Past')
.sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
})
const pastFiltered = computed(() => {
return applyBaseFilters(events.value)
.filter((e) => getEventStatus(e) === 'Past')
.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
})
const upcomingPageCount = computed(() => Math.max(1, Math.ceil(upcomingFiltered.value.length / UPCOMING_PAGE_SIZE)))
const pastPageCount = computed(() => Math.max(1, Math.ceil(pastFiltered.value.length / PAST_PAGE_SIZE)))
const upcomingPaged = computed(() => {
const start = (upcomingPage.value - 1) * UPCOMING_PAGE_SIZE
return upcomingFiltered.value.slice(start, start + UPCOMING_PAGE_SIZE)
})
const pastPaged = computed(() => {
const start = (pastPage.value - 1) * PAST_PAGE_SIZE
return pastFiltered.value.slice(start, start + PAST_PAGE_SIZE)
})
// Reset pagination when filters change
watch([searchQuery, typeFilter, seriesFilter], () => {
upcomingPage.value = 1
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',
})
}
@ -424,7 +309,10 @@ const editEvent = (event) => {
</script>
<style scoped>
.admin-events {}
.admin-events {
max-width: 1100px;
margin: 0 auto;
}
/* ---- PAGE HEADER ---- */
.page-header {
@ -462,34 +350,9 @@ const editEvent = (event) => {
flex-wrap: wrap;
}
/* ---- SECTION DIVIDER ---- */
.section-divider {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 28px 0;
}
.section-divider .section-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: normal;
}
.event-count {
font-size: 10px;
color: var(--text-faint);
background: var(--surface);
border: 1px dashed var(--border);
padding: 1px 7px;
letter-spacing: 0.04em;
}
/* ---- TABLE ---- */
.table-wrap {
padding: 12px 28px 24px;
padding: 0 28px 28px;
}
table {
@ -573,7 +436,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 +449,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 +498,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 +513,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;
}
@ -717,41 +580,6 @@ tbody td {
color: var(--ember);
}
/* ---- PAGINATION ---- */
.pagination {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0 0;
}
.page-btn {
background: none;
border: 1px dashed var(--border);
color: var(--candle);
cursor: pointer;
font-family: 'Commit Mono', monospace;
font-size: 12px;
padding: 3px 10px;
transition: border-color 0.1s;
}
.page-btn:disabled {
color: var(--text-faint);
border-color: var(--border);
cursor: default;
}
.page-btn:not(:disabled):hover {
border-color: var(--candle);
}
.page-info {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
}
/* ---- STATES ---- */
.loading-state {
text-align: center;
@ -769,7 +597,7 @@ tbody td {
.empty-state {
text-align: center;
padding: 32px 24px;
padding: 48px 24px;
color: var(--text-faint);
font-size: 12px;
}
@ -871,15 +699,11 @@ tbody td {
.filter-bar {
flex-direction: column;
padding: 16px 20px;
}
.section-divider {
padding: 16px 20px 0;
padding: 12px 20px;
}
.table-wrap {
padding: 12px 20px 20px;
padding: 0 12px 20px;
overflow-x: auto;
}

View file

@ -1,14 +1,14 @@
<template>
<PageShell
title="Admin Dashboard"
subtitle="Members, events, and community operations"
>
<AdminAlertsPanel />
<div class="admin-dash">
<!-- Page Header -->
<div class="page-header">
<h1>Admin Dashboard</h1>
<p>Members, events, and community operations</p>
</div>
<!-- Stats + Quick Actions row -->
<ColumnsLayout cols="2" collapse="768" class="admin-row">
<template #left>
<div class="admin-block">
<div class="content-row">
<div class="content-block">
<div class="section-label">Overview</div>
<div class="stat-row">
<span class="stat-key">Total Members</span>
@ -27,10 +27,8 @@
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
</div>
</div>
</template>
<template #right>
<div class="admin-block">
<div class="content-block">
<div class="section-label">Quick Actions</div>
<NuxtLink to="/admin/members" class="action-link">
Manage Members<span class="arrow">&rarr;</span>
@ -45,13 +43,11 @@
Create Series<span class="arrow">&rarr;</span>
</NuxtLink>
</div>
</template>
</ColumnsLayout>
</div>
<!-- Recent Activity row -->
<ColumnsLayout cols="2" collapse="768" class="admin-row">
<template #left>
<div class="admin-block">
<div class="content-row">
<div class="content-block">
<div class="section-label">Recent Members</div>
<div v-if="pending" class="loading-inline">
@ -61,11 +57,11 @@
<div v-else-if="recentMembers.length" class="item-list">
<div v-for="member in recentMembers" :key="member._id" class="item-row">
<div>
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
<span class="item-name">{{ member.name }}</span>
<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>
@ -74,10 +70,8 @@
<NuxtLink to="/admin/members" class="section-link">View all members &rarr;</NuxtLink>
</div>
</template>
<template #right>
<div class="admin-block">
<div class="content-block">
<div class="section-label">Upcoming Events</div>
<div v-if="pending" class="loading-inline">
@ -91,7 +85,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>
@ -100,14 +94,11 @@
<NuxtLink to="/admin/events" class="section-link">View all events &rarr;</NuxtLink>
</div>
</template>
</ColumnsLayout>
</PageShell>
</div>
</div>
</template>
<script setup>
import { eventTypeLabel } from '~/config/eventTypes'
definePageMeta({
layout: 'admin',
middleware: 'admin',
@ -134,16 +125,48 @@ const formatDateTime = (dateString) => {
</script>
<style scoped>
/* ---- ROWS ---- */
.admin-row {
.admin-dash {
max-width: 960px;
margin: 0 auto;
}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.admin-block {
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 4px;
}
.page-header p {
font-size: 12px;
color: var(--text-dim);
}
/* ---- CONTENT GRID ---- */
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px dashed var(--border);
}
.content-block {
padding: 24px 28px;
border-right: 1px dashed var(--border);
min-width: 0;
}
.content-block:last-child {
border-right: none;
}
/* ---- STATS ---- */
.stat-row {
display: flex;
@ -218,12 +241,6 @@ const formatDateTime = (dateString) => {
display: block;
color: var(--text);
font-size: 13px;
text-decoration: none;
}
a.item-name:hover {
color: var(--candle);
text-decoration: underline;
}
.item-sub {
@ -291,7 +308,24 @@ a.item-name:hover {
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.admin-block {
.content-row {
grid-template-columns: 1fr;
}
.content-block {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child {
border-bottom: none;
}
.page-header {
padding: 24px 20px 16px;
}
.content-block {
padding: 20px;
}
}

1104
app/pages/admin/members.vue Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,858 +0,0 @@
<template>
<div class="admin-member-detail">
<!-- Page Header -->
<div class="page-header">
<div class="header-nav">
<NuxtLink to="/admin/members" class="back-link"> Members</NuxtLink>
<NuxtLink v-if="member && member.status === 'active' && member.showInDirectory" :to="`/members/${member._id}`" class="profile-link" target="_blank">
View public profile
</NuxtLink>
</div>
<div class="header-row">
<div>
<h1 v-if="member">{{ member.name }}</h1>
<h1 v-else-if="pending">Loading</h1>
<h1 v-else>Member not found</h1>
<p v-if="member" class="member-email">{{ member.email }}</p>
</div>
<div v-if="member" class="header-badges">
<CircleBadge :circle="member.circle" />
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>
</div>
<div v-if="pending" class="loading-state">
<div class="spinner" />
Loading member
</div>
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
<template v-else-if="member">
<div class="detail-body">
<!-- LEFT COLUMN: form + metadata -->
<div class="detail-left">
<!-- Edit form -->
<section class="detail-section">
<div class="section-label">Member details</div>
<form class="edit-form" @submit.prevent="submitEdit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" required >
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" required >
</div>
<div class="field">
<label>Circle</label>
<select v-model="form.circle">
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</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>
</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>
</select>
</div>
<div class="field">
<label>Role</label>
<select v-model="form.role">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? "Saving…" : "Save changes" }}
</button>
<button type="button" class="btn" @click="resetForm">Reset</button>
</div>
</form>
</section>
<!-- Metadata -->
<section class="detail-section">
<div class="section-label">Account info</div>
<dl class="meta-list">
<div v-if="member.memberNumber" class="meta-row">
<dt>Member number</dt>
<dd class="mono">#{{ member.memberNumber }}</dd>
</div>
<div class="meta-row">
<dt>Member ID</dt>
<dd class="mono">{{ member._id }}</dd>
</div>
<div class="meta-row">
<dt>Joined</dt>
<dd>{{ formatDate(member.createdAt) }}</dd>
</div>
<div class="meta-row">
<dt>Invite email</dt>
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
</dd>
</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>
</div>
<div v-if="member.helcimCustomerId" class="meta-row">
<dt>Helcim customer</dt>
<dd class="mono">{{ member.helcimCustomerId }}</dd>
</div>
<div v-if="member.helcimSubscriptionId" class="meta-row">
<dt>Helcim subscription</dt>
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
</div>
</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>
<dl class="meta-list">
<div class="meta-row">
<dt>Event reminders</dt>
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.events !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Community updates</dt>
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.updates !== false ? "On" : "Off" }}
</dd>
</div>
</dl>
</section>
</div>
<!-- RIGHT COLUMN: activity log -->
<div class="detail-right">
<div class="activity-panel">
<div class="activity-panel-header">
<div class="section-label">Activity log</div>
<span class="activity-legend">
<span class="al-vis-badge">admin-only</span> = not visible to member
</span>
</div>
<ClientOnly>
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
<div class="spinner" />
Loading activity...
</div>
<div v-else-if="activityEntries.length" class="activity-timeline">
<div
v-for="entry in activityEntries"
:key="entry._id"
class="al-item"
:class="{ 'al-admin': entry.visibility === 'admin' }"
>
<div class="al-dot" />
<div class="al-body">
<div class="al-row">
<UIcon :name="getActivity(entry).icon" class="al-icon" />
<span class="al-text">{{ getActivity(entry).text }}</span>
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
</div>
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
</div>
</div>
<div v-if="activityHasMore" class="al-load-more">
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
{{ activityLoadingMore ? 'Loading...' : 'Load more' }}
</button>
</div>
</div>
<div v-else class="activity-empty">
No activity recorded.
</div>
</ClientOnly>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
import { STATUS_LABELS } from '~/config/memberStatus'
definePageMeta({
layout: "admin",
middleware: "admin",
});
const route = useRoute();
const toast = useToast();
const {
data: member,
pending,
error: fetchError,
} = await useFetch(`/api/admin/members/${route.params.id}`);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
pageBreadcrumbTitle.value = member.value?.name || "";
watch(member, (val) => {
pageBreadcrumbTitle.value = val?.name || "";
});
onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
const form = reactive({
name: "",
email: "",
circle: "",
contributionAmount: 0,
status: "",
role: "",
});
const saving = ref(false);
function populateForm(m) {
if (!m) return;
form.name = m.name;
form.email = m.email;
form.circle = m.circle;
form.contributionAmount = m.contributionAmount ?? 0;
form.status = m.status || "pending_payment";
form.role = m.role || "member";
}
// Populate once data is ready
if (member.value) populateForm(member.value);
watch(member, populateForm, { immediate: false });
function resetForm() {
populateForm(member.value);
}
async function submitEdit() {
saving.value = true;
try {
const updated = await $fetch(`/api/admin/members/${route.params.id}`, {
method: "PUT",
body: {
name: form.name,
email: form.email,
circle: form.circle,
contributionAmount: form.contributionAmount,
status: form.status,
},
});
// Update role separately if it changed
if (form.role !== member.value?.role) {
await $fetch(`/api/admin/members/${route.params.id}/role`, {
method: "PATCH",
body: { role: form.role },
});
}
// Reflect changes locally
if (member.value) {
member.value = { ...member.value, ...updated, role: form.role };
pageBreadcrumbTitle.value = form.name;
}
toast.add({ title: "Member updated", color: "success" });
} catch (err) {
toast.add({
title: "Failed to update member",
description: err.data?.statusMessage || err.message,
color: "error",
});
} finally {
saving.value = false;
}
}
function formatDate(val) {
if (!val) return "—";
return new Date(val).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function statusClass(status) {
if (status === "active") return "status-ok";
if (status === "cancelled" || status === "suspended") return "status-error";
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)
const activityLoadingMore = ref(false)
const activityHasMore = ref(false)
const activityNextCursor = ref(null)
const getActivity = (entry) => formatActivity(entry)
async function loadActivity() {
activityLoading.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20 }
})
activityEntries.value = data.entries
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
activityLoading.value = false
}
}
async function loadMoreActivity() {
if (!activityNextCursor.value) return
activityLoadingMore.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20, before: activityNextCursor.value }
})
activityEntries.value.push(...data.entries)
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
activityLoadingMore.value = false
}
}
onMounted(loadActivity)
</script>
<style scoped>
.admin-member-detail {}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.back-link {
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
letter-spacing: 0.02em;
}
.back-link:hover {
color: var(--candle);
text-decoration: none;
}
.profile-link {
font-size: 11px;
color: var(--candle);
text-decoration: none;
letter-spacing: 0.02em;
}
.profile-link:hover {
text-decoration: underline;
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 4px;
line-height: 1.2;
}
.member-email {
font-size: 12px;
color: var(--text-faint);
margin: 0;
}
.header-badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-top: 4px;
}
.status-badge {
font-size: 10px;
font-family: "Commit Mono", monospace;
padding: 2px 8px;
border: 1px dashed var(--border);
color: var(--text-dim);
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---- TWO-COLUMN BODY ---- */
.detail-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.detail-left {
border-right: 1px dashed var(--border);
}
.detail-section {
padding: 24px 28px;
border-bottom: 1px dashed var(--border);
}
.edit-form {
display: flex;
flex-direction: column;
gap: 14px;
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;
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed var(--border);
}
.meta-list {
display: flex;
flex-direction: column;
gap: 0;
border: 1px dashed var(--border);
margin-top: 12px;
}
.meta-row {
display: flex;
gap: 16px;
padding: 9px 14px;
border-bottom: 1px dashed var(--border);
}
.meta-row:last-child {
border-bottom: none;
}
.meta-row dt {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.02em;
min-width: 140px;
flex-shrink: 0;
padding-top: 1px;
}
.meta-row dd {
font-size: 12px;
color: var(--text);
margin: 0;
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;
}
/* ---- STATUS ---- */
.status-ok {
color: var(--green);
}
.status-dim {
color: var(--text-faint);
}
.status-error {
color: var(--ember);
}
/* ---- STATES ---- */
.loading-state,
.error-state {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-dim);
font-size: 13px;
padding: 40px 28px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- ACTIVITY PANEL ---- */
.detail-right {
position: relative;
}
.activity-panel {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.activity-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px dashed var(--border);
flex-shrink: 0;
}
.activity-legend {
font-size: 10px;
color: var(--text-faint);
display: flex;
align-items: center;
gap: 6px;
}
.activity-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
.activity-empty {
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
/* Timeline */
.activity-timeline {
overflow-y: auto;
flex: 1;
padding: 16px 0 24px;
}
.al-item {
display: grid;
grid-template-columns: 20px 1fr;
gap: 0 10px;
padding: 0 28px 0 20px;
margin-bottom: 16px;
position: relative;
}
.al-item::before {
content: '';
position: absolute;
left: 27px;
top: 18px;
bottom: -16px;
width: 1px;
border-left: 1px dashed var(--border);
}
.al-item:last-child::before {
display: none;
}
.al-dot {
width: 6px;
height: 6px;
border: 1px dashed var(--border);
background: var(--bg);
flex-shrink: 0;
margin-top: 4px;
align-self: start;
}
.al-admin .al-dot {
border-color: var(--candle-faint);
background: var(--surface);
}
.al-body {
min-width: 0;
}
.al-row {
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.al-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
}
.al-text {
flex: 1;
min-width: 0;
color: var(--text);
font-size: 12px;
line-height: 1.4;
}
.al-time {
display: block;
color: var(--text-faint);
font-size: 10px;
margin-top: 3px;
letter-spacing: 0.02em;
}
.al-vis-badge {
font-size: 9px;
color: var(--candle);
border: 1px dashed var(--candle-faint);
padding: 1px 5px;
flex-shrink: 0;
letter-spacing: 0.04em;
}
.al-load-more {
display: flex;
justify-content: center;
padding: 8px 28px 0;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.detail-body {
grid-template-columns: 1fr;
}
.detail-left {
border-right: none;
}
.activity-panel {
position: static;
max-height: none;
border-top: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.page-header {
padding: 24px 20px 16px;
}
.detail-section {
padding: 20px;
}
.activity-panel-header {
padding: 16px 20px 12px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.al-item {
padding: 0 20px 0 14px;
}
.meta-row {
flex-direction: column;
gap: 4px;
}
.meta-row dt {
min-width: unset;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -1,857 +0,0 @@
<template>
<div class="admin-prereg">
<!-- Page Header -->
<div class="page-header">
<div class="header-row">
<div>
<h1>Pre-Registrants</h1>
<p v-if="stats">
{{ stats.total }} total · {{ stats.pending }} pending ·
{{ stats.selected }} selected · {{ stats.invited }} invited ·
{{ stats.accepted }} accepted
</p>
</div>
<div class="header-actions">
<button
class="btn"
:disabled="!selectedIds.length"
@click="markAsSelected"
>
Mark as Selected ({{ selectedIds.length }})
</button>
<button
class="btn btn-primary"
:disabled="!invitableIds.length"
@click="openInviteModal"
>
Send Invites ({{ invitableIds.length }})
</button>
</div>
</div>
</div>
<!-- Search / Filter -->
<div class="filter-bar">
<div class="field" style="margin-bottom: 0; flex: 1">
<input
v-model="searchQuery"
placeholder="Search by name, email, city, role..."
/>
</div>
<div class="field" style="margin-bottom: 0">
<select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="selected">Selected</option>
<option value="invited">Invited</option>
<option value="accepted">Accepted</option>
<option value="expired">Expired</option>
</select>
</div>
</div>
<!-- Table -->
<div class="table-wrap">
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading pre-registrants...</span>
</div>
<div v-else-if="error" class="error-state">
Error loading pre-registrants: {{ error }}
</div>
<table v-else-if="filtered.length">
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th class="sortable" @click="toggleSort('name')">Name <span v-if="sortKey === 'name'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span v-if="sortKey === 'email'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('city')">City <span v-if="sortKey === 'city'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('role')">Role <span v-if="sortKey === 'role'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span v-if="sortKey === 'status'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable col-date" @click="toggleSort('createdAt')">Registered <span v-if="sortKey === 'createdAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
</tr>
</thead>
<tbody>
<tr
v-for="pr in filtered"
:key="pr._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(pr._id) }"
@click="toggleSelect(pr._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
<input
type="checkbox"
:checked="selectedIds.includes(pr._id)"
@change="toggleSelect(pr._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-name">{{ pr.name || "—" }}</td>
<td class="col-email">{{ pr.email }}</td>
<td>{{ pr.city || "—" }}</td>
<td>{{ pr.role || "—" }}</td>
<td @click.stop>
<select
class="inline-status"
:class="`status-${pr.status}`"
:value="pr.status"
:disabled="savingId === pr._id"
aria-label="Change status"
@change="updateStatus(pr._id, $event.target.value)"
>
<option value="pending">Pending</option>
<option value="selected">Selected</option>
<option value="invited">Invited</option>
<option value="accepted">Accepted</option>
<option value="expired">Expired</option>
</select>
</td>
<td class="col-mono col-date">
{{ formatDate(pr.createdAt) }}
</td>
</tr>
</tbody>
</table>
<div v-else class="empty-state">
No pre-registrants found matching your criteria
</div>
</div>
<!-- Send Invites Modal -->
<div
v-if="showInviteModal"
class="modal-overlay"
@click.self="showInviteModal = false"
>
<div class="modal modal-wide">
<div class="modal-header">
<h2>Send Invitation Emails</h2>
<button class="modal-close" @click="showInviteModal = false">
&times;
</button>
</div>
<div class="modal-body">
<p class="help-text">
Sending to <strong>{{ invitableIds.length }}</strong> pre-registrant{{
invitableIds.length !== 1 ? "s" : ""
}}. Each receives a unique invitation link valid for 48 hours.
</p>
<div class="field">
<label>Email Template</label>
<textarea v-model="inviteTemplate" rows="12"></textarea>
<p class="help-text" style="margin-top: 4px">
Tokens: <code>{name}</code>, <code>{acceptLink}</code>
</p>
</div>
<div v-if="invitePreview" class="field">
<label>Preview ({{ invitePreview.name || invitePreview.email }})</label>
<pre class="preview-box">{{ invitePreviewText }}</pre>
</div>
<div v-if="inviteResults" class="results-box">
<strong>Invitations sent</strong>
<p class="status-ok">{{ inviteResults.sent }} sent</p>
<p v-if="inviteResults.failed" class="status-error">
{{ inviteResults.failed }} failed
</p>
<div v-if="inviteResults.results?.some((r) => !r.success)">
<p
v-for="fail in inviteResults.results.filter((r) => !r.success)"
:key="fail.email"
class="status-error"
style="font-size: 11px"
>
{{ fail.email }}: {{ fail.error }}
</p>
</div>
</div>
</div>
<div class="modal-actions">
<button @click="showInviteModal = false" class="btn">
{{ inviteResults ? "Done" : "Cancel" }}
</button>
<button
v-if="!inviteResults"
:disabled="sendingInvites"
@click="submitInvites"
class="btn btn-primary"
>
{{
sendingInvites
? "Sending..."
: `Send ${invitableIds.length} invitation${invitableIds.length !== 1 ? "s" : ""}`
}}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: "admin",
middleware: "admin",
});
const toast = useToast();
const {
data: preRegistrants,
pending,
error,
refresh,
} = await useFetch("/api/admin/pre-registrants");
const { data: stats, refresh: refreshStats } = await useFetch(
"/api/admin/pre-registrants/stats",
);
const searchQuery = ref("");
const statusFilter = ref("");
const selectedIds = ref([]);
const savingId = ref(null);
const sortKey = ref("");
const sortDir = ref("asc");
// Invite
const showInviteModal = ref(false);
const sendingInvites = ref(false);
const inviteResults = ref(null);
const DEFAULT_INVITE_TEMPLATE = `Hi {name},
You pre-registered for Ghost Guild, and we're ready for you.
Click below to accept your invitation, choose your circle, and set your contribution level:
{acceptLink}
This link expires in 48 hours. If it expires, we can send you a new one. Just reply to this email.
See you soon!
Ghost Guild`;
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
const toggleSort = (key) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = "asc";
}
};
const filtered = computed(() => {
if (!preRegistrants.value) return [];
const result = preRegistrants.value.filter((pr) => {
const q = searchQuery.value.toLowerCase();
const matchesSearch =
!q ||
(pr.name || "").toLowerCase().includes(q) ||
pr.email.toLowerCase().includes(q) ||
(pr.city || "").toLowerCase().includes(q) ||
(pr.role || "").toLowerCase().includes(q);
const matchesStatus = !statusFilter.value || pr.status === statusFilter.value;
return matchesSearch && matchesStatus;
});
if (sortKey.value) {
const dir = sortDir.value === "asc" ? 1 : -1;
const key = sortKey.value;
result.sort((a, b) => {
const aVal = (a[key] || "").toString().toLowerCase();
const bVal = (b[key] || "").toString().toLowerCase();
return aVal < bVal ? -dir : aVal > bVal ? dir : 0;
});
}
return result;
});
// Selection helpers
const allVisibleSelected = computed(() => {
if (!filtered.value.length) return false;
return filtered.value.every((pr) => selectedIds.value.includes(pr._id));
});
const someVisibleSelected = computed(() => {
return filtered.value.some((pr) => selectedIds.value.includes(pr._id));
});
// IDs of selected pre-registrants that can actually be invited (pending, selected, or invited for resend)
const invitableIds = computed(() => {
if (!preRegistrants.value) return [];
return selectedIds.value.filter((id) => {
const pr = preRegistrants.value.find((p) => p._id === id);
return pr && (pr.status === "pending" || pr.status === "selected" || pr.status === "invited");
});
});
const toggleSelectAll = () => {
if (allVisibleSelected.value) {
const visibleIds = new Set(filtered.value.map((pr) => pr._id));
selectedIds.value = selectedIds.value.filter((id) => !visibleIds.has(id));
} else {
const currentSet = new Set(selectedIds.value);
for (const pr of filtered.value) {
currentSet.add(pr._id);
}
selectedIds.value = [...currentSet];
}
};
const toggleSelect = (id) => {
const idx = selectedIds.value.indexOf(id);
if (idx >= 0) {
selectedIds.value.splice(idx, 1);
} else {
selectedIds.value.push(id);
}
};
const updateStatus = async (id, newStatus) => {
savingId.value = id;
try {
await $fetch(`/api/admin/pre-registrants/${id}`, {
method: "PUT",
body: { status: newStatus },
});
await refresh();
await refreshStats();
toast.add({ title: "Status updated", color: "green" });
} catch (err) {
toast.add({
title: "Failed to update",
description: err.data?.statusMessage || err.message,
color: "red",
});
} finally {
savingId.value = null;
}
};
// Mark selected as "selected" status
const markAsSelected = async () => {
try {
await $fetch("/api/admin/pre-registrants/bulk-status", {
method: "PATCH",
body: { ids: selectedIds.value, status: "selected" },
});
await refresh();
await refreshStats();
selectedIds.value = [];
toast.add({ title: "Marked as selected", color: "green" });
} catch (err) {
toast.add({
title: "Failed to update status",
description: err.data?.statusMessage || err.message,
color: "red",
});
}
};
// Invite modal
const invitePreview = computed(() => {
if (!invitableIds.value.length || !preRegistrants.value) return null;
return preRegistrants.value.find((pr) => pr._id === invitableIds.value[0]);
});
const invitePreviewText = computed(() => {
if (!invitePreview.value) return "";
return inviteTemplate.value
.replace(/\{name\}/g, invitePreview.value.name || "there")
.replace(/\{acceptLink\}/g, "https://ghostguild.org/accept-invite#...");
});
const openInviteModal = () => {
inviteResults.value = null;
showInviteModal.value = true;
};
const submitInvites = async () => {
sendingInvites.value = true;
try {
const result = await $fetch("/api/admin/pre-registrants/invite", {
method: "POST",
body: {
preRegistrantIds: invitableIds.value,
emailTemplate: inviteTemplate.value,
},
});
inviteResults.value = result;
await refresh();
await refreshStats();
selectedIds.value = [];
toast.add({
title: `Sent ${result.sent} invitation${result.sent !== 1 ? "s" : ""}`,
description: result.failed ? `${result.failed} failed` : undefined,
color: result.failed ? "orange" : "green",
});
} catch (err) {
toast.add({
title: "Failed to send invitations",
description: err.data?.statusMessage || err.message,
color: "red",
});
} finally {
sendingInvites.value = false;
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
</script>
<style scoped>
.admin-prereg {}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.page-header h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 4px;
}
.page-header p {
font-size: 12px;
color: var(--text-dim);
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* ---- FILTER BAR ---- */
.filter-bar {
display: flex;
gap: 12px;
padding: 16px 28px;
border-bottom: 1px dashed var(--border);
}
/* ---- TABLE ---- */
.table-wrap {
padding: 0 28px 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
thead th {
text-align: left;
padding: 12px 10px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
border-bottom: 1px dashed var(--border);
font-weight: normal;
}
thead th.sortable {
cursor: pointer;
user-select: none;
}
thead th.sortable:hover {
color: var(--text-dim);
}
.sort-arrow {
font-size: 10px;
color: var(--candle);
}
tbody tr {
border-bottom: 1px dashed var(--border);
transition: background 0.1s;
}
tbody tr:hover {
background: var(--surface);
}
tbody td {
padding: 10px;
color: var(--text);
vertical-align: middle;
}
.col-check {
width: 40px;
padding-left: 12px;
padding-right: 4px;
}
.selectable-row {
cursor: pointer;
}
.row-selected {
background: var(--surface);
}
/* ---- CUSTOM CHECKBOX ---- */
.custom-check {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
width: 16px;
height: 16px;
}
.custom-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.check-mark {
width: 14px;
height: 14px;
border: 1px solid var(--border);
background: var(--input-bg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.custom-check:hover .check-mark {
border-color: var(--candle);
}
.custom-check input:checked + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:checked + .check-mark::after {
content: "";
width: 4px;
height: 8px;
border: solid var(--bg);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) translateY(-1px);
}
.custom-check input:indeterminate + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:indeterminate + .check-mark::after {
content: "";
width: 8px;
height: 0;
border-bottom: 1.5px solid var(--bg);
}
.col-name {
font-weight: 500;
color: var(--text-bright);
}
.col-email {
color: var(--text-dim);
font-size: 11px;
}
.col-mono {
font-variant-numeric: tabular-nums;
}
.col-date {
font-size: 11px;
color: var(--text-faint);
}
/* ---- STATUS BADGES ---- */
.status-badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border: 1px dashed var(--border);
}
.status-pending {
color: var(--text-faint);
}
.status-selected {
color: var(--candle);
border-color: var(--candle);
}
.status-invited {
color: var(--text-bright);
border-color: var(--text-dim);
}
.status-accepted {
color: var(--green);
border-color: var(--green);
}
.status-expired {
color: var(--ember);
border-color: var(--ember);
}
/* ---- INLINE STATUS SELECT ---- */
.inline-status {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border: 1px dashed var(--border);
background: transparent;
cursor: pointer;
font-family: "Commit Mono", monospace;
}
.inline-status:disabled {
opacity: 0.5;
cursor: wait;
}
/* ---- STATUS INDICATORS ---- */
.status-ok {
color: var(--green);
font-size: 11px;
}
.status-error {
color: var(--ember);
font-size: 11px;
}
/* ---- MODALS ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: var(--bg);
border: 1px dashed var(--border);
max-width: 440px;
width: 100%;
margin: 16px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-wide {
max-width: 640px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 16px;
border-bottom: 1px dashed var(--border);
}
.modal-header h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
}
.modal-close {
background: none;
border: none;
color: var(--text-faint);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
padding: 20px 24px;
overflow-y: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px dashed var(--border);
}
/* ---- HELP TEXT ---- */
.help-text {
font-size: 11px;
color: var(--text-dim);
line-height: 1.5;
}
.help-text code {
color: var(--text-bright);
font-size: 11px;
}
/* ---- PREVIEW BOX ---- */
.preview-box {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--text-dim);
background: var(--surface);
border: 1px dashed var(--border);
padding: 16px;
white-space: pre-wrap;
overflow: auto;
max-height: 200px;
line-height: 1.5;
}
/* ---- RESULTS BOX ---- */
.results-box {
padding: 16px 20px;
border: 1px dashed var(--border);
margin-top: 16px;
}
.results-box strong {
color: var(--text-bright);
font-size: 13px;
display: block;
margin-bottom: 4px;
}
/* ---- STATES ---- */
.loading-state {
text-align: center;
padding: 48px 24px;
color: var(--text-faint);
font-size: 12px;
}
.error-state {
text-align: center;
padding: 48px 24px;
color: var(--ember);
font-size: 12px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-faint);
font-size: 12px;
}
.spinner {
width: 24px;
height: 24px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.page-header {
padding: 24px 20px 16px;
}
.header-row {
flex-direction: column;
}
.header-actions {
flex-wrap: wrap;
}
.filter-bar {
flex-direction: column;
padding: 12px 20px;
}
.table-wrap {
padding: 0 12px 20px;
overflow-x: auto;
}
table {
min-width: 600px;
}
}
</style>

View file

@ -64,7 +64,7 @@
<div class="series-title-row">
<div>
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
<h2>{{ series.title }}</h2>
<h3>{{ series.title }}</h3>
<p class="series-desc">{{ series.description }}</p>
</div>
<div class="series-meta">
@ -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,7 +171,15 @@
</div>
<div class="modal-body">
<div class="section-label">Series Management Tools</div>
<button @click="exportSeriesData" class="btn bulk-action">
<button @click="reorderAllSeries" class="bulk-action">
<strong>Auto-Reorder Series</strong>
<span>Fix position numbers based on event dates</span>
</button>
<button @click="validateAllSeries" class="bulk-action">
<strong>Validate Series Data</strong>
<span>Check for consistency issues</span>
</button>
<button @click="exportSeriesData" class="bulk-action">
<strong>Export Series Data</strong>
<span>Download series information as JSON</span>
</button>
@ -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' })
@ -698,7 +714,10 @@ const exportSeriesData = () => {
</script>
<style scoped>
.admin-series {}
.admin-series {
max-width: 1100px;
margin: 0 auto;
}
/* ---- PAGE HEADER ---- */
.page-header {
@ -794,7 +813,7 @@ const exportSeriesData = () => {
gap: 16px;
}
.series-header h2 {
.series-header h3 {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
@ -850,7 +869,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 +950,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 +965,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 ---- */
@ -1198,7 +1217,7 @@ const exportSeriesData = () => {
}
.series-list {
padding: 20px 20px;
padding: 16px 12px;
}
.series-header,

View file

@ -251,6 +251,7 @@ const createAndAddEvent = async () => {
<style scoped>
.create-form {
max-width: 800px;
margin: 0 auto;
}
.page-header {
@ -270,14 +271,13 @@ const createAndAddEvent = async () => {
.page-header p { font-size: 12px; color: var(--text-dim); }
.back-link {
font-size: 11px;
color: var(--text-faint);
font-size: 12px;
color: var(--candle);
text-decoration: none;
margin-bottom: 8px;
display: inline-block;
letter-spacing: 0.02em;
}
.back-link:hover { color: var(--candle); text-decoration: none; }
.back-link:hover { text-decoration: underline; }
.form-body { padding: 24px 28px; }

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,55 +15,34 @@ 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",
{
await $fetch("/oidc/interaction/login", {
method: "POST",
body: { email: email.value, uid },
}
);
if (response.registered === false) {
notRegistered.value = true;
} else {
});
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>
<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"
@ -73,29 +50,18 @@ function resetForm() {
required
autocomplete="email"
placeholder="you@example.com"
class="wiki-login-input"
:disabled="loading"
>
</div>
/>
<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"
<button
@click="sent = false; email = '';"
class="wiki-login-link"
>
<h2 class="wiki-login-sent-heading">Not a member yet</h2>
<p class="wiki-login-sent-detail">
<strong>{{ email }}</strong> isn't registered as a Ghost Guild
member. If you've pre-registered, an admin needs to invite you
before you can sign in.
</p>
<p class="wiki-login-sent-detail">
<a href="https://babyghosts.org/ghost-guild/" class="wiki-login-link"
>Pre-register at Baby Ghosts</a
>
or email
<a href="mailto:hello@babyghosts.org" class="wiki-login-link"
>hello@babyghosts.org</a
>
if you think this is a mistake.
</p>
<button class="wiki-login-reset" @click="resetForm">
Try a different email
</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 +0,0 @@
<script setup>
await navigateTo("/board", { replace: true });
</script>

View file

@ -1,3 +0,0 @@
<script setup>
await navigateTo("/board", { replace: true });
</script>

511
app/pages/events/[id].vue Normal file
View file

@ -0,0 +1,511 @@
<template>
<div v-if="pending" class="loading">Loading event details...</div>
<div v-else-if="error" class="loading">
<h2>Event Not Found</h2>
<p>The event you're looking for doesn't exist.</p>
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else>
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
<div class="event-meta-row">
<div class="event-meta-item">
<span class="meta-label">Date</span>
{{ formatDate(event.startDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Time</span>
{{ formatTime(event.startDate, event.endDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Location</span>
{{ 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>
<!-- CANCELLED NOTICE -->
<div v-if="event.isCancelled" class="cancelled-notice">
<strong>Event Cancelled</strong>
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
<p v-else>This event has been cancelled. We apologize for any inconvenience.</p>
</div>
<!-- TWO-COLUMN BODY -->
<div class="event-body">
<!-- LEFT: MAIN CONTENT -->
<div class="event-main">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="section">
<div class="series-note">
<span class="section-label">Part of Series</span>
<NuxtLink :to="`/series/${event.series.id}`">{{ event.series.title }}</NuxtLink>
&mdash; Event {{ event.series.position }} of {{ event.series.totalEvents }}
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles?.length" class="section">
<span class="section-label">Recommended for</span>
<div class="circle-badges">
<CircleBadge v-for="circle in event.targetCircles" :key="circle" :circle="circle" />
</div>
</div>
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<p>{{ event.description }}</p>
</div>
<!-- Series Description -->
<div v-if="event.series?.isSeriesEvent && event.series.description" class="section">
<h2>About the {{ event.series.title }} Series</h2>
<p>{{ event.series.description }}</p>
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ol class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">{{ item }}</li>
</ol>
</div>
<!-- Speakers -->
<div v-if="event.speakers?.length" class="section">
<h2>Speakers</h2>
<div v-for="speaker in event.speakers" :key="speaker.name" class="speaker">
<div class="speaker-name">{{ speaker.name }}</div>
<div v-if="speaker.role" class="speaker-role">{{ speaker.role }}</div>
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
</div>
</div>
</div>
<!-- RIGHT: SIDEBAR PANELS -->
<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"
:user-email="memberData?.email"
@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" @click="handleCancelRegistration" :disabled="isCancelling">
{{ 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" @click="handleRegistration" :disabled="isRegistering">
{{ isRegistering ? 'Registering...' : 'Register for this event' }}
</button>
<a :href="`/api/events/${route.params.id}/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">{{ event.eventType }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
<span class="detail-val">Yes</span>
</div>
</div>
<!-- Questions -->
<div class="dashed-box">
<div class="box-title">Questions?</div>
<p style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Drop us a line.</p>
<a href="mailto:events@ghostguild.org" style="font-size: 12px;">events@ghostguild.org</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute()
const toast = useToast()
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`)
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: 'Event not found' })
}
const { isMember, memberData, checkMemberStatus } = useAuth()
const { isPendingPayment, isSuspended, isCancelled, canRSVP, statusConfig, getRSVPMessage } = useMemberStatus()
const { completePayment, isProcessingPayment } = useMemberPayment()
onMounted(async () => {
await checkMemberStatus()
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 checkRegistrationStatus = async () => {
if (!memberData.value?.email) return
try {
const response = await $fetch(`/api/events/${route.params.id}/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.id}/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.id}/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) => {
const d = new Date(dateStr)
return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(d)
}
const formatTime = (start, end) => {
const fmt = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`
}
const handleRegistration = async () => {
isRegistering.value = true
try {
await $fetch(`/api/events/${route.params.id}/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.id}/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++ }
const handleTicketError = (err) => { console.error('Ticket purchase failed:', err) }
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>
<style scoped>
.loading {
padding: 48px 32px;
color: var(--text-dim);
}
.loading h2 {
font-family: 'Brygada 1918', serif;
font-size: 22px;
color: var(--text-bright);
margin-bottom: 8px;
}
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.event-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.event-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 16px;
}
.event-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 12px;
color: var(--text-dim);
}
.meta-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 2px;
}
.cancelled-notice {
padding: 20px 32px;
border-bottom: 1px dashed var(--border);
color: var(--ember);
font-size: 12px;
}
.cancelled-notice strong {
display: block;
margin-bottom: 4px;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
}
.event-main {
min-width: 0;
}
.event-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.event-aside .dashed-box {
margin: 0;
border: none;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
}
.event-aside .dashed-box:hover { border-color: var(--border); }
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section h2 {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.circle-badges {
display: flex;
gap: 6px;
margin-top: 4px;
}
.series-note {
font-size: 12px;
color: var(--text-dim);
}
.agenda-list {
padding-left: 20px;
font-size: 12px;
color: var(--text-dim);
line-height: 2;
}
.speaker {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.speaker:last-child { border-bottom: none; }
.speaker-name { font-size: 13px; color: var(--text-bright); font-weight: 500; }
.speaker-role { font-size: 11px; color: var(--text-dim); }
.speaker-bio { font-size: 11px; color: var(--text-faint); margin-top: 2px; }
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
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;
padding: 4px 0;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-row:last-child { border-bottom: none; }
.detail-key { color: var(--text-faint); }
.detail-val { color: var(--text); }
@media (max-width: 768px) {
.event-body { grid-template-columns: 1fr; }
.event-aside { border-left: none; border-top: 1px dashed var(--border); }
.event-meta-row { flex-direction: column; gap: 8px; }
}
</style>

View file

@ -1,520 +0,0 @@
<template>
<div v-if="pending" class="loading">Loading event details...</div>
<div v-else-if="error" class="loading">
<h2>Event Not Found</h2>
<p>The event you're looking for doesn't exist.</p>
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
<div class="event-meta-row">
<div class="event-meta-item">
<span class="meta-label">Date</span>
{{ formatDate(event.startDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Time</span>
{{ formatTime(event.startDate, event.endDate) }}
</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>
</div>
<div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" />
</div>
</div>
</div>
<!-- CANCELLED NOTICE -->
<div v-if="event.isCancelled" class="cancelled-notice">
<strong>Event Cancelled</strong>
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
<p v-else>
This event has been cancelled. We apologize for any inconvenience.
</p>
</div>
<!-- FEATURE IMAGE -->
<div v-if="event.featureImage?.url" class="event-feature-image">
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
>
</div>
<!-- TWO-COLUMN BODY -->
<div class="event-body">
<!-- LEFT: MAIN CONTENT -->
<div class="event-main">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="section">
<div class="series-note">
<span class="section-label">Part of Series</span>
<NuxtLink :to="`/series/${event.series.id}`">{{
event.series.title
}}</NuxtLink>
&mdash; Event {{ event.series.position }} of
{{ event.series.totalEvents }}
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles?.length" class="section">
<span class="section-label">Recommended for</span>
<div class="circle-badges">
<CircleBadge
v-for="circle in event.targetCircles"
:key="circle"
:circle="circle"
/>
</div>
</div>
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<div class="prose" v-html="renderMarkdown(event.description)" />
</div>
<!-- Series Description -->
<div
v-if="event.series?.isSeriesEvent && event.series.description"
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)" />
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ul class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">
{{ item }}
</li>
</ul>
</div>
<!-- Speakers -->
<div v-if="event.speakers?.length" class="section">
<h2>Speakers</h2>
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="speaker"
>
<div class="speaker-name">{{ speaker.name }}</div>
<div v-if="speaker.role" class="speaker-role">
{{ speaker.role }}
</div>
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
</div>
</div>
</div>
<!-- RIGHT: SIDEBAR PANELS -->
<div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System -->
<EventTicketPurchase
: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"
/>
<!-- 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>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
<span class="detail-val">Yes</span>
</div>
</div>
<!-- Questions -->
<div class="dashed-box">
<div class="box-title">Questions?</div>
<p
style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px"
>
Drop us a line.
</p>
<a href="mailto:events@ghostguild.org" style="font-size: 12px"
>events@ghostguild.org</a
>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { eventTypeLabel } from "~/config/eventTypes";
const route = useRoute();
const toast = useToast();
const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.slug}`);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
pageBreadcrumbTitle.value = event.value?.title || "";
onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
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();
onMounted(async () => {
await checkMemberStatus();
if (memberData.value && !isComplete.value) {
trackGoal('eventPageVisited');
}
});
const eventTimeZone = computed(
() => event.value?.displayTimezone || "America/Toronto",
);
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 handleTicketSuccess = () => {
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
};
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,
}));
</script>
<style scoped>
.loading {
padding: 48px 32px;
color: var(--text-dim);
}
.loading h2 {
font-family: "Brygada 1918", serif;
font-size: 22px;
color: var(--text-bright);
margin-bottom: 8px;
}
.event-feature-image {
border-bottom: 1px dashed var(--border);
}
.event-feature-image img {
display: block;
width: 100%;
max-height: 400px;
object-fit: cover;
}
.event-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.event-header h1 {
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 16px;
}
.event-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 12px;
color: var(--text-dim);
}
.meta-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 2px;
}
.cancelled-notice {
padding: 20px 32px;
border-bottom: 1px dashed var(--border);
color: var(--ember);
font-size: 12px;
}
.cancelled-notice strong {
display: block;
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;
}
.event-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.event-aside .dashed-box {
margin: 0;
border: none;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
}
.event-aside .dashed-box:hover {
border-color: var(--border);
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.section p {
font-size: 14px;
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;
margin-top: 4px;
}
.series-note {
font-size: 12px;
color: var(--text-dim);
}
.agenda-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
font-size: 14px;
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;
}
.speaker {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.speaker:last-child {
border-bottom: none;
}
.speaker-name {
font-size: 13px;
color: var(--text-bright);
font-weight: 500;
}
.speaker-role {
font-size: 11px;
color: var(--text-dim);
}
.speaker-bio {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-key {
color: var(--text-faint);
}
.detail-val {
color: var(--text);
}
@media (max-width: 768px) {
.event-body {
grid-template-columns: 1fr;
}
.event-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
.event-meta-row {
flex-direction: column;
gap: 8px;
}
}
</style>

View file

@ -3,26 +3,14 @@
<!-- HERO (compact) -->
<div class="hero">
<h1>Events</h1>
<p>
Workshops, meetups, and gatherings for game developers practicing
cooperative models. Some events are open to the public.
</p>
<p>Workshops, meetups, and gatherings for game developers practicing 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 type="checkbox" v-model="includePastEvents"> Show past events
</label>
</FilterBar>
<!-- EVENT LIST -->
@ -31,40 +19,24 @@
v-for="event in filteredEvents"
:key="event._id"
class="event-row"
:class="{ 'is-cancelled': event.isCancelled }"
>
<div class="event-date-col">
<span class="event-date">{{ formatDate(event) }}</span>
<span class="event-time">{{ formatTime(event) }}</span>
</div>
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<div class="event-info">
<div class="event-title">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title
}}</NuxtLink>
<span v-if="event.isCancelled" class="cancelled-tag"
>cancelled</span
>
<span v-if="event.isRegistered" class="registered-tag"
>Registered</span
>
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
</div>
<div v-if="event.tagline" class="event-tagline">
{{ event.tagline }}
<div v-if="event.eventType" class="event-type">{{ event.eventType }}</div>
</div>
<div class="event-sub">
<span v-if="event.eventType" class="event-type-tag">{{
eventTypeLabel(event.eventType)
}}</span>
<span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span>
</div>
</div>
<div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span>
<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>
<CircleBadge v-if="event.circle" :circle="event.circle" />
<span v-else class="badge all">Public</span>
</div>
<span v-else class="badge all">All</span>
</div>
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
</div>
@ -75,114 +47,84 @@
<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
>{{
series.eventCount || series.events?.length || 0
}}
sessions</span
>
<span v-if="series.startDate"
>{{ formatDate(series.startDate) }} &ndash;
{{ formatDate(series.endDate) }}</span
>
<span>{{ series.eventCount || series.events?.length || 0 }} sessions</span>
<span v-if="series.startDate">{{ formatDate(series.startDate) }} &ndash; {{ formatDate(series.endDate) }}</span>
</div>
</NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div>
</div>
<!-- PROPOSE AN EVENT -->
<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>
<NuxtLink to="/events" class="cta">Propose an event &rarr;</NuxtLink>
</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 activeFilter = ref('all')
const includePastEvents = ref(false)
const filterOptions = [
{ label: "All", value: "all" },
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
];
{ label: 'All', value: 'all' },
{ 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 { 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 [];
if (!eventsData.value) return []
return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now)
return false;
if (activeFilter.value !== "all" && event.eventType !== activeFilter.value)
return false;
return true;
});
});
if (!includePastEvents.value && new Date(event.startDate) < now) return false
if (activeFilter.value !== 'all' && event.eventType !== activeFilter.value) return false
return true
})
})
const activeSeries = computed(() => {
if (!seriesData.value) return [];
if (!seriesData.value) return []
return seriesData.value.filter(
(s) => s.status === "active" || s.isOngoing || s.isUpcoming,
);
});
(s) => s.status === 'active' || s.isOngoing || s.isUpcoming,
)
})
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";
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 formatLocation = (event) => {
if (event.isOnline) return "Online";
if (!event.location) return "";
if (event.location.startsWith("#")) return event.location;
// Treat any URL as an online link
if (event.location.startsWith("http")) return "Online";
return event.location;
};
const formatDate = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
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,142 +140,33 @@ 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: 80px 1fr auto auto;
gap: 16px;
align-items: start;
padding: 14px 0;
align-items: baseline;
padding: 12px 0;
border-bottom: 1px dashed var(--border);
transition: padding-left 0.2s;
}
.event-row:first-child {
padding-top: 18px;
}
.event-row:last-child {
border-bottom: none;
padding-bottom: 18px;
}
.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-date-col {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 1px;
}
.event-date {
color: var(--text-faint);
font-size: 12px;
white-space: nowrap;
}
.event-time {
color: var(--text-faint);
font-size: 11px;
white-space: nowrap;
}
.event-info {
min-width: 0;
}
.event-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text);
}
.event-title a {
color: var(--text);
text-decoration: none;
}
.event-title a:hover {
color: var(--candle);
}
.cancelled-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--ember);
border: 1px solid currentColor;
padding: 1px 5px;
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;
color: var(--text-dim);
line-height: 1.55;
margin-top: 3px;
}
.event-sub {
display: flex;
align-items: center;
gap: 5px;
margin-top: 3px;
}
.event-type-tag {
font-size: 10px;
color: var(--text-faint);
text-transform: capitalize;
}
.sep {
font-size: 10px;
color: var(--text-faint);
}
.event-location {
font-size: 10px;
color: var(--text-faint);
}
.event-badges {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.members-badge {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 5px;
white-space: nowrap;
line-height: 1.5;
}
.event-row:first-child { padding-top: 16px; }
.event-row:last-child { border-bottom: none; padding-bottom: 16px; }
.event-row:hover { padding-left: 4px; }
.event-date { color: var(--text-faint); font-size: 12px; white-space: nowrap; }
.event-info { min-width: 0; }
.event-title { color: var(--text); font-size: 13px; }
.event-title a { color: var(--text); text-decoration: none; }
.event-title a:hover { color: var(--candle); }
.event-type { font-size: 10px; color: var(--text-faint); margin-top: 1px; }
.event-capacity { font-size: 11px; color: var(--text-faint); white-space: nowrap; }
.seats-warn { color: var(--ember); }
/* ---- FULL SECTION ---- */
.full-section {
padding: 32px 28px;
padding: 32px;
border-bottom: 1px dashed var(--border);
}
@ -345,26 +178,15 @@ 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) {
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 {
background: var(--surface-hover);
}
.series-box h2 {
font-family: var(--font-display);
.series-box:last-child { border-right: none; }
.series-box:hover { background: var(--surface-hover); }
.series-box h3 {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
@ -384,50 +206,40 @@ 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);
}
.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;
color: var(--text-faint);
@ -435,37 +247,13 @@ 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;
grid-template-columns: 60px 1fr;
gap: 8px;
}
.event-badges {
display: none;
}
.series-grid {
grid-template-columns: 1fr;
}
.series-box {
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;
}
.event-capacity { display: none; }
.series-grid { grid-template-columns: 1fr; }
.series-box { border-right: none; border-bottom: 1px dashed var(--border); }
.series-box:last-child { border-bottom: none; }
}
</style>

View file

@ -2,34 +2,25 @@
<div>
<!-- HERO -->
<div class="hero">
<h1>Ghost Guild is where game developers explore cooperative models.</h1>
<p>
Resources, events, and a community of people figuring it out. Three
circles, pay what you can.
</p>
<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, 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="/join" class="hero-link primary">Become a member</NuxtLink>
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
</div>
</div>
<!-- THREE CIRCLES -->
<div class="content-row">
<div
v-for="circle in circleData"
:key="circle.value"
class="content-block"
>
<div class="label" :style="{ color: `var(--c-${circle.value})` }">
{{ circle.label }}
</div>
<div v-for="circle in circleData" :key="circle.value" class="content-block">
<div class="label" :style="{ color: `var(--c-${circle.value})` }">{{ circle.label }}</div>
<h2>{{ circle.metaphor }}</h2>
<p>{{ circle.blurb }}</p>
<details>
<summary>What's included?</summary>
<p>{{ circle.included }}</p>
</details>
</div>
</div>
@ -42,11 +33,9 @@
<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.date) }}</span>
<span class="event-title">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title
}}</NuxtLink>
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
</div>
@ -60,54 +49,38 @@
<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 v-else class="block-inset">
<p class="empty">
<a href="https://wiki.ghostguild.org">Browse the wiki &rarr;</a>
</p>
</div>
</div>
</div>
<!-- PARCHMENT INSET -->
<ParchmentInset>
<div
class="label"
style="color: var(--candle-faint); margin-bottom: 12px"
>
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>
<div class="label" style="color: var(--candle-faint); opacity: 0.6; margin-bottom: 12px;">From the Wiki</div>
<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>
<p>
<a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a>
</p>
<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="/wiki">practicing together</a>.</p>
<p><a href="/wiki">Read more in the wiki &rarr;</a></p>
</ParchmentInset>
</div>
</template>
@ -115,94 +88,42 @@
<script setup>
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", {
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.",
value: 'community',
label: 'Community',
metaphor: 'The open hall',
blurb: '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.",
value: 'founder',
label: 'Founder',
metaphor: 'The workshop',
blurb: '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.",
value: 'practitioner',
label: 'Practitioner',
metaphor: 'The alcove',
blurb: '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>
<style scoped>
@ -212,7 +133,7 @@ const formatDate = (event) => {
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: "Brygada 1918", serif;
font-family: 'Brygada 1918', serif;
font-size: 36px;
font-weight: 600;
color: var(--text-bright);
@ -279,11 +200,9 @@ const formatDate = (event) => {
padding-left: 28px;
padding-right: 28px;
}
.content-block:last-child {
border-right: none;
}
.content-block:last-child { border-right: none; }
.content-block h2 {
font-family: "Brygada 1918", serif;
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
@ -302,6 +221,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);
@ -311,7 +250,7 @@ const formatDate = (event) => {
}
.event-item-inner {
display: grid;
grid-template-columns: 60px 1fr auto;
grid-template-columns: 80px 1fr auto;
gap: 16px;
align-items: baseline;
padding-top: 10px;
@ -321,21 +260,10 @@ const formatDate = (event) => {
.content-row.two-col .event-item:hover .event-item-inner {
padding-left: calc(28px + 4px);
}
.event-date {
color: var(--text-faint);
font-size: 12px;
}
.event-title {
color: var(--text);
font-size: 13px;
}
.event-title a {
color: var(--text);
text-decoration: none;
}
.event-title a:hover {
color: var(--candle);
}
.event-date { color: var(--text-faint); font-size: 12px; }
.event-title { color: var(--text); font-size: 13px; }
.event-title a { color: var(--text); text-decoration: none; }
.event-title a:hover { color: var(--candle); }
/* ---- WIKI LIST ---- */
.wiki-item {
@ -349,13 +277,8 @@ const formatDate = (event) => {
padding-top: 8px;
padding-bottom: 8px;
}
.wiki-item a {
color: var(--text);
text-decoration: none;
}
.wiki-item a:hover {
color: var(--candle);
}
.wiki-item a { color: var(--text); text-decoration: none; }
.wiki-item a:hover { color: var(--candle); }
.empty {
color: var(--text-faint);
@ -372,9 +295,7 @@ const formatDate = (event) => {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child {
border-bottom: none;
}
.content-block:last-child { border-bottom: none; }
.hero-links {
flex-direction: column;
gap: 8px;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<template>
<PageShell>
<div class="dashboard">
<ClientOnly>
<!-- Loading State -->
<div v-if="authPending" class="loading-state">
@ -13,12 +13,7 @@
<p>Please sign in to access your member dashboard.</p>
<button
class="btn btn-primary"
@click="
openLoginModal({
title: 'Sign in to your dashboard',
description: 'Enter your email to access your member dashboard',
})
"
@click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })"
>
Sign In
</button>
@ -26,23 +21,18 @@
<!-- Dashboard Content -->
<template v-else>
<OnboardingWidget />
<ColumnsLayout cols="events-sidebar" :limit="5">
<div class="dashboard-body">
<!-- Member Status Banner -->
<MemberStatusBanner />
<MemberStatusBanner :dismissible="true" />
<!-- Welcome Header -->
<PageHeader :title="welcomeTitle">
<div class="dashboard-meta">
<div class="welcome">
<h1>Welcome back, {{ memberData?.name }}</h1>
<div class="meta">
<CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
<span>${{ memberData?.contributionTier }} CAD/mo</span>
</div>
</div>
<p v-if="showSlackComingNote" class="slack-coming-note">
Slack workspace access is part of your membership. Invitations are
sent in monthly onboarding waves &mdash; we'll be in touch.
</p>
</PageHeader>
<!-- Upcoming Events + Quick Actions -->
<div class="content-row">
@ -60,18 +50,14 @@
:to="`/events/${evt.slug || evt._id}`"
class="event-item"
>
<span class="event-date">{{ formatEventDate(evt) }}</span>
<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"
}}
{{ calendarLinkCopied ? 'Link copied!' : 'Subscribe to calendar' }}
</button>
</div>
@ -79,75 +65,43 @@
<p>You haven't registered for any upcoming events</p>
</div>
<NuxtLink to="/events" class="section-link"
>Browse all events &rarr;</NuxtLink
>
<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 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"
>
&times;
</button>
<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>
<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>
<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"
to="/members?peerSupport=true"
class="quick-action"
:class="{ disabled: !canPeerSupport }"
:title="
!canPeerSupport
? 'Complete your membership to access the board'
: ''
"
:title="!canPeerSupport ? 'Complete your membership to book peer sessions' : ''"
>
Board<span class="arrow">&rarr;</span>
Book a peer session<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"
>
<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/account" class="quick-action">
<NuxtLink to="/member/profile#account" class="quick-action">
Manage account<span class="arrow">&rarr;</span>
</NuxtLink>
</div>
@ -159,54 +113,44 @@
<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'})`,
}"
>
<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?.contributionAmount ?? 0 }} CAD/month</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 }}
{{ 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>
<span class="val">{{ formatMemberSince(memberData.createdAt) }}</span>
</div>
<NuxtLink to="/member/account" class="section-link">
<NuxtLink to="/member/profile#account" class="section-link">
Change circle or contribution &rarr;
</NuxtLink>
</div>
<div class="content-block">
<div class="section-label">Bulletin Board</div>
<div class="section-label">Peer Support</div>
<DashedBox>
<p class="peer-text">
Make offers and requests related to shared interests and
cooperative topics.
Interested in offering peer support? Set up your profile to connect with other members who share your interests and experience.
</p>
<NuxtLink to="/board" class="section-link">
Browse the Bulletin Board &rarr;
<NuxtLink to="/member/profile" class="section-link">
Set up peer support &rarr;
</NuxtLink>
</DashedBox>
</div>
</div>
</ColumnsLayout>
</div>
</template>
<template #fallback>
@ -216,36 +160,13 @@
</div>
</template>
</ClientOnly>
</PageShell>
</div>
</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 { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
const handleWikiClick = () => {
if (!onboardingComplete.value) {
trackGoal("wikiClicked");
}
};
const registeredEvents = ref([]);
const loadingEvents = ref(false);
@ -280,21 +201,22 @@ const copyCalendarLink = async () => {
const { openLoginModal } = useLoginModal();
// Handle authentication check on page load
// server: false ensures this always runs on the client, even on a hard page load.
// The auth middleware only fires for client-side navigations in Nuxt 4, so we
// can't rely on it to open the modal when the user lands directly on this URL.
const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth",
async () => {
// Only check authentication on client side
if (process.server) return null;
// If no member data, try to authenticate
if (!memberData.value) {
const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) {
// Show login modal instead of redirecting
openLoginModal({
title: "Sign in to continue",
description: "You need to be signed in to access this page",
title: "Sign in to your dashboard",
description: "Enter your email to access your member dashboard",
dismissible: true,
redirectTo: "/member/dashboard",
});
return null;
}
@ -302,7 +224,6 @@ const { pending: authPending } = await useLazyAsyncData(
return memberData.value;
},
{ server: false },
);
// Load registered events
@ -365,22 +286,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) => {
@ -402,6 +321,15 @@ useHead({
</script>
<style scoped>
/* ---- DASHBOARD LAYOUT ---- */
.dashboard {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
/* ---- LOADING / UNAUTH STATES ---- */
.loading-state {
flex: 1;
@ -430,9 +358,7 @@ useHead({
}
@keyframes spin {
to {
transform: rotate(360deg);
}
to { transform: rotate(360deg); }
}
.loading-inline {
@ -455,7 +381,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);
@ -468,21 +394,39 @@ useHead({
margin-bottom: 20px;
}
/* ---- WELCOME HEADER META ---- */
.dashboard-meta {
/* ---- WELCOME HEADER ---- */
.welcome {
padding: 28px 28px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.welcome h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
}
.welcome .meta {
display: flex;
align-items: baseline;
gap: 12px;
font-size: 12px;
color: var(--text-dim);
margin-top: 8px;
}
.slack-coming-note {
margin-top: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
/* ---- CONTENT GRID ---- */
.dashboard-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
.content-row {
@ -546,7 +490,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 +574,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;
@ -692,7 +636,7 @@ useHead({
}
.status-active::before {
content: "";
content: '';
display: inline-block;
width: 6px;
height: 6px;
@ -708,7 +652,6 @@ useHead({
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.content-row {
grid-template-columns: 1fr;
@ -723,8 +666,12 @@ useHead({
border-bottom: none;
}
.welcome {
padding: 24px 20px;
}
.content-block {
padding: 20px 24px;
padding: 20px;
}
.event-item {

View file

@ -1,3 +0,0 @@
<script setup>
await navigateTo('/members', { redirectCode: 301 })
</script>

View file

@ -0,0 +1,600 @@
<template>
<div class="my-updates-page">
<PageHeader
title="My Updates"
subtitle="Your activity and milestones in the Guild"
/>
<!-- Content Area: two-column with events mini sidebar -->
<div class="content-area">
<!-- Main Content -->
<div class="content-main">
<ClientOnly>
<!-- Stats + New Update row -->
<div v-if="isAuthenticated && !pending" class="stats-row">
<span class="stats-count">
<strong>{{ total }}</strong> {{ total === 1 ? 'update' : 'updates' }} posted
</span>
<NuxtLink to="/updates/new" class="btn btn-primary">+ New Update</NuxtLink>
</div>
<!-- Loading State -->
<div v-if="pending && !updates.length" class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
<!-- Unauthenticated State -->
<div v-else-if="!isAuthenticated" class="state-box">
<div class="state-icon">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<h2 class="state-heading">Sign in required</h2>
<p class="state-text">Please sign in to view your updates.</p>
<button
class="btn btn-primary"
@click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })"
>
Sign In
</button>
</div>
<!-- Updates Timeline -->
<div v-else-if="updates.length" class="timeline-wrap">
<div class="timeline">
<div
v-for="update in updates"
:key="update._id"
class="tl-item"
>
<div class="tl-dot">&#9998;</div>
<div class="tl-time">{{ formatDate(update.createdAt) }}</div>
<div class="tl-text">
<NuxtLink :to="`/updates/${update._id}`" class="tl-title">
{{ getUpdateTitle(update) }}
</NuxtLink>
<span v-if="isEdited(update)" class="tl-edited">(edited)</span>
<span v-if="update.privacy === 'private'" class="badge">Private</span>
<span v-if="update.privacy === 'public'" class="badge">Public</span>
</div>
<div v-if="getUpdatePreview(update)" class="tl-detail">
{{ getUpdatePreview(update) }}
</div>
<!-- Images -->
<div v-if="update.images?.length" class="tl-images">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="tl-image"
/>
</div>
<!-- Actions -->
<div v-if="isAuthor(update)" class="tl-actions">
<button class="tl-action-btn" @click="handleEdit(update)">Edit</button>
<span class="tl-action-sep">&middot;</span>
<button class="tl-action-btn tl-action-danger" @click="handleDelete(update)">Delete</button>
</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">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h2 class="state-heading">No updates yet</h2>
<p class="state-text">Share your first update with the community</p>
<NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink>
</div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
</template>
</ClientOnly>
</div>
<!-- Events Mini Sidebar -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal-box">
<h3 class="modal-heading">Delete Update?</h3>
<p class="modal-text">Are you sure you want to delete this update? This action cannot be undone.</p>
<div class="modal-actions">
<button class="btn" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
const { openLoginModal } = useLoginModal()
const updates = ref([])
const pending = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const total = ref(0)
const showDeleteModal = ref(false)
const updateToDelete = ref(null)
const deleting = ref(false)
const upcomingEvents = ref([])
// Check if current user is the author of an update
const isAuthor = (update) => {
return memberData.value && update.author?._id === memberData.value.id
}
// Check if update was edited
const isEdited = (update) => {
const created = new Date(update.createdAt).getTime()
const updated = new Date(update.updatedAt).getTime()
return updated - created > 1000
}
// Extract a title from update content (first line or first ~60 chars)
const getUpdateTitle = (update) => {
if (!update.content) return 'Untitled update'
const firstLine = update.content.split('\n')[0]
if (firstLine.length <= 80) return firstLine
return firstLine.substring(0, 80) + '...'
}
// Get a preview of the update content (after the first line)
const getUpdatePreview = (update) => {
if (!update.content) return ''
const lines = update.content.split('\n')
if (lines.length <= 1 && update.content.length <= 80) return ''
// If the first line was truncated, show the full content as preview
if (lines.length <= 1) return ''
const rest = lines.slice(1).join(' ').trim()
if (!rest) return ''
return rest.length > 200 ? rest.substring(0, 200) + '...' : rest
}
// Format date with relative time
const formatDate = (date) => {
const now = new Date()
const updateDate = new Date(date)
const diffInSeconds = Math.floor((now - updateDate) / 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 updateDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: updateDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
})
}
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus()
if (!authenticated) {
openLoginModal({
title: 'Sign in to view your updates',
description: 'Enter your email to access your updates',
})
return
}
}
await Promise.all([loadUpdates(), loadUpcomingEvents()])
})
// Load updates
const loadUpdates = async () => {
pending.value = true
try {
const response = await $fetch('/api/updates/my-updates', {
params: { limit: 20, skip: 0 },
})
updates.value = response.updates
total.value = response.total
hasMore.value = response.hasMore
} catch (error) {
console.error('Failed to load updates:', error)
} finally {
pending.value = false
}
}
// Load upcoming events for sidebar
const loadUpcomingEvents = async () => {
try {
const response = await $fetch('/api/events', {
params: { limit: 3, upcoming: true },
})
upcomingEvents.value = response.events || response || []
} catch (error) {
console.error('Failed to load upcoming events:', error)
}
}
// Load more updates
const loadMore = async () => {
loadingMore.value = true
try {
const response = await $fetch('/api/updates/my-updates', {
params: { limit: 20, skip: updates.value.length },
})
updates.value.push(...response.updates)
hasMore.value = response.hasMore
} catch (error) {
console.error('Failed to load more updates:', error)
} finally {
loadingMore.value = false
}
}
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`)
}
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update
showDeleteModal.value = true
}
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return
deleting.value = true
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: 'DELETE',
})
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
)
total.value--
showDeleteModal.value = false
updateToDelete.value = null
} catch (error) {
console.error('Failed to delete update:', error)
alert('Failed to delete update. Please try again.')
} finally {
deleting.value = false
}
}
useHead({
title: 'My Updates - Ghost Guild',
})
</script>
<style scoped>
.my-updates-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
min-width: 0;
}
/* ---- STATS ROW ---- */
.stats-row {
padding: 16px 32px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.stats-count {
color: var(--text-dim);
}
.stats-count strong {
color: var(--text-bright);
font-size: 18px;
}
/* ---- STATE BOXES (loading, empty, unauth) ---- */
.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);
}
.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: 11px;
color: var(--text-dim);
}
.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: 8px;
flex-wrap: wrap;
}
.tl-title {
color: var(--text-bright);
text-decoration: none;
font-weight: 500;
}
.tl-title:hover {
color: var(--candle);
text-decoration: underline;
}
.tl-edited {
font-size: 11px;
color: var(--text-faint);
}
.tl-detail {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
padding: 8px 12px;
border-left: 2px solid var(--border);
line-height: 1.6;
}
.tl-images {
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tl-image {
max-width: 200px;
height: auto;
border: 1px dashed var(--border);
}
.tl-actions {
margin-top: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.tl-action-btn {
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-action-btn:hover {
color: var(--candle);
}
.tl-action-danger:hover {
color: var(--ember);
}
.tl-action-sep {
color: var(--border);
font-size: 10px;
}
/* ---- LOAD MORE ---- */
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* ---- MODAL ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(42, 32, 21, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-box {
background: var(--bg);
border: 1px dashed var(--border);
padding: 28px 32px;
max-width: 400px;
width: 90%;
}
.modal-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.modal-text {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-row {
padding: 12px 20px;
}
.timeline-wrap {
padding: 20px 20px 40px;
}
.state-box {
padding: 48px 20px;
}
}
</style>

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>

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