Compare commits
No commits in common. "main" and "worktree-agent-a0328c91" have entirely different histories.
main
...
worktree-a
|
|
@ -6,8 +6,6 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild
|
||||||
# HELCIM_API_TOKEN=your-live-helcim-api-token
|
# HELCIM_API_TOKEN=your-live-helcim-api-token
|
||||||
HELCIM_API_TOKEN=your-test-helcim-api-token
|
HELCIM_API_TOKEN=your-test-helcim-api-token
|
||||||
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
|
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
|
||||||
NUXT_HELCIM_MONTHLY_PLAN_ID=<set_after_migration>
|
|
||||||
NUXT_HELCIM_ANNUAL_PLAN_ID=<set_after_migration>
|
|
||||||
|
|
||||||
# Email Configuration (Resend)
|
# Email Configuration (Resend)
|
||||||
RESEND_API_KEY=your-resend-api-key
|
RESEND_API_KEY=your-resend-api-key
|
||||||
|
|
@ -16,8 +14,6 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org
|
||||||
# Slack Integration
|
# Slack Integration
|
||||||
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
||||||
SLACK_OAUTH_TOKEN=your-slack-oauth-token
|
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 for authentication
|
||||||
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
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_ID=outline-wiki
|
||||||
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
|
|
||||||
# Outline Wiki Integration
|
|
||||||
OUTLINE_API_KEY=
|
|
||||||
|
|
@ -21,16 +21,16 @@ jobs:
|
||||||
playwright:
|
playwright:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: vitest
|
needs: vitest
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
env:
|
env:
|
||||||
MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test
|
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
|
||||||
JWT_SECRET: ci-test-jwt-secret
|
JWT_SECRET: ci-test-jwt-secret
|
||||||
RESEND_API_KEY: re_ci_dummy_not_used
|
|
||||||
HELCIM_API_TOKEN: helcim_ci_dummy_not_used
|
|
||||||
OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret
|
|
||||||
NUXT_PUBLIC_COMING_SOON: 'false'
|
NUXT_PUBLIC_COMING_SOON: 'false'
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
ALLOW_DEV_TEST_ENDPOINTS: 'true'
|
|
||||||
BASE_URL: http://localhost:3000
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
@ -39,35 +39,15 @@ jobs:
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps chromium
|
- run: npx playwright install --with-deps chromium
|
||||||
- name: Start MongoDB
|
|
||||||
run: |
|
|
||||||
docker rm -f mongo-ci 2>/dev/null || true
|
|
||||||
docker run -d --name mongo-ci mongo:7
|
|
||||||
# Forgejo runs each job inside its own container; attach Mongo to
|
|
||||||
# that container's network so MONGODB_URI=mongodb://mongo-ci:27017
|
|
||||||
# resolves from inside the runner.
|
|
||||||
RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
|
|
||||||
docker network connect "$RUNNER_NET" mongo-ci
|
|
||||||
docker ps
|
|
||||||
- name: Wait for MongoDB
|
|
||||||
run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done'
|
|
||||||
- name: MongoDB log on failure
|
|
||||||
if: failure()
|
|
||||||
run: docker logs mongo-ci || true
|
|
||||||
- name: Seed test data
|
|
||||||
run: node scripts/seed-all.js && node scripts/seed-tags.js
|
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- name: Start server
|
- name: Start server
|
||||||
run: node .output/server/index.mjs > /tmp/server.log 2>&1 &
|
run: node .output/server/index.mjs &
|
||||||
env:
|
env:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
- name: Wait for server
|
- name: Wait for server
|
||||||
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
|
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
|
||||||
- name: Server log on failure
|
- run: npx playwright test --ignore-snapshots
|
||||||
if: failure()
|
- uses: actions/upload-artifact@v4
|
||||||
run: cat /tmp/server.log || true
|
|
||||||
- run: npx playwright test
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|
@ -88,3 +68,39 @@ jobs:
|
||||||
-H 'Content-type: application/json' \
|
-H 'Content-type: application/json' \
|
||||||
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
|
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
|
||||||
|
|
||||||
|
visual:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: vitest
|
||||||
|
continue-on-error: true
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
|
env:
|
||||||
|
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
|
||||||
|
JWT_SECRET: ci-test-jwt-secret
|
||||||
|
NUXT_PUBLIC_COMING_SOON: 'false'
|
||||||
|
NODE_ENV: development
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- run: npm run build
|
||||||
|
- name: Start server
|
||||||
|
run: node .output/server/index.mjs &
|
||||||
|
env:
|
||||||
|
PORT: 3000
|
||||||
|
- name: Wait for server
|
||||||
|
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
|
||||||
|
- run: npx playwright test e2e/visual/
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: visual-diffs
|
||||||
|
path: e2e/test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
|
||||||
8
.gitignore
vendored
|
|
@ -26,9 +26,6 @@ logs
|
||||||
!.env.example
|
!.env.example
|
||||||
scripts/*.js
|
scripts/*.js
|
||||||
|
|
||||||
# Migration backup files
|
|
||||||
.migration-backup-*.json
|
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
@ -36,8 +33,3 @@ e2e/.auth/
|
||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.claude/worktrees/
|
|
||||||
.superpowers/
|
|
||||||
|
|
||||||
.claude
|
|
||||||
scripts/dump-babyghosts-preregistrations.mjs
|
|
||||||
|
|
|
||||||
0
.husky/pre-push
Executable file → Normal file
|
|
@ -3,26 +3,21 @@ project_name: "ghostguild-org"
|
||||||
|
|
||||||
|
|
||||||
# list of languages for which language servers are started; choose from:
|
# list of languages for which language servers are started; choose from:
|
||||||
# al angular ansible bash clojure
|
# al bash clojure cpp csharp
|
||||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
# dart elixir elm erlang fortran
|
# fortran fsharp go groovy haskell
|
||||||
# fsharp go groovy haskell haxe
|
# java julia kotlin lua markdown
|
||||||
# hlsl html java json julia
|
# matlab nix pascal perl php
|
||||||
# kotlin lean4 lua luau markdown
|
# php_phpactor powershell python python_jedi r
|
||||||
# matlab msl nix ocaml pascal
|
# rego ruby ruby_solargraph rust scala
|
||||||
# perl php php_phpactor powershell python
|
# swift terraform toml typescript typescript_vts
|
||||||
# python_jedi python_ty r rego ruby
|
# vue yaml zig
|
||||||
# ruby_solargraph rust scala scss solidity
|
|
||||||
# swift systemverilog terraform toml typescript
|
|
||||||
# typescript_vts vue yaml zig
|
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
# Note:
|
# Note:
|
||||||
# - For C, use cpp
|
# - For C, use cpp
|
||||||
# - For JavaScript, use typescript
|
# - 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
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
# Special requirements:
|
# Special requirements:
|
||||||
# Some languages require additional setup/installations.
|
# Some languages require additional setup/installations.
|
||||||
|
|
@ -70,17 +65,53 @@ read_only: false
|
||||||
|
|
||||||
# list of tool names to exclude.
|
# list of tool names to exclude.
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
#
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
excluded_tools: []
|
excluded_tools: []
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
included_optional_tools: []
|
included_optional_tools: []
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
fixed_tools: []
|
fixed_tools: []
|
||||||
|
|
||||||
# list of mode names to that are always to be included in the set of active modes
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
|
@ -91,14 +122,11 @@ fixed_tools: []
|
||||||
# Set this to a list of mode names to always include the respective modes for this project.
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
base_modes:
|
base_modes:
|
||||||
|
|
||||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
# list of mode names that are to be activated by default.
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
||||||
# for this project.
|
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
default_modes:
|
default_modes:
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
|
@ -122,19 +150,3 @@ read_only_memory_patterns: []
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
ignored_memory_patterns: []
|
ignored_memory_patterns: []
|
||||||
|
|
||||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
added_modes:
|
|
||||||
|
|
||||||
# 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: []
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM node:22-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
@ -7,11 +7,8 @@ RUN npm ci --ignore-scripts && npx nuxt prepare
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage — only the self-contained .output is needed.
|
# Production stage — only the self-contained .output is needed
|
||||||
# bash + curl are added so Dokploy scheduled tasks (which wrap commands in
|
FROM node:20-alpine
|
||||||
# `bash -c "..."`) can run; alpine ships only ash and has no curl by default.
|
|
||||||
FROM node:22-alpine
|
|
||||||
RUN apk add --no-cache bash curl
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/.output .output
|
COPY --from=builder /app/.output .output
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,11 @@
|
||||||
--text: #2a2015;
|
--text: #2a2015;
|
||||||
--text-bright: #1a1008;
|
--text-bright: #1a1008;
|
||||||
--text-dim: #5a5040;
|
--text-dim: #5a5040;
|
||||||
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
|
--text-faint: #746a58;
|
||||||
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
|
|
||||||
--text-dim (5.80:1) while meeting AA for small text. */
|
|
||||||
--text-faint: #665c4b;
|
|
||||||
--parch: #2a2015;
|
--parch: #2a2015;
|
||||||
--parch-hover: #3a3025;
|
--parch-hover: #3a3025;
|
||||||
--parch-text: #ede4d0;
|
--parch-text: #ede4d0;
|
||||||
--parch-text-dim: #b8ae98;
|
--parch-text-dim: #b8ae98;
|
||||||
--parch-accent: #c4a448;
|
|
||||||
--parch-border: #b8a880;
|
|
||||||
--c-community: #7a4838;
|
--c-community: #7a4838;
|
||||||
--c-founder: #8a4420;
|
--c-founder: #8a4420;
|
||||||
--c-practitioner: #2a4650;
|
--c-practitioner: #2a4650;
|
||||||
|
|
@ -63,9 +58,10 @@
|
||||||
--text-bright: #d0c8b0;
|
--text-bright: #d0c8b0;
|
||||||
--text-dim: #958774;
|
--text-dim: #958774;
|
||||||
--text-faint: #8b7b62;
|
--text-faint: #8b7b62;
|
||||||
/* Parch family intentionally stays pinned to light-mode values —
|
--parch: #ede4d0;
|
||||||
inverted blocks are a consistent zine/terminal inset in both themes.
|
--parch-hover: #d4c8a8;
|
||||||
See: --parch-accent and --parch-border for on-parch accents/borders. */
|
--parch-text: #2a2015;
|
||||||
|
--parch-text-dim: #5a5040;
|
||||||
--c-community: #a06850;
|
--c-community: #a06850;
|
||||||
--c-founder: #c06030;
|
--c-founder: #c06030;
|
||||||
--c-practitioner: #4a7080;
|
--c-practitioner: #4a7080;
|
||||||
|
|
@ -178,12 +174,6 @@ p a, blockquote a {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
border-color: var(--border-d);
|
border-color: var(--border-d);
|
||||||
}
|
}
|
||||||
/* WCAG 2.4.7 — keyboard focus must be visibly indicated. Dashed outline
|
|
||||||
echoes the design system's zine/dashed aesthetic. */
|
|
||||||
.btn:focus-visible {
|
|
||||||
outline: 2px dashed var(--candle);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--candle);
|
background: var(--candle);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
|
|
@ -276,98 +266,6 @@ p a, blockquote a {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Nuxt UI placeholder contrast ----
|
|
||||||
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
|
|
||||||
AA on cream and white backgrounds (≈2.4:1). Override globally to --text-dim
|
|
||||||
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
|
|
||||||
[data-slot="placeholder"] {
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- SHARED USelectMenu STYLES ----
|
|
||||||
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
|
||||||
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
|
||||||
button.zine-select,
|
|
||||||
button.timezone-select {
|
|
||||||
display: flex !important;
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 8px !important;
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: var(--text-bright) !important;
|
|
||||||
background: var(--input-bg) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
min-height: 0;
|
|
||||||
--tw-ring-shadow: 0 0 #0000;
|
|
||||||
--tw-ring-offset-shadow: 0 0 #0000;
|
|
||||||
--tw-ring-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.zine-select:hover,
|
|
||||||
button.timezone-select:hover {
|
|
||||||
background: var(--input-bg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.zine-select:focus,
|
|
||||||
button.zine-select:focus-visible,
|
|
||||||
button.zine-select[aria-expanded="true"],
|
|
||||||
button.timezone-select:focus,
|
|
||||||
button.timezone-select:focus-visible,
|
|
||||||
button.timezone-select[aria-expanded="true"] {
|
|
||||||
border-color: var(--candle) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-content {
|
|
||||||
background: var(--input-bg) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
|
||||||
--tw-ring-shadow: 0 0 #0000 !important;
|
|
||||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-input {
|
|
||||||
border-bottom: 1px dashed var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-input input {
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: var(--text-bright) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding: 6px 8px !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
--tw-ring-shadow: 0 0 #0000 !important;
|
|
||||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item {
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding: 6px 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item::before {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item[data-highlighted]::before,
|
|
||||||
.tz-item[data-highlighted]:not([data-disabled])::before {
|
|
||||||
background: var(--surface-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item[data-highlighted],
|
|
||||||
.tz-item[data-highlighted]:not([data-disabled]) {
|
|
||||||
color: var(--text-bright) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- MOBILE ---- */
|
/* ---- MOBILE ---- */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
body {
|
body {
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,17 @@
|
||||||
/>
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="sign-out" @click.prevent="handleLogout"
|
||||||
|
>Sign out</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Explore</div>
|
<div class="sidebar-section">Explore</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in exploreItems" :key="item.path">
|
<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
|
<NuxtLink
|
||||||
v-else
|
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
|
|
@ -56,18 +50,7 @@
|
||||||
<div class="sidebar-section">Navigate</div>
|
<div class="sidebar-section">Navigate</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in publicItems" :key="item.path">
|
<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
|
<NuxtLink
|
||||||
v-else
|
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
|
|
@ -94,18 +77,7 @@
|
||||||
<div class="sidebar-section">Navigate</div>
|
<div class="sidebar-section">Navigate</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in publicItems" :key="item.path">
|
<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
|
<NuxtLink
|
||||||
v-else
|
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
|
|
@ -133,11 +105,12 @@
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
Part of
|
Part of
|
||||||
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br />
|
||||||
A Canadian nonprofit
|
A Canadian nonprofit
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
Part of
|
Part of
|
||||||
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a
|
||||||
|
><br />
|
||||||
A Canadian nonprofit
|
A Canadian nonprofit
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
@ -163,8 +136,8 @@ const route = useRoute();
|
||||||
const { isAuthenticated, memberData, logout } = useAuth();
|
const { isAuthenticated, memberData, logout } = useAuth();
|
||||||
const isDev = import.meta.dev;
|
const isDev = import.meta.dev;
|
||||||
|
|
||||||
const showOnboardingDot = computed(
|
const showOnboardingDot = computed(() =>
|
||||||
() => isAuthenticated.value && !memberData.value?.onboarding?.completedAt,
|
isAuthenticated.value && !memberData.value?.onboarding?.completedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
|
|
@ -189,23 +162,28 @@ const publicItems = [
|
||||||
{ label: "Home", path: "/" },
|
{ label: "Home", path: "/" },
|
||||||
{ label: "About", path: "/about" },
|
{ label: "About", path: "/about" },
|
||||||
{ label: "Events", path: "/events" },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
|
{ label: "Members", path: "/members" },
|
||||||
|
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const joinItems = [{ label: "Become a member", path: "/join" }];
|
const joinItems = [
|
||||||
|
{ label: "Become a member", path: "/join" },
|
||||||
|
{ label: "Propose an event", path: "/events" },
|
||||||
|
];
|
||||||
|
|
||||||
// Logged-in nav items
|
// Logged-in nav items
|
||||||
const youItems = [
|
const youItems = [
|
||||||
{ label: "Dashboard", path: "/member/dashboard" },
|
{ label: "Dashboard", path: "/member/dashboard" },
|
||||||
{ label: "Profile", path: "/member/profile" },
|
{ label: "Profile", path: "/member/profile" },
|
||||||
{ label: "Account", path: "/member/account" },
|
{ label: "Account", path: "/member/account" },
|
||||||
|
{ label: "Activity Log", path: "/member/activity" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const exploreItems = [
|
const exploreItems = [
|
||||||
{ label: "Events", path: "/events" },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: "Members", path: "/members" },
|
{ label: "Members", path: "/members" },
|
||||||
{ label: "Board", path: "/board" },
|
{ label: "Ecology", path: "/ecology" },
|
||||||
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
|
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
|
||||||
{ label: "About", path: "/about" },
|
{ label: "About", path: "/about" },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -311,28 +289,13 @@ const exploreItems = [
|
||||||
color: var(--candle-dim);
|
color: var(--candle-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-hint {
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-left: 4px;
|
|
||||||
position: relative;
|
|
||||||
top: -0.5px;
|
|
||||||
}
|
|
||||||
.external-hint::before {
|
|
||||||
content: "[";
|
|
||||||
}
|
|
||||||
.external-hint::after {
|
|
||||||
content: "]";
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-dot {
|
.onboarding-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--green);
|
background: var(--candle);
|
||||||
margin-left: 0px;
|
margin-left: 6px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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 }} →</a>
|
|
||||||
<details v-else-if="slackLinks.length > 1" class="slack-menu">
|
|
||||||
<summary class="slack-link">Discuss on Slack ▾</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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -4,16 +4,13 @@
|
||||||
v-for="circle in circles"
|
v-for="circle in circles"
|
||||||
:key="circle.value"
|
:key="circle.value"
|
||||||
class="circle-option"
|
class="circle-option"
|
||||||
:class="{
|
:class="{ current: modelValue === circle.value }"
|
||||||
selected: modelValue === circle.value,
|
|
||||||
current: savedValue === circle.value,
|
|
||||||
}"
|
|
||||||
@click="$emit('update:modelValue', circle.value)"
|
@click="$emit('update:modelValue', circle.value)"
|
||||||
>
|
>
|
||||||
<span class="circle-name">{{ circle.label }}</span>
|
<span class="circle-name">{{ circle.label }}</span>
|
||||||
<span class="circle-desc">{{ circle.description }}</span>
|
<span class="circle-desc">{{ circle.description }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="savedValue === circle.value"
|
v-if="modelValue === circle.value"
|
||||||
class="circle-tag"
|
class="circle-tag"
|
||||||
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
|
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
|
||||||
>Current</span>
|
>Current</span>
|
||||||
|
|
@ -24,13 +21,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
savedValue: { type: String, default: '' },
|
|
||||||
circles: {
|
circles: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [
|
default: () => [
|
||||||
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
|
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
|
||||||
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
|
{ 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 {
|
.circle-option {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 12px 12px;
|
padding: 14px 12px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
@ -58,7 +54,7 @@ defineEmits(['update:modelValue'])
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-option.selected {
|
.circle-option.current {
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -71,19 +67,19 @@ defineEmits(['update:modelValue'])
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-option.selected .circle-name {
|
.circle-option.current .circle-name {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-desc {
|
.circle-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-faint);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-tag {
|
.circle-tag {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,13 @@ const props = defineProps({
|
||||||
limit: { type: Number, default: 3 },
|
limit: { type: Number, default: 3 },
|
||||||
})
|
})
|
||||||
|
|
||||||
let upcomingEvents = ref([])
|
const upcomingEvents = ref([])
|
||||||
if (props.cols === 'events-sidebar') {
|
if (props.cols === 'events-sidebar') {
|
||||||
const { data } = await useFetch('/api/events', {
|
const { data } = await useFetch('/api/events', {
|
||||||
query: { upcoming: true, limit: props.limit },
|
query: { upcoming: true, limit: props.limit },
|
||||||
default: () => [],
|
default: () => [],
|
||||||
server: false,
|
|
||||||
})
|
})
|
||||||
upcomingEvents = computed(() => data.value || [])
|
upcomingEvents.value = data.value || []
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -54,7 +53,6 @@ if (props.cols === 'events-sidebar') {
|
||||||
/* cols="events-sidebar" */
|
/* cols="events-sidebar" */
|
||||||
.columns-events-sidebar {
|
.columns-events-sidebar {
|
||||||
grid-template-columns: 1fr 200px;
|
grid-template-columns: 1fr 200px;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure grid children don't overflow */
|
/* Ensure grid children don't overflow */
|
||||||
|
|
@ -62,14 +60,11 @@ if (props.cols === 'events-sidebar') {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dashed divider: right border on the first column child (except events-sidebar, which owns its own border-left) */
|
/* Dashed divider: right border on the first column child */
|
||||||
.divider-dashed .col:first-child,
|
.divider-dashed .col:first-child,
|
||||||
.divider-dashed .col-main {
|
.divider-dashed .col-main {
|
||||||
border-right: 1px dashed var(--border);
|
border-right: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.divider-dashed.columns-events-sidebar .col-main {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive collapse at 1024px (default) */
|
/* Responsive collapse at 1024px (default) */
|
||||||
.collapse-1024 {
|
.collapse-1024 {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="coop-tag-selector">
|
<div class="coop-tag-selector">
|
||||||
<div class="pill-grid">
|
<div
|
||||||
<button
|
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag.slug"
|
:key="tag.slug"
|
||||||
type="button"
|
class="coop-row"
|
||||||
class="pill"
|
>
|
||||||
:class="{ selected: modelValue.includes(tag.slug) }"
|
<span class="tag-label">{{ tag.label }}</span>
|
||||||
@click="toggle(tag.slug)"
|
<div class="segmented">
|
||||||
>{{ tag.label || tag.name || tag.slug }}</button>
|
<span
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
class="seg-option"
|
||||||
|
:class="{ on: getState(tag.slug) === opt.value }"
|
||||||
|
@click="toggleState(tag.slug, opt.value)"
|
||||||
|
>{{ opt.label }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggest-link">
|
<div class="suggest-link">
|
||||||
<button type="button" class="suggest-btn" @click="$emit('suggest')">Don't see what you're looking for?</button>
|
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -24,15 +30,32 @@ const props = defineProps({
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "suggest"]);
|
const emit = defineEmits(["update:modelValue", "suggest"]);
|
||||||
|
|
||||||
function toggle(slug) {
|
const options = [
|
||||||
|
{ label: "Can help", value: "help" },
|
||||||
|
{ label: "Interested", value: "interested" },
|
||||||
|
{ label: "Need help", value: "seeking" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getState(slug) {
|
||||||
|
const entry = props.modelValue.find((e) => e.tagSlug === slug);
|
||||||
|
return entry ? entry.state : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleState(slug, value) {
|
||||||
const current = [...props.modelValue];
|
const current = [...props.modelValue];
|
||||||
const idx = current.indexOf(slug);
|
const idx = current.findIndex((e) => e.tagSlug === slug);
|
||||||
if (idx === -1) {
|
const existingState = idx !== -1 ? current[idx].state : null;
|
||||||
emit("update:modelValue", [...current, slug]);
|
|
||||||
|
if (existingState === value) {
|
||||||
|
// clicking active state deselects it
|
||||||
|
if (idx !== -1) current.splice(idx, 1);
|
||||||
|
} else if (idx !== -1) {
|
||||||
|
current[idx] = { tagSlug: slug, state: value };
|
||||||
} else {
|
} else {
|
||||||
current.splice(idx, 1);
|
current.push({ tagSlug: slug, state: value });
|
||||||
emit("update:modelValue", current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit("update:modelValue", current);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -40,60 +63,87 @@ function toggle(slug) {
|
||||||
.coop-tag-selector {
|
.coop-tag-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill-grid {
|
.coop-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.coop-row:first-child {
|
||||||
display: inline-flex;
|
border-top: 1px dashed var(--border);
|
||||||
align-items: center;
|
}
|
||||||
padding: 2px 9px;
|
|
||||||
border: 1px dashed var(--border);
|
.tag-label {
|
||||||
background: transparent;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: all 0.12s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill:hover {
|
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
border-color: var(--border-d);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill.selected {
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option {
|
||||||
|
padding: 2px 7px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option + .seg-option {
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-option.on {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggest-link {
|
.suggest-link {
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggest-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-link span {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggest-btn:hover {
|
.suggest-link span:hover {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
70
app/components/EventSeriesBadge.vue
Normal 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>
|
||||||
|
|
@ -1,27 +1,37 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
|
<div
|
||||||
|
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
|
<div
|
||||||
|
class="bg-gradient-to-br from-candlelight-500 to-candlelight-700 dark:from-candlelight-600 dark:to-candlelight-800 p-6"
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
|
<Icon
|
||||||
<span class="text-sm font-semibold" style="color: var(--parch-text)">
|
name="heroicons:ticket"
|
||||||
|
class="w-5 h-5 text-candlelight-900 dark:text-candlelight-200"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-semibold text-candlelight-900 dark:text-candlelight-200">
|
||||||
Series Pass
|
Series Pass
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-display text-xl font-bold mb-1" style="color: var(--parch-text)">
|
<h3 class="text-xl font-bold text-white mb-1">
|
||||||
{{ ticket.name }}
|
{{ ticket.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="ticket.description" class="text-sm" style="color: var(--parch-text); opacity: 0.85">
|
<p v-if="ticket.description" class="text-sm text-candlelight-900 dark:text-candlelight-200">
|
||||||
{{ ticket.description }}
|
{{ ticket.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
<div class="text-3xl font-bold" style="color: var(--parch-text)">
|
<div class="text-3xl font-bold text-white text-ui-mono">
|
||||||
{{ formatPrice(ticket.price, ticket.currency) }}
|
{{ formatPrice(ticket.price, ticket.currency) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
|
<div
|
||||||
|
v-if="ticket.isEarlyBird"
|
||||||
|
class="text-xs text-candlelight-900 dark:text-candlelight-200 mt-1"
|
||||||
|
>
|
||||||
Early Bird Price
|
Early Bird Price
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,23 +39,29 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="p-6" style="background: var(--surface)">
|
<div class="p-6 bg-guild-800/50 dark:bg-guild-700/30">
|
||||||
<!-- What's Included -->
|
<!-- What's Included -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
||||||
What's Included
|
What's Included
|
||||||
</h4>
|
</h4>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2" style="color: var(--text)">
|
<div class="flex items-center gap-2 text-guild-300 dark:text-guild-300">
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
||||||
<span>Access to all {{ totalEvents }} events in the series</span>
|
<span>Access to all {{ totalEvents }} events in the series</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
|
<div
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
v-if="ticket.isFree && !isMember"
|
||||||
|
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
||||||
<span>Automatic registration for all sessions</span>
|
<span>Automatic registration for all sessions</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
|
<div
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
v-if="memberSavings > 0"
|
||||||
|
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
||||||
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
|
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,31 +69,33 @@
|
||||||
|
|
||||||
<!-- Events List Preview -->
|
<!-- Events List Preview -->
|
||||||
<div v-if="events && events.length > 0" class="mb-6">
|
<div v-if="events && events.length > 0" class="mb-6">
|
||||||
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
||||||
Series Schedule
|
Series Schedule
|
||||||
</h4>
|
</h4>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="(event, index) in events.slice(0, 3)"
|
v-for="(event, index) in events.slice(0, 3)"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
class="flex items-start gap-3 p-3"
|
class="flex items-start gap-3 p-3 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
class="w-8 h-8 rounded-full bg-candlelight-600/20 border border-candlelight-500/30 flex items-center justify-center flex-shrink-0"
|
||||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
|
||||||
>
|
>
|
||||||
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
|
<span class="text-sm font-bold text-candlelight-300">{{ index + 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-sm" style="color: var(--text)">
|
<div class="font-medium text-guild-100 dark:text-guild-100 text-sm">
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs mt-1" style="color: var(--text-faint)">
|
<div class="text-xs text-guild-400 dark:text-guild-400 mt-1">
|
||||||
{{ formatEventDate(event.startDate) }}
|
{{ formatEventDate(event.startDate) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
|
<div
|
||||||
|
v-if="events.length > 3"
|
||||||
|
class="text-center text-sm text-guild-400 dark:text-guild-400 pt-2"
|
||||||
|
>
|
||||||
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
|
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,14 +104,13 @@
|
||||||
<!-- Member Benefit Callout -->
|
<!-- Member Benefit Callout -->
|
||||||
<div
|
<div
|
||||||
v-if="ticket.isFree && isMember"
|
v-if="ticket.isFree && isMember"
|
||||||
class="p-4 mb-6"
|
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
||||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:sparkles" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
<Icon name="heroicons:sparkles" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
|
<div class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
|
||||||
<div class="text-sm" style="color: var(--candle)">
|
<div class="text-sm text-candlelight-400">
|
||||||
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
|
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,14 +120,13 @@
|
||||||
<!-- Public vs Member Pricing -->
|
<!-- Public vs Member Pricing -->
|
||||||
<div
|
<div
|
||||||
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
|
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
|
||||||
class="p-4 mb-6"
|
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
||||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:tag" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
<Icon name="heroicons:tag" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
|
<div class="font-semibold text-candlelight-300 mb-1">Member Savings</div>
|
||||||
<div class="text-sm" style="color: var(--candle)">
|
<div class="text-sm text-candlelight-400">
|
||||||
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
|
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
|
||||||
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
|
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,15 +136,22 @@
|
||||||
|
|
||||||
<!-- Availability -->
|
<!-- Availability -->
|
||||||
<div v-if="availability" class="mb-6">
|
<div v-if="availability" class="mb-6">
|
||||||
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
|
<div
|
||||||
|
v-if="!availability.unlimited && availability.remaining !== null"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
|
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
|
||||||
class="w-5 h-5"
|
:class="[
|
||||||
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
'w-5 h-5',
|
||||||
|
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
|
||||||
|
]"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium"
|
:class="[
|
||||||
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
'text-sm font-medium',
|
||||||
|
availability.remaining > 5 ? 'text-candlelight-300' : 'text-ember-300'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
|
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -137,12 +160,12 @@
|
||||||
|
|
||||||
<!-- Sold Out / Waitlist -->
|
<!-- Sold Out / Waitlist -->
|
||||||
<div v-if="!available" class="space-y-3">
|
<div v-if="!available" class="space-y-3">
|
||||||
<div class="p-4" style="background: var(--ember-bg); border: 1px solid var(--ember)">
|
<div class="p-4 bg-ember-900/20 border border-ember-700/30 rounded-lg">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
|
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
|
<div class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
|
||||||
<div class="text-sm" style="color: var(--ember)">
|
<div class="text-sm text-ember-400">
|
||||||
All series passes have been claimed.
|
All series passes have been claimed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -151,7 +174,7 @@
|
||||||
<UButton
|
<UButton
|
||||||
v-if="availability?.waitlistAvailable"
|
v-if="availability?.waitlistAvailable"
|
||||||
block
|
block
|
||||||
color="neutral"
|
color="gray"
|
||||||
size="lg"
|
size="lg"
|
||||||
@click="$emit('join-waitlist')"
|
@click="$emit('join-waitlist')"
|
||||||
>
|
>
|
||||||
|
|
@ -160,16 +183,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Already Registered -->
|
<!-- Already Registered -->
|
||||||
<div
|
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
|
||||||
v-else-if="alreadyRegistered"
|
|
||||||
class="p-4"
|
|
||||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
|
<Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
|
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
|
||||||
<div class="text-sm" style="color: var(--candle)">
|
<div class="text-sm text-candlelight-400">
|
||||||
You have a series pass and are registered for all {{ totalEvents }} events.
|
You have a series pass and are registered for all {{ totalEvents }} events.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,72 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ticket-card"
|
class="ticket-card rounded-xl border p-6 transition-all duration-200"
|
||||||
:class="{
|
:class="[
|
||||||
'is-selected': isSelected,
|
isSelected
|
||||||
'is-unavailable': !isAvailable || alreadyRegistered,
|
? '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"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<!-- Ticket Header -->
|
<!-- Ticket Header -->
|
||||||
<div class="ticket-header">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
|
<h3 class="text-lg font-semibold text-guild-100">
|
||||||
<p v-if="ticketInfo.description" class="ticket-desc">
|
{{ ticketInfo.name }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="ticketInfo.description" class="text-sm text-guild-300 mt-1">
|
||||||
{{ ticketInfo.description }}
|
{{ ticketInfo.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Price Display -->
|
<!-- Price Display -->
|
||||||
<div class="ticket-price-block">
|
<div class="mb-4">
|
||||||
<div class="ticket-price-row">
|
<div class="flex items-baseline gap-2">
|
||||||
<span
|
<span
|
||||||
class="ticket-price"
|
class="text-3xl font-bold text-ui-mono"
|
||||||
:class="{ 'is-free': ticketInfo.isFree }"
|
:class="ticketInfo.isFree ? 'text-candlelight-400' : 'text-guild-100'"
|
||||||
>
|
>
|
||||||
{{ ticketInfo.formattedPrice }}
|
{{ ticketInfo.formattedPrice }}
|
||||||
</span>
|
</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
|
Early Bird
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 }}
|
Regular: {{ ticketInfo.formattedRegularPrice }}
|
||||||
|
</span>
|
||||||
</div>
|
</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) }}
|
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -44,38 +74,59 @@
|
||||||
<!-- Member Savings -->
|
<!-- Member Savings -->
|
||||||
<div
|
<div
|
||||||
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
|
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="text-sm text-candlelight-400">
|
||||||
<p class="ticket-savings-detail">
|
<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 }}
|
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Availability -->
|
<!-- Availability -->
|
||||||
<div class="ticket-availability">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span v-if="alreadyRegistered" class="status-registered">
|
<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
|
You're registered
|
||||||
</span>
|
</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
|
Sold Out
|
||||||
</span>
|
</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
|
{{ ticketInfo.remaining }} remaining
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="status-remaining">
|
<span v-else class="text-guild-300"> Unlimited availability </span>
|
||||||
Unlimited availability
|
</div>
|
||||||
</span>
|
|
||||||
|
<!-- Selection Indicator -->
|
||||||
|
<div v-if="isSelected && isAvailable && !alreadyRegistered">
|
||||||
|
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Waitlist Option -->
|
<!-- Waitlist Option -->
|
||||||
<div
|
<div
|
||||||
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
|
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
|
Join Waitlist
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -113,11 +164,13 @@ const formatDeadline = (deadline) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date - now;
|
const diff = date - now;
|
||||||
|
|
||||||
|
// If less than 24 hours, show hours
|
||||||
if (diff < 24 * 60 * 60 * 1000) {
|
if (diff < 24 * 60 * 60 * 1000) {
|
||||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||||
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
|
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Otherwise show date
|
||||||
return `on ${date.toLocaleDateString("en-US", {
|
return `on ${date.toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -134,103 +187,6 @@ const formatPrice = (amount) => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ticket-card {
|
.ticket-card {
|
||||||
border-bottom: 1px dashed var(--border);
|
position: relative;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,65 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="event-ticket-purchase">
|
<div class="event-ticket-purchase">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="ticket-panel">
|
<div v-if="loading" class="text-center py-8">
|
||||||
<div class="box-title">Tickets</div>
|
<div
|
||||||
<p class="ticket-status">Loading ticket information...</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="error" class="ticket-panel">
|
<div
|
||||||
<div class="box-title">Tickets</div>
|
v-else-if="error"
|
||||||
<p class="ticket-status" style="color: var(--ember)">
|
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
|
Unable to Load Tickets
|
||||||
</p>
|
</h3>
|
||||||
<p class="ticket-detail">{{ error }}</p>
|
<p class="text-ember-400">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Pass Required -->
|
<!-- Series Pass Required -->
|
||||||
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
|
<div
|
||||||
<div class="box-title">Tickets</div>
|
v-else-if="ticketInfo?.requiresSeriesPass"
|
||||||
<p class="ticket-status" style="color: var(--candle)">
|
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
|
Series Pass Required
|
||||||
</p>
|
</h3>
|
||||||
<p class="ticket-detail">
|
<p class="text-candlelight-400 mb-4">
|
||||||
This event is part of
|
This event is part of
|
||||||
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
|
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
|
||||||
pass to attend.
|
pass to attend.
|
||||||
</p>
|
</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.
|
Purchase a series pass to get access to all events in this series.
|
||||||
</p>
|
</p>
|
||||||
<NuxtLink
|
<UButton
|
||||||
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
|
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
>
|
>
|
||||||
<button class="btn btn-primary">View Series & Purchase Pass</button>
|
View Series & Purchase Pass
|
||||||
</NuxtLink>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Already Registered -->
|
<!-- Already Registered -->
|
||||||
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
<div
|
||||||
<p class="ticket-status" style="color: var(--green)">
|
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!
|
You're Registered!
|
||||||
</p>
|
</h3>
|
||||||
<p class="ticket-detail">
|
<p class="text-candlelight-400 mb-4">
|
||||||
<template v-if="ticketInfo.viaSeriesPass">
|
<template v-if="ticketInfo.viaSeriesPass">
|
||||||
You have access to this event via your series pass for
|
You have access to this event via your series pass for
|
||||||
<strong>{{ ticketInfo.series?.title }}</strong
|
<strong>{{ ticketInfo.series?.title }}</strong
|
||||||
|
|
@ -52,7 +70,7 @@
|
||||||
details.
|
details.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<p class="ticket-hint">
|
<p class="text-sm text-guild-300">
|
||||||
See you on {{ formatEventDate(eventStartDate) }}!
|
See you on {{ formatEventDate(eventStartDate) }}!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,114 +83,88 @@
|
||||||
:is-selected="true"
|
:is-selected="true"
|
||||||
:is-available="ticketInfo.available"
|
:is-available="ticketInfo.available"
|
||||||
:already-registered="ticketInfo.alreadyRegistered"
|
:already-registered="ticketInfo.alreadyRegistered"
|
||||||
|
class="mb-6"
|
||||||
@join-waitlist="handleJoinWaitlist"
|
@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
|
<div
|
||||||
v-if="
|
|
||||||
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
|
|
||||||
"
|
|
||||||
class="ticket-panel"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
||||||
class="ticket-notice"
|
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
||||||
style="color: var(--candle)"
|
|
||||||
>
|
>
|
||||||
|
<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
|
This event is free for Ghost Guild members
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Registration Form (guest) -->
|
<!-- Payment Required Notice -->
|
||||||
<div
|
<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"
|
v-if="!ticketInfo.isFree"
|
||||||
class="ticket-notice"
|
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
||||||
style="color: var(--candle)"
|
|
||||||
>
|
>
|
||||||
|
<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
|
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
||||||
securely
|
securely
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="consent-block">
|
|
||||||
<label class="consent-field">
|
|
||||||
<input
|
|
||||||
v-model="form.createAccount"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="processing"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
>Create a free guest account so I can manage my
|
|
||||||
registration</span
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<p class="field-hint consent-hint">
|
|
||||||
Guest accounts let you view your tickets and register faster next
|
|
||||||
time. We won't add you to member communications.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Submit Button -->
|
||||||
|
<div class="pt-4">
|
||||||
|
<UButton
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
color="primary"
|
||||||
:disabled="processing || !form.name || !form.email"
|
size="lg"
|
||||||
|
block
|
||||||
|
:loading="processing"
|
||||||
|
:disabled="!form.name || !form.email"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
processing
|
processing
|
||||||
|
|
@ -181,29 +173,38 @@
|
||||||
? "Complete Registration"
|
? "Complete Registration"
|
||||||
: `Pay ${ticketInfo.formattedPrice}`
|
: `Pay ${ticketInfo.formattedPrice}`
|
||||||
}}
|
}}
|
||||||
</button>
|
</UButton>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sold Out with Waitlist -->
|
<!-- Sold Out with Waitlist -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
|
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
|
||||||
class="ticket-panel"
|
class="text-center py-8"
|
||||||
>
|
>
|
||||||
<div class="box-title">Waitlist</div>
|
<Icon
|
||||||
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
name="heroicons:ticket"
|
||||||
<p class="ticket-detail">
|
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
|
This event is currently at capacity. Join the waitlist to be notified
|
||||||
if spots become available.
|
if spots become available.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
|
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
|
||||||
|
Join Waitlist
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sold Out (No Waitlist) -->
|
<!-- Sold Out (No Waitlist) -->
|
||||||
<div v-else-if="!ticketInfo.available" class="ticket-panel">
|
<div v-else-if="!ticketInfo.available" class="text-center py-8">
|
||||||
<div class="box-title">Tickets</div>
|
<Icon
|
||||||
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
name="heroicons:x-circle"
|
||||||
<p class="ticket-detail">
|
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
|
Unfortunately, this event is at capacity and no longer accepting
|
||||||
registrations.
|
registrations.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -219,25 +220,17 @@ const props = defineProps({
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
eventStartDate: {
|
eventStartDate: {
|
||||||
type: [String, Date],
|
type: Date,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
eventTitle: {
|
eventTitle: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
eventTimezone: {
|
|
||||||
type: String,
|
|
||||||
default: "America/Toronto",
|
|
||||||
},
|
|
||||||
userEmail: {
|
userEmail: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
userName: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["success", "error"]);
|
const emit = defineEmits(["success", "error"]);
|
||||||
|
|
@ -252,9 +245,8 @@ const error = ref(null);
|
||||||
const ticketInfo = ref(null);
|
const ticketInfo = ref(null);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: props.userName || "",
|
name: "",
|
||||||
email: props.userEmail || "",
|
email: props.userEmail || "",
|
||||||
createAccount: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!props.userEmail);
|
const isLoggedIn = computed(() => !!props.userEmail);
|
||||||
|
|
@ -264,13 +256,11 @@ onMounted(async () => {
|
||||||
await fetchTicketInfo();
|
await fetchTicketInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTicketInfo = async (emailOverride = null) => {
|
const fetchTicketInfo = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const effectiveEmail = emailOverride || props.userEmail;
|
|
||||||
|
|
||||||
// First check if this event requires a series pass
|
// First check if this event requires a series pass
|
||||||
if (props.userEmail) {
|
if (props.userEmail) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -280,6 +270,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
||||||
|
|
||||||
if (seriesAccess.requiresSeriesPass) {
|
if (seriesAccess.requiresSeriesPass) {
|
||||||
if (seriesAccess.hasSeriesPass) {
|
if (seriesAccess.hasSeriesPass) {
|
||||||
|
// User has series pass - show as already registered
|
||||||
ticketInfo.value = {
|
ticketInfo.value = {
|
||||||
available: true,
|
available: true,
|
||||||
alreadyRegistered: true,
|
alreadyRegistered: true,
|
||||||
|
|
@ -290,6 +281,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
// User needs to buy series pass
|
||||||
ticketInfo.value = {
|
ticketInfo.value = {
|
||||||
available: false,
|
available: false,
|
||||||
requiresSeriesPass: true,
|
requiresSeriesPass: true,
|
||||||
|
|
@ -301,14 +293,13 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (seriesErr) {
|
} catch (seriesErr) {
|
||||||
|
// If series check fails, continue with regular ticket check
|
||||||
console.warn("Series access check failed:", seriesErr);
|
console.warn("Series access check failed:", seriesErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular ticket availability check
|
// Regular ticket availability check
|
||||||
const params = effectiveEmail
|
const params = props.userEmail ? `?email=${props.userEmail}` : "";
|
||||||
? `?email=${encodeURIComponent(effectiveEmail)}`
|
|
||||||
: "";
|
|
||||||
const response = await $fetch(
|
const response = await $fetch(
|
||||||
`/api/events/${props.eventId}/tickets/available${params}`,
|
`/api/events/${props.eventId}/tickets/available${params}`,
|
||||||
);
|
);
|
||||||
|
|
@ -329,19 +320,24 @@ const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
let transactionId = null;
|
let transactionId = null;
|
||||||
|
|
||||||
|
// If payment is required, initialize Helcim and process payment
|
||||||
if (!ticketInfo.value.isFree) {
|
if (!ticketInfo.value.isFree) {
|
||||||
|
// Initialize Helcim payment
|
||||||
await initializeTicketPayment(
|
await initializeTicketPayment(
|
||||||
props.eventId,
|
props.eventId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
|
ticketInfo.value.price,
|
||||||
props.eventTitle,
|
props.eventTitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Show Helcim modal and complete payment
|
||||||
const paymentResult = await verifyPayment();
|
const paymentResult = await verifyPayment();
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (!paymentResult.success) {
|
||||||
throw new Error("Payment was not completed");
|
throw new Error("Payment was not completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For purchase transactions, we get a transactionId
|
||||||
transactionId = paymentResult.transactionId;
|
transactionId = paymentResult.transactionId;
|
||||||
|
|
||||||
if (!transactionId) {
|
if (!transactionId) {
|
||||||
|
|
@ -349,38 +345,32 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = {
|
// Purchase ticket
|
||||||
name: form.value.name,
|
|
||||||
email: form.value.email,
|
|
||||||
createAccount: form.value.createAccount,
|
|
||||||
};
|
|
||||||
if (transactionId) body.transactionId = transactionId;
|
|
||||||
|
|
||||||
const response = await $fetch(
|
const response = await $fetch(
|
||||||
`/api/events/${props.eventId}/tickets/purchase`,
|
`/api/events/${props.eventId}/tickets/purchase`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body: {
|
||||||
|
name: form.value.name,
|
||||||
|
email: form.value.email,
|
||||||
|
transactionId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Success!
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
description: ticketInfo.value.isFree
|
description: ticketInfo.value.isFree
|
||||||
? "You're registered for this event"
|
? "You're registered for this event"
|
||||||
: "Ticket purchased successfully!",
|
: "Ticket purchased successfully!",
|
||||||
color: "success",
|
color: "green",
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("success", response);
|
emit("success", response);
|
||||||
|
|
||||||
if (response?.signedIn) {
|
// Refresh ticket info to show registered state
|
||||||
// New guest account or returning guest — refresh client auth state so the
|
await fetchTicketInfo();
|
||||||
// rest of the app sees them as logged in.
|
|
||||||
await useAuth().checkMemberStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchTicketInfo(form.value.email);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error purchasing ticket:", err);
|
console.error("Error purchasing ticket:", err);
|
||||||
|
|
||||||
|
|
@ -392,7 +382,7 @@ const handleSubmit = async () => {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Registration Failed",
|
title: "Registration Failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
color: "error",
|
color: "red",
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("error", err);
|
emit("error", err);
|
||||||
|
|
@ -403,10 +393,11 @@ const handleSubmit = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinWaitlist = () => {
|
const handleJoinWaitlist = () => {
|
||||||
|
// TODO: Implement waitlist functionality
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Waitlist",
|
title: "Waitlist",
|
||||||
description: "Waitlist functionality coming soon!",
|
description: "Waitlist functionality coming soon!",
|
||||||
color: "info",
|
color: "blue",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -416,64 +407,6 @@ const formatEventDate = (date) => {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
timeZone: props.eventTimezone || "America/Toronto",
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<div v-if="events?.length" class="em-rows">
|
<div v-if="events?.length" class="em-rows">
|
||||||
<div v-for="event in events" :key="event._id" class="em-item">
|
<div v-for="event in events" :key="event._id" class="em-item">
|
||||||
<div class="em-inset em-item-body">
|
<div class="em-inset em-item-body">
|
||||||
<span class="em-date">{{ formatDate(event) }}</span>
|
<span class="em-date">{{ formatDate(event.startDate) }}</span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/events/${event.slug || event._id}`"
|
:to="`/events/${event.slug || event._id}`"
|
||||||
class="em-title"
|
class="em-title"
|
||||||
|
|
@ -37,13 +37,10 @@ defineProps({
|
||||||
events: { type: Array, default: () => [] },
|
events: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (event) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!event?.startDate) return "";
|
if (!dateStr) return "";
|
||||||
return new Date(event.startDate).toLocaleDateString("en-US", {
|
const d = new Date(dateStr);
|
||||||
month: "short",
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
day: "numeric",
|
|
||||||
timeZone: event.displayTimezone || "America/Toronto",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -107,7 +104,7 @@ const formatDate = (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.em-circle {
|
.em-circle {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
padding: 16px 28px;
|
padding: 14px 32px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,14 @@
|
||||||
<img
|
<img
|
||||||
:src="transformedImageUrl"
|
:src="transformedImageUrl"
|
||||||
:alt="modelValue.alt || 'Event image'"
|
:alt="modelValue.alt || 'Event image'"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover rounded-lg border border-guild-700"
|
||||||
style="border: 1px solid var(--border)"
|
|
||||||
@error="console.log('Image failed to load:', transformedImageUrl)"
|
@error="console.log('Image failed to load:', transformedImageUrl)"
|
||||||
@load="console.log('Image loaded successfully:', transformedImageUrl)"
|
@load="console.log('Image loaded successfully:', transformedImageUrl)"
|
||||||
>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
class="absolute top-2 right-2 p-1 rounded-full transition-colors"
|
|
||||||
style="background: var(--ember); color: var(--parch-text)"
|
|
||||||
@click="removeImage"
|
@click="removeImage"
|
||||||
|
type="button"
|
||||||
|
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -23,84 +21,67 @@
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
<div
|
<div
|
||||||
v-if="!modelValue?.url"
|
v-if="!modelValue?.url"
|
||||||
class="border-2 border-dashed p-6 text-center transition-colors"
|
class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors"
|
||||||
:style="
|
|
||||||
isDragging
|
|
||||||
? 'border-color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent)'
|
|
||||||
: 'border-color: var(--border)'
|
|
||||||
"
|
|
||||||
@dragover.prevent="isDragging = true"
|
@dragover.prevent="isDragging = true"
|
||||||
@dragleave.prevent="isDragging = false"
|
@dragleave.prevent="isDragging = false"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
|
:class="{ 'border-candlelight-400 bg-candlelight-900/20': isDragging }"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="hidden"
|
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
>
|
class="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Icon
|
<Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" />
|
||||||
name="heroicons:photo"
|
|
||||||
class="w-12 h-12 mx-auto"
|
|
||||||
style="color: var(--text-dim)"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<p style="color: var(--text-dim)">
|
<p class="text-guild-400">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="font-medium"
|
|
||||||
style="color: var(--candle)"
|
|
||||||
@click="$refs.fileInput.click()"
|
@click="$refs.fileInput.click()"
|
||||||
|
class="text-candlelight-400 hover:text-candlelight-300 font-medium"
|
||||||
>
|
>
|
||||||
Click to upload
|
Click to upload
|
||||||
</button>
|
</button>
|
||||||
or drag and drop
|
or drag and drop
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm" style="color: var(--text-faint)">
|
<p class="text-sm text-guild-500">PNG, JPG, GIF up to 10MB</p>
|
||||||
PNG, JPG, GIF up to 10MB
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alt Text Input -->
|
<!-- Alt Text Input -->
|
||||||
<div v-if="modelValue?.url">
|
<div v-if="modelValue?.url">
|
||||||
<label
|
<label class="block text-sm font-medium text-guild-100 mb-1">
|
||||||
class="block text-sm font-medium mb-1"
|
|
||||||
style="color: var(--text-bright)"
|
|
||||||
>
|
|
||||||
Alt Text (for accessibility)
|
Alt Text (for accessibility)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
:value="modelValue.alt || ''"
|
:value="modelValue.alt || ''"
|
||||||
placeholder="Describe this image..."
|
|
||||||
class="w-full px-3 py-2 alt-text-input"
|
|
||||||
@input="updateAltText($event.target.value)"
|
@input="updateAltText($event.target.value)"
|
||||||
>
|
placeholder="Describe this image..."
|
||||||
|
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-candlelight-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Progress -->
|
<!-- Upload Progress -->
|
||||||
<div v-if="isUploading" class="space-y-2">
|
<div v-if="isUploading" class="space-y-2">
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span style="color: var(--text-dim)">Uploading...</span>
|
<span class="text-guild-400">Uploading...</span>
|
||||||
<span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
|
<span class="text-guild-400">{{ uploadProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full bg-guild-800 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
class="w-full rounded-full h-2"
|
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
|
||||||
style="background: var(--surface)"
|
:style="`width: ${uploadProgress}%`"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-2 rounded-full transition-all duration-300"
|
|
||||||
:style="`width: ${uploadProgress}%; background: var(--candle)`"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
|
<div v-if="errorMessage" class="text-sm text-ember-400">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,16 +201,3 @@ const updateAltText = (altText) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.alt-text-input {
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alt-text-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--candle);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="your.email@example.com"
|
placeholder="your.email@example.com"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
|
|
@ -182,7 +182,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
|
|
||||||
.modal-overline {
|
.modal-overline {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: 'Brygada 1918', serif;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
@ -218,7 +218,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
.info-box {
|
.info-box {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
padding: 12px 16px;
|
padding: 10px 14px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,67 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="natural-date-input">
|
<div class="space-y-2">
|
||||||
|
<div class="relative">
|
||||||
<UInput
|
<UInput
|
||||||
:model-value="rawInput"
|
v-model="naturalInput"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:color="trailingState"
|
:color="
|
||||||
@update:model-value="onInputChange"
|
hasError && naturalInput.trim()
|
||||||
|
? 'error'
|
||||||
|
: isValidParse && naturalInput.trim()
|
||||||
|
? 'success'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@input="parseNaturalInput"
|
||||||
|
@blur="onBlur"
|
||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="isValid && rawInput.trim()"
|
v-if="isValidParse && naturalInput.trim()"
|
||||||
name="heroicons:check-circle"
|
name="heroicons:check-circle"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5 text-candlelight-500"
|
||||||
style="color: var(--candle)"
|
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
v-else-if="hasError && rawInput.trim()"
|
v-else-if="hasError && naturalInput.trim()"
|
||||||
name="heroicons:exclamation-circle"
|
name="heroicons:exclamation-circle"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5 text-ember-500"
|
||||||
style="color: var(--ember)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
<p
|
</div>
|
||||||
v-if="rawInput.trim() && isValid"
|
|
||||||
class="preview-line"
|
<div
|
||||||
style="color: var(--candle)"
|
v-if="parsedDate && isValidParse"
|
||||||
|
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800"
|
||||||
>
|
>
|
||||||
→ {{ previewText }}
|
<div class="flex items-center gap-2">
|
||||||
</p>
|
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
||||||
<p
|
<span>{{ formatParsedDate(parsedDate) }}</span>
|
||||||
v-else-if="rawInput.trim() && hasError"
|
</div>
|
||||||
class="preview-line"
|
</div>
|
||||||
style="color: var(--ember)"
|
|
||||||
|
<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 }}
|
<div class="flex items-center gap-2">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -42,197 +69,176 @@
|
||||||
import * as chrono from "chrono-node";
|
import * as chrono from "chrono-node";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: "" },
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
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 emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
const rawInput = ref("");
|
const naturalInput = ref("");
|
||||||
const isValid = ref(false);
|
const parsedDate = ref(null);
|
||||||
|
const isValidParse = ref(false);
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
// previewDate holds the parsed value as a UTC Date so we can format it in
|
const datetimeValue = ref("");
|
||||||
// arbitrary timezones without re-parsing. Source of truth for the preview.
|
|
||||||
const previewDate = ref(null);
|
|
||||||
|
|
||||||
const trailingState = computed(() => {
|
// Initialize with current value
|
||||||
if (!rawInput.value.trim()) return undefined;
|
onMounted(() => {
|
||||||
if (hasError.value) return "error";
|
if (props.modelValue) {
|
||||||
if (isValid.value) return "success";
|
const date = new Date(props.modelValue);
|
||||||
return undefined;
|
if (!isNaN(date.getTime())) {
|
||||||
});
|
parsedDate.value = date;
|
||||||
|
datetimeValue.value = formatForDatetimeLocal(date);
|
||||||
const previewText = computed(() => {
|
isValidParse.value = true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(next) => {
|
(newValue) => {
|
||||||
const tz = activeTZ();
|
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
|
||||||
const expected = previewDate.value
|
const date = new Date(newValue);
|
||||||
? utcToZonedLocal(previewDate.value, tz)
|
if (!isNaN(date.getTime())) {
|
||||||
: "";
|
parsedDate.value = date;
|
||||||
if (next === expected) return;
|
datetimeValue.value = formatForDatetimeLocal(date);
|
||||||
seedFromModelValue();
|
isValidParse.value = true;
|
||||||
|
naturalInput.value = ""; // Clear natural input when set externally
|
||||||
|
}
|
||||||
|
} else if (!newValue) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
const parseNaturalInput = () => {
|
||||||
() => props.displayTimezone,
|
const input = naturalInput.value.trim();
|
||||||
() => {
|
|
||||||
// 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 onInputChange = (value) => {
|
if (!input) {
|
||||||
rawInput.value = value;
|
reset();
|
||||||
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", "");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tz = activeTZ();
|
|
||||||
let results;
|
|
||||||
try {
|
try {
|
||||||
results = chrono.parse(trimmed, referenceNowInTZ(tz));
|
// Parse with chrono-node
|
||||||
} catch {
|
const results = chrono.parse(input);
|
||||||
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
|
||||||
return;
|
if (results.length > 0) {
|
||||||
}
|
const result = results[0];
|
||||||
if (!results.length) {
|
const date = result.date();
|
||||||
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
|
||||||
return;
|
// Validate the parsed date
|
||||||
}
|
if (date && !isNaN(date.getTime())) {
|
||||||
const date = results[0].date();
|
parsedDate.value = date;
|
||||||
if (!date || Number.isNaN(date.getTime())) {
|
isValidParse.value = true;
|
||||||
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;
|
|
||||||
hasError.value = false;
|
hasError.value = false;
|
||||||
errorMessage.value = "";
|
datetimeValue.value = formatForDatetimeLocal(date);
|
||||||
previewDate.value = utc;
|
emit("update:modelValue", formatForDatetimeLocal(date));
|
||||||
emit("update:modelValue", localStr);
|
} 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) => {
|
const onBlur = () => {
|
||||||
isValid.value = false;
|
// If we have a valid parse but the input changed, try to parse again
|
||||||
hasError.value = true;
|
if (naturalInput.value.trim() && !isValidParse.value) {
|
||||||
errorMessage.value = msg;
|
parseNaturalInput();
|
||||||
previewDate.value = null;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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", "");
|
emit("update:modelValue", "");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a Date object whose browser-local components equal the current
|
const setError = (message) => {
|
||||||
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
|
isValidParse.value = false;
|
||||||
// Friday" anchor to the event TZ rather than the editor's browser TZ.
|
hasError.value = true;
|
||||||
const referenceNowInTZ = (tz) => {
|
errorMessage.value = message;
|
||||||
const nowStr = utcToZonedLocal(new Date(), tz);
|
parsedDate.value = null;
|
||||||
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 browserComponentsToString = (date) => {
|
const formatForDatetimeLocal = (date) => {
|
||||||
const y = date.getFullYear();
|
if (!date) return "";
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
// Format as YYYY-MM-DDTHH:MM for datetime-local input
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
const year = date.getFullYear();
|
||||||
const h = String(date.getHours()).padStart(2, "0");
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
return `${y}-${mo}-${d}T${h}:${mi}`;
|
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) => {
|
const formatParsedDate = (date) => {
|
||||||
// Format chosen to round-trip cleanly through chrono.parse.
|
if (!date) return "";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: tz,
|
const now = new Date();
|
||||||
month: "short",
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
day: "numeric",
|
const tomorrow = new Date(now);
|
||||||
year: "numeric",
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const isTomorrow = date.toDateString() === tomorrow.toDateString();
|
||||||
|
|
||||||
|
const timeStr = date.toLocaleString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.natural-date-input {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-line {
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,176 +1,215 @@
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="!loading" class="onboarding-widget">
|
<div v-if="!loading" class="onboarding-widget dashed-box no-hover">
|
||||||
<!-- Welcome mode: onboarding in progress -->
|
<!-- Welcome mode: onboarding in progress -->
|
||||||
<template v-if="!isComplete">
|
<template v-if="!isComplete">
|
||||||
<div class="ow-prompt">> welcome</div>
|
<div class="ow-header">
|
||||||
<div class="ow-message">You are in the <strong>Ghost Guild</strong>. A few passages remain unexplored.</div>
|
<h3 class="ow-title">Welcome to Ghost Guild</h3>
|
||||||
<div class="ow-hint">Next: {{ currentSuggestion.text }}</div>
|
<p class="ow-intro">Get oriented — here are a few things to explore as a new member.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ow-suggestion">
|
||||||
|
<span class="ow-suggestion-text">{{ currentSuggestion.text }}</span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
|
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
|
||||||
:to="currentSuggestion.action"
|
:to="currentSuggestion.action"
|
||||||
class="ow-action"
|
class="btn btn-primary ow-action"
|
||||||
>
|
>
|
||||||
{{ currentSuggestion.actionText }} →
|
{{ currentSuggestion.actionText }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<a
|
<a
|
||||||
v-else-if="currentSuggestion.isExternal"
|
v-else-if="currentSuggestion.isExternal"
|
||||||
:href="currentSuggestion.action"
|
href="https://wiki.ghostguild.org"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="ow-action"
|
class="btn btn-primary ow-action"
|
||||||
@click="trackGoal('wikiClicked')"
|
@click="trackGoal('wikiClicked')"
|
||||||
>
|
>
|
||||||
{{ currentSuggestion.actionText }} →
|
{{ currentSuggestion.actionText }}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ow-progress">
|
<div class="ow-progress">
|
||||||
<span class="ow-bar"><span class="ow-bar-fill">{{ barFill }}</span><span class="ow-bar-empty">{{ barEmpty }}</span></span>
|
<span class="ow-progress-label">{{ completedCount }} of 4</span>
|
||||||
{{ completedCount }} of 4 explored
|
<span class="ow-dots">
|
||||||
<button
|
<span
|
||||||
v-if="currentSuggestion.key"
|
v-for="i in 4"
|
||||||
type="button"
|
:key="i"
|
||||||
class="ow-skip"
|
class="ow-dot"
|
||||||
@click="handleSkip"
|
:class="{ 'ow-dot--done': i <= completedCount }"
|
||||||
>Skip this</button>
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Suggestion mode: onboarding complete -->
|
<!-- Suggestion mode: onboarding complete -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-if="currentSuggestion.key === 'empty'" class="ow-prompt">> look</div>
|
<div v-if="currentSuggestion.key === 'empty'" class="ow-empty">
|
||||||
<div v-if="currentSuggestion.key === 'empty'" class="ow-message ow-message--dim">{{ currentSuggestion.text }}</div>
|
{{ currentSuggestion.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recommendation (event, board, or wiki) -->
|
<!-- Event recommendation -->
|
||||||
<template v-if="currentSuggestion.key !== 'empty'">
|
<div v-else-if="currentSuggestion.key === 'event'" class="ow-rec">
|
||||||
<div class="ow-prompt">> look</div>
|
<div class="section-label">Suggested</div>
|
||||||
<div class="ow-message">{{ currentSuggestion.text }}</div>
|
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
|
||||||
<a
|
|
||||||
v-if="currentSuggestion.isExternal && currentSuggestion.action"
|
|
||||||
:href="currentSuggestion.action"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="ow-action"
|
|
||||||
>
|
|
||||||
{{ currentSuggestion.actionText }} →
|
|
||||||
</a>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-else-if="currentSuggestion.action"
|
|
||||||
:to="currentSuggestion.action"
|
:to="currentSuggestion.action"
|
||||||
class="ow-action"
|
class="ow-rec-link"
|
||||||
>
|
>
|
||||||
{{ currentSuggestion.actionText }} →
|
{{ currentSuggestion.actionText }} →
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
|
<!-- Ecology recommendation -->
|
||||||
|
<div v-else-if="currentSuggestion.key === 'ecology'" class="ow-rec">
|
||||||
|
<div class="section-label">Suggested</div>
|
||||||
|
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
|
||||||
|
<NuxtLink
|
||||||
|
:to="currentSuggestion.action"
|
||||||
|
class="ow-rec-link"
|
||||||
|
>
|
||||||
|
{{ currentSuggestion.actionText }} →
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wiki recommendation -->
|
||||||
|
<div v-else-if="currentSuggestion.key === 'wiki'" class="ow-rec">
|
||||||
|
<div class="section-label">Suggested</div>
|
||||||
|
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
|
||||||
|
<a
|
||||||
|
v-if="currentSuggestion.action"
|
||||||
|
:href="currentSuggestion.action"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="ow-rec-link"
|
||||||
|
@click="trackGoal('wikiClicked')"
|
||||||
|
>
|
||||||
|
{{ currentSuggestion.actionText }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { goals, isComplete, currentSuggestion, trackGoal, skipSuggestion, loading } = useOnboarding()
|
const { goals, isComplete, currentSuggestion, trackGoal, loading } = useOnboarding()
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
const key = currentSuggestion.value?.key
|
|
||||||
if (key) skipSuggestion(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedCount = computed(() => {
|
const completedCount = computed(() => {
|
||||||
const g = goals.value
|
const g = goals.value
|
||||||
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedBoard, g.hasClickedWiki]
|
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedEcology, g.hasClickedWiki]
|
||||||
.filter(Boolean).length
|
.filter(Boolean).length
|
||||||
})
|
})
|
||||||
|
|
||||||
const barFill = computed(() => '[' + '#'.repeat(completedCount.value * 2))
|
|
||||||
const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.onboarding-widget {
|
.onboarding-widget {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px dashed var(--parch-border);
|
}
|
||||||
background: var(--parch);
|
|
||||||
color: var(--parch-text);
|
/* Welcome mode */
|
||||||
|
.ow-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ow-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ow-intro {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
color: var(--text-dim);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-prompt {
|
.ow-suggestion {
|
||||||
color: var(--parch-accent);
|
display: flex;
|
||||||
margin-bottom: 6px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-message {
|
.ow-suggestion-text {
|
||||||
color: var(--parch-text);
|
font-size: 12px;
|
||||||
margin-bottom: 2px;
|
color: var(--text);
|
||||||
}
|
line-height: 1.5;
|
||||||
|
|
||||||
.ow-message--dim {
|
|
||||||
color: var(--parch-text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ow-hint {
|
|
||||||
color: var(--parch-text-dim);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-action {
|
.ow-action {
|
||||||
display: inline-block;
|
flex-shrink: 0;
|
||||||
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;
|
font-size: 11px;
|
||||||
text-decoration: none;
|
padding: 5px 14px;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ow-action:hover {
|
|
||||||
border-color: var(--parch-accent);
|
|
||||||
border-style: solid;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-progress {
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-bar {
|
.ow-progress-label {
|
||||||
display: inline-flex;
|
font-size: 10px;
|
||||||
gap: 0;
|
letter-spacing: 0.08em;
|
||||||
letter-spacing: 0;
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-bar-fill {
|
.ow-dots {
|
||||||
color: var(--parch-accent);
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-bar-empty {
|
.ow-dot {
|
||||||
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-skip {
|
.ow-dot--done {
|
||||||
margin-left: auto;
|
background: var(--candle);
|
||||||
background: none;
|
border-color: var(--candle);
|
||||||
border: none;
|
border-style: solid;
|
||||||
color: var(--parch-text-dim);
|
}
|
||||||
font-family: inherit;
|
|
||||||
|
/* Suggestion mode (graduated) */
|
||||||
|
.ow-rec {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ow-rec .section-label {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ow-rec-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ow-rec-link {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
color: var(--candle);
|
||||||
padding: 0;
|
margin-top: 2px;
|
||||||
text-decoration: underline;
|
display: inline-block;
|
||||||
text-decoration-style: dashed;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-skip:hover {
|
.ow-empty {
|
||||||
color: var(--parch-accent);
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
color: var(--parch-text);
|
color: var(--parch-text);
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-bottom: 1px dashed var(--parch-border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.parchment-inset :deep(h2) {
|
.parchment-inset :deep(h2) {
|
||||||
|
|
@ -30,6 +30,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.parchment-inset :deep(a) {
|
.parchment-inset :deep(a) {
|
||||||
color: var(--parch-accent);
|
color: var(--candle-faint);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
66
app/components/PrivacyToggle.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<div class="priv segmented">
|
||||||
|
<span
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
:class="{ on: modelValue === opt.value }"
|
||||||
|
@click="$emit('update:modelValue', opt.value)"
|
||||||
|
>{{ opt.label }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: String, default: "public" },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: "Public", value: "public" },
|
||||||
|
{ label: "Members", value: "members" },
|
||||||
|
{ label: "Private", value: "private" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.priv {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span {
|
||||||
|
padding: 2px 7px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span + span {
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span.on {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,16 +4,19 @@
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="loading" class="text-center py-8">
|
||||||
<div
|
<div
|
||||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||||
/>
|
></div>
|
||||||
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="error" class="error-state p-6">
|
<div
|
||||||
<h3 class="error-state__heading text-lg font-semibold mb-2">
|
v-else-if="error"
|
||||||
|
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold text-ember-300 mb-2">
|
||||||
Unable to Load Series Pass
|
Unable to Load Series Pass
|
||||||
</h3>
|
</h3>
|
||||||
<p class="error-state__body">{{ error }}</p>
|
<p class="text-ember-400">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
|
|
@ -45,7 +48,7 @@
|
||||||
<!-- Registration Form -->
|
<!-- Registration Form -->
|
||||||
<div
|
<div
|
||||||
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
||||||
class="registration-form p-6"
|
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
||||||
{{
|
{{
|
||||||
|
|
@ -55,7 +58,7 @@
|
||||||
}}
|
}}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
<!-- Name Field -->
|
<!-- Name Field -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|
@ -100,20 +103,18 @@
|
||||||
<!-- Member Benefits Notice -->
|
<!-- Member Benefits Notice -->
|
||||||
<div
|
<div
|
||||||
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
||||||
class="p-4"
|
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
|
||||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon
|
<Icon
|
||||||
name="heroicons:sparkles"
|
name="heroicons:sparkles"
|
||||||
class="w-5 h-5 flex-shrink-0 mt-0.5"
|
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
|
||||||
style="color: var(--candle)"
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold mb-1" style="color: var(--candle)">
|
<div class="font-semibold text-candlelight-300 mb-1">
|
||||||
Member Benefit
|
Member Benefit
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm" style="color: var(--candle)">
|
<div class="text-sm text-candlelight-400">
|
||||||
This series pass is free for Ghost Guild members!
|
This series pass is free for Ghost Guild members!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,7 +144,6 @@
|
||||||
<p class="text-xs text-[--ui-text-muted] text-center">
|
<p class="text-xs text-[--ui-text-muted] text-center">
|
||||||
By registering, you'll be automatically registered for all
|
By registering, you'll be automatically registered for all
|
||||||
{{ seriesInfo.totalEvents }} events in this series.
|
{{ seriesInfo.totalEvents }} events in this series.
|
||||||
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
|
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +182,7 @@ const props = defineProps({
|
||||||
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
|
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
@ -264,9 +264,10 @@ const handleSubmit = async () => {
|
||||||
paymentProcessing.value = true;
|
paymentProcessing.value = true;
|
||||||
|
|
||||||
// Initialize Helcim payment for series pass
|
// Initialize Helcim payment for series pass
|
||||||
await initializeSeriesTicketPayment(
|
await initializeTicketPayment(
|
||||||
props.seriesId,
|
props.seriesId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
|
passInfo.value.ticket.price,
|
||||||
props.seriesInfo.title,
|
props.seriesInfo.title,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -285,7 +286,6 @@ const handleSubmit = async () => {
|
||||||
const purchaseBody = {
|
const purchaseBody = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
email: form.value.email,
|
email: form.value.email,
|
||||||
ticketType: passInfo.value.ticket.type,
|
|
||||||
};
|
};
|
||||||
if (transactionId) purchaseBody.paymentId = transactionId;
|
if (transactionId) purchaseBody.paymentId = transactionId;
|
||||||
|
|
||||||
|
|
@ -297,17 +297,12 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh client auth state if server signed us in (guest upgrade)
|
|
||||||
if (purchaseResponse?.signedIn) {
|
|
||||||
await useAuth().checkMemberStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Series Pass Purchased!",
|
title: "Series Pass Purchased!",
|
||||||
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
||||||
color: "green",
|
color: "green",
|
||||||
duration: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit success event
|
// Emit success event
|
||||||
|
|
@ -327,7 +322,7 @@ const handleSubmit = async () => {
|
||||||
title: "Purchase Failed",
|
title: "Purchase Failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
color: "red",
|
color: "red",
|
||||||
duration: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("purchase-error", errorMessage);
|
emit("purchase-error", errorMessage);
|
||||||
|
|
@ -354,18 +349,3 @@ const formatPrice = (price, currency = "CAD") => {
|
||||||
}).format(price);
|
}).format(price);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.error-state {
|
|
||||||
background: color-mix(in srgb, var(--ember) 8%, transparent);
|
|
||||||
border: 1px dashed var(--ember);
|
|
||||||
}
|
|
||||||
.error-state__heading,
|
|
||||||
.error-state__body {
|
|
||||||
color: var(--ember);
|
|
||||||
}
|
|
||||||
.registration-form {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
102
app/components/TierPicker.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<template>
|
||||||
|
<div class="tier-picker">
|
||||||
|
<div
|
||||||
|
v-for="tier in tiers"
|
||||||
|
:key="tier.amount"
|
||||||
|
class="tier-option"
|
||||||
|
:class="{ current: modelValue === tier.amount }"
|
||||||
|
@click="$emit('update:modelValue', tier.amount)"
|
||||||
|
>
|
||||||
|
<span class="tier-amount">{{ tier.display }}</span>
|
||||||
|
<span class="tier-label">{{ tier.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: Number, default: 0 },
|
||||||
|
tiers: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ amount: 0, display: "$0", label: "Free" },
|
||||||
|
{ amount: 5, display: "$5", label: "/month" },
|
||||||
|
{ amount: 15, display: "$15", label: "/month" },
|
||||||
|
{ amount: 30, display: "$30", label: "/month" },
|
||||||
|
{ amount: 50, display: "$50", label: "/month" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["update:modelValue"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tier-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||||
|
.tier-option + .tier-option {
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active item paints its solid border on top of any neighbor */
|
||||||
|
.tier-option.current {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-amount {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option.current .tier-amount {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option.current .tier-label {
|
||||||
|
color: var(--candle-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tier-picker {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tier-option {
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -12,17 +12,12 @@
|
||||||
class="breadcrumb-link"
|
class="breadcrumb-link"
|
||||||
>{{ crumb.label }}</NuxtLink
|
>{{ crumb.label }}</NuxtLink
|
||||||
>
|
>
|
||||||
<ClientOnly v-else>
|
<span v-else class="breadcrumb-current">{{ crumb.label }}</span>
|
||||||
<span class="breadcrumb-current">{{ crumb.label }}</span>
|
|
||||||
<template #fallback>
|
|
||||||
<span class="breadcrumb-current"> </span>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
<span class="right">
|
<span>
|
||||||
<slot name="right">
|
<slot name="right">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<template v-if="memberData">
|
<template v-if="memberData">
|
||||||
|
|
@ -32,7 +27,7 @@
|
||||||
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
||||||
:alt="memberData.name"
|
:alt="memberData.name"
|
||||||
class="member-avatar"
|
class="member-avatar"
|
||||||
>
|
/>
|
||||||
<svg
|
<svg
|
||||||
v-else
|
v-else
|
||||||
class="member-avatar default-ghost"
|
class="member-avatar default-ghost"
|
||||||
|
|
@ -61,13 +56,9 @@
|
||||||
</svg>
|
</svg>
|
||||||
{{ memberData.name }}
|
{{ memberData.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="sep" aria-hidden="true">/</span>
|
|
||||||
<a href="#" class="sign-out" @click.prevent="handleLogout"
|
|
||||||
>sign out</a
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else> The Baby Ghosts member program </template>
|
<template v-else> A cooperative for game developers </template>
|
||||||
<template #fallback> The Baby Ghosts member program </template>
|
<template #fallback> A cooperative for game developers </template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -79,12 +70,7 @@ const props = defineProps({
|
||||||
pagePath: { type: String, default: "" },
|
pagePath: { type: String, default: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { memberData, logout } = useAuth();
|
const { memberData } = useAuth();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
navigateTo("/");
|
|
||||||
};
|
|
||||||
|
|
||||||
const capitalize = (str) => {
|
const capitalize = (str) => {
|
||||||
if (!str) return "";
|
if (!str) return "";
|
||||||
|
|
@ -126,9 +112,6 @@ const breadcrumbs = computed(() => {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.member-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.member-avatar {
|
.member-avatar {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|
@ -137,23 +120,6 @@ const breadcrumbs = computed(() => {
|
||||||
.default-ghost {
|
.default-ghost {
|
||||||
color: var(--border);
|
color: var(--border);
|
||||||
}
|
}
|
||||||
.right {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.sep {
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
.top-strip a.sign-out {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ember);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.top-strip a.sign-out:hover {
|
|
||||||
color: var(--ember);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-nav {
|
.breadcrumb-nav {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
app/composables/useEcology.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const useEcology = () => {
|
||||||
|
const getSuggestions = (params = {}) =>
|
||||||
|
$fetch('/api/ecology/suggestions', { params })
|
||||||
|
|
||||||
|
return { getSuggestions }
|
||||||
|
}
|
||||||
|
|
@ -1,98 +1,85 @@
|
||||||
// Utility composable for event date handling with timezone support.
|
// Utility composable for event date handling with timezone support
|
||||||
// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ.
|
|
||||||
export const useEventDateUtils = () => {
|
export const useEventDateUtils = () => {
|
||||||
const DEFAULT_TIMEZONE = "America/Toronto";
|
const TIMEZONE = "America/Toronto";
|
||||||
|
|
||||||
|
// Format a date to a specific format
|
||||||
const formatDate = (date, options = {}) => {
|
const formatDate = (date, options = {}) => {
|
||||||
if (!date) return "";
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
if (isNaN(dateObj.getTime())) return "";
|
const { month = "short", day = "numeric", year = "numeric" } = options;
|
||||||
const {
|
|
||||||
month = "short",
|
|
||||||
day = "numeric",
|
|
||||||
year = "numeric",
|
|
||||||
weekday,
|
|
||||||
timeZone,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
...(weekday && { weekday }),
|
|
||||||
month,
|
month,
|
||||||
day,
|
day,
|
||||||
year,
|
year,
|
||||||
...(timeZone && { timeZone }),
|
|
||||||
}).format(dateObj);
|
}).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";
|
if (!startDate || !endDate) return "No dates";
|
||||||
|
|
||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
|
|
||||||
const tzOpts = timeZone ? { timeZone } : {};
|
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
|
||||||
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
|
||||||
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
const startDay = start.getDate();
|
||||||
const startDay = Number(
|
const endDay = end.getDate();
|
||||||
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
const year = end.getFullYear();
|
||||||
);
|
|
||||||
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 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
if (startMonthIdx === endMonthIdx && startYear === year) {
|
if (
|
||||||
|
start.getMonth() === end.getMonth() &&
|
||||||
|
start.getFullYear() === end.getFullYear()
|
||||||
|
) {
|
||||||
return `${startMonth} ${startDay}-${endDay}`;
|
return `${startMonth} ${startDay}-${endDay}`;
|
||||||
}
|
}
|
||||||
return `${startMonth} ${startDay} - ${endMonth} ${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}`;
|
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||||
} else if (startYear === year) {
|
} else if (start.getFullYear() === end.getFullYear()) {
|
||||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||||
} else {
|
} 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 isPastDate = (date) => {
|
||||||
const dateObj = date instanceof Date ? date : new Date(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 dateObj = date instanceof Date ? date : new Date(date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
|
|
||||||
return (
|
return (
|
||||||
dateObj.toLocaleDateString("en-US", opts) ===
|
dateObj.getDate() === today.getDate() &&
|
||||||
today.toLocaleDateString("en-US", opts)
|
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);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
const options = {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
...(includeSeconds && { second: "2-digit" }),
|
...(includeSeconds && { second: "2-digit" }),
|
||||||
...(timeZone && { timeZone }),
|
};
|
||||||
}).format(dateObj);
|
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DEFAULT_TIMEZONE,
|
TIMEZONE,
|
||||||
// Legacy alias for callers that hard-coded the constant.
|
|
||||||
TIMEZONE: DEFAULT_TIMEZONE,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
isPastDate,
|
isPastDate,
|
||||||
|
|
|
||||||
90
app/composables/useHelcim.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ export const useHelcimPay = () => {
|
||||||
let checkoutToken = null;
|
let checkoutToken = null;
|
||||||
let secretToken = null;
|
let secretToken = null;
|
||||||
|
|
||||||
// Initialize HelcimPay.js session (membership signup flow)
|
// Initialize HelcimPay.js session
|
||||||
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||||
|
|
@ -12,7 +12,6 @@ export const useHelcimPay = () => {
|
||||||
customerId,
|
customerId,
|
||||||
customerCode,
|
customerCode,
|
||||||
amount,
|
amount,
|
||||||
metadata: { type: "membership_signup" },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -29,14 +28,26 @@ export const useHelcimPay = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _initializeTicket = async (metadata, errorPrefix) => {
|
// Initialize payment for event ticket purchase
|
||||||
|
const initializeTicketPayment = async (
|
||||||
|
eventId,
|
||||||
|
email,
|
||||||
|
amount,
|
||||||
|
eventTitle = null,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
customerId: null,
|
customerId: null,
|
||||||
customerCode: metadata.email,
|
customerCode: email, // Use email as customer code for event tickets
|
||||||
metadata,
|
amount,
|
||||||
|
metadata: {
|
||||||
|
type: "event_ticket",
|
||||||
|
eventId,
|
||||||
|
email,
|
||||||
|
eventTitle,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -46,29 +57,16 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
checkoutToken: response.checkoutToken,
|
checkoutToken: response.checkoutToken,
|
||||||
amount: response.amount,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to initialize ${errorPrefix} session`);
|
throw new Error("Failed to initialize ticket payment session");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${errorPrefix} initialization error:`, error);
|
console.error("Ticket payment initialization error:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeTicketPayment = (eventId, email, eventTitle = null) =>
|
|
||||||
_initializeTicket(
|
|
||||||
{ type: "event_ticket", eventId, email, eventTitle },
|
|
||||||
"ticket payment",
|
|
||||||
);
|
|
||||||
|
|
||||||
const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
|
|
||||||
_initializeTicket(
|
|
||||||
{ type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
|
|
||||||
"series payment",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show payment modal
|
// Show payment modal
|
||||||
const showPaymentModal = () => {
|
const showPaymentModal = () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -141,7 +139,6 @@ export const useHelcimPay = () => {
|
||||||
if (typeof window.appendHelcimPayIframe === "function") {
|
if (typeof window.appendHelcimPayIframe === "function") {
|
||||||
// Set up event listener for HelcimPay.js responses
|
// Set up event listener for HelcimPay.js responses
|
||||||
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
||||||
let observerTimer, paymentTimer;
|
|
||||||
|
|
||||||
const handleHelcimPayEvent = (event) => {
|
const handleHelcimPayEvent = (event) => {
|
||||||
console.log("Received window message:", event.data);
|
console.log("Received window message:", event.data);
|
||||||
|
|
@ -151,8 +148,6 @@ export const useHelcimPay = () => {
|
||||||
|
|
||||||
// Remove event listener to prevent multiple responses
|
// Remove event listener to prevent multiple responses
|
||||||
window.removeEventListener("message", handleHelcimPayEvent);
|
window.removeEventListener("message", handleHelcimPayEvent);
|
||||||
clearTimeout(observerTimer);
|
|
||||||
clearTimeout(paymentTimer);
|
|
||||||
|
|
||||||
// Close the Helcim modal
|
// Close the Helcim modal
|
||||||
if (typeof window.removeHelcimPayIframe === "function") {
|
if (typeof window.removeHelcimPayIframe === "function") {
|
||||||
|
|
@ -242,10 +237,10 @@ export const useHelcimPay = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up observer after a timeout
|
// Clean up observer after a timeout
|
||||||
observerTimer = setTimeout(() => observer.disconnect(), 5000);
|
setTimeout(() => observer.disconnect(), 5000);
|
||||||
|
|
||||||
// Add timeout to clean up if no response (10 minutes for manual card entry)
|
// Add timeout to clean up if no response (10 minutes for manual card entry)
|
||||||
paymentTimer = setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("Payment timeout reached, cleaning up event listener...");
|
console.log("Payment timeout reached, cleaning up event listener...");
|
||||||
window.removeEventListener("message", handleHelcimPayEvent);
|
window.removeEventListener("message", handleHelcimPayEvent);
|
||||||
reject(new Error("Payment timeout - no response received"));
|
reject(new Error("Payment timeout - no response received"));
|
||||||
|
|
@ -277,7 +272,6 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
initializeHelcimPay,
|
initializeHelcimPay,
|
||||||
initializeTicketPayment,
|
initializeTicketPayment,
|
||||||
initializeSeriesTicketPayment,
|
|
||||||
verifyPayment,
|
verifyPayment,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,59 +25,25 @@ export const useMemberPayment = () => {
|
||||||
paymentSuccess.value = false
|
paymentSuccess.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fast-path: when both Helcim ids are already cached on the member doc
|
// Step 1: Get or create Helcim customer
|
||||||
// AND a card's on file, we can skip the paid getOrCreateCustomer round
|
await getOrCreateCustomer()
|
||||||
// trip entirely and go straight to subscription creation.
|
|
||||||
const hasCachedHelcimIds = Boolean(
|
|
||||||
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
|
||||||
)
|
|
||||||
|
|
||||||
let existing = null
|
// Step 2: Initialize Helcim payment with $0 for card verification
|
||||||
let probedExistingCard = false
|
|
||||||
let cardToken = null
|
|
||||||
|
|
||||||
if (hasCachedHelcimIds) {
|
|
||||||
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
|
|
||||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
probedExistingCard = true
|
|
||||||
if (existing?.cardToken) {
|
|
||||||
customerId.value = memberData.value.helcimCustomerId
|
|
||||||
customerCode.value = memberData.value.helcimCustomerCode
|
|
||||||
cardToken = existing.cardToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cardToken) {
|
|
||||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
|
||||||
// to re-save it, breaking retries after a partial-failed signup.
|
|
||||||
const [, existingFromFull] = await Promise.all([
|
|
||||||
getOrCreateCustomer(),
|
|
||||||
probedExistingCard
|
|
||||||
? Promise.resolve(existing)
|
|
||||||
: $fetch('/api/helcim/existing-card').catch((err) => {
|
|
||||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
|
||||||
return null
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
cardToken = existingFromFull?.cardToken || null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cardToken) {
|
|
||||||
await initializeHelcimPay(
|
await initializeHelcimPay(
|
||||||
customerId.value,
|
customerId.value,
|
||||||
customerCode.value,
|
customerCode.value,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Step 3: Show payment modal and get payment result
|
||||||
const paymentResult = await verifyPayment()
|
const paymentResult = await verifyPayment()
|
||||||
|
console.log('Payment result:', paymentResult)
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (!paymentResult.success) {
|
||||||
throw new Error('Payment verification failed')
|
throw new Error('Payment verification failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4: Verify payment on backend
|
||||||
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -90,16 +56,14 @@ export const useMemberPayment = () => {
|
||||||
throw new Error('Payment verification failed on backend')
|
throw new Error('Payment verification failed on backend')
|
||||||
}
|
}
|
||||||
|
|
||||||
cardToken = paymentResult.cardToken
|
// Step 5: Create subscription with proper contribution tier
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
customerId: customerId.value,
|
customerId: customerId.value,
|
||||||
customerCode: customerCode.value,
|
customerCode: customerCode.value,
|
||||||
contributionAmount: memberData.value?.contributionAmount ?? 5,
|
contributionTier: memberData.value?.contributionTier || '5',
|
||||||
cardToken,
|
cardToken: paymentResult.cardToken,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -107,6 +71,7 @@ export const useMemberPayment = () => {
|
||||||
throw new Error('Subscription creation failed')
|
throw new Error('Subscription creation failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 6: Payment successful - refresh member data
|
||||||
paymentSuccess.value = true
|
paymentSuccess.value = true
|
||||||
await checkMemberStatus()
|
await checkMemberStatus()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,146 +4,137 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MEMBER_STATUSES = {
|
export const MEMBER_STATUSES = {
|
||||||
PENDING_PAYMENT: "pending_payment",
|
PENDING_PAYMENT: 'pending_payment',
|
||||||
ACTIVE: "active",
|
ACTIVE: 'active',
|
||||||
SUSPENDED: "suspended",
|
SUSPENDED: 'suspended',
|
||||||
CANCELLED: "cancelled",
|
CANCELLED: 'cancelled',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const MEMBER_STATUS_CONFIG = {
|
export const MEMBER_STATUS_CONFIG = {
|
||||||
pending_payment: {
|
pending_payment: {
|
||||||
label: "Setting up payment",
|
label: 'Payment Pending',
|
||||||
color: "orange",
|
color: 'orange',
|
||||||
bgColor: "bg-orange-500/10",
|
bgColor: 'bg-orange-500/10',
|
||||||
borderColor: "border-orange-500/30",
|
borderColor: 'border-orange-500/30',
|
||||||
textColor: "text-orange-300",
|
textColor: 'text-orange-300',
|
||||||
icon: "heroicons:exclamation-triangle",
|
icon: 'heroicons:exclamation-triangle',
|
||||||
severity: "warning",
|
severity: 'warning',
|
||||||
canRSVP: true,
|
canRSVP: false,
|
||||||
canAccessMembers: true,
|
canAccessMembers: true,
|
||||||
canPeerSupport: true,
|
canPeerSupport: false,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
label: "Active Member",
|
label: 'Active Member',
|
||||||
color: "green",
|
color: 'green',
|
||||||
bgColor: "bg-green-500/10",
|
bgColor: 'bg-green-500/10',
|
||||||
borderColor: "border-green-500/30",
|
borderColor: 'border-green-500/30',
|
||||||
textColor: "text-green-300",
|
textColor: 'text-green-300',
|
||||||
icon: "heroicons:check-circle",
|
icon: 'heroicons:check-circle',
|
||||||
severity: "success",
|
severity: 'success',
|
||||||
canRSVP: true,
|
canRSVP: true,
|
||||||
canAccessMembers: true,
|
canAccessMembers: true,
|
||||||
canPeerSupport: true,
|
canPeerSupport: true,
|
||||||
},
|
},
|
||||||
suspended: {
|
suspended: {
|
||||||
label: "Membership Suspended",
|
label: 'Membership Suspended',
|
||||||
color: "red",
|
color: 'red',
|
||||||
bgColor: "bg-red-500/10",
|
bgColor: 'bg-red-500/10',
|
||||||
borderColor: "border-red-500/30",
|
borderColor: 'border-red-500/30',
|
||||||
textColor: "text-red-300",
|
textColor: 'text-red-300',
|
||||||
icon: "heroicons:no-symbol",
|
icon: 'heroicons:no-symbol',
|
||||||
severity: "error",
|
severity: 'error',
|
||||||
canRSVP: false,
|
canRSVP: false,
|
||||||
canAccessMembers: false,
|
canAccessMembers: false,
|
||||||
canPeerSupport: false,
|
canPeerSupport: false,
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
label: "Membership Cancelled",
|
label: 'Membership Cancelled',
|
||||||
color: "gray",
|
color: 'gray',
|
||||||
bgColor: "bg-gray-500/10",
|
bgColor: 'bg-gray-500/10',
|
||||||
borderColor: "border-gray-500/30",
|
borderColor: 'border-gray-500/30',
|
||||||
textColor: "text-gray-300",
|
textColor: 'text-gray-300',
|
||||||
icon: "heroicons:x-circle",
|
icon: 'heroicons:x-circle',
|
||||||
severity: "error",
|
severity: 'error',
|
||||||
canRSVP: false,
|
canRSVP: false,
|
||||||
canAccessMembers: false,
|
canAccessMembers: false,
|
||||||
canPeerSupport: false,
|
canPeerSupport: false,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useMemberStatus = () => {
|
export const useMemberStatus = () => {
|
||||||
const { memberData } = useAuth();
|
const { memberData } = useAuth()
|
||||||
|
|
||||||
// Get current member status
|
// Get current member status
|
||||||
const status = computed(
|
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT)
|
||||||
() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get status configuration
|
// Get status configuration
|
||||||
const statusConfig = computed(
|
const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment)
|
||||||
() =>
|
|
||||||
MEMBER_STATUS_CONFIG[status.value] ||
|
|
||||||
MEMBER_STATUS_CONFIG.pending_payment,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
|
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
|
||||||
const isPendingPayment = computed(
|
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
|
||||||
() => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
|
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
|
||||||
);
|
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
|
||||||
const isSuspended = computed(
|
const isInactive = computed(() => !isActive.value)
|
||||||
() => status.value === MEMBER_STATUSES.SUSPENDED,
|
|
||||||
);
|
|
||||||
const isCancelled = computed(
|
|
||||||
() => status.value === MEMBER_STATUSES.CANCELLED,
|
|
||||||
);
|
|
||||||
const isInactive = computed(() => !isActive.value);
|
|
||||||
|
|
||||||
// Check if member can perform action
|
// Check if member can perform action
|
||||||
const canRSVP = computed(() => statusConfig.value.canRSVP);
|
const canRSVP = computed(() => statusConfig.value.canRSVP)
|
||||||
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
|
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
|
||||||
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
|
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
|
||||||
|
|
||||||
// Get action button text and link based on status
|
// Get action button text and link based on status
|
||||||
const getNextAction = () => {
|
const getNextAction = () => {
|
||||||
if (isPendingPayment.value) {
|
if (isPendingPayment.value) {
|
||||||
return {
|
return {
|
||||||
label: "Complete Payment",
|
label: 'Complete Payment',
|
||||||
link: "/member/account",
|
link: '/member/profile#account',
|
||||||
icon: "heroicons:credit-card",
|
icon: 'heroicons:credit-card',
|
||||||
color: "orange",
|
color: 'orange',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
if (isCancelled.value) {
|
if (isCancelled.value) {
|
||||||
return {
|
return {
|
||||||
label: "Reactivate Membership",
|
label: 'Reactivate Membership',
|
||||||
link: "/member/account",
|
link: '/member/profile#account',
|
||||||
icon: "heroicons:arrow-path",
|
icon: 'heroicons:arrow-path',
|
||||||
color: "blue",
|
color: 'blue',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
if (isSuspended.value) {
|
if (isSuspended.value) {
|
||||||
return {
|
return {
|
||||||
label: "Contact Support",
|
label: 'Contact Support',
|
||||||
link: "mailto:support@ghostguild.org",
|
link: 'mailto:support@ghostguild.org',
|
||||||
icon: "heroicons:envelope",
|
icon: 'heroicons:envelope',
|
||||||
color: "gray",
|
color: 'gray',
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get banner message based on status
|
// Get banner message based on status
|
||||||
const getBannerMessage = () => {
|
const getBannerMessage = () => {
|
||||||
if (isPendingPayment.value) {
|
if (isPendingPayment.value) {
|
||||||
return "Your payment setup isn't finished yet. Your membership and access aren't affected — finish whenever you're ready, or reach out if there's a snag.";
|
return 'Your membership is pending payment. Please complete your payment to unlock full features.'
|
||||||
}
|
}
|
||||||
if (isSuspended.value) {
|
if (isSuspended.value) {
|
||||||
return "Your account is paused while we work through a community issue. We'll be in touch.";
|
return 'Your membership has been suspended. Please contact support to reactivate your account.'
|
||||||
}
|
}
|
||||||
if (isCancelled.value) {
|
if (isCancelled.value) {
|
||||||
return "Your account is closed. Reach out if you'd like to come back.";
|
return 'Your membership has been cancelled. Would you like to reactivate?'
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get RSVP restriction message
|
// Get RSVP restriction message
|
||||||
const getRSVPMessage = () => {
|
const getRSVPMessage = () => {
|
||||||
if (isSuspended.value || isCancelled.value) {
|
if (isPendingPayment.value) {
|
||||||
return "Your account isn't active right now. Reach out if you have questions.";
|
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 {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|
@ -160,5 +151,5 @@ export const useMemberStatus = () => {
|
||||||
getBannerMessage,
|
getBannerMessage,
|
||||||
getRSVPMessage,
|
getRSVPMessage,
|
||||||
MEMBER_STATUSES,
|
MEMBER_STATUSES,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,42 +6,26 @@ export function useOnboarding(options = {}) {
|
||||||
const goals = useState('onboarding.goals', () => ({
|
const goals = useState('onboarding.goals', () => ({
|
||||||
hasProfileTags: false,
|
hasProfileTags: false,
|
||||||
hasVisitedEvent: false,
|
hasVisitedEvent: false,
|
||||||
hasEngagedBoard: false,
|
hasEngagedEcology: false,
|
||||||
hasClickedWiki: false,
|
hasClickedWiki: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const skipped = useState('onboarding.skipped', () => ({
|
|
||||||
profileTags: false,
|
|
||||||
visitEvent: false,
|
|
||||||
board: false,
|
|
||||||
wiki: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const completedAt = useState('onboarding.completedAt', () => null)
|
const completedAt = useState('onboarding.completedAt', () => null)
|
||||||
const loading = useState('onboarding.loading', () => false)
|
const loading = useState('onboarding.loading', () => false)
|
||||||
const recommendations = useState('onboarding.recommendations', () => ({
|
const recommendations = useState('onboarding.recommendations', () => ({
|
||||||
events: [],
|
events: [],
|
||||||
|
ecology: [],
|
||||||
wiki: [],
|
wiki: [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Track whether we've already fetched status this session
|
// Track whether we've already fetched status this session
|
||||||
const _fetched = useState('onboarding._fetched', () => false)
|
const _fetched = useState('onboarding._fetched', () => false)
|
||||||
|
|
||||||
// For the purpose of advancing the suggestion widget, a skipped goal is
|
|
||||||
// treated as "done" — the underlying goal/graduation check is unchanged.
|
|
||||||
const effectiveGoals = computed(() => ({
|
|
||||||
hasProfileTags: goals.value.hasProfileTags || skipped.value.profileTags,
|
|
||||||
hasVisitedEvent: goals.value.hasVisitedEvent || skipped.value.visitEvent,
|
|
||||||
hasEngagedBoard: goals.value.hasEngagedBoard || skipped.value.board,
|
|
||||||
hasClickedWiki: goals.value.hasClickedWiki || skipped.value.wiki,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const isComplete = computed(() =>
|
const isComplete = computed(() =>
|
||||||
!!completedAt.value ||
|
goals.value.hasProfileTags &&
|
||||||
(effectiveGoals.value.hasProfileTags &&
|
goals.value.hasVisitedEvent &&
|
||||||
effectiveGoals.value.hasVisitedEvent &&
|
goals.value.hasEngagedEcology &&
|
||||||
effectiveGoals.value.hasEngagedBoard &&
|
goals.value.hasClickedWiki
|
||||||
effectiveGoals.value.hasClickedWiki)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const pickCategory = options.pickCategory || ((categories) => {
|
const pickCategory = options.pickCategory || ((categories) => {
|
||||||
|
|
@ -49,9 +33,9 @@ export function useOnboarding(options = {}) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentSuggestion = computed(() => {
|
const currentSuggestion = computed(() => {
|
||||||
// Not graduated — return highest-priority incomplete, non-skipped goal
|
// Not graduated — return highest-priority incomplete goal
|
||||||
if (!isComplete.value) {
|
if (!isComplete.value) {
|
||||||
if (!effectiveGoals.value.hasProfileTags) {
|
if (!goals.value.hasProfileTags) {
|
||||||
return {
|
return {
|
||||||
key: 'profileTags',
|
key: 'profileTags',
|
||||||
text: 'Complete your profile by adding your craft and community tags',
|
text: 'Complete your profile by adding your craft and community tags',
|
||||||
|
|
@ -59,7 +43,7 @@ export function useOnboarding(options = {}) {
|
||||||
actionText: 'Set up tags',
|
actionText: 'Set up tags',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!effectiveGoals.value.hasVisitedEvent) {
|
if (!goals.value.hasVisitedEvent) {
|
||||||
return {
|
return {
|
||||||
key: 'visitEvent',
|
key: 'visitEvent',
|
||||||
text: 'Check out upcoming events',
|
text: 'Check out upcoming events',
|
||||||
|
|
@ -67,19 +51,19 @@ export function useOnboarding(options = {}) {
|
||||||
actionText: 'Browse events',
|
actionText: 'Browse events',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!effectiveGoals.value.hasEngagedBoard) {
|
if (!goals.value.hasEngagedEcology) {
|
||||||
return {
|
return {
|
||||||
key: 'board',
|
key: 'ecology',
|
||||||
text: 'Explore the board to find collaborators',
|
text: 'Explore the community ecology to find collaborators',
|
||||||
action: '/board',
|
action: '/ecology',
|
||||||
actionText: 'Explore board',
|
actionText: 'Explore ecology',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!effectiveGoals.value.hasClickedWiki) {
|
if (!goals.value.hasClickedWiki) {
|
||||||
return {
|
return {
|
||||||
key: 'wiki',
|
key: 'wiki',
|
||||||
text: 'Browse the wiki for resources and guides',
|
text: 'Browse the wiki for resources and guides',
|
||||||
action: 'https://wiki.ghostguild.org',
|
action: null,
|
||||||
actionText: 'Browse wiki',
|
actionText: 'Browse wiki',
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +71,7 @@ export function useOnboarding(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graduated — suggestion mode
|
// Graduated — suggestion mode
|
||||||
const cats = ['events', 'wiki'].filter(
|
const cats = ['events', 'ecology', 'wiki'].filter(
|
||||||
(c) => recommendations.value[c]?.length > 0
|
(c) => recommendations.value[c]?.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -102,6 +86,13 @@ export function useOnboarding(options = {}) {
|
||||||
return buildRecommendation(selected, items[0])
|
return buildRecommendation(selected, items[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to first non-empty category (shouldn't happen since we filtered)
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (recommendations.value[cat]?.length > 0) {
|
||||||
|
return buildRecommendation(cat, recommendations.value[cat][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { key: 'empty', text: 'No suggestions right now' }
|
return { key: 'empty', text: 'No suggestions right now' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -110,10 +101,18 @@ export function useOnboarding(options = {}) {
|
||||||
return {
|
return {
|
||||||
key: 'event',
|
key: 'event',
|
||||||
text: `Upcoming event: ${item.title}`,
|
text: `Upcoming event: ${item.title}`,
|
||||||
action: `/events/${item.slug}`,
|
action: `/events/${item._id}`,
|
||||||
actionText: 'View event',
|
actionText: 'View event',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (category === 'ecology') {
|
||||||
|
return {
|
||||||
|
key: 'ecology',
|
||||||
|
text: `Connect with ${item.name || 'a member'} in the ecology`,
|
||||||
|
action: '/ecology',
|
||||||
|
actionText: 'Explore ecology',
|
||||||
|
}
|
||||||
|
}
|
||||||
if (category === 'wiki') {
|
if (category === 'wiki') {
|
||||||
return {
|
return {
|
||||||
key: 'wiki',
|
key: 'wiki',
|
||||||
|
|
@ -134,16 +133,13 @@ export function useOnboarding(options = {}) {
|
||||||
if (data?.goals) {
|
if (data?.goals) {
|
||||||
goals.value = { ...goals.value, ...data.goals }
|
goals.value = { ...goals.value, ...data.goals }
|
||||||
}
|
}
|
||||||
if (data?.skipped) {
|
|
||||||
skipped.value = { ...skipped.value, ...data.skipped }
|
|
||||||
}
|
|
||||||
if (data?.completedAt) {
|
if (data?.completedAt) {
|
||||||
completedAt.value = data.completedAt
|
completedAt.value = data.completedAt
|
||||||
}
|
}
|
||||||
_fetched.value = true
|
_fetched.value = true
|
||||||
|
|
||||||
// If graduated, fetch recommendations
|
// If graduated, fetch recommendations
|
||||||
if (completedAt.value) {
|
if (isComplete.value) {
|
||||||
await fetchRecommendations()
|
await fetchRecommendations()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -154,12 +150,14 @@ export function useOnboarding(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRecommendations() {
|
async function fetchRecommendations() {
|
||||||
const [events, wiki] = await Promise.allSettled([
|
const [events, ecology, wiki] = await Promise.allSettled([
|
||||||
$fetch('/api/events/recommended'),
|
$fetch('/api/events/recommended'),
|
||||||
|
$fetch('/api/ecology/suggestions'),
|
||||||
$fetch('/api/wiki/recommended'),
|
$fetch('/api/wiki/recommended'),
|
||||||
])
|
])
|
||||||
recommendations.value = {
|
recommendations.value = {
|
||||||
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
||||||
|
ecology: ecology.status === 'fulfilled' ? (ecology.value?.suggestions || []) : [],
|
||||||
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
|
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -176,21 +174,6 @@ export function useOnboarding(options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function skipSuggestion(key) {
|
|
||||||
// Optimistically advance locally; server call is fire-and-forget.
|
|
||||||
if (skipped.value[key] !== undefined) {
|
|
||||||
skipped.value = { ...skipped.value, [key]: true }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await $fetch('/api/onboarding/track', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { skip: key },
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Non-fatal — will re-fetch on next session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on first use
|
// Initialize on first use
|
||||||
fetchStatus()
|
fetchStatus()
|
||||||
|
|
||||||
|
|
@ -200,8 +183,6 @@ export function useOnboarding(options = {}) {
|
||||||
completedAt: readonly(completedAt),
|
completedAt: readonly(completedAt),
|
||||||
currentSuggestion,
|
currentSuggestion,
|
||||||
trackGoal,
|
trackGoal,
|
||||||
skipSuggestion,
|
|
||||||
skipped: readonly(skipped),
|
|
||||||
recommendations: readonly(recommendations),
|
recommendations: readonly(recommendations),
|
||||||
loading: readonly(loading),
|
loading: readonly(loading),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const CIRCLES = {
|
||||||
shortDescription: "Building your studio",
|
shortDescription: "Building your studio",
|
||||||
description: "For those actively establishing or growing their coop",
|
description: "For those actively establishing or growing their coop",
|
||||||
features: [
|
features: [
|
||||||
"Teams working toward applying for Cooperative Foundations",
|
"Teams working toward applying for the Peer Accelerator",
|
||||||
"Early-stage coop studios",
|
"Early-stage coop studios",
|
||||||
"Studios transitioning to coop model",
|
"Studios transitioning to coop model",
|
||||||
],
|
],
|
||||||
|
|
@ -33,7 +33,7 @@ export const CIRCLES = {
|
||||||
value: "practitioner",
|
value: "practitioner",
|
||||||
label: "Practitioners",
|
label: "Practitioners",
|
||||||
shortDescription: "Leading and mentoring",
|
shortDescription: "Leading and mentoring",
|
||||||
description: "For alumni and experienced studio founders",
|
description: "For Peer Accelerator alumni and experienced studio founders",
|
||||||
features: [
|
features: [
|
||||||
"Those implementing cooperative models",
|
"Those implementing cooperative models",
|
||||||
"Industry mentors and advisors",
|
"Industry mentors and advisors",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,82 @@
|
||||||
// Guidance presets for the contribution amount input.
|
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
|
||||||
// These are NOT tiers — just suggested amounts with matching guidance copy.
|
export const CONTRIBUTION_TIERS = {
|
||||||
export const CONTRIBUTION_PRESETS = [
|
FREE: {
|
||||||
{ amount: 0, label: "I need support right now" },
|
value: "0",
|
||||||
{ amount: 5, label: "I can contribute" },
|
amount: 0,
|
||||||
{ amount: 15, label: "I can sustain the community" },
|
label: "$0 - I need support right now",
|
||||||
{ amount: 30, label: "I can support others too" },
|
tier: "free",
|
||||||
{ amount: 50, label: "I want to sponsor multiple members" },
|
helcimPlanId: null, // No Helcim plan needed for free tier
|
||||||
]
|
},
|
||||||
|
SUPPORTER: {
|
||||||
|
value: "5",
|
||||||
|
amount: 5,
|
||||||
|
label: "$5 - I can contribute",
|
||||||
|
tier: "supporter",
|
||||||
|
helcimPlanId: "supporter-monthly-5",
|
||||||
|
},
|
||||||
|
MEMBER: {
|
||||||
|
value: "15",
|
||||||
|
amount: 15,
|
||||||
|
label: "$15 - I can sustain the community",
|
||||||
|
tier: "member",
|
||||||
|
helcimPlanId: "member-monthly-15",
|
||||||
|
},
|
||||||
|
ADVOCATE: {
|
||||||
|
value: "30",
|
||||||
|
amount: 30,
|
||||||
|
label: "$30 - I can support others too",
|
||||||
|
tier: "advocate",
|
||||||
|
helcimPlanId: "advocate-monthly-30",
|
||||||
|
},
|
||||||
|
CHAMPION: {
|
||||||
|
value: "50",
|
||||||
|
amount: 50,
|
||||||
|
label: "$50 - I want to sponsor multiple members",
|
||||||
|
tier: "champion",
|
||||||
|
helcimPlanId: "champion-monthly-50",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const requiresPayment = (amount) => amount > 0
|
// Get all contribution options as an array (useful for forms)
|
||||||
|
export const getContributionOptions = () => {
|
||||||
|
return Object.values(CONTRIBUTION_TIERS);
|
||||||
|
};
|
||||||
|
|
||||||
export const isValidContributionAmount = (amount) =>
|
// Get valid contribution values for validation
|
||||||
Number.isInteger(amount) && amount >= 0
|
export const getValidContributionValues = () => {
|
||||||
|
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
|
||||||
|
};
|
||||||
|
|
||||||
export const getGuidanceLabel = (amount) => {
|
// Get contribution tier by value
|
||||||
if (amount === null || amount === undefined) return null
|
export const getContributionTierByValue = (value) => {
|
||||||
const n = Number(amount)
|
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
|
||||||
if (!Number.isFinite(n) || n < 0) return null
|
};
|
||||||
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
|
|
||||||
return match?.label ?? null
|
// Get Helcim plan ID for a contribution tier
|
||||||
}
|
export const getHelcimPlanId = (contributionValue) => {
|
||||||
|
const tier = getContributionTierByValue(contributionValue);
|
||||||
|
return tier?.helcimPlanId || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a contribution tier requires payment
|
||||||
|
export const requiresPayment = (contributionValue) => {
|
||||||
|
const tier = getContributionTierByValue(contributionValue);
|
||||||
|
return tier?.amount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a contribution value is valid
|
||||||
|
export const isValidContributionValue = (value) => {
|
||||||
|
return getValidContributionValues().includes(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get contribution tier by Helcim plan ID
|
||||||
|
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
|
||||||
|
return Object.values(CONTRIBUTION_TIERS).find(
|
||||||
|
(tier) => tier.helcimPlanId === helcimPlanId,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get paid tiers only (excluding free tier)
|
||||||
|
export const getPaidContributionTiers = () => {
|
||||||
|
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 || "";
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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. John’s', 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' },
|
|
||||||
];
|
|
||||||
|
|
@ -50,30 +50,6 @@
|
||||||
Series
|
Series
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
@ -84,7 +60,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="admin-tag">admin</span><br >
|
<span class="admin-tag">admin</span><br />
|
||||||
<a href="#" @click.prevent="logout">Sign out</a>
|
<a href="#" @click.prevent="logout">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -160,33 +136,6 @@
|
||||||
Series
|
Series
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
@ -207,7 +156,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="admin-tag">admin</span><br >
|
<span class="admin-tag">admin</span><br />
|
||||||
<a href="#" @click.prevent="logout">Sign out</a>
|
<a href="#" @click.prevent="logout">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -217,8 +166,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useSiteMeta({ title: "Admin", noindex: true });
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isMobileMenuOpen = ref(false);
|
const isMobileMenuOpen = ref(false);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="coming-soon-layout">
|
<div class="min-h-screen bg-guild-900">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.coming-soon-layout {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,11 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
if (
|
if (
|
||||||
to.path === "/coming-soon" ||
|
to.path === "/coming-soon" ||
|
||||||
to.path === "/auth/wiki-login" ||
|
to.path === "/auth/wiki-login" ||
|
||||||
to.path === "/auth/oidc-error" ||
|
|
||||||
to.path === "/auth/logout-confirm" ||
|
|
||||||
to.path === "/auth/logout-success" ||
|
|
||||||
to.path === "/verify" ||
|
|
||||||
to.path.startsWith("/admin")
|
to.path.startsWith("/admin")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
|
|
||||||
try {
|
|
||||||
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
|
|
||||||
const member = await $fetch("/api/auth/member", { headers });
|
|
||||||
if (member?.role === "admin") return;
|
|
||||||
} catch {
|
|
||||||
// Not authenticated — fall through to redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect all other routes to coming-soon
|
// Redirect all other routes to coming-soon
|
||||||
return navigateTo("/coming-soon");
|
return navigateTo("/coming-soon");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -6,27 +6,26 @@
|
||||||
<h1>About Ghost Guild</h1>
|
<h1>About Ghost Guild</h1>
|
||||||
<p>
|
<p>
|
||||||
A membership community for game developers exploring cooperative
|
A membership community for game developers exploring cooperative
|
||||||
models.
|
business models.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-hero-right">
|
<div class="about-hero-right">
|
||||||
<div class="section-label">Our Story</div>
|
<div class="section-label">Our Story</div>
|
||||||
<p>
|
<p>
|
||||||
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
|
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
|
||||||
advancing cooperative and worker-centric models in the game industry
|
supporting indie game developers since 2018. We noticed a gap: game
|
||||||
since 2023.
|
developers interested in cooperative models had nowhere to learn,
|
||||||
|
practice, and connect with others doing the same work.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Developers interested in co-op practice had few places to learn,
|
Ghost Guild is the response — a membership program where
|
||||||
connect, and figure things out alongside others doing the same work.
|
developers at every stage of cooperative practice can find resources,
|
||||||
Ghost Guild is that place: a membership community for developers at
|
events, mentorship, and community.
|
||||||
every stage of cooperative practice, with resources, events, and peers
|
|
||||||
to learn from.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We don't prescribe a single model. We're here to explore the options,
|
We don't prescribe a single model. We're a place to explore the
|
||||||
learn from people who've tried them, and build something that works
|
options, learn from people who've tried them, and build something that
|
||||||
for your team.
|
works for your team.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,17 +37,28 @@
|
||||||
<div class="section-label">The Circles</div>
|
<div class="section-label">The Circles</div>
|
||||||
<div class="circles-grid">
|
<div class="circles-grid">
|
||||||
<div id="community" class="circle-cell">
|
<div id="community" class="circle-cell">
|
||||||
<h2 style="color: var(--c-community)">Community</h2>
|
<h3 style="color: var(--c-community)">Community</h3>
|
||||||
|
<div class="circle-subtitle">"The open hall"</div>
|
||||||
<p>For anyone exploring cooperative models.</p>
|
<p>
|
||||||
|
For anyone exploring cooperative models. Wiki access, public
|
||||||
|
events, Slack community, monthly meetings.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="founder" class="circle-cell">
|
<div id="founder" class="circle-cell">
|
||||||
<h2 style="color: var(--c-founder)">Founder</h2>
|
<h3 style="color: var(--c-founder)">Founder</h3>
|
||||||
<p>For people actively building cooperatives.</p>
|
<div class="circle-subtitle">"The workshop"</div>
|
||||||
|
<p>
|
||||||
|
For people actively building cooperatives. Peer accelerator,
|
||||||
|
mentorship, governance templates.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="practitioner" class="circle-cell">
|
<div id="practitioner" class="circle-cell">
|
||||||
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
|
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
|
||||||
<p>For experienced practitioners sharing what they know.</p>
|
<div class="circle-subtitle">"The alcove"</div>
|
||||||
|
<p>
|
||||||
|
For experienced practitioners. Mentoring, teaching, shaping the
|
||||||
|
program direction.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -91,12 +101,13 @@
|
||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<div class="section-label">About Baby Ghosts</div>
|
<div class="section-label">About Baby Ghosts</div>
|
||||||
<p>
|
<p>
|
||||||
Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing
|
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit
|
||||||
cooperative models in game development.
|
advancing cooperative models in game development. No tracking. No ads.
|
||||||
|
No venture capital.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://babyghosts.org" target="_blank"
|
<a href="https://babyghosts.fund" target="_blank"
|
||||||
>babyghosts.org →</a
|
>babyghosts.fund →</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,13 +115,7 @@
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup></script>
|
||||||
useSiteMeta({
|
|
||||||
title: 'About',
|
|
||||||
description:
|
|
||||||
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ---- ABOUT HERO ---- */
|
/* ---- ABOUT HERO ---- */
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
class="form-input"
|
class="form-input"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="accept-email">Email</label>
|
<label class="form-label" for="accept-email">Email</label>
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
class="form-input"
|
class="form-input"
|
||||||
type="email"
|
type="email"
|
||||||
disabled
|
disabled
|
||||||
>
|
/>
|
||||||
<p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p>
|
<p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
class="form-input"
|
class="form-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. they/them, she/her"
|
placeholder="e.g. they/them, she/her"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="accept-location">City / Region</label>
|
<label class="form-label" for="accept-location">City / Region</label>
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
class="form-input"
|
class="form-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Vancouver, BC"
|
placeholder="e.g. Vancouver, BC"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="community"
|
value="community"
|
||||||
>
|
/>
|
||||||
<label for="circle-community">
|
<label for="circle-community">
|
||||||
<span class="circle-label-name" style="color: var(--c-community);">Community</span>
|
<span class="circle-label-name" style="color: var(--c-community);">Community</span>
|
||||||
<span class="circle-label-desc">Learning about co-ops</span>
|
<span class="circle-label-desc">Learning about co-ops</span>
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="founder"
|
value="founder"
|
||||||
>
|
/>
|
||||||
<label for="circle-founder">
|
<label for="circle-founder">
|
||||||
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span>
|
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span>
|
||||||
<span class="circle-label-desc">Building your studio</span>
|
<span class="circle-label-desc">Building your studio</span>
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="practitioner"
|
value="practitioner"
|
||||||
>
|
/>
|
||||||
<label for="circle-practitioner">
|
<label for="circle-practitioner">
|
||||||
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span>
|
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span>
|
||||||
<span class="circle-label-desc">Leading and mentoring</span>
|
<span class="circle-label-desc">Leading and mentoring</span>
|
||||||
|
|
@ -120,90 +120,36 @@
|
||||||
class="form-input"
|
class="form-input"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="2-3 sentences about what you're looking for"
|
placeholder="2-3 sentences about what you're looking for"
|
||||||
/>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label">Billing Cadence</label>
|
<label class="form-label" for="accept-tier">Monthly Contribution</label>
|
||||||
<div class="cadence-radios">
|
<select
|
||||||
<div class="circle-radio">
|
id="accept-tier"
|
||||||
<input
|
v-model="form.contributionTier"
|
||||||
id="accept-cadence-annual"
|
class="form-select"
|
||||||
v-model="cadence"
|
|
||||||
type="radio"
|
|
||||||
name="cadence"
|
|
||||||
value="annual"
|
|
||||||
>
|
>
|
||||||
<label for="accept-cadence-annual">
|
<option value="0">$0/mo -- Access is a right</option>
|
||||||
<span class="circle-label-name">Per Year</span>
|
<option value="5">$5/mo -- A small gesture</option>
|
||||||
</label>
|
<option value="15">$15/mo -- Sustaining (suggested)</option>
|
||||||
</div>
|
<option value="30">$30/mo -- Supporting</option>
|
||||||
<div class="circle-radio">
|
<option value="50">$50/mo -- Solidarity</option>
|
||||||
<input
|
</select>
|
||||||
id="accept-cadence-monthly"
|
<p class="field-note">Every dollar above $0 goes to the Solidarity Fund. Your contribution is never a gate -- it is a gift.</p>
|
||||||
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 × 12)</span>.
|
|
||||||
</p>
|
|
||||||
<p class="billing-summary-line">
|
|
||||||
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input
|
<input
|
||||||
v-model="form.agreedToGuidelines"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
>
|
v-model="form.agreedToTerms"
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
I agree to the Ghost Guild
|
I've read and agree to the
|
||||||
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>.
|
<NuxtLink to="/agreement" target="_blank">Member Agreement</NuxtLink>
|
||||||
|
and
|
||||||
|
<NuxtLink to="/guidelines" target="_blank">Code of Conduct</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -223,29 +169,43 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Flow overlay: covers the page through payment + redirect. -->
|
<!-- Payment Step -->
|
||||||
<SignupFlowOverlay
|
<div v-else-if="step === 'payment'" class="form-container">
|
||||||
:state="flowState"
|
<h1>Payment Information</h1>
|
||||||
:summary="flowSummary"
|
<p class="form-intro">
|
||||||
:error-message="errorMessage"
|
You're signing up for ${{ form.contributionTier }} CAD / month.
|
||||||
dashboard-href="/member/dashboard?welcome=1"
|
</p>
|
||||||
@close="closeFlowOverlay"
|
|
||||||
/>
|
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
|
||||||
|
|
||||||
|
<DashedBox :hoverable="false">
|
||||||
|
<p class="payment-instruction">Click "Complete Payment" below to open the secure payment modal and verify your payment method.</p>
|
||||||
|
</DashedBox>
|
||||||
|
|
||||||
|
<div class="button-row" style="margin-top: 24px;">
|
||||||
|
<button class="btn" :disabled="isSubmitting" @click="step = 'form'">Back</button>
|
||||||
|
<button class="form-submit" :disabled="isSubmitting" @click="processPayment">
|
||||||
|
<span v-if="isSubmitting">Processing...</span>
|
||||||
|
<span v-else>Complete Payment</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation -->
|
||||||
|
<div v-else-if="step === 'confirmation'" class="center-box">
|
||||||
|
<h1>Welcome to Ghost Guild!</h1>
|
||||||
|
<p>Your membership is active. Redirecting to your dashboard...</p>
|
||||||
|
<NuxtLink to="/welcome" class="btn btn-primary" style="margin-top: 16px">Go to Dashboard</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { requiresPayment } from "~/config/contributions";
|
||||||
requiresPayment,
|
|
||||||
CONTRIBUTION_PRESETS,
|
|
||||||
getGuidanceLabel,
|
|
||||||
} from "~/config/contributions";
|
|
||||||
|
|
||||||
definePageMeta({ layout: false });
|
definePageMeta({ layout: false });
|
||||||
useSiteMeta({ title: "Accept Invitation", noindex: true });
|
|
||||||
|
|
||||||
const { checkMemberStatus } = useAuth();
|
const { checkMemberStatus } = useAuth();
|
||||||
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
|
|
||||||
|
|
||||||
const step = ref("verifying");
|
const step = ref("verifying");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
|
|
@ -253,10 +213,6 @@ const isSubmitting = ref(false);
|
||||||
const preRegId = ref(null);
|
const preRegId = ref(null);
|
||||||
const preRegEmail = ref("");
|
const preRegEmail = ref("");
|
||||||
const token = ref("");
|
const token = ref("");
|
||||||
const cadence = ref("annual"); // 'monthly' | 'annual'
|
|
||||||
|
|
||||||
// Flow overlay state — drives the post-submit full-viewport UI.
|
|
||||||
const flowState = ref("idle");
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -264,49 +220,22 @@ const form = reactive({
|
||||||
location: "",
|
location: "",
|
||||||
circle: "community",
|
circle: "community",
|
||||||
motivation: "",
|
motivation: "",
|
||||||
contributionAmount: 15,
|
contributionTier: "15",
|
||||||
agreedToGuidelines: false,
|
agreedToTerms: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFormValid = computed(() => {
|
const isFormValid = computed(() => {
|
||||||
return (
|
return form.name && form.circle && form.contributionTier && form.agreedToTerms;
|
||||||
form.name &&
|
|
||||||
form.circle &&
|
|
||||||
Number.isInteger(form.contributionAmount) &&
|
|
||||||
form.contributionAmount >= 0 &&
|
|
||||||
form.agreedToGuidelines
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const needsPayment = computed(() => {
|
const needsPayment = computed(() => {
|
||||||
return requiresPayment(form.contributionAmount);
|
return requiresPayment(form.contributionTier);
|
||||||
});
|
});
|
||||||
|
|
||||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
// Helcim state for paid tiers
|
||||||
|
const memberId = ref(null);
|
||||||
const firstCharge = computed(() => {
|
const customerId = ref(null);
|
||||||
const amount = form.contributionAmount || 0;
|
const customerCode = ref(null);
|
||||||
return cadence.value === "annual" ? amount * 12 : amount;
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatContributionAmount = (amount) => {
|
|
||||||
if (!amount || amount === 0) return "$0";
|
|
||||||
const display = cadence.value === "annual" ? amount * 12 : amount;
|
|
||||||
const suffix = cadence.value === "annual" ? "/yr" : "/mo";
|
|
||||||
return `$${display}${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const flowSummary = computed(() => ({
|
|
||||||
name: form.name,
|
|
||||||
email: preRegEmail.value,
|
|
||||||
circle: form.circle,
|
|
||||||
contribution: formatContributionAmount(form.contributionAmount),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const closeFlowOverlay = () => {
|
|
||||||
flowState.value = "idle";
|
|
||||||
errorMessage.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// On mount: extract token from fragment, verify
|
// On mount: extract token from fragment, verify
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -342,10 +271,9 @@ const handleAccept = async () => {
|
||||||
|
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
flowState.value = "creating-customer";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accepted = await $fetch("/api/invite/accept", {
|
const result = await $fetch("/api/invite/accept", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
preRegistrationId: preRegId.value,
|
preRegistrationId: preRegId.value,
|
||||||
|
|
@ -354,59 +282,96 @@ const handleAccept = async () => {
|
||||||
location: form.location || undefined,
|
location: form.location || undefined,
|
||||||
circle: form.circle,
|
circle: form.circle,
|
||||||
motivation: form.motivation || undefined,
|
motivation: form.motivation || undefined,
|
||||||
contributionAmount: form.contributionAmount,
|
contributionTier: form.contributionTier,
|
||||||
agreedToGuidelines: form.agreedToGuidelines,
|
agreedToTerms: form.agreedToTerms,
|
||||||
token: token.value,
|
token: token.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!accepted.requiresPayment) {
|
memberId.value = result.member.id;
|
||||||
|
|
||||||
|
if (result.requiresPayment) {
|
||||||
|
// Need to create Helcim customer + payment
|
||||||
|
await setupPayment(result.member);
|
||||||
|
} else {
|
||||||
// Free tier — session cookie already set by accept endpoint
|
// Free tier — session cookie already set by accept endpoint
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
flowState.value = "success";
|
step.value = "confirmation";
|
||||||
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
setTimeout(() => navigateTo("/welcome"), 3000);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value =
|
||||||
|
err.data?.statusMessage || "Failed to accept invitation. Please try again.";
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Paid tier: initialize HelcimPay session, auto-open modal
|
const setupPayment = async (member) => {
|
||||||
flowState.value = "opening-payment";
|
try {
|
||||||
await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0);
|
// Create Helcim customer for paid tier
|
||||||
|
const customerResult = await $fetch("/api/helcim/customer", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: member.name,
|
||||||
|
email: member.email,
|
||||||
|
circle: member.circle,
|
||||||
|
contributionTier: form.contributionTier,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
customerId.value = customerResult.customerId;
|
||||||
|
customerCode.value = customerResult.customerCode;
|
||||||
|
|
||||||
|
// Initialize HelcimPay.js
|
||||||
|
const { initializeHelcimPay } = useHelcimPay();
|
||||||
|
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||||
|
|
||||||
|
step.value = "payment";
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value =
|
||||||
|
err.data?.statusMessage || "Failed to set up payment. Please try again.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processPayment = async () => {
|
||||||
|
if (isSubmitting.value) return;
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { verifyPayment } = useHelcimPay();
|
||||||
const paymentResult = await verifyPayment();
|
const paymentResult = await verifyPayment();
|
||||||
if (!paymentResult?.success) {
|
|
||||||
throw new Error("Payment was not completed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
flowState.value = "processing-payment";
|
if (paymentResult.success) {
|
||||||
|
// Verify payment on server
|
||||||
await $fetch("/api/helcim/verify-payment", {
|
await $fetch("/api/helcim/verify-payment", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken: paymentResult.cardToken,
|
||||||
customerId: accepted.customerId,
|
customerId: customerId.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
flowState.value = "creating-subscription";
|
// Create subscription
|
||||||
await $fetch("/api/helcim/subscription", {
|
await $fetch("/api/helcim/subscription", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
customerId: accepted.customerId,
|
customerId: customerId.value,
|
||||||
customerCode: accepted.customerCode,
|
customerCode: customerCode.value,
|
||||||
contributionAmount: form.contributionAmount,
|
contributionTier: form.contributionTier,
|
||||||
cadence: cadence.value,
|
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken: paymentResult.cardToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
flowState.value = "success";
|
step.value = "confirmation";
|
||||||
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
setTimeout(() => navigateTo("/welcome"), 3000);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.value =
|
errorMessage.value =
|
||||||
err.data?.statusMessage ||
|
err.message || "Payment verification failed. Please try again.";
|
||||||
err.message ||
|
|
||||||
"Failed to accept invitation. Please try again.";
|
|
||||||
flowState.value = "error";
|
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false;
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -524,72 +489,6 @@ textarea.form-input {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
|
|
||||||
.contribution-input-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
.contribution-currency {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.contribution-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--parch);
|
|
||||||
font-family: 'Commit Mono', monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.contribution-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--candle);
|
|
||||||
}
|
|
||||||
.contribution-presets {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.contribution-preset-chip {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px dashed var(--parch);
|
|
||||||
font-family: 'Commit Mono', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.contribution-preset-chip:hover {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--candle);
|
|
||||||
}
|
|
||||||
.contribution-guidance {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--ink-soft, currentColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- BILLING SUMMARY ---- */
|
|
||||||
.billing-summary {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.billing-summary-line {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.billing-summary-line + .billing-summary-line {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.billing-summary-line strong {
|
|
||||||
color: var(--text-bright);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- CIRCLE RADIOS ---- */
|
/* ---- CIRCLE RADIOS ---- */
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -597,12 +496,6 @@ textarea.form-input {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cadence-radios {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-radio {
|
.circle-radio {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
@ -720,9 +613,5 @@ textarea.form-input {
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cadence-radios {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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">×</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>
|
|
||||||
|
|
@ -27,8 +27,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="saveEvent">
|
<form @submit.prevent="saveEvent">
|
||||||
<div class="form-layout">
|
|
||||||
<div class="form-main">
|
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2 class="section-heading">Basic Information</h2>
|
<h2 class="section-heading">Basic Information</h2>
|
||||||
|
|
@ -40,7 +38,6 @@
|
||||||
placeholder="Enter a clear, descriptive event title"
|
placeholder="Enter a clear, descriptive event title"
|
||||||
required
|
required
|
||||||
:color="fieldErrors.title ? 'error' : undefined"
|
:color="fieldErrors.title ? 'error' : undefined"
|
||||||
:ui="{ base: 'title-input' }"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p v-if="fieldErrors.title" class="field-error">
|
<p v-if="fieldErrors.title" class="field-error">
|
||||||
|
|
@ -63,8 +60,7 @@
|
||||||
v-model="eventForm.description"
|
v-model="eventForm.description"
|
||||||
placeholder="Provide a clear description of what attendees can expect from this event"
|
placeholder="Provide a clear description of what attendees can expect from this event"
|
||||||
required
|
required
|
||||||
:rows="8"
|
:rows="4"
|
||||||
autoresize
|
|
||||||
:color="fieldErrors.description ? 'error' : undefined"
|
:color="fieldErrors.description ? 'error' : undefined"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
@ -81,8 +77,7 @@
|
||||||
<UTextarea
|
<UTextarea
|
||||||
v-model="eventForm.content"
|
v-model="eventForm.content"
|
||||||
placeholder="Add detailed information, agenda, requirements, or other important details"
|
placeholder="Add detailed information, agenda, requirements, or other important details"
|
||||||
:rows="12"
|
:rows="6"
|
||||||
autoresize
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
|
|
@ -90,21 +85,6 @@
|
||||||
requirements
|
requirements
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Event Agenda</label>
|
|
||||||
<UTextarea
|
|
||||||
v-model="agendaText"
|
|
||||||
placeholder="Introduction and welcome - 10 mins Main talk - 30 mins Q&A - 15 mins"
|
|
||||||
:rows="6"
|
|
||||||
autoresize
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<p class="help-text">
|
|
||||||
One agenda item per line. Help attendees know what to expect
|
|
||||||
during the event.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event Details -->
|
<!-- Event Details -->
|
||||||
|
|
@ -117,7 +97,12 @@
|
||||||
<USelect
|
<USelect
|
||||||
v-model="eventForm.eventType"
|
v-model="eventForm.eventType"
|
||||||
aria-label="Event type"
|
aria-label="Event type"
|
||||||
:items="EVENT_TYPES"
|
:items="[
|
||||||
|
{ label: 'Community Meetup', value: 'community' },
|
||||||
|
{ label: 'Workshop', value: 'workshop' },
|
||||||
|
{ label: 'Social Event', value: 'social' },
|
||||||
|
{ label: 'Showcase', value: 'showcase' },
|
||||||
|
]"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
|
|
@ -126,32 +111,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label> Event Timezone <span class="required">*</span> </label>
|
<label> Location <span class="required">*</span> </label>
|
||||||
<USelectMenu
|
|
||||||
v-model="eventForm.displayTimezone"
|
|
||||||
:items="timezoneItems"
|
|
||||||
value-key="value"
|
|
||||||
searchable
|
|
||||||
searchable-placeholder="Search timezones..."
|
|
||||||
placeholder="Select a timezone"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<p class="help-text">
|
|
||||||
Dates below are interpreted in this timezone. Attendees see the
|
|
||||||
event time in this zone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Location</label>
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="eventForm.location"
|
v-model="eventForm.location"
|
||||||
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
|
placeholder="e.g., https://zoom.us/j/123... or #channel-name"
|
||||||
|
required
|
||||||
|
:color="fieldErrors.location ? 'error' : undefined"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="fieldErrors.location" class="field-error">
|
||||||
|
{{ fieldErrors.location }}
|
||||||
|
</p>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
Video conference link, Slack channel (#channel-name), or 'TBD' if
|
Enter a video conference link or Slack channel (starting with #)
|
||||||
the platform is undecided
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -159,7 +131,6 @@
|
||||||
<label> Start Date & Time <span class="required">*</span> </label>
|
<label> Start Date & Time <span class="required">*</span> </label>
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.startDate"
|
v-model="eventForm.startDate"
|
||||||
:display-timezone="eventForm.displayTimezone"
|
|
||||||
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
|
|
@ -172,7 +143,6 @@
|
||||||
<label> End Date & Time <span class="required">*</span> </label>
|
<label> End Date & Time <span class="required">*</span> </label>
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.endDate"
|
v-model="eventForm.endDate"
|
||||||
:display-timezone="eventForm.displayTimezone"
|
|
||||||
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
|
|
@ -199,7 +169,6 @@
|
||||||
<label>Registration Deadline</label>
|
<label>Registration Deadline</label>
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.registrationDeadline"
|
v-model="eventForm.registrationDeadline"
|
||||||
:display-timezone="eventForm.displayTimezone"
|
|
||||||
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
|
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
|
|
@ -209,87 +178,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event Settings -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h2 class="section-heading">Event Settings</h2>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="check-group">
|
|
||||||
<label class="check-label">
|
|
||||||
<input v-model="eventForm.isOnline" type="checkbox" >
|
|
||||||
<div>
|
|
||||||
<strong>Online Event</strong>
|
|
||||||
<span class="help-text">
|
|
||||||
Event will be conducted virtually
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="check-label">
|
|
||||||
<input
|
|
||||||
v-model="eventForm.registrationRequired"
|
|
||||||
type="checkbox"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong>Registration Required</strong>
|
|
||||||
<span class="help-text">
|
|
||||||
Attendees must register before attending
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="check-group">
|
|
||||||
<label class="check-label">
|
|
||||||
<input v-model="eventForm.isVisible" type="checkbox" >
|
|
||||||
<div>
|
|
||||||
<strong>Visible on Public Calendar</strong>
|
|
||||||
<span class="help-text">
|
|
||||||
Event will appear on the public events page
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="check-label">
|
|
||||||
<input v-model="eventForm.isCancelled" type="checkbox" >
|
|
||||||
<div>
|
|
||||||
<strong>Event Cancelled</strong>
|
|
||||||
<span class="help-text"> Mark this event as cancelled </span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="check-label">
|
|
||||||
<input v-model="eventForm.membersOnly" type="checkbox" >
|
|
||||||
<div>
|
|
||||||
<strong>Members Only</strong>
|
|
||||||
<span class="help-text">
|
|
||||||
Hide this event from the public; only members can see it
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cancellation Message (conditional) -->
|
|
||||||
<div v-if="eventForm.isCancelled" class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<label>Cancellation Message</label>
|
|
||||||
<UTextarea
|
|
||||||
v-model="eventForm.cancellationMessage"
|
|
||||||
placeholder="Explain why the event was cancelled and any next steps..."
|
|
||||||
:rows="3"
|
|
||||||
color="error"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<p class="help-text">
|
|
||||||
This message will be displayed to users viewing the event page
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="form-aside">
|
|
||||||
<!-- Target Audience -->
|
<!-- Target Audience -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2 class="section-heading">Target Audience</h2>
|
<h2 class="section-heading">Target Audience</h2>
|
||||||
|
|
@ -302,24 +190,39 @@
|
||||||
v-model="eventForm.targetCircles"
|
v-model="eventForm.targetCircles"
|
||||||
value="community"
|
value="community"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
>
|
/>
|
||||||
|
<div>
|
||||||
<strong>Community Circle</strong>
|
<strong>Community Circle</strong>
|
||||||
|
<span class="help-text">
|
||||||
|
New members and those exploring the community
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input
|
||||||
v-model="eventForm.targetCircles"
|
v-model="eventForm.targetCircles"
|
||||||
value="founder"
|
value="founder"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
>
|
/>
|
||||||
|
<div>
|
||||||
<strong>Founder Circle</strong>
|
<strong>Founder Circle</strong>
|
||||||
|
<span class="help-text">
|
||||||
|
Entrepreneurs and business leaders
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input
|
||||||
v-model="eventForm.targetCircles"
|
v-model="eventForm.targetCircles"
|
||||||
value="practitioner"
|
value="practitioner"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
>
|
/>
|
||||||
|
<div>
|
||||||
<strong>Practitioner Circle</strong>
|
<strong>Practitioner Circle</strong>
|
||||||
|
<span class="help-text">
|
||||||
|
Experts and professionals sharing knowledge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
|
|
@ -329,51 +232,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h2 class="section-heading">Tags</h2>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Event Tags</label>
|
|
||||||
<USelectMenu
|
|
||||||
v-model="eventForm.tags"
|
|
||||||
:items="tagOptions"
|
|
||||||
value-key="value"
|
|
||||||
multiple
|
|
||||||
searchable
|
|
||||||
create-item
|
|
||||||
placeholder="Select or type to add tags..."
|
|
||||||
class="w-full"
|
|
||||||
@create="onTagCreate"
|
|
||||||
/>
|
|
||||||
<div class="field new-tag-pool">
|
|
||||||
<label>New tag pool</label>
|
|
||||||
<USelect
|
|
||||||
v-model="newTagPool"
|
|
||||||
:items="[
|
|
||||||
{ label: 'Cooperative', value: 'cooperative' },
|
|
||||||
{ label: 'Craft', value: 'craft' },
|
|
||||||
]"
|
|
||||||
value-key="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<p class="help-text">
|
|
||||||
Pool assigned to any new tag you create from this field.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="help-text">
|
|
||||||
Tag this event to help with discovery and recommendations. Type a
|
|
||||||
new tag and press enter to add it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ticketing -->
|
<!-- Ticketing -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2 class="section-heading">Ticketing</h2>
|
<h2 class="section-heading">Ticketing</h2>
|
||||||
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input v-model="eventForm.tickets.enabled" type="checkbox" >
|
<input v-model="eventForm.tickets.enabled" type="checkbox" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Enable Ticketing</strong>
|
<strong>Enable Ticketing</strong>
|
||||||
<span class="help-text"> Allow ticket sales for this event </span>
|
<span class="help-text"> Allow ticket sales for this event </span>
|
||||||
|
|
@ -385,7 +249,7 @@
|
||||||
<input
|
<input
|
||||||
v-model="eventForm.tickets.public.available"
|
v-model="eventForm.tickets.public.available"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<strong>Public Tickets Available</strong>
|
<strong>Public Tickets Available</strong>
|
||||||
<span class="help-text">
|
<span class="help-text">
|
||||||
|
|
@ -394,12 +258,6 @@
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="note-box">
|
|
||||||
<strong>Note:</strong> Public ticket pricing applies to non-members.
|
|
||||||
Members register for events from their dashboard at no charge,
|
|
||||||
regardless of public ticket settings.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="eventForm.tickets.public.available">
|
<div v-if="eventForm.tickets.public.available">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -467,7 +325,6 @@
|
||||||
<label>Early Bird Deadline</label>
|
<label>Early Bird Deadline</label>
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
||||||
:display-timezone="eventForm.displayTimezone"
|
|
||||||
placeholder="e.g., '1 week before event', 'next Monday'"
|
placeholder="e.g., '1 week before event', 'next Monday'"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
|
|
@ -476,6 +333,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="note-box">
|
||||||
|
<strong>Note:</strong> Members always get free access to all events
|
||||||
|
regardless of ticket settings.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Management -->
|
<!-- Series Management -->
|
||||||
|
|
@ -483,7 +345,7 @@
|
||||||
<h2 class="section-heading">Series Management</h2>
|
<h2 class="section-heading">Series Management</h2>
|
||||||
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" >
|
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Part of Event Series</strong>
|
<strong>Part of Event Series</strong>
|
||||||
<span class="help-text">
|
<span class="help-text">
|
||||||
|
|
@ -499,6 +361,7 @@
|
||||||
<USelect
|
<USelect
|
||||||
v-model="selectedSeriesId"
|
v-model="selectedSeriesId"
|
||||||
aria-label="Select series"
|
aria-label="Select series"
|
||||||
|
@update:model-value="onSeriesSelect"
|
||||||
:items="
|
:items="
|
||||||
availableSeries.map((series) => ({
|
availableSeries.map((series) => ({
|
||||||
label: `${series.title} (${series.eventCount || 0} events)`,
|
label: `${series.title} (${series.eventCount || 0} events)`,
|
||||||
|
|
@ -508,7 +371,6 @@
|
||||||
placeholder="Choose existing series or create new..."
|
placeholder="Choose existing series or create new..."
|
||||||
value-key="value"
|
value-key="value"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@update:model-value="onSeriesSelect"
|
|
||||||
/>
|
/>
|
||||||
<NuxtLink to="/admin/series/create" class="btn btn-primary">
|
<NuxtLink to="/admin/series/create" class="btn btn-primary">
|
||||||
New Series
|
New Series
|
||||||
|
|
@ -566,7 +428,113 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
|
<!-- Event Agenda -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h2 class="section-heading">Event Agenda</h2>
|
||||||
|
|
||||||
|
<div class="agenda-items">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in eventForm.agenda"
|
||||||
|
:key="index"
|
||||||
|
class="agenda-row"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="eventForm.agenda[index]"
|
||||||
|
placeholder="Enter agenda item (e.g., 'Introduction and welcome - 10 mins')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeAgendaItem(index)"
|
||||||
|
class="link-btn link-btn-danger"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addAgendaItem"
|
||||||
|
class="btn add-agenda-btn"
|
||||||
|
>
|
||||||
|
+ Add Agenda Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="help-text">
|
||||||
|
Add agenda items to help attendees know what to expect during the
|
||||||
|
event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Settings -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h2 class="section-heading">Event Settings</h2>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="check-group">
|
||||||
|
<label class="check-label">
|
||||||
|
<input v-model="eventForm.isOnline" type="checkbox" />
|
||||||
|
<div>
|
||||||
|
<strong>Online Event</strong>
|
||||||
|
<span class="help-text">
|
||||||
|
Event will be conducted virtually
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="check-label">
|
||||||
|
<input
|
||||||
|
v-model="eventForm.registrationRequired"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong>Registration Required</strong>
|
||||||
|
<span class="help-text">
|
||||||
|
Attendees must register before attending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="check-group">
|
||||||
|
<label class="check-label">
|
||||||
|
<input v-model="eventForm.isVisible" type="checkbox" />
|
||||||
|
<div>
|
||||||
|
<strong>Visible on Public Calendar</strong>
|
||||||
|
<span class="help-text">
|
||||||
|
Event will appear on the public events page
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="check-label">
|
||||||
|
<input v-model="eventForm.isCancelled" type="checkbox" />
|
||||||
|
<div>
|
||||||
|
<strong>Event Cancelled</strong>
|
||||||
|
<span class="help-text"> Mark this event as cancelled </span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancellation Message (conditional) -->
|
||||||
|
<div v-if="eventForm.isCancelled" class="form-section">
|
||||||
|
<div class="field">
|
||||||
|
<label>Cancellation Message</label>
|
||||||
|
<UTextarea
|
||||||
|
v-model="eventForm.cancellationMessage"
|
||||||
|
placeholder="Explain why the event was cancelled and any next steps..."
|
||||||
|
:rows="3"
|
||||||
|
color="error"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="help-text">
|
||||||
|
This message will be displayed to users viewing the event page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
|
|
@ -577,9 +545,9 @@
|
||||||
<button
|
<button
|
||||||
v-if="!editingEvent"
|
v-if="!editingEvent"
|
||||||
type="button"
|
type="button"
|
||||||
|
@click="saveAndCreateAnother"
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
class="btn"
|
class="btn"
|
||||||
@click="saveAndCreateAnother"
|
|
||||||
>
|
>
|
||||||
{{ creating ? "Saving..." : "Save & Create Another" }}
|
{{ creating ? "Saving..." : "Save & Create Another" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -601,9 +569,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
|
||||||
import { EVENT_TYPES } from "~/config/eventTypes";
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
middleware: "admin",
|
middleware: "admin",
|
||||||
|
|
@ -619,34 +584,6 @@ const formErrors = ref([]);
|
||||||
const fieldErrors = ref({});
|
const fieldErrors = ref({});
|
||||||
const selectedSeriesId = ref(null);
|
const selectedSeriesId = ref(null);
|
||||||
const availableSeries = ref([]);
|
const availableSeries = ref([]);
|
||||||
const availableTags = ref([]);
|
|
||||||
|
|
||||||
const tagOptions = computed(() =>
|
|
||||||
availableTags.value.map((t) => ({ label: t.label, value: t.slug })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const newTagPool = ref("cooperative");
|
|
||||||
|
|
||||||
const onTagCreate = async (item) => {
|
|
||||||
const label = typeof item === "string" ? item : item?.label || item?.value;
|
|
||||||
if (!label?.trim()) return;
|
|
||||||
try {
|
|
||||||
const { tag } = await $fetch("/api/admin/tags", {
|
|
||||||
method: "POST",
|
|
||||||
body: { label: label.trim(), pool: newTagPool.value },
|
|
||||||
});
|
|
||||||
if (!availableTags.value.some((t) => t.slug === tag.slug)) {
|
|
||||||
availableTags.value.push({ slug: tag.slug, label: tag.label });
|
|
||||||
}
|
|
||||||
if (!eventForm.tags.includes(tag.slug)) {
|
|
||||||
eventForm.tags.push(tag.slug);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
formErrors.value.push(
|
|
||||||
`Failed to create tag "${label}": ${err?.data?.statusMessage || err?.statusMessage || err?.message || "unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const eventForm = reactive({
|
const eventForm = reactive({
|
||||||
title: "",
|
title: "",
|
||||||
|
|
@ -655,16 +592,13 @@ const eventForm = reactive({
|
||||||
featureImage: null,
|
featureImage: null,
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
eventType: "community-meetup",
|
eventType: "community",
|
||||||
displayTimezone: "America/Toronto",
|
|
||||||
location: "",
|
location: "",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
membersOnly: false,
|
|
||||||
cancellationMessage: "",
|
cancellationMessage: "",
|
||||||
targetCircles: [],
|
targetCircles: [],
|
||||||
tags: [],
|
|
||||||
maxAttendees: "",
|
maxAttendees: "",
|
||||||
registrationRequired: false,
|
registrationRequired: false,
|
||||||
registrationDeadline: "",
|
registrationDeadline: "",
|
||||||
|
|
@ -689,68 +623,24 @@ const eventForm = reactive({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format a Date/ISO value into a datetime-local string using local-time components.
|
// Agenda management functions
|
||||||
// `toISOString().slice(0,16)` drifts by the browser's UTC offset on edit round-trip.
|
const addAgendaItem = () => {
|
||||||
const formatForDatetimeLocal = (value) => {
|
eventForm.agenda.push("");
|
||||||
if (!value) return "";
|
|
||||||
const d = new Date(value);
|
|
||||||
if (isNaN(d.getTime())) return "";
|
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render the form's datetime fields in the event's display timezone.
|
const removeAgendaItem = (index) => {
|
||||||
const formatForEventTZ = (value) => {
|
eventForm.agenda.splice(index, 1);
|
||||||
if (!value) return "";
|
|
||||||
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const utcOffsetLabel = (tz) => {
|
// Load available series
|
||||||
try {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: tz,
|
|
||||||
timeZoneName: "longOffset",
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
|
|
||||||
if (name === "GMT") return "UTC+00:00";
|
|
||||||
return name.replace("GMT", "UTC");
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const timezoneItems = computed(() => {
|
|
||||||
const list = TIMEZONE_OPTIONS.map((t) => {
|
|
||||||
const off = utcOffsetLabel(t.value);
|
|
||||||
return { ...t, label: off ? `${t.label} (${off})` : t.label };
|
|
||||||
});
|
|
||||||
const saved = eventForm.displayTimezone;
|
|
||||||
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
|
|
||||||
list.unshift({ label: saved, value: saved });
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
});
|
|
||||||
|
|
||||||
const agendaText = computed({
|
|
||||||
get() {
|
|
||||||
return (eventForm.agenda || []).join("\n");
|
|
||||||
},
|
|
||||||
set(v) {
|
|
||||||
eventForm.agenda = v.split("\n");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load available series and tags
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [seriesResponse, tagsResponse] = await Promise.all([
|
const response = await $fetch("/api/admin/series");
|
||||||
$fetch("/api/admin/series"),
|
console.log("Loaded series:", response);
|
||||||
$fetch("/api/tags"),
|
availableSeries.value = response;
|
||||||
]);
|
console.log("availableSeries.value:", availableSeries.value);
|
||||||
availableSeries.value = seriesResponse;
|
|
||||||
availableTags.value = tagsResponse.tags || [];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load form data:", error);
|
console.error("Failed to load series:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -780,32 +670,33 @@ const onSeriesSelect = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function populateEditForm(payload) {
|
// Check if we're editing an event
|
||||||
const event = payload?.data;
|
if (route.query.edit) {
|
||||||
if (!event) return;
|
try {
|
||||||
|
const response = await $fetch(`/api/admin/events/${route.query.edit}`);
|
||||||
|
const event = response.data;
|
||||||
|
|
||||||
|
if (event) {
|
||||||
editingEvent.value = event;
|
editingEvent.value = event;
|
||||||
// Pin the form's timezone first so subsequent date conversions use it.
|
|
||||||
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
|
|
||||||
Object.assign(eventForm, {
|
Object.assign(eventForm, {
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
content: event.content || "",
|
content: event.content || "",
|
||||||
featureImage: event.featureImage || null,
|
featureImage: event.featureImage || null,
|
||||||
startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
|
startDate: new Date(event.startDate).toISOString().slice(0, 16),
|
||||||
endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
|
endDate: new Date(event.endDate).toISOString().slice(0, 16),
|
||||||
eventType: event.eventType,
|
eventType: event.eventType,
|
||||||
displayTimezone: eventForm.displayTimezone,
|
|
||||||
location: event.location || "",
|
location: event.location || "",
|
||||||
isOnline: event.isOnline,
|
isOnline: event.isOnline,
|
||||||
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
||||||
isCancelled: event.isCancelled || false,
|
isCancelled: event.isCancelled || false,
|
||||||
membersOnly: event.membersOnly || false,
|
|
||||||
cancellationMessage: event.cancellationMessage || "",
|
cancellationMessage: event.cancellationMessage || "",
|
||||||
targetCircles: event.targetCircles || [],
|
targetCircles: event.targetCircles || [],
|
||||||
tags: event.tags || [],
|
|
||||||
maxAttendees: event.maxAttendees || "",
|
maxAttendees: event.maxAttendees || "",
|
||||||
registrationRequired: event.registrationRequired,
|
registrationRequired: event.registrationRequired,
|
||||||
registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
|
registrationDeadline: event.registrationDeadline
|
||||||
|
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
|
||||||
|
: "",
|
||||||
agenda: event.agenda || [],
|
agenda: event.agenda || [],
|
||||||
tickets: event.tickets || {
|
tickets: event.tickets || {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
@ -826,30 +717,22 @@ function populateEditForm(payload) {
|
||||||
description: "",
|
description: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Handle early bird deadline formatting
|
||||||
if (event.tickets?.public?.earlyBirdDeadline) {
|
if (event.tickets?.public?.earlyBirdDeadline) {
|
||||||
eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
|
eventForm.tickets.public.earlyBirdDeadline = new Date(
|
||||||
event.tickets.public.earlyBirdDeadline,
|
event.tickets.public.earlyBirdDeadline,
|
||||||
eventForm.displayTimezone,
|
)
|
||||||
);
|
.toISOString()
|
||||||
|
.slice(0, 16);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// useFetch forwards auth cookies to SSR; $fetch did not, leaving the
|
|
||||||
// SSR-rendered form empty and triggering hydration mismatches that left
|
|
||||||
// required textareas DOM-empty in dev.
|
|
||||||
if (route.query.edit) {
|
|
||||||
const { data: editEvent, error: editError } = await useFetch(
|
|
||||||
`/api/admin/events/${route.query.edit}`,
|
|
||||||
);
|
|
||||||
if (editError.value) {
|
|
||||||
console.error("Failed to load event for editing:", editError.value);
|
|
||||||
}
|
}
|
||||||
if (editEvent.value) populateEditForm(editEvent.value);
|
} catch (error) {
|
||||||
watch(editEvent, populateEditForm, { immediate: false });
|
console.error("Failed to load event for editing:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're duplicating an event
|
// Check if we're duplicating an event
|
||||||
if (route.query.duplicate && import.meta.client) {
|
if (route.query.duplicate && process.client) {
|
||||||
const duplicateData = sessionStorage.getItem("duplicateEventData");
|
const duplicateData = sessionStorage.getItem("duplicateEventData");
|
||||||
if (duplicateData) {
|
if (duplicateData) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -863,7 +746,7 @@ if (route.query.duplicate && import.meta.client) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're creating a series event
|
// Check if we're creating a series event
|
||||||
if (route.query.series && import.meta.client) {
|
if (route.query.series && process.client) {
|
||||||
const seriesData = sessionStorage.getItem("seriesEventData");
|
const seriesData = sessionStorage.getItem("seriesEventData");
|
||||||
if (seriesData) {
|
if (seriesData) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -902,6 +785,12 @@ const validateForm = () => {
|
||||||
fieldErrors.value.endDate = "Please select when the event ends";
|
fieldErrors.value.endDate = "Please select when the event ends";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!eventForm.location.trim()) {
|
||||||
|
formErrors.value.push("Location is required");
|
||||||
|
fieldErrors.value.location =
|
||||||
|
"Please enter a location (URL or Slack channel)";
|
||||||
|
}
|
||||||
|
|
||||||
// Date validation
|
// Date validation
|
||||||
if (eventForm.startDate && eventForm.endDate) {
|
if (eventForm.startDate && eventForm.endDate) {
|
||||||
const startDate = new Date(eventForm.startDate);
|
const startDate = new Date(eventForm.startDate);
|
||||||
|
|
@ -918,6 +807,23 @@ const validateForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location format validation
|
||||||
|
if (eventForm.location.trim()) {
|
||||||
|
const urlPattern = /^https?:\/\/.+/;
|
||||||
|
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!urlPattern.test(eventForm.location) &&
|
||||||
|
!slackPattern.test(eventForm.location)
|
||||||
|
) {
|
||||||
|
formErrors.value.push(
|
||||||
|
"Location must be a valid URL or Slack channel (starting with #)",
|
||||||
|
);
|
||||||
|
fieldErrors.value.location =
|
||||||
|
"Enter a video conference link (https://...) or Slack channel (#channel-name)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Registration deadline validation
|
// Registration deadline validation
|
||||||
if (eventForm.registrationDeadline && eventForm.startDate) {
|
if (eventForm.registrationDeadline && eventForm.startDate) {
|
||||||
const regDeadline = new Date(eventForm.registrationDeadline);
|
const regDeadline = new Date(eventForm.registrationDeadline);
|
||||||
|
|
@ -952,40 +858,15 @@ const saveEvent = async (redirect = true) => {
|
||||||
// Individual series creation is handled through the series management page
|
// Individual series creation is handled through the series management page
|
||||||
}
|
}
|
||||||
|
|
||||||
const tz = eventForm.displayTimezone || "America/Toronto";
|
|
||||||
const toUTC = (v) => {
|
|
||||||
const d = zonedLocalToUTC(v, tz);
|
|
||||||
return d ? d.toISOString() : v;
|
|
||||||
};
|
|
||||||
const payload = {
|
|
||||||
...eventForm,
|
|
||||||
startDate: toUTC(eventForm.startDate),
|
|
||||||
endDate: toUTC(eventForm.endDate),
|
|
||||||
registrationDeadline: eventForm.registrationDeadline
|
|
||||||
? toUTC(eventForm.registrationDeadline)
|
|
||||||
: eventForm.registrationDeadline,
|
|
||||||
agenda: (eventForm.agenda || [])
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
tickets: {
|
|
||||||
...eventForm.tickets,
|
|
||||||
public: {
|
|
||||||
...eventForm.tickets.public,
|
|
||||||
earlyBirdDeadline: eventForm.tickets.public.earlyBirdDeadline
|
|
||||||
? toUTC(eventForm.tickets.public.earlyBirdDeadline)
|
|
||||||
: eventForm.tickets.public.earlyBirdDeadline,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (editingEvent.value) {
|
if (editingEvent.value) {
|
||||||
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: payload,
|
body: eventForm,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await $fetch("/api/admin/events", {
|
await $fetch("/api/admin/events", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: payload,
|
body: eventForm,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1024,16 +905,13 @@ const saveAndCreateAnother = async () => {
|
||||||
featureImage: null,
|
featureImage: null,
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
eventType: "community-meetup",
|
eventType: "community",
|
||||||
displayTimezone: "America/Toronto",
|
|
||||||
location: "",
|
location: "",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
membersOnly: false,
|
|
||||||
cancellationMessage: "",
|
cancellationMessage: "",
|
||||||
targetCircles: [],
|
targetCircles: [],
|
||||||
tags: [],
|
|
||||||
maxAttendees: "",
|
maxAttendees: "",
|
||||||
registrationRequired: false,
|
registrationRequired: false,
|
||||||
registrationDeadline: "",
|
registrationDeadline: "",
|
||||||
|
|
@ -1070,42 +948,7 @@ const saveAndCreateAnother = async () => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.create-form {
|
.create-form {
|
||||||
display: flex;
|
max-width: 800px;
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vertical divider between main + aside, full viewport height */
|
|
||||||
.create-form::after {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 340px;
|
|
||||||
border-left: 1px dashed var(--border);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 340px;
|
|
||||||
align-items: stretch;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-main {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 24px 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-aside {
|
|
||||||
padding: 24px 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-aside .form-section:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -1142,21 +985,7 @@ const saveAndCreateAnother = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-body {
|
.form-body {
|
||||||
display: flex;
|
padding: 24px 28px;
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-body > .error-box,
|
|
||||||
.form-body > .success-box {
|
|
||||||
margin: 24px 28px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-body > form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
|
|
@ -1164,9 +993,7 @@ const saveAndCreateAnother = async () => {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin-left: -28px;
|
padding-bottom: 10px;
|
||||||
margin-right: -28px;
|
|
||||||
padding: 0 28px 10px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -1264,7 +1091,7 @@ const saveAndCreateAnother = async () => {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px 28px;
|
padding-top: 20px;
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1297,50 +1124,59 @@ const saveAndCreateAnother = async () => {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agenda-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-row .w-full {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-danger {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-agenda-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
color: var(--candle);
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled,
|
.btn:disabled,
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.title-input) {
|
|
||||||
font-family: "Brygada 1918", serif;
|
|
||||||
font-size: 24px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.create-form::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.form-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.form-aside {
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 24px 20px 16px;
|
padding: 24px 20px 16px;
|
||||||
}
|
}
|
||||||
.form-main,
|
.form-body {
|
||||||
.form-aside,
|
padding: 20px;
|
||||||
.form-actions {
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
.form-body > .error-box,
|
|
||||||
.form-body > .success-box {
|
|
||||||
margin-left: 20px;
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
.section-heading {
|
|
||||||
margin-left: -20px;
|
|
||||||
margin-right: -20px;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
}
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,15 @@
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<div class="field" style="margin-bottom: 0; flex: 1;">
|
<div class="field" style="margin-bottom: 0; flex: 1;">
|
||||||
<input v-model="searchQuery" placeholder="Search events..." >
|
<input v-model="searchQuery" placeholder="Search events..." />
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0;">
|
<div class="field" style="margin-bottom: 0;">
|
||||||
<select v-model="typeFilter">
|
<select v-model="typeFilter">
|
||||||
<option value="all">All Types</option>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0;">
|
<div class="field" style="margin-bottom: 0;">
|
||||||
|
|
@ -68,7 +71,7 @@
|
||||||
<td class="col-title">
|
<td class="col-title">
|
||||||
<div class="event-title-cell">
|
<div class="event-title-cell">
|
||||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span class="event-name">{{ event.title }}</span>
|
<span class="event-name">{{ event.title }}</span>
|
||||||
|
|
@ -86,11 +89,11 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-date">
|
<td class="col-date">
|
||||||
<span class="date-main">{{ formatDate(event) }}</span>
|
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||||
<span class="date-time">{{ formatTime(event) }}</span>
|
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
|
|
@ -125,9 +128,9 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
<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 @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||||
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||||
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -166,7 +169,7 @@
|
||||||
<td class="col-title">
|
<td class="col-title">
|
||||||
<div class="event-title-cell">
|
<div class="event-title-cell">
|
||||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span class="event-name">{{ event.title }}</span>
|
<span class="event-name">{{ event.title }}</span>
|
||||||
|
|
@ -184,11 +187,11 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-date">
|
<td class="col-date">
|
||||||
<span class="date-main">{{ formatDate(event) }}</span>
|
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||||
<span class="date-time">{{ formatTime(event) }}</span>
|
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
|
|
@ -223,9 +226,9 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
<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 @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||||
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||||
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -264,8 +267,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
middleware: 'admin',
|
middleware: 'admin',
|
||||||
|
|
@ -348,23 +349,19 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
|
||||||
pastPage.value = 1
|
pastPage.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (event) => {
|
const formatDate = (dateString) => {
|
||||||
if (!event?.startDate) return ''
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
return new Date(event.startDate).toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
timeZone: event.displayTimezone || 'America/Toronto',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (event) => {
|
const formatTime = (dateString) => {
|
||||||
if (!event?.startDate) return ''
|
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||||
return new Date(event.startDate).toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
timeZone: event.displayTimezone || 'America/Toronto',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +570,7 @@ tbody td {
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--c-founder);
|
color: var(--c-founder);
|
||||||
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
|
border: 1px dashed rgba(138, 68, 32, 0.3);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -586,7 +583,7 @@ tbody td {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--c-founder);
|
color: var(--c-founder);
|
||||||
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
border: 1px dashed rgba(138, 68, 32, 0.4);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,12 +632,12 @@ tbody td {
|
||||||
|
|
||||||
.status-upcoming {
|
.status-upcoming {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
border-color: rgba(122, 90, 16, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-ongoing {
|
.status-ongoing {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
border-color: rgba(74, 106, 56, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-past {
|
.status-past {
|
||||||
|
|
@ -650,7 +647,7 @@ tbody td {
|
||||||
|
|
||||||
.status-cancelled {
|
.status-cancelled {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
|
border-color: rgba(138, 68, 32, 0.3);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
<span class="item-sub">{{ member.email }}</span>
|
<span class="item-sub">{{ member.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-meta">
|
<div class="item-meta">
|
||||||
<CircleBadge :circle="member.circle" />
|
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||||
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-meta">
|
<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>
|
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,8 +106,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { eventTypeLabel } from '~/config/eventTypes'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
middleware: 'admin',
|
middleware: 'admin',
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<p v-if="member" class="member-email">{{ member.email }}</p>
|
<p v-if="member" class="member-email">{{ member.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member" class="header-badges">
|
<div v-if="member" class="header-badges">
|
||||||
<CircleBadge :circle="member.circle" />
|
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||||
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
|
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,11 +39,11 @@
|
||||||
<form class="edit-form" @submit.prevent="submitEdit">
|
<form class="edit-form" @submit.prevent="submitEdit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input v-model="form.name" type="text" required >
|
<input v-model="form.name" type="text" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input v-model="form.email" type="email" required >
|
<input v-model="form.email" type="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Circle</label>
|
<label>Circle</label>
|
||||||
|
|
@ -54,20 +54,22 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Contribution ($/mo)</label>
|
<label>Contribution tier ($/mo)</label>
|
||||||
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
|
<select v-model="form.contributionTier">
|
||||||
<p class="field-hint field-hint--warn">
|
<option value="0">$0</option>
|
||||||
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard — this form does not sync.
|
<option value="5">$5</option>
|
||||||
</p>
|
<option value="15">$15</option>
|
||||||
|
<option value="30">$30</option>
|
||||||
|
<option value="50">$50</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
<select v-model="form.status">
|
<select v-model="form.status">
|
||||||
<option
|
<option value="pending_payment">pending_payment</option>
|
||||||
v-for="(label, value) in STATUS_LABELS"
|
<option value="active">active</option>
|
||||||
:key="value"
|
<option value="suspended">suspended</option>
|
||||||
:value="value"
|
<option value="cancelled">cancelled</option>
|
||||||
>{{ label }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -110,19 +112,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<dt>Slack invite</dt>
|
<dt>Slack invite</dt>
|
||||||
<dd v-if="member.slackInvited" class="status-ok">
|
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
||||||
Invited {{ formatDate(member.slackInvitedAt) }}
|
{{ member.slackInvited ? "Invited" : "Pending" }}
|
||||||
</dd>
|
|
||||||
<dd v-else class="meta-action">
|
|
||||||
<span class="status-dim">Not yet invited</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="link-btn"
|
|
||||||
:disabled="markingSlackInvited"
|
|
||||||
@click="markSlackInvited"
|
|
||||||
>
|
|
||||||
{{ markingSlackInvited ? "Marking…" : "Mark as Slack invited" }}
|
|
||||||
</button>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member.helcimCustomerId" class="meta-row">
|
<div v-if="member.helcimCustomerId" class="meta-row">
|
||||||
|
|
@ -136,43 +127,6 @@
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</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 -->
|
<!-- Notification preferences -->
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<div class="section-label">Notification preferences</div>
|
<div class="section-label">Notification preferences</div>
|
||||||
|
|
@ -243,7 +197,6 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { formatActivity } from '~/utils/activityText'
|
import { formatActivity } from '~/utils/activityText'
|
||||||
import { STATUS_LABELS } from '~/config/memberStatus'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
|
|
@ -274,7 +227,7 @@ const form = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
circle: "",
|
circle: "",
|
||||||
contributionAmount: 0,
|
contributionTier: "",
|
||||||
status: "",
|
status: "",
|
||||||
role: "",
|
role: "",
|
||||||
});
|
});
|
||||||
|
|
@ -286,7 +239,7 @@ function populateForm(m) {
|
||||||
form.name = m.name;
|
form.name = m.name;
|
||||||
form.email = m.email;
|
form.email = m.email;
|
||||||
form.circle = m.circle;
|
form.circle = m.circle;
|
||||||
form.contributionAmount = m.contributionAmount ?? 0;
|
form.contributionTier = String(m.contributionTier);
|
||||||
form.status = m.status || "pending_payment";
|
form.status = m.status || "pending_payment";
|
||||||
form.role = m.role || "member";
|
form.role = m.role || "member";
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +261,7 @@ async function submitEdit() {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
circle: form.circle,
|
circle: form.circle,
|
||||||
contributionAmount: form.contributionAmount,
|
contributionTier: form.contributionTier,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -351,47 +304,6 @@ function statusClass(status) {
|
||||||
return "status-dim";
|
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
|
// Activity log
|
||||||
const activityEntries = ref([])
|
const activityEntries = ref([])
|
||||||
const activityLoading = ref(false)
|
const activityLoading = ref(false)
|
||||||
|
|
@ -539,24 +451,6 @@ onMounted(loadActivity)
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin: 6px 0 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint--warn {
|
|
||||||
color: var(--ember);
|
|
||||||
border-left: 2px solid var(--ember);
|
|
||||||
padding: 4px 0 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint code {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -600,32 +494,6 @@ onMounted(loadActivity)
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-action {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--candle);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-btn:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<!-- Search / Filter -->
|
<!-- Search / Filter -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<div class="field" style="margin-bottom: 0; flex: 1">
|
<div class="field" style="margin-bottom: 0; flex: 1">
|
||||||
<input v-model="searchQuery" placeholder="Search members..." >
|
<input v-model="searchQuery" placeholder="Search members..." />
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0">
|
<div class="field" style="margin-bottom: 0">
|
||||||
<select v-model="circleFilter" aria-label="Filter by circle">
|
<select v-model="circleFilter" aria-label="Filter by circle">
|
||||||
|
|
@ -38,16 +38,6 @@
|
||||||
<option value="practitioner">Practitioner</option>
|
<option value="practitioner">Practitioner</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0">
|
|
||||||
<select v-model="statusFilter" aria-label="Filter by status">
|
|
||||||
<option value="">All Statuses</option>
|
|
||||||
<option
|
|
||||||
v-for="(label, value) in STATUS_LABELS"
|
|
||||||
:key="value"
|
|
||||||
:value="value"
|
|
||||||
>{{ label }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Members Table -->
|
<!-- Members Table -->
|
||||||
|
|
@ -71,18 +61,17 @@
|
||||||
:checked="allVisibleSelected"
|
:checked="allVisibleSelected"
|
||||||
:indeterminate="!allVisibleSelected && someVisibleSelected"
|
:indeterminate="!allVisibleSelected && someVisibleSelected"
|
||||||
@change="toggleSelectAll"
|
@change="toggleSelectAll"
|
||||||
>
|
/>
|
||||||
<span class="check-mark" />
|
<span class="check-mark" />
|
||||||
</label>
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<th class="sortable" @click="toggleSort('name')">Name <span class="sort-ind">{{ sortIndicator('name') }}</span></th>
|
<th>Name</th>
|
||||||
<th class="sortable" @click="toggleSort('email')">Email <span class="sort-ind">{{ sortIndicator('email') }}</span></th>
|
<th>Email</th>
|
||||||
<th class="sortable" @click="toggleSort('circle')">Circle <span class="sort-ind">{{ sortIndicator('circle') }}</span></th>
|
<th>Circle</th>
|
||||||
<th class="sortable" @click="toggleSort('contributionAmount')">Contribution <span class="sort-ind">{{ sortIndicator('contributionAmount') }}</span></th>
|
<th>Tier</th>
|
||||||
<th class="sortable" @click="toggleSort('status')">Status <span class="sort-ind">{{ sortIndicator('status') }}</span></th>
|
|
||||||
<th>Invite</th>
|
<th>Invite</th>
|
||||||
<th>Slack</th>
|
<th>Slack</th>
|
||||||
<th class="sortable" @click="toggleSort('createdAt')">Joined <span class="sort-ind">{{ sortIndicator('createdAt') }}</span></th>
|
<th>Joined</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -100,7 +89,7 @@
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="selectedMemberIds.includes(member._id)"
|
:checked="selectedMemberIds.includes(member._id)"
|
||||||
@change="toggleSelect(member._id)"
|
@change="toggleSelect(member._id)"
|
||||||
>
|
/>
|
||||||
<span class="check-mark" />
|
<span class="check-mark" />
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -109,12 +98,11 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="col-email">{{ member.email }}</td>
|
<td class="col-email">{{ member.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<CircleBadge :circle="member.circle" />
|
<span class="badge" :class="member.circle">{{
|
||||||
</td>
|
member.circle
|
||||||
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
|
}}</span>
|
||||||
<td>
|
|
||||||
<span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="col-mono">${{ member.contributionTier }}/mo</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
:class="member.inviteEmailSent ? 'status-ok' : 'status-dim'"
|
:class="member.inviteEmailSent ? 'status-ok' : 'status-dim'"
|
||||||
|
|
@ -123,11 +111,8 @@
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="member.slackInvited" class="status-ok">
|
<span :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
||||||
Invited {{ formatDate(member.slackInvitedAt) }}
|
{{ member.slackInvited ? "Invited" : "Pending" }}
|
||||||
</span>
|
|
||||||
<span v-else class="status-dim">
|
|
||||||
Not yet invited
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-mono col-date">
|
<td class="col-mono col-date">
|
||||||
|
|
@ -137,14 +122,10 @@
|
||||||
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
|
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
|
||||||
>View</NuxtLink
|
>View</NuxtLink
|
||||||
>
|
>
|
||||||
<button
|
<button @click.stop="sendSlackInvite(member)" class="link-btn">
|
||||||
v-if="!member.slackInvited"
|
Slack
|
||||||
class="link-btn"
|
|
||||||
@click.stop="markSlackInvited(member)"
|
|
||||||
>
|
|
||||||
Mark as Slack invited
|
|
||||||
</button>
|
</button>
|
||||||
<button class="link-btn" @click.stop="editMember(member)">Edit</button>
|
<button @click.stop="editMember(member)" class="link-btn">Edit</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -169,10 +150,10 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="modal-body" @submit.prevent="createMember">
|
<form @submit.prevent="createMember" class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input v-model="newMember.name" placeholder="Full name" required >
|
<input v-model="newMember.name" placeholder="Full name" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
|
|
@ -181,7 +162,7 @@
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Circle</label>
|
<label>Circle</label>
|
||||||
|
|
@ -192,8 +173,14 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Contribution ($/mo)</label>
|
<label>Contribution Tier</label>
|
||||||
<input v-model.number="newMember.contributionAmount" type="number" min="0" step="1">
|
<select v-model="newMember.contributionTier">
|
||||||
|
<option value="0">$0/month</option>
|
||||||
|
<option value="5">$5/month</option>
|
||||||
|
<option value="15">$15/month</option>
|
||||||
|
<option value="30">$30/month</option>
|
||||||
|
<option value="50">$50/month</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn" @click="showCreateModal = false">
|
<button type="button" class="btn" @click="showCreateModal = false">
|
||||||
|
|
@ -223,18 +210,19 @@
|
||||||
<div v-if="!csvRows.length">
|
<div v-if="!csvRows.length">
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
Upload a CSV with columns:
|
Upload a CSV with columns:
|
||||||
<code>name,email,circle,contributionAmount</code>
|
<code>name,email,circle,contributionTier</code>
|
||||||
</p>
|
</p>
|
||||||
<p class="help-text" style="margin-bottom: 12px">
|
<p class="help-text" style="margin-bottom: 12px">
|
||||||
Valid circles: community, founder, practitioner. Contribution: whole number ≥ 0.
|
Valid circles: community, founder, practitioner. Valid tiers: 0,
|
||||||
|
5, 15, 30, 50.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref="csvFileInput"
|
ref="csvFileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
class="file-input"
|
|
||||||
@change="handleCsvFile"
|
@change="handleCsvFile"
|
||||||
>
|
class="file-input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="csvParseError" class="error-box">
|
<div v-if="csvParseError" class="error-box">
|
||||||
|
|
@ -255,7 +243,7 @@
|
||||||
>
|
>
|
||||||
{{ csvRows.length - csvValidRows.length }} with errors.
|
{{ csvRows.length - csvValidRows.length }} with errors.
|
||||||
</span>
|
</span>
|
||||||
<button class="link-btn" @click="resetCsvImport">
|
<button @click="resetCsvImport" class="link-btn">
|
||||||
Choose different file
|
Choose different file
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -268,7 +256,7 @@
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Circle</th>
|
<th>Circle</th>
|
||||||
<th>Contribution</th>
|
<th>Tier</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -286,7 +274,7 @@
|
||||||
<td>{{ row.name }}</td>
|
<td>{{ row.name }}</td>
|
||||||
<td class="col-email">{{ row.email }}</td>
|
<td class="col-email">{{ row.email }}</td>
|
||||||
<td>{{ row.circle }}</td>
|
<td>{{ row.circle }}</td>
|
||||||
<td>${{ row.contributionAmount }}/mo</td>
|
<td>${{ row.contributionTier }}/mo</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -315,14 +303,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn" @click="closeImportModal">
|
<button @click="closeImportModal" class="btn">
|
||||||
{{ importResults ? "Done" : "Cancel" }}
|
{{ importResults ? "Done" : "Cancel" }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="csvValidRows.length && !importResults"
|
v-if="csvValidRows.length && !importResults"
|
||||||
:disabled="importing"
|
:disabled="importing"
|
||||||
class="btn btn-primary"
|
|
||||||
@click="submitImport"
|
@click="submitImport"
|
||||||
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
importing
|
importing
|
||||||
|
|
@ -348,14 +336,14 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="modal-body" @submit.prevent="submitEditMember">
|
<form @submit.prevent="submitEditMember" class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input v-model="editingMember.name" required >
|
<input v-model="editingMember.name" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input v-model="editingMember.email" type="email" required >
|
<input v-model="editingMember.email" type="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Circle</label>
|
<label>Circle</label>
|
||||||
|
|
@ -366,17 +354,22 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Contribution ($/mo)</label>
|
<label>Contribution Tier</label>
|
||||||
<input v-model.number="editingMember.contributionAmount" type="number" min="0" step="1">
|
<select v-model="editingMember.contributionTier">
|
||||||
|
<option value="0">$0/month</option>
|
||||||
|
<option value="5">$5/month</option>
|
||||||
|
<option value="15">$15/month</option>
|
||||||
|
<option value="30">$30/month</option>
|
||||||
|
<option value="50">$50/month</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
<select v-model="editingMember.status">
|
<select v-model="editingMember.status">
|
||||||
<option
|
<option value="pending_payment">Pending Payment</option>
|
||||||
v-for="(label, value) in STATUS_LABELS"
|
<option value="active">Active</option>
|
||||||
:key="value"
|
<option value="suspended">Suspended</option>
|
||||||
:value="value"
|
<option value="cancelled">Cancelled</option>
|
||||||
>{{ label }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|
@ -414,7 +407,7 @@
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Email Template</label>
|
<label>Email Template</label>
|
||||||
<textarea v-model="inviteTemplate" rows="12"/>
|
<textarea v-model="inviteTemplate" rows="12"></textarea>
|
||||||
<p class="help-text" style="margin-top: 4px">
|
<p class="help-text" style="margin-top: 4px">
|
||||||
Tokens: <code>{name}</code>, <code>{loginLink}</code>,
|
Tokens: <code>{name}</code>, <code>{loginLink}</code>,
|
||||||
<code>{circle}</code>
|
<code>{circle}</code>
|
||||||
|
|
@ -446,14 +439,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn" @click="showInviteModal = false">
|
<button @click="showInviteModal = false" class="btn">
|
||||||
{{ inviteResults ? "Done" : "Cancel" }}
|
{{ inviteResults ? "Done" : "Cancel" }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!inviteResults"
|
v-if="!inviteResults"
|
||||||
:disabled="sendingInvites"
|
:disabled="sendingInvites"
|
||||||
class="btn btn-primary"
|
|
||||||
@click="submitInvites"
|
@click="submitInvites"
|
||||||
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
sendingInvites
|
sendingInvites
|
||||||
|
|
@ -468,8 +461,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
middleware: "admin",
|
middleware: "admin",
|
||||||
|
|
@ -486,22 +477,6 @@ const {
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const circleFilter = ref("");
|
const circleFilter = ref("");
|
||||||
const statusFilter = ref("");
|
|
||||||
const sortKey = ref("createdAt");
|
|
||||||
const sortDir = ref("desc");
|
|
||||||
|
|
||||||
const toggleSort = (key) => {
|
|
||||||
if (sortKey.value === key) {
|
|
||||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
|
||||||
} else {
|
|
||||||
sortKey.value = key;
|
|
||||||
sortDir.value = key === "createdAt" ? "desc" : "asc";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sortIndicator = (key) => {
|
|
||||||
if (sortKey.value !== key) return "";
|
|
||||||
return sortDir.value === "asc" ? "▲" : "▼";
|
|
||||||
};
|
|
||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
|
|
||||||
|
|
@ -523,14 +498,14 @@ const inviteResults = ref(null);
|
||||||
|
|
||||||
const DEFAULT_INVITE_TEMPLATE = `Hi {name},
|
const DEFAULT_INVITE_TEMPLATE = `Hi {name},
|
||||||
|
|
||||||
You've been invited to Ghost Guild.
|
You've been invited to Ghost Guild as a member of the {circle} circle.
|
||||||
|
|
||||||
Sign in here to get started:
|
Sign in here to get started:
|
||||||
{loginLink}
|
{loginLink}
|
||||||
|
|
||||||
This link expires in 48 hours. After that, you can always request a new login link at https://ghostguild.org/login.
|
This link expires in 48 hours. After that, you can always request a new login link at https://ghostguild.org/login.
|
||||||
|
|
||||||
Reply to this email if you have any trouble!`;
|
See you inside.`;
|
||||||
|
|
||||||
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
||||||
|
|
||||||
|
|
@ -538,13 +513,13 @@ const newMember = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
circle: "community",
|
circle: "community",
|
||||||
contributionAmount: 0,
|
contributionTier: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredMembers = computed(() => {
|
const filteredMembers = computed(() => {
|
||||||
if (!members.value) return [];
|
if (!members.value) return [];
|
||||||
|
|
||||||
const filtered = members.value.filter((member) => {
|
return members.value.filter((member) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!searchQuery.value ||
|
!searchQuery.value ||
|
||||||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||||
|
|
@ -553,33 +528,7 @@ const filteredMembers = computed(() => {
|
||||||
const matchesCircle =
|
const matchesCircle =
|
||||||
!circleFilter.value || member.circle === circleFilter.value;
|
!circleFilter.value || member.circle === circleFilter.value;
|
||||||
|
|
||||||
const matchesStatus =
|
return matchesSearch && matchesCircle;
|
||||||
!statusFilter.value || (member.status || "pending_payment") === statusFilter.value;
|
|
||||||
|
|
||||||
return matchesSearch && matchesCircle && matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
const key = sortKey.value;
|
|
||||||
const dir = sortDir.value === "asc" ? 1 : -1;
|
|
||||||
return [...filtered].sort((a, b) => {
|
|
||||||
let av = a[key];
|
|
||||||
let bv = b[key];
|
|
||||||
if (key === "contributionAmount") {
|
|
||||||
av = Number(av) || 0;
|
|
||||||
bv = Number(bv) || 0;
|
|
||||||
} else if (key === "createdAt") {
|
|
||||||
av = av ? new Date(av).getTime() : 0;
|
|
||||||
bv = bv ? new Date(bv).getTime() : 0;
|
|
||||||
} else if (key === "status") {
|
|
||||||
av = a.status || "pending_payment";
|
|
||||||
bv = b.status || "pending_payment";
|
|
||||||
} else {
|
|
||||||
av = (av || "").toString().toLowerCase();
|
|
||||||
bv = (bv || "").toString().toLowerCase();
|
|
||||||
}
|
|
||||||
if (av < bv) return -1 * dir;
|
|
||||||
if (av > bv) return 1 * dir;
|
|
||||||
return 0;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -656,7 +605,7 @@ const createMember = async () => {
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
circle: "community",
|
circle: "community",
|
||||||
contributionAmount: 0,
|
contributionTier: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
@ -675,6 +624,7 @@ const createMember = async () => {
|
||||||
|
|
||||||
// --- CSV Import ---
|
// --- CSV Import ---
|
||||||
const VALID_CIRCLES = ["community", "founder", "practitioner"];
|
const VALID_CIRCLES = ["community", "founder", "practitioner"];
|
||||||
|
const VALID_TIERS = ["0", "5", "15", "30", "50"];
|
||||||
|
|
||||||
const handleCsvFile = (event) => {
|
const handleCsvFile = (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
|
|
@ -703,10 +653,10 @@ const parseCsv = (text) => {
|
||||||
const nameIdx = header.indexOf("name");
|
const nameIdx = header.indexOf("name");
|
||||||
const emailIdx = header.indexOf("email");
|
const emailIdx = header.indexOf("email");
|
||||||
const circleIdx = header.indexOf("circle");
|
const circleIdx = header.indexOf("circle");
|
||||||
const amountIdx = header.indexOf("contributionamount");
|
const tierIdx = header.indexOf("contributiontier");
|
||||||
|
|
||||||
if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || amountIdx === -1) {
|
if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || tierIdx === -1) {
|
||||||
csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionAmount`;
|
csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionTier`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,21 +668,20 @@ const parseCsv = (text) => {
|
||||||
const name = cols[nameIdx] || "";
|
const name = cols[nameIdx] || "";
|
||||||
const email = (cols[emailIdx] || "").toLowerCase();
|
const email = (cols[emailIdx] || "").toLowerCase();
|
||||||
const circle = (cols[circleIdx] || "").toLowerCase();
|
const circle = (cols[circleIdx] || "").toLowerCase();
|
||||||
const rawAmount = cols[amountIdx] || "";
|
const contributionTier = cols[tierIdx] || "";
|
||||||
const contributionAmount = Number(rawAmount);
|
|
||||||
|
|
||||||
let error = null;
|
let error = null;
|
||||||
if (!name) error = "Missing name";
|
if (!name) error = "Missing name";
|
||||||
else if (!email || !email.includes("@")) error = "Invalid email";
|
else if (!email || !email.includes("@")) error = "Invalid email";
|
||||||
else if (!VALID_CIRCLES.includes(circle))
|
else if (!VALID_CIRCLES.includes(circle))
|
||||||
error = `Invalid circle: ${circle}`;
|
error = `Invalid circle: ${circle}`;
|
||||||
else if (!Number.isInteger(contributionAmount) || contributionAmount < 0)
|
else if (!VALID_TIERS.includes(contributionTier))
|
||||||
error = `Invalid contribution: ${rawAmount}`;
|
error = `Invalid tier: ${contributionTier}`;
|
||||||
else if (seenEmails.has(email)) error = "Duplicate email in CSV";
|
else if (seenEmails.has(email)) error = "Duplicate email in CSV";
|
||||||
|
|
||||||
if (!error) seenEmails.add(email);
|
if (!error) seenEmails.add(email);
|
||||||
|
|
||||||
rows.push({ name, email, circle, contributionAmount, error });
|
rows.push({ name, email, circle, contributionTier, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
csvRows.value = rows;
|
csvRows.value = rows;
|
||||||
|
|
@ -759,11 +708,11 @@ const submitImport = async () => {
|
||||||
importing.value = true;
|
importing.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = csvValidRows.value.map(
|
const payload = csvValidRows.value.map(
|
||||||
({ name, email, circle, contributionAmount }) => ({
|
({ name, email, circle, contributionTier }) => ({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
circle,
|
circle,
|
||||||
contributionAmount,
|
contributionTier,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -830,25 +779,8 @@ const submitInvites = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Existing actions ---
|
// --- Existing actions ---
|
||||||
const markSlackInvited = async (member) => {
|
const sendSlackInvite = (member) => {
|
||||||
try {
|
console.log("Send Slack invite to:", member.email);
|
||||||
const res = await $fetch(
|
|
||||||
`/api/admin/members/${member._id}/slack-status`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
body: { slackInvited: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const idx = members.value.findIndex((m) => m._id === member._id);
|
|
||||||
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
|
|
||||||
toast.add({ title: "Marked as Slack invited", color: "success" });
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({
|
|
||||||
title: "Failed to mark Slack invited",
|
|
||||||
description: err.data?.statusMessage || err.message,
|
|
||||||
color: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Edit Member ---
|
// --- Edit Member ---
|
||||||
|
|
@ -859,7 +791,7 @@ const editingMember = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
circle: "community",
|
circle: "community",
|
||||||
contributionAmount: 0,
|
contributionTier: "0",
|
||||||
status: "pending_payment",
|
status: "pending_payment",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -869,7 +801,7 @@ const editMember = (member) => {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
circle: member.circle,
|
circle: member.circle,
|
||||||
contributionAmount: member.contributionAmount ?? 0,
|
contributionTier: String(member.contributionTier),
|
||||||
status: member.status || "pending_payment",
|
status: member.status || "pending_payment",
|
||||||
});
|
});
|
||||||
showEditModal.value = true;
|
showEditModal.value = true;
|
||||||
|
|
@ -884,7 +816,7 @@ const submitEditMember = async () => {
|
||||||
name: editingMember.name,
|
name: editingMember.name,
|
||||||
email: editingMember.email,
|
email: editingMember.email,
|
||||||
circle: editingMember.circle,
|
circle: editingMember.circle,
|
||||||
contributionAmount: editingMember.contributionAmount,
|
contributionTier: editingMember.contributionTier,
|
||||||
status: editingMember.status,
|
status: editingMember.status,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1123,44 +1055,6 @@ tbody td {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- SORTABLE HEADERS ---- */
|
|
||||||
th.sortable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
th.sortable:hover {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
.sort-ind {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--candle);
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- MEMBER STATUS BADGES ---- */
|
|
||||||
.badge.status {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.badge.status-active {
|
|
||||||
color: var(--green);
|
|
||||||
border-color: rgba(58, 107, 58, 0.45);
|
|
||||||
}
|
|
||||||
.badge.status-pending_payment {
|
|
||||||
color: var(--text-dim);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
.badge.status-suspended {
|
|
||||||
color: var(--ember);
|
|
||||||
border-color: color-mix(in srgb, var(--ember) 45%, transparent);
|
|
||||||
}
|
|
||||||
.badge.status-cancelled {
|
|
||||||
color: var(--text-faint);
|
|
||||||
border-color: var(--border);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- MODALS ---- */
|
/* ---- MODALS ---- */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -1301,7 +1195,7 @@ th.sortable:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-error {
|
.row-error {
|
||||||
background: color-mix(in srgb, var(--ember) 4%, transparent);
|
background: rgba(138, 68, 32, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- PREVIEW BOX ---- */
|
/* ---- PREVIEW BOX ---- */
|
||||||
|
|
|
||||||
|
|
@ -247,11 +247,9 @@ Click below to accept your invitation, choose your circle, and set your contribu
|
||||||
|
|
||||||
{acceptLink}
|
{acceptLink}
|
||||||
|
|
||||||
This link expires in 48 hours. If it expires, we can send you a new one. Just reply to this email.
|
This link expires in 48 hours. If it expires, we can send you a new one.
|
||||||
|
|
||||||
See you soon!
|
See you inside.`;
|
||||||
|
|
||||||
– Ghost Guild`;
|
|
||||||
|
|
||||||
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
||||||
|
|
||||||
|
|
@ -643,8 +641,8 @@ tbody td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-accepted {
|
.status-accepted {
|
||||||
color: var(--green);
|
color: var(--green, #4a7);
|
||||||
border-color: var(--green);
|
border-color: var(--green, #4a7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-expired {
|
.status-expired {
|
||||||
|
|
@ -671,7 +669,7 @@ tbody td {
|
||||||
|
|
||||||
/* ---- STATUS INDICATORS ---- */
|
/* ---- STATUS INDICATORS ---- */
|
||||||
.status-ok {
|
.status-ok {
|
||||||
color: var(--green);
|
color: var(--green, #4a7);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@
|
||||||
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
|
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
|
||||||
<button @click="editSeries(series)" class="link-btn">Edit</button>
|
<button @click="editSeries(series)" class="link-btn">Edit</button>
|
||||||
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
|
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
|
||||||
|
<button @click="duplicateSeries(series)" class="link-btn">Duplicate</button>
|
||||||
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
|
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,6 +171,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="section-label">Series Management Tools</div>
|
<div class="section-label">Series Management Tools</div>
|
||||||
|
<button @click="reorderAllSeries" class="btn bulk-action">
|
||||||
|
<strong>Auto-Reorder Series</strong>
|
||||||
|
<span>Fix position numbers based on event dates</span>
|
||||||
|
</button>
|
||||||
|
<button @click="validateAllSeries" class="btn bulk-action">
|
||||||
|
<strong>Validate Series Data</strong>
|
||||||
|
<span>Check for consistency issues</span>
|
||||||
|
</button>
|
||||||
<button @click="exportSeriesData" class="btn bulk-action">
|
<button @click="exportSeriesData" class="btn bulk-action">
|
||||||
<strong>Export Series Data</strong>
|
<strong>Export Series Data</strong>
|
||||||
<span>Download series information as JSON</span>
|
<span>Download series information as JSON</span>
|
||||||
|
|
@ -566,6 +575,10 @@ const addEventToSeries = (series) => {
|
||||||
navigateTo('/admin/events/create?series=true')
|
navigateTo('/admin/events/create?series=true')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateSeries = () => {
|
||||||
|
// TODO: Implement
|
||||||
|
}
|
||||||
|
|
||||||
const editSeries = (series) => {
|
const editSeries = (series) => {
|
||||||
editingSeriesId.value = series.id
|
editingSeriesId.value = series.id
|
||||||
editingSeriesData.value = {
|
editingSeriesData.value = {
|
||||||
|
|
@ -683,6 +696,9 @@ const saveTicketsEdit = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reorderAllSeries = () => { /* TODO */ }
|
||||||
|
const validateAllSeries = () => { /* TODO */ }
|
||||||
|
|
||||||
const exportSeriesData = () => {
|
const exportSeriesData = () => {
|
||||||
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||||
|
|
@ -850,7 +866,7 @@ const exportSeriesData = () => {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--c-founder);
|
color: var(--c-founder);
|
||||||
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
border: 1px dashed rgba(138, 68, 32, 0.4);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -931,12 +947,12 @@ const exportSeriesData = () => {
|
||||||
|
|
||||||
.status-active {
|
.status-active {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
border-color: rgba(74, 106, 56, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-upcoming {
|
.status-upcoming {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
border-color: rgba(122, 90, 16, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-completed {
|
.status-completed {
|
||||||
|
|
@ -946,7 +962,7 @@ const exportSeriesData = () => {
|
||||||
|
|
||||||
.status-ongoing {
|
.status-ongoing {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
border-color: rgba(74, 106, 56, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- LINK BUTTONS ---- */
|
/* ---- LINK BUTTONS ---- */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -2,14 +2,12 @@
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: false,
|
layout: false,
|
||||||
});
|
});
|
||||||
useSiteMeta({ title: "Wiki Sign In", noindex: true });
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const uid = route.query.uid as string;
|
const uid = route.query.uid as string;
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const sent = ref(false);
|
const sent = ref(false);
|
||||||
const notRegistered = ref(false);
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
|
||||||
|
|
@ -17,55 +15,34 @@ async function sendMagicLink() {
|
||||||
if (!email.value || !uid) return;
|
if (!email.value || !uid) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
notRegistered.value = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch<{ success: boolean; registered: boolean }>(
|
await $fetch("/oidc/interaction/login", {
|
||||||
"/oidc/interaction/login",
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { email: email.value, uid },
|
body: { email: email.value, uid },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (response.registered === false) {
|
|
||||||
notRegistered.value = true;
|
|
||||||
} else {
|
|
||||||
sent.value = true;
|
sent.value = true;
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value =
|
error.value = e?.data?.statusMessage || "Something went wrong. Please try again.";
|
||||||
e?.data?.statusMessage || "Something went wrong. Please try again.";
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
sent.value = false;
|
|
||||||
notRegistered.value = false;
|
|
||||||
email.value = "";
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="wiki-login">
|
<div class="wiki-login">
|
||||||
<div class="dashed-box wiki-login-box">
|
<div class="wiki-login-card">
|
||||||
<header class="wiki-login-header">
|
<div class="wiki-login-header">
|
||||||
<p class="section-label">Ghost Guild</p>
|
<span class="wiki-login-overline">Ghost Guild</span>
|
||||||
<h1 class="wiki-login-title">Wiki</h1>
|
<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">
|
<Transition name="wiki-fade" mode="out-in">
|
||||||
<form
|
<form v-if="!sent" key="form" @submit.prevent="sendMagicLink" class="wiki-login-form">
|
||||||
v-if="!sent && !notRegistered"
|
<label for="email" class="wiki-login-label">Email address</label>
|
||||||
key="form"
|
|
||||||
class="wiki-login-form"
|
|
||||||
@submit.prevent="sendMagicLink"
|
|
||||||
>
|
|
||||||
<div class="field">
|
|
||||||
<label for="email">Email address</label>
|
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
v-model="email"
|
v-model="email"
|
||||||
|
|
@ -73,29 +50,18 @@ function resetForm() {
|
||||||
required
|
required
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
class="wiki-login-input"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<p v-if="error" class="wiki-login-error">{{ error }}</p>
|
||||||
v-if="error"
|
|
||||||
class="wiki-login-error"
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary wiki-login-submit"
|
|
||||||
:disabled="loading || !email"
|
:disabled="loading || !email"
|
||||||
|
class="wiki-login-button"
|
||||||
>
|
>
|
||||||
<span
|
<span v-if="loading" class="wiki-login-spinner" />
|
||||||
v-if="loading"
|
|
||||||
class="wiki-login-spinner"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{{ loading ? "Sending" : "Continue" }}
|
{{ loading ? "Sending" : "Continue" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -104,130 +70,187 @@ function resetForm() {
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div
|
<div v-else key="sent" class="wiki-login-sent">
|
||||||
v-else-if="sent"
|
<p class="wiki-login-sent-heading">Check your inbox</p>
|
||||||
key="sent"
|
|
||||||
class="wiki-login-sent"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<h2 class="wiki-login-sent-heading">Check your inbox</h2>
|
|
||||||
<p class="wiki-login-sent-detail">
|
<p class="wiki-login-sent-detail">
|
||||||
A sign-in link was sent to <strong>{{ email }}</strong>
|
A sign-in link was sent to <strong>{{ email }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<button class="wiki-login-reset" @click="resetForm">
|
<button
|
||||||
Try a different email
|
@click="sent = false; email = '';"
|
||||||
</button>
|
class="wiki-login-link"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
key="not-registered"
|
|
||||||
class="wiki-login-sent"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
<h2 class="wiki-login-sent-heading">Not a member yet</h2>
|
|
||||||
<p class="wiki-login-sent-detail">
|
|
||||||
<strong>{{ email }}</strong> isn't registered as a Ghost Guild
|
|
||||||
member. If you've pre-registered, an admin needs to invite you
|
|
||||||
before you can sign in.
|
|
||||||
</p>
|
|
||||||
<p class="wiki-login-sent-detail">
|
|
||||||
<a href="https://babyghosts.org/ghost-guild/" class="wiki-login-link"
|
|
||||||
>Pre-register at Baby Ghosts</a
|
|
||||||
>
|
|
||||||
or email
|
|
||||||
<a href="mailto:hello@babyghosts.org" class="wiki-login-link"
|
|
||||||
>hello@babyghosts.org</a
|
|
||||||
>
|
|
||||||
if you think this is a mistake.
|
|
||||||
</p>
|
|
||||||
<button class="wiki-login-reset" @click="resetForm">
|
|
||||||
Try a different email
|
Try a different email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wiki-login {
|
.wiki-login {
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: 100dvh;
|
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%;
|
width: 100%;
|
||||||
max-width: 360px;
|
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 {
|
.wiki-login-header {
|
||||||
text-align: center;
|
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 {
|
.wiki-login-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-sans);
|
||||||
font-size: 36px;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1.1;
|
letter-spacing: -0.02em;
|
||||||
letter-spacing: -0.01em;
|
line-height: 1.15;
|
||||||
color: var(--candle);
|
color: var(--color-candlelight-400);
|
||||||
margin: 0;
|
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 {
|
.wiki-login-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.wiki-login-error {
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
color: var(--ember);
|
color: var(--color-ember-400);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-submit {
|
.wiki-login-button {
|
||||||
width: 100%;
|
display: flex;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
margin-top: 4px;
|
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;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-spinner {
|
.wiki-login-spinner {
|
||||||
display: inline-block;
|
width: 14px;
|
||||||
width: 10px;
|
height: 14px;
|
||||||
height: 10px;
|
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||||
border: 1.5px solid color-mix(in srgb, var(--bg) 35%, transparent);
|
border-top-color: white;
|
||||||
border-top-color: var(--bg);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: wiki-spin 0.7s linear infinite;
|
animation: wiki-spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes wiki-spin {
|
@keyframes wiki-spin {
|
||||||
to {
|
to { transform: rotate(360deg); }
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-hint {
|
.wiki-login-hint {
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
color: var(--text-faint);
|
color: var(--color-guild-500);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 4px 0 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent {
|
.wiki-login-sent {
|
||||||
|
|
@ -235,58 +258,45 @@ function resetForm() {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent-heading {
|
.wiki-login-sent-heading {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-sans);
|
||||||
font-size: 20px;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--color-guild-100);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent-detail {
|
.wiki-login-sent-detail {
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-dim);
|
color: var(--color-guild-400);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent-detail strong {
|
.wiki-login-sent-detail strong {
|
||||||
color: var(--text-bright);
|
color: var(--color-guild-200);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-link {
|
.wiki-login-link {
|
||||||
color: var(--candle);
|
font-size: 0.8125rem;
|
||||||
text-decoration: underline;
|
color: var(--color-candlelight-500);
|
||||||
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);
|
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
padding: 0;
|
||||||
text-underline-offset: 2px;
|
margin-top: 0.5rem;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-reset:hover {
|
.wiki-login-link:hover {
|
||||||
color: var(--candle-dim);
|
color: var(--color-candlelight-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* State transition */
|
/* Transition */
|
||||||
.wiki-fade-enter-active,
|
.wiki-fade-enter-active,
|
||||||
.wiki-fade-leave-active {
|
.wiki-fade-leave-active {
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,30 +1,44 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="coming-soon">
|
<div class="min-h-screen w-full flex flex-col items-center justify-center px-4">
|
||||||
<h1 class="coming-soon-title">Ghost Guild</h1>
|
<h1 class="text-display-xl font-bold mb-2 uppercase font-sans!">Ghost Guild</h1>
|
||||||
<p v-if="!isAuthenticated" class="coming-soon-subtitle">Coming Soon</p>
|
<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 -->
|
<!-- Logged-in state -->
|
||||||
<div v-if="isAuthenticated" class="coming-soon-auth">
|
<div v-if="isAuthenticated" class="w-full max-w-sm flex flex-col items-center space-y-4 text-center mt-8">
|
||||||
<p>
|
<p class="text-guild-200 font-sans py-4 text-center">
|
||||||
Welcome, <strong>{{ memberData.name || memberData.email }}</strong>
|
Welcome, <strong class="text-guild-100">{{ memberData.name || memberData.email }}</strong>
|
||||||
</p>
|
</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
|
Go to Wiki
|
||||||
</a>
|
</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
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login form -->
|
<!-- Login form -->
|
||||||
<div v-else class="coming-soon-form">
|
<div v-else class="w-full max-w-sm">
|
||||||
<!-- Success state -->
|
<!-- Success state -->
|
||||||
<div v-if="loginSuccess" class="coming-soon-success">
|
<div v-if="loginSuccess" class="text-center py-4">
|
||||||
<h3>Check your email</h3>
|
<div
|
||||||
<p>
|
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
|
We've sent a magic link to
|
||||||
<strong>{{ email }}</strong
|
<strong class="text-guild-100">{{ email }}</strong>.
|
||||||
>. Click the link to sign in.
|
Click the link to sign in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -36,28 +50,32 @@
|
||||||
type="email"
|
type="email"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="your.email@example.com"
|
placeholder="your.email@example.com" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<div v-if="loginError" class="coming-soon-error">
|
<div v-if="loginError" class="mb-4 p-3 bg-ember-500/10 border border-ember-500/30 rounded-lg">
|
||||||
<p>{{ loginError }}</p>
|
<p class="text-ember-400 text-sm">{{ loginError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="coming-soon-actions">
|
<div class="flex justify-center">
|
||||||
<UButton
|
<UButton
|
||||||
type="submit"
|
type="submit"
|
||||||
:loading="isLoggingIn"
|
:loading="isLoggingIn"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="uppercase tracking-wide font-semibold whitespace-nowrap"
|
class="rounded-full uppercase tracking-wide font-semibold whitespace-nowrap">
|
||||||
>
|
|
||||||
Send Magic Link
|
Send Magic Link
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="coming-soon-preregister">
|
<div class="text-center pt-6 border-t border-guild-700 mt-6">
|
||||||
<a href="https://babyghosts.org/ghost-guild/">Pre-Register</a>
|
<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>
|
</div>
|
||||||
</UForm>
|
</UForm>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,138 +127,3 @@ const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
`
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
await navigateTo("/board", { replace: true });
|
definePageMeta({ middleware: "auth" });
|
||||||
|
await navigateTo("/ecology", { replace: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,471 @@
|
||||||
|
<template>
|
||||||
|
<PageShell
|
||||||
|
title="Community Ecology"
|
||||||
|
subtitle="Find members who share your cooperative interests"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<p>Loading ecology...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="tagOptions.length > 0" class="filter-bar">
|
||||||
|
<select
|
||||||
|
v-model="filterTag"
|
||||||
|
class="filter-select"
|
||||||
|
@change="loadSuggestions"
|
||||||
|
>
|
||||||
|
<option value="">All topics</option>
|
||||||
|
<option v-for="tag in tagOptions" :key="tag.slug" :value="tag.slug">
|
||||||
|
{{ tag.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connections-section">
|
||||||
|
<div class="section-label">Suggested Matches</div>
|
||||||
|
|
||||||
|
<div v-if="suggestions.length > 0" class="connection-grid">
|
||||||
|
<div
|
||||||
|
v-for="suggestion in suggestions"
|
||||||
|
:key="suggestion.member._id"
|
||||||
|
class="connection-card"
|
||||||
|
>
|
||||||
|
<div class="cc-head">
|
||||||
|
<div class="cc-avatar">
|
||||||
|
<img
|
||||||
|
v-if="suggestion.member.avatar"
|
||||||
|
:src="`/ghosties/Ghost-${capitalize(suggestion.member.avatar)}.png`"
|
||||||
|
:alt="suggestion.member.name"
|
||||||
|
class="cc-avatar-img"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ getInitials(suggestion.member.name) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cc-info">
|
||||||
|
<div class="cc-name">
|
||||||
|
<NuxtLink :to="`/members/${suggestion.member._id}`">
|
||||||
|
{{ suggestion.member.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="cc-meta">
|
||||||
|
<CircleBadge :circle="suggestion.member.circle || 'community'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="suggestion.member.craftTags?.length"
|
||||||
|
class="cc-craft-tags"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="tag in suggestion.member.craftTags.slice(0, 5)"
|
||||||
|
:key="tag"
|
||||||
|
class="craft-pill"
|
||||||
|
>{{ craftTagLabel(tag) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cc-matches">
|
||||||
|
<div
|
||||||
|
v-for="match in suggestion.matchingTags"
|
||||||
|
:key="match.tagSlug"
|
||||||
|
class="match-row"
|
||||||
|
>
|
||||||
|
<span class="match-tag">{{ cooperativeTagLabel(match.tagSlug) }}</span>
|
||||||
|
<span class="match-states">
|
||||||
|
<span class="match-you">You: {{ stateLabel(match.yourState) }}</span>
|
||||||
|
<span class="match-sep">·</span>
|
||||||
|
<span class="match-them">They: {{ stateLabel(match.theirState) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="suggestion.member.slackHandle" class="cc-contact">
|
||||||
|
<span class="cc-slack">@{{ suggestion.member.slackHandle }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-action"
|
||||||
|
@click="copyHandle(suggestion.member.slackHandle)"
|
||||||
|
>
|
||||||
|
{{ copiedHandle === suggestion.member.slackHandle ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p class="empty-title">No matches yet</p>
|
||||||
|
<p class="empty-sub">
|
||||||
|
Add cooperative topics to your
|
||||||
|
<NuxtLink to="/member/profile">profile</NuxtLink>
|
||||||
|
to find members with shared interests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="loading-state">
|
||||||
|
<p>Loading ecology...</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
await navigateTo("/board", { replace: true });
|
definePageMeta({ middleware: 'auth' })
|
||||||
|
|
||||||
|
const { getSuggestions } = useEcology()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const suggestions = ref([])
|
||||||
|
const filterTag = ref('')
|
||||||
|
const tagOptions = ref([])
|
||||||
|
const craftTagOptions = ref([])
|
||||||
|
const copiedHandle = ref(null)
|
||||||
|
|
||||||
|
const stateLabels = {
|
||||||
|
help: 'Can help',
|
||||||
|
interested: 'Interested',
|
||||||
|
seeking: 'Need help',
|
||||||
|
}
|
||||||
|
const stateLabel = (state) => stateLabels[state] || state || ''
|
||||||
|
|
||||||
|
const cooperativeTagLabel = (slug) => {
|
||||||
|
const found = tagOptions.value.find((t) => t.slug === slug)
|
||||||
|
return found ? found.label : slug
|
||||||
|
}
|
||||||
|
const craftTagLabel = (slug) => {
|
||||||
|
const found = craftTagOptions.value.find((t) => t.slug === slug)
|
||||||
|
return found ? found.label : slug
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (str) => {
|
||||||
|
if (!str) return ''
|
||||||
|
return str
|
||||||
|
.split('-')
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
.join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (filterTag.value) params.tag = filterTag.value
|
||||||
|
const data = await getSuggestions(params)
|
||||||
|
suggestions.value = data.suggestions || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load suggestions:', error)
|
||||||
|
suggestions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { memberData } = useAuth()
|
||||||
|
|
||||||
|
const loadTags = async () => {
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/tags')
|
||||||
|
const tags = data.tags || []
|
||||||
|
const cooperativeTags = tags
|
||||||
|
.filter((t) => t.pool === 'cooperative')
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }))
|
||||||
|
craftTagOptions.value = tags
|
||||||
|
.filter((t) => t.pool === 'craft')
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }))
|
||||||
|
|
||||||
|
const myTopicSlugs = (memberData.value?.communityEcology?.topics || []).map(
|
||||||
|
(t) => t.tagSlug,
|
||||||
|
)
|
||||||
|
tagOptions.value = myTopicSlugs.length
|
||||||
|
? cooperativeTags.filter((t) => myTopicSlugs.includes(t.slug))
|
||||||
|
: cooperativeTags
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tags:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let copyTimer = null
|
||||||
|
const copyHandle = async (handle) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`@${handle}`)
|
||||||
|
copiedHandle.value = handle
|
||||||
|
if (copyTimer) clearTimeout(copyTimer)
|
||||||
|
copyTimer = setTimeout(() => {
|
||||||
|
copiedHandle.value = null
|
||||||
|
copyTimer = null
|
||||||
|
}, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clipboard write failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (copyTimer) clearTimeout(copyTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([loadTags(), loadSuggestions()])
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Community Ecology - Ghost Guild',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content:
|
||||||
|
'Find Ghost Guild members who share your cooperative interests and reach out on Slack.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-state {
|
||||||
|
padding: 60px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 26px;
|
||||||
|
}
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: var(--candle-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connections-section {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-card {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
margin: -1px 0 0 -1px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.connection-card:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-avatar-img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
.cc-name a {
|
||||||
|
color: var(--text-bright);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.cc-name a:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-craft-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.craft-pill {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-matches {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-tag {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-states {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-sep {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-you,
|
||||||
|
.match-them {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-contact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-slack {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--candle-dim);
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-action {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.text-action:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.text-action:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.empty-sub a {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.connection-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
.connections-section {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.connection-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="page-fill">
|
<div v-else>
|
||||||
<!-- EVENT HEADER -->
|
<!-- EVENT HEADER -->
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
<h1>{{ event.title }}</h1>
|
<h1>{{ event.title }}</h1>
|
||||||
|
|
@ -22,14 +22,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="event-meta-item">
|
<div class="event-meta-item">
|
||||||
<span class="meta-label">Location</span>
|
<span class="meta-label">Location</span>
|
||||||
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
|
{{ event.location }}
|
||||||
Platform TBD
|
|
||||||
</span>
|
|
||||||
<template v-else>{{ event.location }}</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="event.circle" class="event-meta-item">
|
<div v-if="event.circle" class="event-meta-item">
|
||||||
<CircleBadge :circle="event.circle" />
|
<CircleBadge :circle="event.circle" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -47,7 +48,7 @@
|
||||||
<img
|
<img
|
||||||
:src="event.featureImage.url"
|
:src="event.featureImage.url"
|
||||||
:alt="event.featureImage.alt || event.title"
|
:alt="event.featureImage.alt || event.title"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TWO-COLUMN BODY -->
|
<!-- TWO-COLUMN BODY -->
|
||||||
|
|
@ -81,7 +82,7 @@
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>About This Event</h2>
|
<h2>About This Event</h2>
|
||||||
<div class="prose" v-html="renderMarkdown(event.description)" />
|
<p>{{ event.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Description -->
|
<!-- Series Description -->
|
||||||
|
|
@ -90,23 +91,17 @@
|
||||||
class="section"
|
class="section"
|
||||||
>
|
>
|
||||||
<h2>About the {{ event.series.title }} Series</h2>
|
<h2>About the {{ event.series.title }} Series</h2>
|
||||||
<div class="prose" v-html="renderMarkdown(event.series.description)" />
|
<p>{{ event.series.description }}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Additional Information -->
|
|
||||||
<div v-if="event.content" class="section">
|
|
||||||
<h2>Additional Information</h2>
|
|
||||||
<div class="prose" v-html="renderMarkdown(event.content)" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Agenda -->
|
<!-- Agenda -->
|
||||||
<div v-if="event.agenda?.length" class="section">
|
<div v-if="event.agenda?.length" class="section">
|
||||||
<h2>Agenda</h2>
|
<h2>Agenda</h2>
|
||||||
<ul class="agenda-list">
|
<ol class="agenda-list">
|
||||||
<li v-for="(item, index) in event.agenda" :key="index">
|
<li v-for="(item, index) in event.agenda" :key="index">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Speakers -->
|
<!-- Speakers -->
|
||||||
|
|
@ -130,22 +125,158 @@
|
||||||
<div v-if="!event.isCancelled" class="event-aside">
|
<div v-if="!event.isCancelled" class="event-aside">
|
||||||
<!-- Ticket System -->
|
<!-- Ticket System -->
|
||||||
<EventTicketPurchase
|
<EventTicketPurchase
|
||||||
|
v-if="event.tickets?.enabled"
|
||||||
:event-id="event._id || event.id"
|
:event-id="event._id || event.id"
|
||||||
:event-start-date="event.startDate"
|
:event-start-date="event.startDate"
|
||||||
:event-title="event.title"
|
:event-title="event.title"
|
||||||
:event-timezone="eventTimeZone"
|
|
||||||
:user-email="memberData?.email"
|
:user-email="memberData?.email"
|
||||||
:user-name="memberData?.name"
|
|
||||||
@success="handleTicketSuccess"
|
@success="handleTicketSuccess"
|
||||||
@error="handleTicketError"
|
@error="handleTicketError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Legacy Registration -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Already Registered -->
|
||||||
|
<div v-if="registrationStatus === 'registered'" class="dashed-box">
|
||||||
|
<div class="box-title">Registration</div>
|
||||||
|
<p class="reg-status" style="color: var(--green)">
|
||||||
|
You're registered!
|
||||||
|
</p>
|
||||||
|
<p class="reg-price">Confirmation sent to your email</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
:disabled="isCancelling"
|
||||||
|
@click="handleCancelRegistration"
|
||||||
|
>
|
||||||
|
{{ isCancelling ? "Cancelling..." : "Cancel Registration" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Member Status Issues -->
|
||||||
|
<div v-else-if="memberData && !canRSVP" class="dashed-box">
|
||||||
|
<div class="box-title">Registration</div>
|
||||||
|
<p class="reg-status" style="color: var(--ember)">
|
||||||
|
{{ statusConfig.label }}
|
||||||
|
</p>
|
||||||
|
<p class="reg-price">{{ getRSVPMessage() }}</p>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="isPendingPayment"
|
||||||
|
to="#"
|
||||||
|
@click.prevent="completePayment"
|
||||||
|
>
|
||||||
|
<button class="btn btn-primary" :disabled="isProcessingPayment">
|
||||||
|
{{ isProcessingPayment ? "Processing..." : "Complete Payment" }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members-Only Gate -->
|
||||||
|
<div
|
||||||
|
v-else-if="event.membersOnly && memberData && !isMember"
|
||||||
|
class="dashed-box"
|
||||||
|
>
|
||||||
|
<div class="box-title">Registration</div>
|
||||||
|
<p class="reg-status" style="color: var(--ember)">
|
||||||
|
Membership Required
|
||||||
|
</p>
|
||||||
|
<p class="reg-price">This event is exclusive to members.</p>
|
||||||
|
<NuxtLink to="/join"
|
||||||
|
><button class="btn btn-primary">
|
||||||
|
Become a Member
|
||||||
|
</button></NuxtLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Can Register (logged in) -->
|
||||||
|
<div
|
||||||
|
v-else-if="memberData && (!event.membersOnly || isMember)"
|
||||||
|
class="dashed-box"
|
||||||
|
>
|
||||||
|
<div class="box-title">Registration</div>
|
||||||
|
<div v-if="event.maxAttendees" class="reg-status">
|
||||||
|
{{ event.maxAttendees - (event.registeredCount || 0) }} spots
|
||||||
|
remaining
|
||||||
|
</div>
|
||||||
|
<div class="reg-price">Free for members</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="isRegistering"
|
||||||
|
@click="handleRegistration"
|
||||||
|
>
|
||||||
|
{{ isRegistering ? "Registering..." : "Register for this event" }}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
:href="`/api/events/${route.params.slug}/calendar`"
|
||||||
|
download
|
||||||
|
class="cal-link"
|
||||||
|
>Add to calendar</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not Logged In -->
|
||||||
|
<div v-else class="dashed-box">
|
||||||
|
<div class="box-title">Registration</div>
|
||||||
|
<form @submit.prevent="handleRegistration">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="registrationForm.name" type="text" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="registrationForm.email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="isRegistering"
|
||||||
|
>
|
||||||
|
{{ isRegistering ? "Registering..." : "Register for Event" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waitlist -->
|
||||||
|
<div
|
||||||
|
v-if="event.tickets?.waitlist?.enabled && isEventFull"
|
||||||
|
class="dashed-box"
|
||||||
|
>
|
||||||
|
<div class="box-title">Waitlist</div>
|
||||||
|
<div v-if="isOnWaitlist">
|
||||||
|
<p class="reg-status">
|
||||||
|
You're on the waitlist (#{{ waitlistPosition }})
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
@click="handleLeaveWaitlist"
|
||||||
|
:disabled="isJoiningWaitlist"
|
||||||
|
>
|
||||||
|
Leave Waitlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="reg-status" style="color: var(--ember)">
|
||||||
|
This event is full
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="handleJoinWaitlist">
|
||||||
|
<div v-if="!memberData" class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="waitlistForm.email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" :disabled="isJoiningWaitlist">
|
||||||
|
{{ isJoiningWaitlist ? "Joining..." : "Join Waitlist" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Event Details Box -->
|
<!-- Event Details Box -->
|
||||||
<div class="dashed-box">
|
<div class="dashed-box">
|
||||||
<div class="box-title">Event Details</div>
|
<div class="box-title">Event Details</div>
|
||||||
<div v-if="event.eventType" class="detail-row">
|
<div v-if="event.eventType" class="detail-row">
|
||||||
<span class="detail-key">Type</span>
|
<span class="detail-key">Type</span>
|
||||||
<span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
|
<span class="detail-val">{{ event.eventType }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="event.membersOnly" class="detail-row">
|
<div v-if="event.membersOnly" class="detail-row">
|
||||||
<span class="detail-key">Members only</span>
|
<span class="detail-key">Members only</span>
|
||||||
|
|
@ -171,8 +302,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { eventTypeLabel } from "~/config/eventTypes";
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
|
@ -192,44 +321,192 @@ if (error.value?.statusCode === 404) {
|
||||||
throw createError({ statusCode: 404, statusMessage: "Event not found" });
|
throw createError({ statusCode: 404, statusMessage: "Event not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { isMember, memberData, checkMemberStatus } = useAuth();
|
||||||
const { trackGoal, isComplete } = useOnboarding();
|
const {
|
||||||
const { render: renderMarkdown } = useMarkdown();
|
isPendingPayment,
|
||||||
|
isSuspended,
|
||||||
|
isCancelled,
|
||||||
|
canRSVP,
|
||||||
|
statusConfig,
|
||||||
|
getRSVPMessage,
|
||||||
|
} = useMemberStatus();
|
||||||
|
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
if (memberData.value && !isComplete.value) {
|
if (memberData.value) {
|
||||||
trackGoal('eventPageVisited');
|
registrationForm.value.name = memberData.value.name;
|
||||||
|
registrationForm.value.email = memberData.value.email;
|
||||||
|
registrationForm.value.membershipLevel =
|
||||||
|
memberData.value.membershipLevel || "non-member";
|
||||||
|
await checkRegistrationStatus();
|
||||||
|
checkWaitlistStatus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventTimeZone = computed(
|
const checkRegistrationStatus = async () => {
|
||||||
() => event.value?.displayTimezone || "America/Toronto",
|
if (!memberData.value?.email) return;
|
||||||
);
|
try {
|
||||||
|
const response = await $fetch(
|
||||||
|
`/api/events/${route.params.slug}/check-registration`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: { email: memberData.value.email },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.isRegistered) registrationStatus.value = "registered";
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check registration status:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registrationForm = ref({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
membershipLevel: "non-member",
|
||||||
|
});
|
||||||
|
const isRegistering = ref(false);
|
||||||
|
const isCancelling = ref(false);
|
||||||
|
const registrationStatus = ref("not-registered");
|
||||||
|
const isJoiningWaitlist = ref(false);
|
||||||
|
const isOnWaitlist = ref(false);
|
||||||
|
const waitlistPosition = ref(0);
|
||||||
|
const waitlistForm = ref({ email: "" });
|
||||||
|
|
||||||
|
const isEventFull = computed(() => {
|
||||||
|
if (!event.value?.maxAttendees) return false;
|
||||||
|
return (event.value.registeredCount || 0) >= event.value.maxAttendees;
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkWaitlistStatus = () => {
|
||||||
|
const email = memberData.value?.email || waitlistForm.value.email;
|
||||||
|
if (!email || !event.value?.tickets?.waitlist?.enabled) return;
|
||||||
|
const entries = event.value.tickets.waitlist.entries || [];
|
||||||
|
const idx = entries.findIndex(
|
||||||
|
(e) => e.email.toLowerCase() === email.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (idx !== -1) {
|
||||||
|
isOnWaitlist.value = true;
|
||||||
|
waitlistPosition.value = idx + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinWaitlist = async () => {
|
||||||
|
isJoiningWaitlist.value = true;
|
||||||
|
try {
|
||||||
|
const email = memberData.value?.email || waitlistForm.value.email;
|
||||||
|
const name = memberData.value?.name || "Guest";
|
||||||
|
const response = await $fetch(`/api/events/${route.params.slug}/waitlist`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { email, name },
|
||||||
|
});
|
||||||
|
isOnWaitlist.value = true;
|
||||||
|
waitlistPosition.value = response.position;
|
||||||
|
toast.add({
|
||||||
|
title: "Added to Waitlist",
|
||||||
|
description: `You're #${response.position} on the waitlist.`,
|
||||||
|
color: "orange",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: "Couldn't Join Waitlist",
|
||||||
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isJoiningWaitlist.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeaveWaitlist = async () => {
|
||||||
|
isJoiningWaitlist.value = true;
|
||||||
|
try {
|
||||||
|
const email = memberData.value?.email || waitlistForm.value.email;
|
||||||
|
await $fetch(`/api/events/${route.params.slug}/waitlist`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { email },
|
||||||
|
});
|
||||||
|
isOnWaitlist.value = false;
|
||||||
|
waitlistPosition.value = 0;
|
||||||
|
toast.add({ title: "Removed from Waitlist", color: "blue" });
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to leave waitlist.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isJoiningWaitlist.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return "";
|
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
timeZone: eventTimeZone.value,
|
|
||||||
}).format(d);
|
}).format(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (start, end) => {
|
const formatTime = (start, end) => {
|
||||||
if (!start || !end) return "";
|
|
||||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
timeZone: eventTimeZone.value,
|
|
||||||
});
|
});
|
||||||
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`;
|
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRegistration = async () => {
|
||||||
|
isRegistering.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/events/${route.params.slug}/register`, {
|
||||||
|
method: "POST",
|
||||||
|
body: registrationForm.value,
|
||||||
|
});
|
||||||
|
registrationStatus.value = "registered";
|
||||||
|
toast.add({
|
||||||
|
title: "Registered!",
|
||||||
|
description: `You're registered for ${event.value.title}.`,
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
if (event.value.registeredCount !== undefined)
|
||||||
|
event.value.registeredCount++;
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: "Registration Failed",
|
||||||
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRegistering.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRegistration = async () => {
|
||||||
|
isCancelling.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/events/${route.params.slug}/cancel-registration`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { email: registrationForm.value.email || memberData.value?.email },
|
||||||
|
});
|
||||||
|
registrationStatus.value = "not-registered";
|
||||||
|
toast.add({ title: "Registration Cancelled", color: "blue" });
|
||||||
|
if (event.value.registeredCount !== undefined)
|
||||||
|
event.value.registeredCount--;
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: "Cancellation Failed",
|
||||||
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isCancelling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTicketSuccess = () => {
|
const handleTicketSuccess = () => {
|
||||||
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
|
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
|
||||||
};
|
};
|
||||||
|
|
@ -237,12 +514,16 @@ const handleTicketError = (err) => {
|
||||||
console.error("Ticket purchase failed:", err);
|
console.error("Ticket purchase failed:", err);
|
||||||
};
|
};
|
||||||
|
|
||||||
useSiteMeta(() => ({
|
useHead(() => ({
|
||||||
title: event.value ? `${event.value.title} · Events` : "Event",
|
title: event.value
|
||||||
description:
|
? `${event.value.title} - Ghost Guild Events`
|
||||||
event.value?.description || "View event details and register.",
|
: "Event - Ghost Guild",
|
||||||
type: "article",
|
meta: [
|
||||||
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
|
{
|
||||||
|
name: "description",
|
||||||
|
content: event.value?.description || "View event details and register",
|
||||||
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -307,19 +588,10 @@ useSiteMeta(() => ({
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
|
|
||||||
.page-fill {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- TWO-COLUMN BODY ---- */
|
/* ---- TWO-COLUMN BODY ---- */
|
||||||
.event-body {
|
.event-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 280px;
|
grid-template-columns: 1fr 280px;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
.event-main {
|
.event-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -350,79 +622,12 @@ useSiteMeta(() => ({
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.section p {
|
.section p {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
max-width: 560px;
|
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 {
|
.circle-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -435,27 +640,10 @@ useSiteMeta(() => ({
|
||||||
}
|
}
|
||||||
|
|
||||||
.agenda-list {
|
.agenda-list {
|
||||||
list-style: none;
|
padding-left: 20px;
|
||||||
padding: 0;
|
font-size: 12px;
|
||||||
margin: 8px 0 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.7;
|
line-height: 2;
|
||||||
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 {
|
.speaker {
|
||||||
|
|
@ -487,6 +675,23 @@ useSiteMeta(() => ({
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-bottom: 8px;
|
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 {
|
.detail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,15 @@
|
||||||
<h1>Events</h1>
|
<h1>Events</h1>
|
||||||
<p>
|
<p>
|
||||||
Workshops, meetups, and gatherings for game developers practicing
|
Workshops, meetups, and gatherings for game developers practicing
|
||||||
cooperative models. Some events are open to the public.
|
cooperative models.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FILTER BAR -->
|
<!-- FILTER BAR -->
|
||||||
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
||||||
<button
|
<label class="filter-toggle">
|
||||||
type="button"
|
<input v-model="includePastEvents" type="checkbox" /> Show past events
|
||||||
class="past-toggle"
|
</label>
|
||||||
:class="{ active: includePastEvents }"
|
|
||||||
:aria-pressed="includePastEvents"
|
|
||||||
@click="includePastEvents = !includePastEvents"
|
|
||||||
>
|
|
||||||
<span class="past-toggle-box" aria-hidden="true">
|
|
||||||
<span v-if="includePastEvents" class="past-toggle-check">×</span>
|
|
||||||
</span>
|
|
||||||
Show past events
|
|
||||||
</button>
|
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<!-- EVENT LIST -->
|
<!-- EVENT LIST -->
|
||||||
|
|
@ -34,8 +25,8 @@
|
||||||
:class="{ 'is-cancelled': event.isCancelled }"
|
:class="{ 'is-cancelled': event.isCancelled }"
|
||||||
>
|
>
|
||||||
<div class="event-date-col">
|
<div class="event-date-col">
|
||||||
<span class="event-date">{{ formatDate(event) }}</span>
|
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||||
<span class="event-time">{{ formatTime(event) }}</span>
|
<span class="event-time">{{ formatTime(event.startDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<div class="event-title">
|
<div class="event-title">
|
||||||
|
|
@ -45,25 +36,30 @@
|
||||||
<span v-if="event.isCancelled" class="cancelled-tag"
|
<span v-if="event.isCancelled" class="cancelled-tag"
|
||||||
>cancelled</span
|
>cancelled</span
|
||||||
>
|
>
|
||||||
<span v-if="event.isRegistered" class="registered-tag"
|
|
||||||
>Registered</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="event.tagline" class="event-tagline">
|
<div v-if="event.tagline" class="event-tagline">
|
||||||
{{ event.tagline }}
|
{{ event.tagline }}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-sub">
|
<div class="event-sub">
|
||||||
<span v-if="event.eventType" class="event-type-tag">{{
|
<span v-if="event.eventType" class="event-type-tag">{{
|
||||||
eventTypeLabel(event.eventType)
|
event.eventType
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-if="event.eventType" class="sep">·</span>
|
<span v-if="event.eventType" class="sep">·</span>
|
||||||
<span class="event-location">{{ formatLocation(event) }}</span>
|
<span class="event-location">{{ formatLocation(event) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="event-capacity">
|
||||||
|
<template v-if="event.maxAttendees">
|
||||||
|
<span :class="{ 'seats-warn': isAlmostFull(event) }">
|
||||||
|
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>Open</template>
|
||||||
|
</span>
|
||||||
<div class="event-badges">
|
<div class="event-badges">
|
||||||
<span v-if="event.membersOnly" class="members-badge">Members</span>
|
<span v-if="event.membersOnly" class="members-badge">Members</span>
|
||||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||||
<span v-else class="badge all">Public</span>
|
<span v-else class="badge all">All</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
|
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
|
||||||
|
|
@ -75,11 +71,11 @@
|
||||||
<div class="series-grid">
|
<div class="series-grid">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="series in activeSeries"
|
v-for="series in activeSeries"
|
||||||
:key="series.id"
|
:key="series._id"
|
||||||
:to="`/series/${series.id}`"
|
:to="`/series/${series._id}`"
|
||||||
class="series-box"
|
class="series-box"
|
||||||
>
|
>
|
||||||
<h2>{{ series.title }}</h2>
|
<h3>{{ series.title }}</h3>
|
||||||
<p class="series-desc">{{ series.description }}</p>
|
<p class="series-desc">{{ series.description }}</p>
|
||||||
<div class="series-meta">
|
<div class="series-meta">
|
||||||
<span
|
<span
|
||||||
|
|
@ -94,39 +90,55 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div
|
|
||||||
v-if="activeSeries.length % 2"
|
|
||||||
class="series-box series-box-filler"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PROPOSE AN EVENT -->
|
||||||
|
<!-- TODO: Build /events/propose page + form for members to submit event ideas.
|
||||||
|
Think through before building:
|
||||||
|
- Who can propose? Members only, or any circle?
|
||||||
|
- Required fields: title, description, proposed date/time, target circle,
|
||||||
|
format (workshop/social/talk/etc.), estimated attendance
|
||||||
|
- Approval workflow: does an admin review and publish, or does it auto-post
|
||||||
|
as a draft?
|
||||||
|
- Interest threshold mechanic: can other members +1 a proposal to signal
|
||||||
|
demand before it gets formally scheduled?
|
||||||
|
- Notifications: proposer gets notified when approved/declined
|
||||||
|
See CLAUDE.md product spec for additional context. -->
|
||||||
|
<div class="full-section">
|
||||||
|
<div class="section-label">Have an idea?</div>
|
||||||
|
<DashedBox>
|
||||||
|
<h3>Propose an Event</h3>
|
||||||
|
<p>
|
||||||
|
Members can propose events for any circle. Workshops, social hangs,
|
||||||
|
talks, or anything else that serves the community.
|
||||||
|
</p>
|
||||||
|
<span class="cta cta-soon"
|
||||||
|
>Propose an event → <em>coming soon</em></span
|
||||||
|
>
|
||||||
|
</DashedBox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 activeFilter = ref("all");
|
||||||
const includePastEvents = ref(false);
|
const includePastEvents = ref(false);
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ label: "All", value: "all" },
|
{ label: "All", value: "all" },
|
||||||
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
|
{ label: "Workshops", value: "workshop" },
|
||||||
|
{ label: "Community", value: "community" },
|
||||||
|
{ label: "Social", value: "social" },
|
||||||
|
{ label: "Showcase", value: "showcase" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { data: eventsData } = await useFetch("/api/events");
|
const { data: eventsData } = await useFetch("/api/events");
|
||||||
const { data: seriesData } = await useFetch("/api/series");
|
const { data: seriesData } = await useFetch("/api/series");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
const filteredEvents = computed(() => {
|
||||||
const now = new Date();
|
|
||||||
if (!eventsData.value) return [];
|
if (!eventsData.value) return [];
|
||||||
return eventsData.value.filter((event) => {
|
return eventsData.value.filter((event) => {
|
||||||
if (!includePastEvents.value && new Date(event.startDate) < now)
|
if (!includePastEvents.value && new Date(event.startDate) < now)
|
||||||
|
|
@ -144,25 +156,18 @@ const activeSeries = computed(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (event) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!event?.startDate) return "";
|
if (!dateStr) return "";
|
||||||
const tz = event.displayTimezone || "America/Toronto";
|
const d = new Date(dateStr);
|
||||||
const d = new Date(event.startDate);
|
const opts = { month: "short", day: "numeric" };
|
||||||
const opts = { month: "short", day: "numeric", timeZone: tz };
|
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
|
||||||
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);
|
return d.toLocaleDateString("en-US", opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (event) => {
|
const formatTime = (dateStr) => {
|
||||||
if (!event?.startDate) return "";
|
if (!dateStr) return "";
|
||||||
return new Date(event.startDate).toLocaleTimeString("en-US", {
|
const d = new Date(dateStr);
|
||||||
hour: "numeric",
|
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||||
minute: "2-digit",
|
|
||||||
timeZoneName: "short",
|
|
||||||
timeZone: event.displayTimezone || "America/Toronto",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLocation = (event) => {
|
const formatLocation = (event) => {
|
||||||
|
|
@ -174,15 +179,19 @@ const formatLocation = (event) => {
|
||||||
return event.location;
|
return event.location;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAlmostFull = (event) => {
|
||||||
|
if (!event.maxAttendees) return false;
|
||||||
|
return (event.registeredCount || 0) / event.maxAttendees > 0.8;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.hero {
|
.hero {
|
||||||
padding: 32px 28px 24px;
|
padding: 32px 32px 24px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-family: var(--font-display);
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -198,13 +207,13 @@ const formatLocation = (event) => {
|
||||||
|
|
||||||
/* ---- EVENT LIST ---- */
|
/* ---- EVENT LIST ---- */
|
||||||
.event-list-full {
|
.event-list-full {
|
||||||
padding: 0 28px;
|
padding: 0 32px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-row {
|
.event-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 90px 1fr auto;
|
grid-template-columns: 90px 1fr auto auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
|
|
@ -221,12 +230,8 @@ const formatLocation = (event) => {
|
||||||
.event-row:hover {
|
.event-row:hover {
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
.event-row.is-cancelled .event-title a {
|
.event-row.is-cancelled {
|
||||||
text-decoration: line-through;
|
opacity: 0.5;
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
}
|
|
||||||
.event-row.is-cancelled .event-tagline {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-col {
|
.event-date-col {
|
||||||
|
|
@ -267,7 +272,7 @@ const formatLocation = (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancelled-tag {
|
.cancelled-tag {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
|
|
@ -276,16 +281,6 @@ const formatLocation = (event) => {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
flex-shrink: 0;
|
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 {
|
.event-tagline {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -314,6 +309,16 @@ const formatLocation = (event) => {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-capacity {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.seats-warn {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
.event-badges {
|
.event-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -321,7 +326,7 @@ const formatLocation = (event) => {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.members-badge {
|
.members-badge {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
@ -333,7 +338,7 @@ const formatLocation = (event) => {
|
||||||
|
|
||||||
/* ---- FULL SECTION ---- */
|
/* ---- FULL SECTION ---- */
|
||||||
.full-section {
|
.full-section {
|
||||||
padding: 32px 28px;
|
padding: 32px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,26 +350,19 @@ const formatLocation = (event) => {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.series-box {
|
.series-box {
|
||||||
padding: 20px 24px;
|
padding: 20px;
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
}
|
||||||
.series-box:nth-child(2n) {
|
.series-box:last-child {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
.series-box:nth-last-child(-n + 2) {
|
.series-box:hover {
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.series-box-filler {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.series-box:not(.series-box-filler):hover {
|
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
.series-box h2 {
|
.series-box h3 {
|
||||||
font-family: var(--font-display);
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -384,49 +382,47 @@ const formatLocation = (event) => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- PROPOSE ---- */
|
||||||
.past-toggle {
|
.full-section h3 {
|
||||||
display: inline-flex;
|
font-family: "Brygada 1918", serif;
|
||||||
align-items: center;
|
font-size: 16px;
|
||||||
gap: 8px;
|
font-weight: 500;
|
||||||
margin-left: auto;
|
color: var(--text-bright);
|
||||||
font-family: "Commit Mono", monospace;
|
margin-bottom: 4px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.past-toggle:hover {
|
.full-section p {
|
||||||
border-color: var(--candle-faint);
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
line-height: 1.7;
|
||||||
.past-toggle:focus-visible {
|
max-width: 560px;
|
||||||
outline: 2px dashed var(--candle);
|
}
|
||||||
outline-offset: 3px;
|
.cta {
|
||||||
}
|
display: inline-block;
|
||||||
.past-toggle.active {
|
margin-top: 8px;
|
||||||
border-color: var(--candle);
|
|
||||||
border-style: solid;
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
.past-toggle-box {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.past-toggle-check {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
.cta-soon {
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.cta-soon em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filter-toggle input {
|
||||||
|
accent-color: var(--candle-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
|
|
@ -435,16 +431,11 @@ const formatLocation = (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero,
|
|
||||||
.event-list-full,
|
|
||||||
.full-section {
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
.event-row {
|
.event-row {
|
||||||
grid-template-columns: 70px 1fr;
|
grid-template-columns: 70px 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.event-capacity,
|
||||||
.event-badges {
|
.event-badges {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -455,17 +446,8 @@ const formatLocation = (event) => {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.series-box:nth-child(2n) {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.series-box:nth-last-child(-n + 2) {
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.series-box:last-child {
|
.series-box:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.series-box-filler {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,20 @@
|
||||||
<div>
|
<div>
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Ghost Guild is where game developers explore cooperative models.</h1>
|
<h1>
|
||||||
|
Ghost Guild is where game developers practice cooperative business
|
||||||
|
models.
|
||||||
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Resources, events, and a community of people figuring it out. Three
|
Resources, events, and a community of people figuring it out. Three
|
||||||
circles, pay what you can.
|
circles, no hierarchy. $0–50/mo, pay what you can.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-links">
|
<div class="hero-links">
|
||||||
<NuxtLink to="/join" class="hero-link primary"
|
<NuxtLink to="/join" class="hero-link primary"
|
||||||
>Become a member</NuxtLink
|
>Become a member</NuxtLink
|
||||||
>
|
>
|
||||||
<a href="https://wiki.ghostguild.org" class="hero-link"
|
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
|
||||||
>Read the wiki</a
|
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
|
||||||
>
|
|
||||||
<NuxtLink to="/about" class="hero-link">About the Guild</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -30,6 +31,10 @@
|
||||||
</div>
|
</div>
|
||||||
<h2>{{ circle.metaphor }}</h2>
|
<h2>{{ circle.metaphor }}</h2>
|
||||||
<p>{{ circle.blurb }}</p>
|
<p>{{ circle.blurb }}</p>
|
||||||
|
<details>
|
||||||
|
<summary>What's included?</summary>
|
||||||
|
<p>{{ circle.included }}</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -42,7 +47,7 @@
|
||||||
<div v-if="events?.length" class="event-list">
|
<div v-if="events?.length" class="event-list">
|
||||||
<div v-for="event in events" :key="event._id" class="event-item">
|
<div v-for="event in events" :key="event._id" class="event-item">
|
||||||
<div class="block-inset event-item-inner">
|
<div class="block-inset event-item-inner">
|
||||||
<span class="event-date">{{ formatDate(event) }}</span>
|
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||||
<span class="event-title">
|
<span class="event-title">
|
||||||
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
||||||
event.title
|
event.title
|
||||||
|
|
@ -60,21 +65,27 @@
|
||||||
<div class="block-inset">
|
<div class="block-inset">
|
||||||
<div class="label">Recently in the Wiki</div>
|
<div class="label">Recently in the Wiki</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="wikiArticles?.length" class="wiki-list">
|
<div class="wiki-list">
|
||||||
<div
|
<div class="wiki-item">
|
||||||
v-for="article in wikiArticles"
|
|
||||||
:key="article._id"
|
|
||||||
class="wiki-item"
|
|
||||||
>
|
|
||||||
<div class="block-inset wiki-item-inner">
|
<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>
|
</div>
|
||||||
|
<div class="wiki-item">
|
||||||
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">What is a cooperative studio?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wiki-item">
|
||||||
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">Governance structures</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wiki-item">
|
||||||
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">Legal incorporation guide</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="block-inset">
|
|
||||||
<p class="empty">
|
|
||||||
<a href="https://wiki.ghostguild.org">Browse the wiki →</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,27 +98,18 @@
|
||||||
>
|
>
|
||||||
From the Wiki
|
From the Wiki
|
||||||
</div>
|
</div>
|
||||||
<template v-if="hasCustomWikiFeature">
|
|
||||||
<h2>{{ wikiFeature.title || DEFAULT_WIKI_FEATURE_TITLE }}</h2>
|
|
||||||
<p v-for="(para, i) in customWikiParagraphs" :key="i">{{ para }}</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<h2>What is a cooperative studio?</h2>
|
<h2>What is a cooperative studio?</h2>
|
||||||
<p>
|
<p>
|
||||||
A cooperative studio is a game development company owned and governed
|
A cooperative studio is a game development company owned and governed by
|
||||||
by the people who work there. Decisions are made collectively. Profits
|
the people who work there. Decisions are made collectively. Profits are
|
||||||
are shared according to contribution, not ownership stake.
|
shared according to contribution, not ownership stake.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The games industry is full of stories about crunch, layoffs, and
|
The games industry is full of stories about crunch, layoffs, and studios
|
||||||
studios that extract value from workers. Cooperatives are one
|
that extract value from workers. Cooperatives are one alternative — not
|
||||||
alternative — not the only one, but one worth
|
the only one, but one worth <a href="/wiki">practicing together</a>.
|
||||||
<a href="https://wiki.ghostguild.org">practicing together</a>.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<p>
|
|
||||||
<a href="https://wiki.ghostguild.org">Read more in the wiki →</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
<p><a href="/wiki">Read more in the wiki →</a></p>
|
||||||
</ParchmentInset>
|
</ParchmentInset>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -117,91 +119,45 @@ definePageMeta({
|
||||||
layout: "default",
|
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 },
|
query: { limit: 4, upcoming: true },
|
||||||
default: () => [],
|
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 = [
|
const circleData = [
|
||||||
{
|
{
|
||||||
value: "community",
|
value: "community",
|
||||||
label: "Community",
|
label: "Community",
|
||||||
metaphor: "The open hall",
|
metaphor: "The open hall",
|
||||||
blurb:
|
blurb:
|
||||||
"For anyone exploring cooperative models in game development. Solo devs, researchers, students, people who just heard about this and want to know more.",
|
"Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.",
|
||||||
|
included:
|
||||||
|
"Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "founder",
|
value: "founder",
|
||||||
label: "Founder",
|
label: "Founder",
|
||||||
metaphor: "The workshop",
|
metaphor: "The workshop",
|
||||||
blurb:
|
blurb:
|
||||||
"For people actively building cooperative studios. You're working through governance, legal structure, revenue sharing, and all the hard parts.",
|
"For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.",
|
||||||
|
included:
|
||||||
|
"Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "practitioner",
|
value: "practitioner",
|
||||||
label: "Practitioner",
|
label: "Practitioner",
|
||||||
metaphor: "The alcove",
|
metaphor: "The alcove",
|
||||||
blurb:
|
blurb:
|
||||||
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
|
"Where experience is shared and knowledge given back. 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) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!event?.startDate) return "";
|
if (!dateStr) return "";
|
||||||
return new Date(event.startDate).toLocaleDateString("en-US", {
|
const d = new Date(dateStr);
|
||||||
month: "short",
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
day: "numeric",
|
|
||||||
timeZone: event.displayTimezone || "America/Toronto",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -302,6 +258,26 @@ const formatDate = (event) => {
|
||||||
margin-bottom: 8px;
|
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 LIST ---- */
|
||||||
.event-item {
|
.event-item {
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<span
|
<span
|
||||||
class="status-dot"
|
class="status-dot"
|
||||||
:class="memberData.status || 'active'"
|
:class="memberData.status || 'active'"
|
||||||
/>
|
></span>
|
||||||
<span>{{
|
<span>{{
|
||||||
formatStatus(memberData.status || "active")
|
formatStatus(memberData.status || "active")
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|
@ -57,11 +57,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Contribution</span>
|
<span class="membership-k">Contribution</span>
|
||||||
<span class="membership-v">{{ currentContributionLabel }}</span>
|
<span class="membership-v"
|
||||||
</div>
|
>${{ memberData.contributionTier || 0 }} / month</span
|
||||||
<div v-if="nextPaymentDate" class="membership-row">
|
>
|
||||||
<span class="membership-k">Next payment</span>
|
|
||||||
<span class="membership-v">{{ formatNextPaymentDate(nextPaymentDate) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Member since</span>
|
<span class="membership-k">Member since</span>
|
||||||
|
|
@ -72,89 +70,6 @@
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
<!-- PAYMENT HISTORY (shown when a paid plan is active OR past payments exist) -->
|
|
||||||
<PageSection
|
|
||||||
v-if="memberData.helcimCustomerId && ((memberData.contributionAmount || 0) > 0 || paymentHistory.length > 0)"
|
|
||||||
divider="top"
|
|
||||||
>
|
|
||||||
<div class="section-label">Payment history</div>
|
|
||||||
|
|
||||||
<div v-if="nextPaymentDate" class="next-charge">
|
|
||||||
<span class="next-charge-label">Next charge</span>
|
|
||||||
<span class="next-charge-value">${{ nextChargeAmount }} on {{ formatNextPaymentDate(nextPaymentDate) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="paymentHistoryLoading" class="history-card">
|
|
||||||
<div class="history-row history-state">Loading…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="paymentHistoryError"
|
|
||||||
class="history-card"
|
|
||||||
>
|
|
||||||
<div class="history-row history-state">
|
|
||||||
Payment history temporarily unavailable. Try again in a few minutes.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="paymentHistory.length === 0"
|
|
||||||
class="history-card"
|
|
||||||
>
|
|
||||||
<div class="history-row history-state">
|
|
||||||
No payments yet. Your first charge will appear here after your next billing cycle.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="history-card">
|
|
||||||
<div
|
|
||||||
v-for="txn in paymentHistory"
|
|
||||||
:key="txn.id"
|
|
||||||
class="history-row"
|
|
||||||
>
|
|
||||||
<span class="history-date">{{ formatTxnDate(txn.date) }}</span>
|
|
||||||
<span class="history-amount">{{ formatTxnAmount(txn.amount, txn.currency) }}</span>
|
|
||||||
<span
|
|
||||||
class="history-status"
|
|
||||||
:class="`status-${txn.status}`"
|
|
||||||
>{{ formatTxnStatus(txn.status) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageSection>
|
|
||||||
|
|
||||||
<!-- CHANGE CARD (only for active subscriptions) -->
|
|
||||||
<PageSection
|
|
||||||
v-if="canChangeCard"
|
|
||||||
divider="top"
|
|
||||||
>
|
|
||||||
<div class="section-label">Change card</div>
|
|
||||||
<p class="change-card-hint">
|
|
||||||
Replace the card on file. Future charges will use the new card.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-section"
|
|
||||||
:disabled="isChangingCard"
|
|
||||||
@click="handleChangeCard"
|
|
||||||
>
|
|
||||||
{{ changeCardButtonLabel }}
|
|
||||||
</button>
|
|
||||||
</PageSection>
|
|
||||||
|
|
||||||
<!-- ADVANCED BILLING LINK (escape hatch) -->
|
|
||||||
<PageSection
|
|
||||||
v-if="helcimPortalUrl && memberData.helcimCustomerId"
|
|
||||||
divider="top"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="helcimPortalUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="billing-link"
|
|
||||||
>
|
|
||||||
Advanced billing in Helcim →
|
|
||||||
</a>
|
|
||||||
</PageSection>
|
|
||||||
|
|
||||||
<PageSection divider="top">
|
<PageSection divider="top">
|
||||||
<div class="section-label">Email</div>
|
<div class="section-label">Email</div>
|
||||||
|
|
||||||
|
|
@ -169,26 +84,26 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>New email address</label>
|
<label>New email address</label>
|
||||||
<input
|
<input
|
||||||
v-model="newEmail"
|
|
||||||
type="email"
|
type="email"
|
||||||
|
v-model="newEmail"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
autofocus
|
|
||||||
@keydown.enter="handleUpdateEmail"
|
@keydown.enter="handleUpdateEmail"
|
||||||
@keydown.escape="cancelEmailEdit"
|
@keydown.escape="cancelEmailEdit"
|
||||||
>
|
autofocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-edit-actions">
|
<div class="email-edit-actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="isUpdatingEmail || !newEmail.trim()"
|
|
||||||
@click="handleUpdateEmail"
|
@click="handleUpdateEmail"
|
||||||
|
:disabled="isUpdatingEmail || !newEmail.trim()"
|
||||||
>
|
>
|
||||||
{{ isUpdatingEmail ? "Saving…" : "Save" }}
|
{{ isUpdatingEmail ? "Saving…" : "Save" }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
:disabled="isUpdatingEmail"
|
|
||||||
@click="cancelEmailEdit"
|
@click="cancelEmailEdit"
|
||||||
|
:disabled="isUpdatingEmail"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -204,12 +119,9 @@
|
||||||
<div class="section-label danger">Danger Zone</div>
|
<div class="section-label danger">Danger Zone</div>
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<p>
|
<p>
|
||||||
Cancelling closes your account and ends access to member-only
|
Cancelling your membership will immediately revoke access to
|
||||||
spaces, including Slack.<template v-if="(memberData.contributionAmount || 0) > 0"> If you're cancelling because of a
|
member-only resources, events, and the Slack workspace.
|
||||||
money issue, the
|
<strong>This action cannot be easily undone.</strong>
|
||||||
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
|
|
||||||
and the $0 tier are always available — reach out before you
|
|
||||||
go.</template>
|
|
||||||
</p>
|
</p>
|
||||||
<div v-if="showCancelConfirm" class="cancel-confirm">
|
<div v-if="showCancelConfirm" class="cancel-confirm">
|
||||||
<p class="cancel-confirm-prompt">
|
<p class="cancel-confirm-prompt">
|
||||||
|
|
@ -218,8 +130,8 @@
|
||||||
<div class="cancel-confirm-actions">
|
<div class="cancel-confirm-actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
:disabled="isCancelling"
|
|
||||||
@click="confirmCancelMembership"
|
@click="confirmCancelMembership"
|
||||||
|
:disabled="isCancelling"
|
||||||
>
|
>
|
||||||
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -231,8 +143,8 @@
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
:disabled="isCancelling"
|
|
||||||
@click="handleCancelMembership"
|
@click="handleCancelMembership"
|
||||||
|
:disabled="isCancelling"
|
||||||
>
|
>
|
||||||
Cancel Membership
|
Cancel Membership
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -245,45 +157,17 @@
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<div class="section-label">Change Contribution</div>
|
<div class="section-label">Change Contribution</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
||||||
<label class="form-label" for="account-contribution">
|
<div class="tier-hint">
|
||||||
Monthly Contribution
|
Changes take effect on your next billing cycle
|
||||||
</label>
|
|
||||||
<div class="contribution-input-row">
|
|
||||||
<span class="contribution-currency">$</span>
|
|
||||||
<input
|
|
||||||
id="account-contribution"
|
|
||||||
v-model.number="form.contributionAmount"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
inputmode="numeric"
|
|
||||||
class="contribution-input"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
|
|
||||||
<button
|
|
||||||
v-for="preset in CONTRIBUTION_PRESETS"
|
|
||||||
:key="preset.amount"
|
|
||||||
type="button"
|
|
||||||
class="contribution-preset-chip"
|
|
||||||
@click="form.contributionAmount = preset.amount"
|
|
||||||
>
|
|
||||||
${{ preset.amount }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="contributionChangeHint" class="tier-hint">
|
|
||||||
{{ contributionChangeHint }}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-section"
|
class="btn btn-primary btn-section"
|
||||||
|
@click="handleUpdateTier"
|
||||||
:disabled="
|
:disabled="
|
||||||
form.contributionAmount === Number(memberData.contributionAmount || 0) ||
|
selectedTier === Number(memberData.contributionTier || 0) ||
|
||||||
isUpdating
|
isUpdating
|
||||||
"
|
"
|
||||||
@click="handleUpdateContribution"
|
|
||||||
>
|
>
|
||||||
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -294,13 +178,12 @@
|
||||||
|
|
||||||
<CirclePicker
|
<CirclePicker
|
||||||
v-model="selectedCircle"
|
v-model="selectedCircle"
|
||||||
:saved-value="memberData.circle"
|
|
||||||
:circles="circleOptions"
|
:circles="circleOptions"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-section"
|
class="btn btn-primary btn-section"
|
||||||
:disabled="selectedCircle === memberData.circle || isUpdating"
|
|
||||||
@click="handleUpdateCircle"
|
@click="handleUpdateCircle"
|
||||||
|
:disabled="selectedCircle === memberData.circle || isUpdating"
|
||||||
>
|
>
|
||||||
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -314,22 +197,15 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
|
|
||||||
import { STATUS_LABELS } from '~/config/memberStatus';
|
|
||||||
|
|
||||||
useSiteMeta({ title: 'Account', noindex: true });
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
middleware: "auth",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal();
|
||||||
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
|
|
||||||
|
|
||||||
const form = reactive({ contributionAmount: 0 });
|
const selectedTier = ref(0);
|
||||||
const selectedCircle = ref("");
|
const selectedCircle = ref("");
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
const isCancelling = ref(false);
|
const isCancelling = ref(false);
|
||||||
|
|
@ -339,87 +215,42 @@ const showEmailEdit = ref(false);
|
||||||
const newEmail = ref("");
|
const newEmail = ref("");
|
||||||
const isUpdatingEmail = ref(false);
|
const isUpdatingEmail = ref(false);
|
||||||
|
|
||||||
// Payment history state
|
const tiers = [
|
||||||
const paymentHistory = ref([]);
|
{ amount: 0, display: "$0", label: "Solidarity" },
|
||||||
const paymentHistoryLoading = ref(false);
|
{ amount: 5, display: "$5", label: "Supporter" },
|
||||||
const paymentHistoryError = ref(false);
|
{ amount: 15, display: "$15", label: "Sustainer" },
|
||||||
const paymentHistoryLoaded = ref(false);
|
{ amount: 30, display: "$30", label: "Builder" },
|
||||||
|
{ amount: 50, display: "$50", label: "Champion" },
|
||||||
// Next payment (refreshed lazily from Helcim when cached date is stale)
|
];
|
||||||
const refreshedNextBillingDate = ref(null);
|
|
||||||
const nextBillingRefreshed = ref(false);
|
|
||||||
|
|
||||||
const nextPaymentDate = computed(() => {
|
|
||||||
const m = memberData.value;
|
|
||||||
if (!m) return null;
|
|
||||||
if (m.status !== 'active') return null;
|
|
||||||
if (!Number(m.contributionAmount)) return null;
|
|
||||||
return refreshedNextBillingDate.value || m.nextBillingDate || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change-card state
|
|
||||||
const isChangingCard = ref(false);
|
|
||||||
const changeCardButtonLabel = ref("Change card");
|
|
||||||
|
|
||||||
const canChangeCard = computed(() => {
|
|
||||||
const m = memberData.value;
|
|
||||||
if (!m) return false;
|
|
||||||
if (!m.helcimCustomerId) return false;
|
|
||||||
if (!["active", "pending_payment"].includes(m.status)) return false;
|
|
||||||
// $0 tier has no subscription to attach a card to
|
|
||||||
if (!requiresPayment(Number(m.contributionAmount || 0))) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
|
|
||||||
|
|
||||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
|
||||||
|
|
||||||
const contributionChangeHint = computed(() => {
|
|
||||||
const current = Number(memberData.value?.contributionAmount || 0);
|
|
||||||
const next = Number(form.contributionAmount || 0);
|
|
||||||
if (current === next) return "";
|
|
||||||
if (current === 0 && next > 0) {
|
|
||||||
const firstCharge = cadence.value === "annual" ? next * 12 : next;
|
|
||||||
return `You'll be charged $${firstCharge} today to start your subscription.`;
|
|
||||||
}
|
|
||||||
if (current > 0 && next === 0) {
|
|
||||||
return "Your paid subscription will be cancelled.";
|
|
||||||
}
|
|
||||||
return "Changes apply on your next billing cycle.";
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentContributionLabel = computed(() => {
|
|
||||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
|
||||||
if (!amount) return '$0';
|
|
||||||
const displayAmount = cadence.value === 'annual' ? amount * 12 : amount;
|
|
||||||
return cadence.value === 'annual' ? `$${displayAmount} / year` : `$${displayAmount} / month`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextChargeAmount = computed(() => {
|
|
||||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
|
||||||
if (!amount) return null;
|
|
||||||
return cadence.value === 'annual' ? amount * 12 : amount;
|
|
||||||
});
|
|
||||||
|
|
||||||
const circleOptions = [
|
const circleOptions = [
|
||||||
{
|
{
|
||||||
value: "community",
|
value: "community",
|
||||||
label: "Community",
|
label: "Community",
|
||||||
description: "Exploring cooperative ideas",
|
description:
|
||||||
|
"For anyone interested in cooperative game dev. Access discussions, events, and resources.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "founder",
|
value: "founder",
|
||||||
label: "Founder",
|
label: "Founder",
|
||||||
description: "Building a cooperative studio",
|
description:
|
||||||
|
"For those actively building or running a cooperative studio. Peer support and deep dives.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "practitioner",
|
value: "practitioner",
|
||||||
label: "Practitioner",
|
label: "Practitioner",
|
||||||
description: "Experienced in cooperative practice",
|
description:
|
||||||
|
"For professionals advising co-ops: lawyers, accountants, facilitators, consultants.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
active: "Active",
|
||||||
|
pending_payment: "Pending",
|
||||||
|
suspended: "Suspended",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
const formatStatus = (s) => STATUS_LABELS[s] || s;
|
const formatStatus = (s) => STATUS_LABELS[s] || s;
|
||||||
|
|
||||||
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||||
|
|
@ -427,7 +258,7 @@ const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||||
// Initialize from member data
|
// Initialize from member data
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (memberData.value) {
|
if (memberData.value) {
|
||||||
form.contributionAmount = Number(memberData.value.contributionAmount || 0);
|
selectedTier.value = Number(memberData.value.contributionTier || 0);
|
||||||
selectedCircle.value = memberData.value.circle || "community";
|
selectedCircle.value = memberData.value.circle || "community";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -440,71 +271,21 @@ const formatMemberSince = (dateStr) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatNextPaymentDate = (dateStr) => {
|
const handleUpdateTier = async () => {
|
||||||
if (!dateStr) return "";
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const STALE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const isNextBillingStale = (dateStr) => {
|
|
||||||
if (!dateStr) return true;
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
if (Number.isNaN(d.getTime())) return true;
|
|
||||||
return d.getTime() - Date.now() < STALE_WINDOW_MS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshNextBillingIfStale = async () => {
|
|
||||||
if (nextBillingRefreshed.value) return;
|
|
||||||
const m = memberData.value;
|
|
||||||
if (!m) return;
|
|
||||||
if (m.status !== 'active') return;
|
|
||||||
if (!Number(m.contributionAmount)) return;
|
|
||||||
if (!isNextBillingStale(m.nextBillingDate)) return;
|
|
||||||
|
|
||||||
nextBillingRefreshed.value = true;
|
|
||||||
try {
|
|
||||||
const response = await $fetch("/api/helcim/subscription");
|
|
||||||
const fresh = response?.subscription?.nextBillingDate;
|
|
||||||
if (fresh) refreshedNextBillingDate.value = fresh;
|
|
||||||
} catch (err) {
|
|
||||||
// Silent — fall back to cached value (if any)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateContribution = async () => {
|
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/members/update-contribution", {
|
await $fetch("/api/members/update-contribution", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: { contributionTier: String(selectedTier.value) },
|
||||||
contributionAmount: form.contributionAmount,
|
|
||||||
cadence: cadence.value,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
toast.add({ title: "Contribution updated", color: "success" });
|
toast.add({ title: "Contribution updated", color: "green" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Paid upgrade without a saved card — route to payment setup instead of erroring.
|
selectedTier.value = Number(memberData.value?.contributionTier || 0);
|
||||||
if (err.data?.data?.requiresPaymentSetup) {
|
|
||||||
await navigateTo(
|
|
||||||
`/member/payment-setup?tier=${form.contributionAmount}&circle=${
|
|
||||||
selectedCircle.value || memberData.value?.circle || 'community'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.contributionAmount = Number(memberData.value?.contributionAmount || 0);
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Update failed",
|
title: "Update failed",
|
||||||
description: err.data?.statusMessage || "Please try again.",
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
color: "error",
|
color: "red",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating.value = false;
|
isUpdating.value = false;
|
||||||
|
|
@ -519,13 +300,13 @@ const handleUpdateCircle = async () => {
|
||||||
body: { circle: selectedCircle.value },
|
body: { circle: selectedCircle.value },
|
||||||
});
|
});
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
toast.add({ title: "Circle updated", color: "success" });
|
toast.add({ title: "Circle updated", color: "green" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
selectedCircle.value = memberData.value?.circle || "community";
|
selectedCircle.value = memberData.value?.circle || "community";
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Update failed",
|
title: "Update failed",
|
||||||
description: err.data?.statusMessage || "Please try again.",
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
color: "error",
|
color: "red",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating.value = false;
|
isUpdating.value = false;
|
||||||
|
|
@ -548,163 +329,18 @@ const handleUpdateEmail = async () => {
|
||||||
});
|
});
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
cancelEmailEdit();
|
cancelEmailEdit();
|
||||||
toast.add({ title: "Email updated", color: "success" });
|
toast.add({ title: "Email updated", color: "green" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Update failed",
|
title: "Update failed",
|
||||||
description: err.data?.statusMessage || "Please try again.",
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
color: "error",
|
color: "red",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isUpdatingEmail.value = false;
|
isUpdatingEmail.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Payment history
|
|
||||||
const loadPaymentHistory = async () => {
|
|
||||||
if (paymentHistoryLoaded.value) return;
|
|
||||||
if (!memberData.value?.helcimCustomerId) return;
|
|
||||||
paymentHistoryLoading.value = true;
|
|
||||||
paymentHistoryError.value = false;
|
|
||||||
try {
|
|
||||||
const response = await $fetch("/api/helcim/payment-history");
|
|
||||||
if (response?.error === "unavailable") {
|
|
||||||
paymentHistoryError.value = true;
|
|
||||||
paymentHistory.value = [];
|
|
||||||
} else {
|
|
||||||
paymentHistory.value = Array.isArray(response?.transactions)
|
|
||||||
? response.transactions
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
paymentHistoryError.value = true;
|
|
||||||
paymentHistory.value = [];
|
|
||||||
} finally {
|
|
||||||
paymentHistoryLoading.value = false;
|
|
||||||
paymentHistoryLoaded.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (memberData.value?.helcimCustomerId) {
|
|
||||||
loadPaymentHistory();
|
|
||||||
}
|
|
||||||
refreshNextBillingIfStale();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => memberData.value?.helcimCustomerId,
|
|
||||||
(id) => {
|
|
||||||
if (id && !paymentHistoryLoaded.value) {
|
|
||||||
loadPaymentHistory();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => memberData.value?.status,
|
|
||||||
() => {
|
|
||||||
refreshNextBillingIfStale();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatTxnDate = (iso) => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTxnAmount = (amount, currency) => {
|
|
||||||
const num = Number(amount) || 0;
|
|
||||||
const cur = currency ? ` ${currency}` : "";
|
|
||||||
return `$${num.toFixed(2)}${cur}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_BADGE = {
|
|
||||||
paid: "Paid",
|
|
||||||
refunded: "Refunded",
|
|
||||||
failed: "Failed",
|
|
||||||
other: "—",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTxnStatus = (s) => STATUS_BADGE[s] || "—";
|
|
||||||
|
|
||||||
// Change card
|
|
||||||
const handleChangeCard = async () => {
|
|
||||||
if (isChangingCard.value) return;
|
|
||||||
if (!canChangeCard.value) return;
|
|
||||||
|
|
||||||
isChangingCard.value = true;
|
|
||||||
changeCardButtonLabel.value = "Opening…";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch current customer id + code
|
|
||||||
const customerResponse = await $fetch("/api/helcim/customer-code");
|
|
||||||
const customerId = customerResponse?.customerId;
|
|
||||||
const customerCode = customerResponse?.customerCode;
|
|
||||||
if (!customerId || !customerCode) {
|
|
||||||
throw new Error("Could not locate customer record");
|
|
||||||
}
|
|
||||||
|
|
||||||
await initializeHelcimPay(customerId, customerCode, 0);
|
|
||||||
|
|
||||||
let paymentResult;
|
|
||||||
try {
|
|
||||||
paymentResult = await verifyPayment();
|
|
||||||
} catch (cancelOrFailure) {
|
|
||||||
// User cancelled or iframe failed — no server call
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!paymentResult?.success || !paymentResult?.cardToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeCardButtonLabel.value = "Updating…";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch("/api/helcim/update-card", {
|
|
||||||
method: "POST",
|
|
||||||
body: { cardToken: paymentResult.cardToken },
|
|
||||||
});
|
|
||||||
toast.add({
|
|
||||||
title: "Card updated",
|
|
||||||
description: "Future charges will use your new card.",
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[change-card] update failed", err);
|
|
||||||
toast.add({
|
|
||||||
title: "Could not update card",
|
|
||||||
description: "Please try again.",
|
|
||||||
color: "error",
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Retry",
|
|
||||||
onClick: () => handleChangeCard(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[change-card] flow failed", err);
|
|
||||||
toast.add({
|
|
||||||
title: "Could not update card",
|
|
||||||
description: "Please try again.",
|
|
||||||
color: "error",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
cleanupHelcimPay();
|
|
||||||
isChangingCard.value = false;
|
|
||||||
changeCardButtonLabel.value = "Change card";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showCancelConfirm = ref(false);
|
const showCancelConfirm = ref(false);
|
||||||
|
|
||||||
const handleCancelMembership = () => {
|
const handleCancelMembership = () => {
|
||||||
|
|
@ -726,13 +362,13 @@ const confirmCancelMembership = async () => {
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.add({ title: "Membership cancelled", color: "warning" });
|
toast.add({ title: "Membership cancelled", color: "orange" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Cancellation failed",
|
title: "Cancellation failed",
|
||||||
description: err.data?.statusMessage || "Please try again.",
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
color: "error",
|
color: "red",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isCancelling.value = false;
|
isCancelling.value = false;
|
||||||
|
|
@ -890,136 +526,9 @@ const confirmCancelMembership = async () => {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
|
|
||||||
.contribution-input-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
.contribution-currency {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.contribution-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--parch);
|
|
||||||
font-family: 'Commit Mono', monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.contribution-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--candle);
|
|
||||||
}
|
|
||||||
.contribution-presets {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.contribution-preset-chip {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px dashed var(--parch);
|
|
||||||
font-family: 'Commit Mono', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.contribution-preset-chip:hover {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--candle);
|
|
||||||
}
|
|
||||||
.contribution-guidance {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--ink-soft, currentColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-section {
|
.btn-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.billing-link {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-faint);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.billing-link:hover {
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- NEXT CHARGE ---- */
|
|
||||||
.next-charge {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 120px 1fr;
|
|
||||||
gap: 0 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px dashed var(--candle);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.next-charge-label {
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.next-charge-value {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAYMENT HISTORY ---- */
|
|
||||||
.history-card {
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.history-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 120px 1fr auto;
|
|
||||||
gap: 0 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.history-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.history-date {
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.history-amount {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.history-status {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.history-status.status-failed {
|
|
||||||
color: var(--ember);
|
|
||||||
}
|
|
||||||
.history-state {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-style: italic;
|
|
||||||
display: block;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- CHANGE CARD ---- */
|
|
||||||
.change-card-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
332
app/pages/member/activity.vue
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<template>
|
||||||
|
<PageShell
|
||||||
|
title="Activity Log"
|
||||||
|
subtitle="Your activity and milestones in the Guild"
|
||||||
|
>
|
||||||
|
<ColumnsLayout cols="events-sidebar" :limit="5">
|
||||||
|
<ClientOnly>
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading && !entries.length" class="state-box">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="state-text">Loading activity...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div v-else-if="entries.length" class="timeline-wrap">
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="entry in entries" :key="entry._id" class="tl-item">
|
||||||
|
<div class="tl-dot">
|
||||||
|
<UIcon :name="getActivity(entry).icon" class="tl-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="tl-time">{{ formatDate(entry.timestamp) }}</div>
|
||||||
|
<div class="tl-text">
|
||||||
|
<template v-if="getActivity(entry).link">
|
||||||
|
<span>{{ getActivity(entry).text.split(getActivity(entry).linkText)[0] }}</span>
|
||||||
|
<NuxtLink :to="getActivity(entry).link" class="tl-link">{{ getActivity(entry).linkText }}</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ getActivity(entry).text }}</span>
|
||||||
|
<span v-if="entry.performedBy" class="tl-admin-badge">admin</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email body expandable -->
|
||||||
|
<div v-if="entry.type === 'email_sent' && getActivity(entry).emailBody" class="tl-email">
|
||||||
|
<button class="tl-email-toggle" @click="toggleEmail(entry._id)">
|
||||||
|
{{ expandedEmails[entry._id] ? 'Hide email' : 'View email' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedEmails[entry._id]" class="dashed-box tl-email-body">
|
||||||
|
<pre>{{ getActivity(entry).emailBody }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
<div v-if="hasMore" class="load-more">
|
||||||
|
<button class="btn" :disabled="loadingMore" @click="loadMore">
|
||||||
|
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="state-box">
|
||||||
|
<div class="state-icon">
|
||||||
|
<UIcon name="i-lucide-activity" />
|
||||||
|
</div>
|
||||||
|
<h2 class="state-heading">No activity yet</h2>
|
||||||
|
<p class="state-text">Your activity will appear here as you use the Guild</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="state-box">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="state-text">Loading activity...</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</ColumnsLayout>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { formatActivity } from '~/utils/activityText'
|
||||||
|
|
||||||
|
const entries = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const nextCursor = ref(null)
|
||||||
|
const expandedEmails = ref({})
|
||||||
|
|
||||||
|
const getActivity = (entry) => formatActivity(entry)
|
||||||
|
|
||||||
|
const toggleEmail = (id) => {
|
||||||
|
expandedEmails.value[id] = !expandedEmails.value[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
const now = new Date()
|
||||||
|
const d = new Date(date)
|
||||||
|
const diffInSeconds = Math.floor((now - d) / 1000)
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) return 'just now'
|
||||||
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||||
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||||
|
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||||
|
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEntries = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/members/me/activity', {
|
||||||
|
params: { limit: 20 }
|
||||||
|
})
|
||||||
|
entries.value = data.entries
|
||||||
|
hasMore.value = data.hasMore
|
||||||
|
nextCursor.value = data.nextCursor
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load activity:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!nextCursor.value) return
|
||||||
|
loadingMore.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/members/me/activity', {
|
||||||
|
params: { limit: 20, before: nextCursor.value }
|
||||||
|
})
|
||||||
|
entries.value.push(...data.entries)
|
||||||
|
hasMore.value = data.hasMore
|
||||||
|
nextCursor.value = data.nextCursor
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load more activity:', err)
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadEntries)
|
||||||
|
|
||||||
|
useHead({ title: 'Activity Log - Ghost Guild' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---- STATE BOXES ---- */
|
||||||
|
.state-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-heading {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px dashed var(--candle);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TIMELINE ---- */
|
||||||
|
.timeline-wrap {
|
||||||
|
padding: 24px 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 11px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
border-left: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -32px;
|
||||||
|
top: 2px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-link {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-admin-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 1px 5px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- EMAIL EXPANDABLE ---- */
|
||||||
|
.tl-email {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-email-toggle {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-email-toggle:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-email-body {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-email-body pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LOAD MORE ---- */
|
||||||
|
.load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESPONSIVE ---- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline-wrap {
|
||||||
|
padding: 20px 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-box {
|
||||||
|
padding: 48px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -33,15 +33,11 @@
|
||||||
<MemberStatusBanner />
|
<MemberStatusBanner />
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<PageHeader :title="welcomeTitle">
|
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
|
||||||
<div class="dashboard-meta">
|
<div class="dashboard-meta">
|
||||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="showSlackComingNote" class="slack-coming-note">
|
|
||||||
Slack workspace access is part of your membership. Invitations are
|
|
||||||
sent in monthly onboarding waves — we'll be in touch.
|
|
||||||
</p>
|
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Upcoming Events + Quick Actions -->
|
<!-- Upcoming Events + Quick Actions -->
|
||||||
|
|
@ -60,7 +56,9 @@
|
||||||
:to="`/events/${evt.slug || evt._id}`"
|
:to="`/events/${evt.slug || evt._id}`"
|
||||||
class="event-item"
|
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>
|
<span class="event-title">{{ evt.title }}</span>
|
||||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -91,17 +89,16 @@
|
||||||
<div class="ci-header">
|
<div class="ci-header">
|
||||||
<strong>How to Subscribe to Your Calendar</strong>
|
<strong>How to Subscribe to Your Calendar</strong>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
class="ci-close"
|
|
||||||
@click="showCalendarInstructions = false"
|
@click="showCalendarInstructions = false"
|
||||||
|
class="ci-close"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Google Calendar:</strong> Click "+" then "From URL"
|
<strong>Google Calendar:</strong> Click "+" then "From
|
||||||
then paste the link
|
URL" then paste the link
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Apple Calendar:</strong> File then New Calendar
|
<strong>Apple Calendar:</strong> File then New Calendar
|
||||||
|
|
@ -122,16 +119,16 @@
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
<div class="section-label">Quick Actions</div>
|
<div class="section-label">Quick Actions</div>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/board"
|
to="/ecology"
|
||||||
class="quick-action"
|
class="quick-action"
|
||||||
:class="{ disabled: !canPeerSupport }"
|
:class="{ disabled: !canPeerSupport }"
|
||||||
:title="
|
:title="
|
||||||
!canPeerSupport
|
!canPeerSupport
|
||||||
? 'Complete your membership to access the board'
|
? 'Complete your membership to access community ecology'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Board<span class="arrow">→</span>
|
Community ecology<span class="arrow">→</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/member/profile" class="quick-action">
|
<NuxtLink to="/member/profile" class="quick-action">
|
||||||
Update your profile<span class="arrow">→</span>
|
Update your profile<span class="arrow">→</span>
|
||||||
|
|
@ -140,14 +137,13 @@
|
||||||
href="https://wiki.ghostguild.org"
|
href="https://wiki.ghostguild.org"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="quick-action"
|
class="quick-action"
|
||||||
@click="handleWikiClick"
|
|
||||||
>
|
>
|
||||||
Browse the wiki<span class="arrow">→</span>
|
Browse the wiki<span class="arrow">→</span>
|
||||||
</a>
|
</a>
|
||||||
<NuxtLink to="/members" class="quick-action">
|
<NuxtLink to="/members" class="quick-action">
|
||||||
Browse members<span class="arrow">→</span>
|
Browse members<span class="arrow">→</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/member/account" class="quick-action">
|
<NuxtLink to="/member/profile#account" class="quick-action">
|
||||||
Manage account<span class="arrow">→</span>
|
Manage account<span class="arrow">→</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -171,7 +167,7 @@
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="key">Contribution</span>
|
<span class="key">Contribution</span>
|
||||||
<span class="val"
|
<span class="val"
|
||||||
>${{ memberData?.contributionAmount ?? 0 }} CAD/month</span
|
>${{ memberData?.contributionTier }} CAD/month</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
|
|
@ -188,20 +184,20 @@
|
||||||
formatMemberSince(memberData.createdAt)
|
formatMemberSince(memberData.createdAt)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/member/account" class="section-link">
|
<NuxtLink to="/member/profile#account" class="section-link">
|
||||||
Change circle or contribution →
|
Change circle or contribution →
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
<div class="section-label">Bulletin Board</div>
|
<div class="section-label">Community</div>
|
||||||
<DashedBox>
|
<DashedBox>
|
||||||
<p class="peer-text">
|
<p class="peer-text">
|
||||||
Make offers and requests related to shared interests and
|
Connect with other members through shared interests and
|
||||||
cooperative topics.
|
cooperative topics.
|
||||||
</p>
|
</p>
|
||||||
<NuxtLink to="/board" class="section-link">
|
<NuxtLink to="/ecology" class="section-link">
|
||||||
Browse the Bulletin Board →
|
Browse community ecology →
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</DashedBox>
|
</DashedBox>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -220,32 +216,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useSiteMeta({ title: 'Dashboard', noindex: true });
|
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||||
useMemberStatus();
|
useMemberStatus();
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const isNewSignup = computed(() => route.query.welcome === "1");
|
|
||||||
const showSlackComingNote = computed(
|
|
||||||
() =>
|
|
||||||
memberData.value?.status === "active" && !memberData.value?.slackInvited,
|
|
||||||
);
|
|
||||||
const welcomeTitle = computed(() => {
|
|
||||||
const name = memberData.value?.name || "";
|
|
||||||
return isNewSignup.value
|
|
||||||
? `Welcome to Ghost Guild, ${name}`
|
|
||||||
: `Welcome back, ${name}`;
|
|
||||||
});
|
|
||||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
|
|
||||||
|
|
||||||
const handleWikiClick = () => {
|
|
||||||
if (!onboardingComplete.value) {
|
|
||||||
trackGoal("wikiClicked");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const registeredEvents = ref([]);
|
const registeredEvents = ref([]);
|
||||||
const loadingEvents = ref(false);
|
const loadingEvents = ref(false);
|
||||||
|
|
@ -365,22 +339,20 @@ const getEventImageUrl = (featureImage) => {
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventDate = (event) => {
|
const formatEventDate = (dateString) => {
|
||||||
if (!event?.startDate) return "";
|
const date = new Date(dateString);
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
timeZone: event.displayTimezone || "America/Toronto",
|
}).format(date);
|
||||||
}).format(new Date(event.startDate));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventTime = (event) => {
|
const formatEventTime = (dateString) => {
|
||||||
if (!event?.startDate) return "";
|
const date = new Date(dateString);
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZone: event.displayTimezone || "America/Toronto",
|
}).format(date);
|
||||||
}).format(new Date(event.startDate));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMemberSince = (dateString) => {
|
const formatMemberSince = (dateString) => {
|
||||||
|
|
@ -455,7 +427,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.unauth-state h2 {
|
.unauth-state h2 {
|
||||||
font-family: var(--font-display);
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -478,13 +450,6 @@ useHead({
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slack-coming-note {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-row {
|
.content-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
@ -546,7 +511,7 @@ useHead({
|
||||||
|
|
||||||
/* ---- CALENDAR BUTTON ---- */
|
/* ---- CALENDAR BUTTON ---- */
|
||||||
.calendar-btn {
|
.calendar-btn {
|
||||||
font-family: inherit;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--candle-dim);
|
color: var(--candle-dim);
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -630,7 +595,7 @@ useHead({
|
||||||
/* ---- QUICK ACTIONS ---- */
|
/* ---- QUICK ACTIONS ---- */
|
||||||
.quick-action {
|
.quick-action {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 12px 20px;
|
padding: 14px 20px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -724,7 +689,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-block {
|
.content-block {
|
||||||
padding: 20px 24px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item {
|
.event-item {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -25,13 +25,12 @@
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- PAGE HEADER -->
|
<!-- PAGE HEADER -->
|
||||||
<PageHeader title="Edit Profile">
|
<PageHeader
|
||||||
|
title="Edit Profile"
|
||||||
|
subtitle="How you appear to other members"
|
||||||
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="
|
v-if="memberId && memberData?.status === MEMBER_STATUSES.ACTIVE && formData.showInDirectory"
|
||||||
memberId &&
|
|
||||||
memberData?.status === MEMBER_STATUSES.ACTIVE &&
|
|
||||||
formData.showInDirectory
|
|
||||||
"
|
|
||||||
:to="`/members/${memberId}`"
|
:to="`/members/${memberId}`"
|
||||||
class="view-profile-link"
|
class="view-profile-link"
|
||||||
>
|
>
|
||||||
|
|
@ -51,33 +50,28 @@
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Pronouns</label>
|
<label>Pronouns</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.pronouns"
|
v-model="formData.pronouns"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g., she/her, they/them"
|
placeholder="e.g., she/her, they/them"
|
||||||
>
|
/>
|
||||||
|
<PrivacyToggle v-model="formData.pronounsPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Timezone</label>
|
<label>Timezone</label>
|
||||||
<USelectMenu
|
<input
|
||||||
v-model="formData.timeZone"
|
v-model="formData.timeZone"
|
||||||
:items="timezoneItems"
|
type="text"
|
||||||
value-key="value"
|
placeholder="e.g., America/Toronto"
|
||||||
searchable
|
|
||||||
searchable-placeholder="Search timezones..."
|
|
||||||
placeholder="Select a timezone"
|
|
||||||
class="timezone-select"
|
|
||||||
:ui="{
|
|
||||||
content: 'tz-content',
|
|
||||||
item: 'tz-item',
|
|
||||||
input: 'tz-input',
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
|
<PrivacyToggle v-model="formData.timeZonePrivacy" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -92,9 +86,10 @@
|
||||||
:title="ghost.label"
|
:title="ghost.label"
|
||||||
@click="formData.avatar = ghost.value"
|
@click="formData.avatar = ghost.value"
|
||||||
>
|
>
|
||||||
<img :src="ghost.image" :alt="ghost.label" >
|
<img :src="ghost.image" :alt="ghost.label" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PrivacyToggle v-model="formData.avatarPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
|
|
@ -108,7 +103,8 @@
|
||||||
v-model="formData.studio"
|
v-model="formData.studio"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Studio name"
|
placeholder="Studio name"
|
||||||
>
|
/>
|
||||||
|
<PrivacyToggle v-model="formData.studioPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
|
|
@ -116,7 +112,8 @@
|
||||||
v-model="formData.location"
|
v-model="formData.location"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Toronto, ON"
|
placeholder="Toronto, ON"
|
||||||
>
|
/>
|
||||||
|
<PrivacyToggle v-model="formData.locationPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -127,10 +124,11 @@
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder="Share your background, interests, and experience..."
|
placeholder="Share your background, interests, and experience..."
|
||||||
maxlength="300"
|
maxlength="300"
|
||||||
/>
|
></textarea>
|
||||||
<div class="char-count">
|
<div class="char-count">
|
||||||
{{ formData.bio?.length || 0 }} / 300
|
{{ formData.bio?.length || 0 }} / 300
|
||||||
</div>
|
</div>
|
||||||
|
<PrivacyToggle v-model="formData.bioPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -140,6 +138,7 @@
|
||||||
:tags="craftTags"
|
:tags="craftTags"
|
||||||
@suggest="openTagSuggest('craft')"
|
@suggest="openTagSuggest('craft')"
|
||||||
/>
|
/>
|
||||||
|
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
|
|
@ -154,8 +153,7 @@
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Show in Member Directory
|
Show in Member Directory
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
>Your profile will appear in the private member
|
>Your profile will appear in the public member listing</span
|
||||||
directory.</span
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -164,53 +162,76 @@
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<div class="section-label">Board</div>
|
<div class="section-label">Community Ecology</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Gamma Space Slack Handle</label>
|
<label>Topics</label>
|
||||||
|
<CooperativeTagSelector
|
||||||
|
v-model="formData.communityEcologyTopics"
|
||||||
|
:tags="cooperativeTags"
|
||||||
|
@suggest="openTagSuggest('cooperative')"
|
||||||
|
/>
|
||||||
|
<PrivacyToggle v-model="formData.communityEcologyPrivacy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Details</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.communityEcologyDetails"
|
||||||
|
rows="3"
|
||||||
|
placeholder="What are you hoping to connect about?"
|
||||||
|
maxlength="300"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">
|
||||||
|
{{ formData.communityEcologyDetails?.length || 0 }} / 300
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-field">
|
||||||
|
<USwitch
|
||||||
|
v-model="formData.communityEcologyOfferPeerSupport"
|
||||||
|
aria-label="Offer Peer Support"
|
||||||
|
/>
|
||||||
|
<div class="toggle-label">
|
||||||
|
Offer Peer Support
|
||||||
|
<span class="toggle-sub"
|
||||||
|
>Share your Slack handle so other members can reach out</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formData.communityEcologyOfferPeerSupport" class="connections-panel">
|
||||||
|
<div class="field">
|
||||||
|
<label>Availability</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.communityEcologyAvailability"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g. Weekday afternoons ET"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Slack Handle</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.boardSlackHandle"
|
v-model="formData.communityEcologySlackHandle"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="@yourslackname"
|
placeholder="@yourslackname"
|
||||||
>
|
/>
|
||||||
<div class="field-help">
|
|
||||||
Shown on your board posts so other members can reach out.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="posts-header">
|
<div class="field">
|
||||||
<div class="posts-heading">Your Posts</div>
|
<label>Personal Message</label>
|
||||||
<NuxtLink to="/board" class="posts-new-link"
|
<textarea
|
||||||
>+ New Post</NuxtLink
|
v-model="formData.communityEcologyPersonalMessage"
|
||||||
>
|
rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Brief note shown alongside your Slack handle"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">
|
||||||
|
{{ formData.communityEcologyPersonalMessage?.length || 0 }} / 200
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="myPosts.length === 0" class="posts-empty">
|
|
||||||
No posts yet.
|
|
||||||
<NuxtLink to="/board" class="posts-empty-link">
|
|
||||||
Visit the Board
|
|
||||||
</NuxtLink>
|
|
||||||
to share what you're seeking or offering.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="posts-list">
|
|
||||||
<li v-for="post in myPosts" :key="post._id" class="post-item">
|
|
||||||
<div class="post-body">
|
|
||||||
<div class="post-title">{{ post.title }}</div>
|
|
||||||
<div class="post-excerpt">{{ postExcerpt(post) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="post-actions">
|
|
||||||
<NuxtLink to="/board" class="post-action">Edit</NuxtLink>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="post-action post-action-danger"
|
|
||||||
@click="handleDeletePost(post)"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
<PageSection divider="top">
|
<PageSection divider="top">
|
||||||
|
|
@ -231,44 +252,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
<PageSection divider="top">
|
|
||||||
<div class="section-label">Recent Activity</div>
|
|
||||||
|
|
||||||
<div v-if="activityLoading" class="activity-empty">
|
|
||||||
Loading activity…
|
|
||||||
</div>
|
|
||||||
<ul v-else-if="recentActivity.length" class="activity-list">
|
|
||||||
<li
|
|
||||||
v-for="entry in recentActivity"
|
|
||||||
:key="entry._id"
|
|
||||||
class="activity-item"
|
|
||||||
>
|
|
||||||
<div class="activity-time">
|
|
||||||
{{ formatActivityTime(entry.timestamp) }}
|
|
||||||
</div>
|
|
||||||
<div class="activity-text">
|
|
||||||
<template v-if="formatActivity(entry).link">
|
|
||||||
<span>{{
|
|
||||||
formatActivity(entry).text.split(
|
|
||||||
formatActivity(entry).linkText,
|
|
||||||
)[0]
|
|
||||||
}}</span>
|
|
||||||
<NuxtLink
|
|
||||||
:to="formatActivity(entry).link"
|
|
||||||
class="activity-link"
|
|
||||||
>
|
|
||||||
{{ formatActivity(entry).linkText }}
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<span v-else>{{ formatActivity(entry).text }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-else class="activity-empty">
|
|
||||||
Your activity will appear here as you use the Guild.
|
|
||||||
</div>
|
|
||||||
</PageSection>
|
|
||||||
</template>
|
</template>
|
||||||
</ColumnsLayout>
|
</ColumnsLayout>
|
||||||
|
|
||||||
|
|
@ -284,6 +267,12 @@
|
||||||
<button type="button" class="btn" @click="resetForm">
|
<button type="button" class="btn" @click="resetForm">
|
||||||
Reset Changes
|
Reset Changes
|
||||||
</button>
|
</button>
|
||||||
|
<span v-if="saveSuccess" class="save-msg save-msg-ok"
|
||||||
|
>Profile updated.</span
|
||||||
|
>
|
||||||
|
<span v-if="saveError" class="save-msg save-msg-err">{{
|
||||||
|
saveError
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -294,86 +283,32 @@
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
<TagSuggestModal
|
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
|
||||||
v-model:open="showTagSuggestModal"
|
|
||||||
:pool="tagSuggestPool"
|
|
||||||
/>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
||||||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
|
||||||
import { formatActivity } from "~/utils/activityText";
|
|
||||||
|
|
||||||
useSiteMeta({ title: "Profile", noindex: true });
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
middleware: 'auth',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal();
|
||||||
const { posts: myPosts, fetchPosts, deletePost } = useBoardPosts();
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
const availableGhosts = [
|
const availableGhosts = [
|
||||||
{
|
{ value: "disbelieving", label: "Disbelieving", image: "/ghosties/Ghost-Disbelieving.png" },
|
||||||
value: "disbelieving",
|
{ value: "double-take", label: "Double Take", image: "/ghosties/Ghost-Double-Take.png" },
|
||||||
label: "Disbelieving",
|
{ value: "exasperated", label: "Exasperated", image: "/ghosties/Ghost-Exasperated.png" },
|
||||||
image: "/ghosties/Ghost-Disbelieving.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "double-take",
|
|
||||||
label: "Double Take",
|
|
||||||
image: "/ghosties/Ghost-Double-Take.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "exasperated",
|
|
||||||
label: "Exasperated",
|
|
||||||
image: "/ghosties/Ghost-Exasperated.png",
|
|
||||||
},
|
|
||||||
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
|
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
|
||||||
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
|
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
|
||||||
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
|
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compute current UTC offset for an IANA timezone (DST-aware).
|
|
||||||
const utcOffset = (tz) => {
|
|
||||||
try {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: tz,
|
|
||||||
timeZoneName: "longOffset",
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
|
|
||||||
// "GMT-05:00" → "UTC-05:00"; "GMT" → "UTC+00:00"
|
|
||||||
if (name === "GMT") return "UTC+00:00";
|
|
||||||
return name.replace("GMT", "UTC");
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include the saved timezone as a custom option if it's not in the curated list.
|
|
||||||
const timezoneItems = computed(() => {
|
|
||||||
const saved = formData.timeZone;
|
|
||||||
const list = TIMEZONE_OPTIONS.map((t) => {
|
|
||||||
const off = utcOffset(t.value);
|
|
||||||
return { ...t, label: off ? `${t.label} (${off})` : t.label };
|
|
||||||
});
|
|
||||||
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
|
|
||||||
const off = utcOffset(saved);
|
|
||||||
list.unshift({ label: off ? `${saved} (${off})` : saved, value: saved });
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
});
|
|
||||||
|
|
||||||
const notificationToggles = [
|
const notificationToggles = [
|
||||||
{
|
{ key: "events", label: "Event reminders", sub: "Get notified about upcoming events" },
|
||||||
key: "events",
|
{ key: "updates", label: "Community updates", sub: "New posts from members you follow" },
|
||||||
label: "Registration & cancellation emails",
|
|
||||||
sub: "Confirmation when you register for an event, and notice if it's cancelled",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const { data: tagsData } = await useFetch("/api/tags");
|
const { data: tagsData } = await useFetch("/api/tags");
|
||||||
|
|
@ -381,6 +316,9 @@ const { data: tagsData } = await useFetch("/api/tags");
|
||||||
const craftTags = computed(() =>
|
const craftTags = computed(() =>
|
||||||
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
|
(tagsData.value?.tags || []).filter((t) => t.pool === "craft"),
|
||||||
);
|
);
|
||||||
|
const cooperativeTags = computed(() =>
|
||||||
|
(tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"),
|
||||||
|
);
|
||||||
|
|
||||||
const showTagSuggestModal = ref(false);
|
const showTagSuggestModal = ref(false);
|
||||||
const tagSuggestPool = ref("");
|
const tagSuggestPool = ref("");
|
||||||
|
|
@ -400,48 +338,32 @@ const formData = reactive({
|
||||||
location: "",
|
location: "",
|
||||||
showInDirectory: true,
|
showInDirectory: true,
|
||||||
craftTags: [],
|
craftTags: [],
|
||||||
boardSlackHandle: "",
|
craftTagsPrivacy: "members",
|
||||||
|
communityEcologyTopics: [],
|
||||||
|
communityEcologyPrivacy: "members",
|
||||||
|
communityEcologyDetails: "",
|
||||||
|
communityEcologyOfferPeerSupport: false,
|
||||||
|
communityEcologyAvailability: "",
|
||||||
|
communityEcologySlackHandle: "",
|
||||||
|
communityEcologyPersonalMessage: "",
|
||||||
|
pronounsPrivacy: "members",
|
||||||
|
timeZonePrivacy: "members",
|
||||||
|
avatarPrivacy: "members",
|
||||||
|
studioPrivacy: "members",
|
||||||
|
bioPrivacy: "members",
|
||||||
|
locationPrivacy: "members",
|
||||||
notifications: {
|
notifications: {
|
||||||
events: true,
|
events: true,
|
||||||
|
updates: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const saveSuccess = ref(false);
|
||||||
|
const saveError = ref(null);
|
||||||
const initialData = ref(null);
|
const initialData = ref(null);
|
||||||
|
let saveSuccessTimer = null;
|
||||||
const recentActivity = ref([]);
|
|
||||||
const activityLoading = ref(false);
|
|
||||||
|
|
||||||
const formatActivityTime = (date) => {
|
|
||||||
const now = new Date();
|
|
||||||
const d = new Date(date);
|
|
||||||
const diff = Math.floor((now - d) / 1000);
|
|
||||||
if (diff < 60) return "just now";
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadRecentActivity = async () => {
|
|
||||||
activityLoading.value = true;
|
|
||||||
try {
|
|
||||||
const data = await $fetch("/api/members/me/activity", {
|
|
||||||
params: { limit: 5 },
|
|
||||||
});
|
|
||||||
recentActivity.value = data.entries || [];
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load activity:", err);
|
|
||||||
recentActivity.value = [];
|
|
||||||
} finally {
|
|
||||||
activityLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const memberId = computed(() => memberData.value?._id || memberData.value?.id);
|
const memberId = computed(() => memberData.value?._id || memberData.value?.id);
|
||||||
|
|
||||||
|
|
@ -465,36 +387,69 @@ const loadProfile = () => {
|
||||||
? [...memberData.value.craftTags]
|
? [...memberData.value.craftTags]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const board = memberData.value.board || {};
|
const ecology = memberData.value.communityEcology || {};
|
||||||
formData.boardSlackHandle = board.slackHandle || "";
|
formData.communityEcologyTopics = Array.isArray(ecology.topics) ? [...ecology.topics] : [];
|
||||||
|
formData.communityEcologyOfferPeerSupport = ecology.offerPeerSupport ?? false;
|
||||||
|
formData.communityEcologyAvailability = ecology.availability || "";
|
||||||
|
formData.communityEcologySlackHandle = ecology.slackHandle || "";
|
||||||
|
formData.communityEcologyPersonalMessage = ecology.personalMessage || "";
|
||||||
|
formData.communityEcologyDetails = ecology.details || "";
|
||||||
|
|
||||||
|
const privacy = memberData.value.privacy || {};
|
||||||
|
formData.pronounsPrivacy = privacy.pronouns || "members";
|
||||||
|
formData.timeZonePrivacy = privacy.timeZone || "members";
|
||||||
|
formData.avatarPrivacy = privacy.avatar || "members";
|
||||||
|
formData.studioPrivacy = privacy.studio || "members";
|
||||||
|
formData.bioPrivacy = privacy.bio || "members";
|
||||||
|
formData.locationPrivacy = privacy.location || "members";
|
||||||
|
formData.craftTagsPrivacy = privacy.craftTags || "members";
|
||||||
|
formData.communityEcologyPrivacy = privacy.communityEcology || "members";
|
||||||
|
|
||||||
const notifs = memberData.value.notifications || {};
|
const notifs = memberData.value.notifications || {};
|
||||||
formData.notifications.events = notifs.events ?? true;
|
formData.notifications.events = notifs.events ?? true;
|
||||||
|
formData.notifications.updates = notifs.updates ?? true;
|
||||||
|
|
||||||
initialData.value = JSON.parse(JSON.stringify(formData));
|
initialData.value = JSON.parse(JSON.stringify(formData));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
saveSuccess.value = false;
|
||||||
|
saveError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/members/profile", {
|
await Promise.all([
|
||||||
|
$fetch("/api/members/profile", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { ...formData },
|
body: { ...formData },
|
||||||
});
|
}),
|
||||||
|
$fetch("/api/members/me/community-ecology", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
topics: formData.communityEcologyTopics,
|
||||||
|
offerPeerSupport: formData.communityEcologyOfferPeerSupport,
|
||||||
|
availability: formData.communityEcologyAvailability,
|
||||||
|
slackHandle: formData.communityEcologySlackHandle,
|
||||||
|
personalMessage: formData.communityEcologyPersonalMessage,
|
||||||
|
details: formData.communityEcologyDetails,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
saveSuccess.value = true;
|
||||||
|
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
|
||||||
toast.add({ title: "Profile updated", color: "success" });
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
|
||||||
|
saveSuccessTimer = setTimeout(() => {
|
||||||
|
saveSuccess.value = false;
|
||||||
|
saveSuccessTimer = null;
|
||||||
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Profile save error:", error);
|
console.error("Profile save error:", error);
|
||||||
toast.add({
|
saveError.value =
|
||||||
title: "Update failed",
|
error.data?.message || "Failed to save profile. Please try again.";
|
||||||
description:
|
|
||||||
error.data?.statusMessage || error.data?.message || "Please try again.",
|
|
||||||
color: "error",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -502,6 +457,8 @@ const handleSubmit = async () => {
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
saveSuccess.value = false;
|
||||||
|
saveError.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -520,34 +477,11 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
|
||||||
if (memberId.value) {
|
|
||||||
await Promise.allSettled([
|
|
||||||
fetchPosts({ author: memberId.value }),
|
|
||||||
loadRecentActivity(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const postExcerpt = (post) => {
|
onBeforeUnmount(() => {
|
||||||
const text = post.seeking || post.offering || "";
|
if (saveSuccessTimer) clearTimeout(saveSuccessTimer);
|
||||||
if (text.length <= 80) return text;
|
});
|
||||||
return text.slice(0, 80).trimEnd() + "...";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePost = async (post) => {
|
|
||||||
if (!window.confirm(`Delete "${post.title}"?`)) return;
|
|
||||||
try {
|
|
||||||
await deletePost(post._id, { author: memberId.value });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete post error:", error);
|
|
||||||
toast.add({
|
|
||||||
title: "Failed to delete post",
|
|
||||||
description: error.data?.message || "Please try again.",
|
|
||||||
color: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Edit Profile - Ghost Guild",
|
title: "Edit Profile - Ghost Guild",
|
||||||
|
|
@ -569,7 +503,12 @@ useHead({
|
||||||
.row-2 {
|
.row-2 {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- PRIVACY TOGGLE SPACING ---- */
|
||||||
|
.field :deep(.priv) {
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- FIELD LABELS (distinct from .section-label) ---- */
|
/* ---- FIELD LABELS (distinct from .section-label) ---- */
|
||||||
|
|
@ -616,6 +555,7 @@ useHead({
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
|
@ -672,142 +612,13 @@ useHead({
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- FIELD HELPER TEXT ---- */
|
/* ---- CONNECTIONS PANEL ---- */
|
||||||
.field-help {
|
.connections-panel {
|
||||||
font-size: 11px;
|
border: 1px dashed var(--border);
|
||||||
color: var(--text-faint);
|
padding: 12px 14px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
margin-bottom: 12px;
|
||||||
|
background: var(--surface);
|
||||||
/* ---- YOUR POSTS LIST ---- */
|
|
||||||
.posts-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-heading {
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-new-link {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-new-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: 12px 0;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-empty-link {
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-title {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-excerpt {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-action {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--candle);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-action:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-action-danger {
|
|
||||||
color: var(--ember);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- RECENT ACTIVITY ---- */
|
|
||||||
.activity-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.activity-item {
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.activity-time {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.activity-text {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.activity-link {
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.activity-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.activity-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- DISABLED BUTTON ---- */
|
/* ---- DISABLED BUTTON ---- */
|
||||||
|
|
@ -819,7 +630,7 @@ useHead({
|
||||||
/* ---- SAVE BAR ---- */
|
/* ---- SAVE BAR ---- */
|
||||||
.save-bar {
|
.save-bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 24px 28px;
|
padding: 24px 28px 24px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -827,6 +638,19 @@ useHead({
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-msg {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-msg-ok {
|
||||||
|
color: var(--green, var(--candle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-msg-err {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.row-2 {
|
.row-2 {
|
||||||
|
|
@ -840,4 +664,3 @@ useHead({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@
|
||||||
<span class="profile-pronouns">{{ member.pronouns }}</span>
|
<span class="profile-pronouns">{{ member.pronouns }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-meta">
|
<div class="profile-meta">
|
||||||
<CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[member.circle]" />
|
<span v-if="member.circle" class="badge" :class="member.circle">
|
||||||
|
{{ circleLabels[member.circle] }}
|
||||||
|
</span>
|
||||||
<template v-if="member.studio">
|
<template v-if="member.studio">
|
||||||
<span class="meta-sep">·</span>
|
<span class="meta-sep">·</span>
|
||||||
<span class="profile-studio">{{ member.studio }}</span>
|
<span class="profile-studio">{{ member.studio }}</span>
|
||||||
|
|
@ -106,10 +108,15 @@
|
||||||
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Craft Tags -->
|
<!-- Two-column: Craft Tags + Community Ecology -->
|
||||||
<div v-if="craftTagsDisplay.length > 0" class="profile-section">
|
<div
|
||||||
|
v-if="craftTagsDisplay.length > 0 || ecologyTopics.length > 0 || member.communityEcology?.details"
|
||||||
|
class="profile-two-col"
|
||||||
|
>
|
||||||
|
<!-- Left: What I Do -->
|
||||||
|
<div class="profile-section">
|
||||||
<div class="section-label">What I Do</div>
|
<div class="section-label">What I Do</div>
|
||||||
<div class="tag-list">
|
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="tag in craftTagsDisplay"
|
v-for="tag in craftTagsDisplay"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
|
|
@ -118,27 +125,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Board Posts -->
|
<!-- Right: Community Ecology -->
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<div class="section-label">Board Posts</div>
|
<div class="section-label">Community Ecology</div>
|
||||||
<p v-if="memberPosts.length === 0" class="profile-detail posts-empty">
|
<div v-if="ecologyTopics.length > 0" class="tag-list">
|
||||||
No posts yet.
|
|
||||||
</p>
|
|
||||||
<ul v-else class="posts-list">
|
|
||||||
<li v-for="post in memberPosts" :key="post._id" class="post-item">
|
|
||||||
<NuxtLink to="/board" class="post-link">
|
|
||||||
<div class="post-title">{{ post.title }}</div>
|
|
||||||
<div class="post-excerpt">{{ postExcerpt(post) }}</div>
|
|
||||||
<div v-if="post.tags && post.tags.length" class="tag-list post-tags">
|
|
||||||
<span
|
<span
|
||||||
v-for="tag in post.tags"
|
v-for="topic in ecologyTopics"
|
||||||
:key="tag"
|
:key="topic.tagSlug"
|
||||||
class="tag-pill"
|
class="tag-pill connection-pill"
|
||||||
>{{ tagLabel('cooperative', tag) }}</span>
|
>
|
||||||
|
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
|
||||||
|
{{ tagLabel('cooperative', topic.tagSlug) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="member.communityEcology?.details" class="profile-detail connection-details">
|
||||||
|
{{ member.communityEcology.details }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer Support -->
|
||||||
|
<div v-if="member.communityEcology?.offerPeerSupport" class="profile-section">
|
||||||
|
<div class="section-label">Peer Support</div>
|
||||||
|
<div class="dashed-box no-hover">
|
||||||
|
<p v-if="member.communityEcology?.personalMessage" class="profile-detail">
|
||||||
|
{{ member.communityEcology.personalMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="member.communityEcology?.availability" class="profile-detail peer-availability">
|
||||||
|
{{ member.communityEcology.availability }}
|
||||||
|
</p>
|
||||||
|
<p v-if="member.communityEcology?.slackHandle" class="profile-detail peer-availability">
|
||||||
|
Reach out on Slack: <span class="slack-handle">@{{ member.communityEcology.slackHandle }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
|
|
@ -181,8 +200,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({ middleware: ["members-auth"] });
|
|
||||||
|
|
||||||
import { formatActivity } from '~/utils/activityText'
|
import { formatActivity } from '~/utils/activityText'
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
@ -198,6 +215,15 @@ const circleLabels = {
|
||||||
practitioner: "Practitioner",
|
practitioner: "Practitioner",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// State display text mapping
|
||||||
|
const stateLabels = {
|
||||||
|
help: "Can help",
|
||||||
|
interested: "Interested",
|
||||||
|
seeking: "Need help",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateLabel = (state) => stateLabels[state] || state || "";
|
||||||
|
|
||||||
const getInitials = (name) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return "?";
|
if (!name) return "?";
|
||||||
return name
|
return name
|
||||||
|
|
@ -246,18 +272,9 @@ const tagLabel = (pool, slug) => {
|
||||||
|
|
||||||
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
|
const craftTagsDisplay = computed(() => member.value?.craftTags || []);
|
||||||
|
|
||||||
// Board posts authored by this member
|
const ecologyTopics = computed(
|
||||||
const { data: postsData } = useFetch(`/api/board/posts`, {
|
() => member.value?.communityEcology?.topics || [],
|
||||||
params: { author: id },
|
);
|
||||||
default: () => ({ posts: [] }),
|
|
||||||
})
|
|
||||||
const memberPosts = computed(() => postsData.value?.posts || [])
|
|
||||||
|
|
||||||
const postExcerpt = (post) => {
|
|
||||||
const text = post.seeking || post.offering || "";
|
|
||||||
if (text.length <= 80) return text;
|
|
||||||
return text.slice(0, 80).trimEnd() + "...";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Whether the member has any social links (for hero layout)
|
// Whether the member has any social links (for hero layout)
|
||||||
const hasSocialLinks = computed(() =>
|
const hasSocialLinks = computed(() =>
|
||||||
|
|
@ -276,10 +293,14 @@ onUnmounted(() => {
|
||||||
pageBreadcrumbTitle.value = "";
|
pageBreadcrumbTitle.value = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
useSiteMeta(() => ({
|
// Page head
|
||||||
title: member.value ? member.value.name : "Member Profile",
|
useHead({
|
||||||
noindex: true,
|
title: computed(() =>
|
||||||
}));
|
member.value
|
||||||
|
? `${member.value.name} — Ghost Guild`
|
||||||
|
: "Member Profile — Ghost Guild",
|
||||||
|
),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -342,6 +363,7 @@ useSiteMeta(() => ({
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -366,7 +388,7 @@ useSiteMeta(() => ({
|
||||||
}
|
}
|
||||||
.profile-name {
|
.profile-name {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 36px;
|
font-size: 42px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -446,7 +468,6 @@ useSiteMeta(() => ({
|
||||||
/* Bio: parch (inverted) block */
|
/* Bio: parch (inverted) block */
|
||||||
.profile-section--parch {
|
.profile-section--parch {
|
||||||
background: var(--parch);
|
background: var(--parch);
|
||||||
border-bottom-color: var(--parch-border);
|
|
||||||
}
|
}
|
||||||
.profile-section--parch .section-label {
|
.profile-section--parch .section-label {
|
||||||
color: var(--parch-text-dim);
|
color: var(--parch-text-dim);
|
||||||
|
|
@ -455,7 +476,7 @@ useSiteMeta(() => ({
|
||||||
color: var(--parch-text);
|
color: var(--parch-text);
|
||||||
}
|
}
|
||||||
.profile-section--parch .profile-bio :deep(a) {
|
.profile-section--parch .profile-bio :deep(a) {
|
||||||
color: var(--parch-accent);
|
color: var(--candle-faint);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
@ -483,6 +504,22 @@ useSiteMeta(() => ({
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ====================================================
|
||||||
|
TWO-COLUMN: Craft Tags + Community Ecology
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
|
.profile-two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.profile-two-col .profile-section {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.profile-two-col .profile-section:first-child {
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
SHARED SECTION ELEMENTS
|
SHARED SECTION ELEMENTS
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
@ -493,6 +530,9 @@ useSiteMeta(() => ({
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.connection-details {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tags */
|
/* Tags */
|
||||||
.tag-list {
|
.tag-list {
|
||||||
|
|
@ -508,47 +548,30 @@ useSiteMeta(() => ({
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.connection-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.connection-state {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
BOARD POSTS
|
PEER SUPPORT
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
||||||
.posts-empty {
|
.peer-availability {
|
||||||
color: var(--text-faint);
|
margin-top: 12px;
|
||||||
}
|
padding-top: 12px;
|
||||||
.posts-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.post-item {
|
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.post-item:last-child {
|
.slack-handle {
|
||||||
border-bottom: 1px dashed var(--border);
|
font-family: "Commit Mono", monospace;
|
||||||
}
|
color: var(--candle-dim);
|
||||||
.post-link {
|
|
||||||
display: block;
|
|
||||||
padding: 10px 0;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.post-link:hover .post-title {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
.post-title {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
.post-excerpt {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.post-tags {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
|
|
@ -640,6 +663,17 @@ useSiteMeta(() => ({
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
/* ColumnsLayout events-sidebar hides itself at ≤1024px */
|
||||||
|
.profile-two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.profile-two-col .profile-section:first-child {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profile-hero,
|
.profile-hero,
|
||||||
.profile-hero--with-links {
|
.profile-hero--with-links {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<PageShell title="Members">
|
<PageShell
|
||||||
|
title="Members"
|
||||||
|
:subtitle="`${totalCount} member${totalCount === 1 ? '' : 's'} across 3 circles`"
|
||||||
|
>
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<input
|
<input
|
||||||
|
|
@ -8,41 +11,49 @@
|
||||||
class="filter-search"
|
class="filter-search"
|
||||||
placeholder="Search members..."
|
placeholder="Search members..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
>
|
|
||||||
<USelectMenu
|
|
||||||
v-model="selectedCircle"
|
|
||||||
:items="circleOptions"
|
|
||||||
value-key="value"
|
|
||||||
:search-input="false"
|
|
||||||
class="zine-select circle-select"
|
|
||||||
:ui="{
|
|
||||||
content: 'tz-content',
|
|
||||||
item: 'tz-item',
|
|
||||||
}"
|
|
||||||
@update:model-value="loadMembers"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<select
|
||||||
v-if="craftTagOptions.length > 0"
|
v-model="selectedCircle"
|
||||||
type="button"
|
class="filter-select"
|
||||||
class="drawer-btn"
|
@change="loadMembers"
|
||||||
@click="showTagsDrawer = !showTagsDrawer"
|
>
|
||||||
|
<option
|
||||||
|
v-for="opt in circleOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="filter-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="peerSupportFilter === 'true'"
|
||||||
|
@change="togglePeerSupport"
|
||||||
|
/>
|
||||||
|
Offering support
|
||||||
|
</label>
|
||||||
|
<span class="filter-count"
|
||||||
|
>Showing {{ totalCount }} member{{ totalCount === 1 ? "" : "s" }}</span
|
||||||
>
|
>
|
||||||
Tags...
|
|
||||||
<span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags Drawer -->
|
<!-- Craft Tags Filter -->
|
||||||
<div v-if="showTagsDrawer && craftTagOptions.length > 0" class="tags-drawer">
|
<div
|
||||||
<div class="skills-bar">
|
v-if="craftTagOptions.length > 0"
|
||||||
|
class="skills-bar"
|
||||||
|
>
|
||||||
<span class="tag-label">Craft:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<button
|
<button
|
||||||
v-for="tag in visibleTagOptions"
|
v-for="tag in craftTagOptions.slice(
|
||||||
|
0,
|
||||||
|
showAllCraftTags ? undefined : 10,
|
||||||
|
)"
|
||||||
:key="tag.slug"
|
:key="tag.slug"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
:class="{ active: directoryCraftTags.includes(tag.slug) }"
|
:class="{ active: selectedCraftTags.includes(tag.slug) }"
|
||||||
@click="toggleDirectoryCraftTag(tag.slug)"
|
@click="toggleCraftTag(tag.slug)"
|
||||||
>
|
>
|
||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -50,11 +61,43 @@
|
||||||
v-if="craftTagOptions.length > 10"
|
v-if="craftTagOptions.length > 10"
|
||||||
type="button"
|
type="button"
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllTags = !showAllTags"
|
@click="showAllCraftTags = !showAllCraftTags"
|
||||||
>
|
>
|
||||||
{{ showAllTags ? 'Show less' : `+${craftTagOptions.length - 10} more` }}
|
{{
|
||||||
|
showAllCraftTags ? "Show less" : `+${craftTagOptions.length - 10} more`
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Tags Filter -->
|
||||||
|
<div
|
||||||
|
v-if="connectionTagOptions.length > 0"
|
||||||
|
class="skills-bar"
|
||||||
|
>
|
||||||
|
<span class="tag-label">Topics:</span>
|
||||||
|
<button
|
||||||
|
v-for="tag in connectionTagOptions.slice(
|
||||||
|
0,
|
||||||
|
showAllConnectionTags ? undefined : 10,
|
||||||
|
)"
|
||||||
|
:key="tag.slug"
|
||||||
|
type="button"
|
||||||
|
class="skill-tag"
|
||||||
|
:class="{ active: selectedConnectionTags.includes(tag.slug) }"
|
||||||
|
@click="toggleConnectionTag(tag.slug)"
|
||||||
|
>
|
||||||
|
{{ tag.label }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="connectionTagOptions.length > 10"
|
||||||
|
type="button"
|
||||||
|
class="more-btn"
|
||||||
|
@click="showAllConnectionTags = !showAllConnectionTags"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
showAllConnectionTags ? "Show less" : `+${connectionTagOptions.length - 10} more`
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Filters -->
|
<!-- Active Filters -->
|
||||||
|
|
@ -64,12 +107,23 @@
|
||||||
{{ circleLabels[selectedCircle] }}
|
{{ circleLabels[selectedCircle] }}
|
||||||
<button type="button" @click="clearCircleFilter">×</button>
|
<button type="button" @click="clearCircleFilter">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag">
|
<span
|
||||||
|
v-if="peerSupportFilter && peerSupportFilter !== 'all'"
|
||||||
|
class="af-tag"
|
||||||
|
>
|
||||||
|
Offering Support
|
||||||
|
<button type="button" @click="clearPeerSupportFilter">×</button>
|
||||||
|
</span>
|
||||||
|
<span v-for="slug in selectedCraftTags" :key="'c-' + slug" class="af-tag">
|
||||||
{{ craftTagLabel(slug) }}
|
{{ craftTagLabel(slug) }}
|
||||||
<button type="button" @click="toggleDirectoryCraftTag(slug)">×</button>
|
<button type="button" @click="toggleCraftTag(slug)">×</button>
|
||||||
|
</span>
|
||||||
|
<span v-for="slug in selectedConnectionTags" :key="'t-' + slug" class="af-tag">
|
||||||
|
{{ connectionTagLabel(slug) }}
|
||||||
|
<button type="button" @click="toggleConnectionTag(slug)">×</button>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="hasActiveFilters"
|
v-if="selectedCraftTags.length > 0 || selectedConnectionTags.length > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="clear-all-btn"
|
class="clear-all-btn"
|
||||||
@click="clearAllFilters"
|
@click="clearAllFilters"
|
||||||
|
|
@ -78,30 +132,37 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DIRECTORY -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading && !members.length" class="loading-state">
|
<div v-if="loading && !members.length" class="loading-state">
|
||||||
<p>Loading members...</p>
|
<p>Loading members...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Member Grid -->
|
||||||
<div v-else-if="members.length > 0" class="member-grid">
|
<div v-else-if="members.length > 0" class="member-grid">
|
||||||
<div v-for="member in members" :key="member._id" class="member-card">
|
<div v-for="member in members" :key="member._id" class="member-card">
|
||||||
<div class="mc-head">
|
<div class="mc-head">
|
||||||
<div class="mc-avatar">
|
<div class="mc-avatar">
|
||||||
<img
|
<img
|
||||||
v-if="member.avatar"
|
v-if="member.avatar"
|
||||||
:src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`"
|
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
||||||
:alt="member.name"
|
:alt="member.name"
|
||||||
class="mc-avatar-img"
|
class="mc-avatar-img"
|
||||||
>
|
/>
|
||||||
<span v-else>{{ getInitials(member.name) }}</span>
|
<span v-else>{{ getInitials(member.name) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-info">
|
<div class="mc-info">
|
||||||
<div class="mc-name">
|
<div class="mc-name">
|
||||||
<NuxtLink :to="`/members/${member._id}`">{{ member.name }}</NuxtLink>
|
<NuxtLink :to="`/members/${member._id}`">{{
|
||||||
<span v-if="member.pronouns" class="mc-pronouns">{{ member.pronouns }}</span>
|
member.name
|
||||||
|
}}</NuxtLink>
|
||||||
|
<span v-if="member.pronouns" class="mc-pronouns">{{
|
||||||
|
member.pronouns
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-meta">
|
<div class="mc-meta">
|
||||||
<CircleBadge :circle="member.circle" :label="circleLabels[member.circle]" />
|
<span class="badge" :class="member.circle">{{
|
||||||
|
circleLabels[member.circle]
|
||||||
|
}}</span>
|
||||||
<template v-if="member.studio">
|
<template v-if="member.studio">
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
{{ member.studio }}
|
{{ member.studio }}
|
||||||
|
|
@ -114,176 +175,322 @@
|
||||||
v-if="member.bio"
|
v-if="member.bio"
|
||||||
class="mc-bio"
|
class="mc-bio"
|
||||||
v-html="renderMarkdown(member.bio)"
|
v-html="renderMarkdown(member.bio)"
|
||||||
/>
|
></div>
|
||||||
|
|
||||||
<div v-if="member.craftTags?.length > 0" class="mc-tags">
|
<div v-if="member.location || member.timeZone" class="mc-location">
|
||||||
|
{{
|
||||||
|
[member.location, member.timeZone].filter(Boolean).join(" \u00b7 ")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="member.craftTags?.length > 0"
|
||||||
|
class="mc-tags"
|
||||||
|
>
|
||||||
<span class="tag-label">Craft:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.craftTags.slice(0, 3)"
|
v-for="tag in member.craftTags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
>{{ craftTagLabel(tag) }}</span>
|
>{{ craftTagLabel(tag) }}</span
|
||||||
<span v-if="member.craftTags.length > 3" class="tag-overflow">+{{ member.craftTags.length - 3 }} more</span>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="member.communityEcology?.topics?.length > 0"
|
||||||
|
class="mc-looking"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="topic in member.communityEcology.topics"
|
||||||
|
:key="topic.tagSlug"
|
||||||
|
class="connection-topic"
|
||||||
|
>
|
||||||
|
<span class="connection-state">{{ stateLabel(topic.state) }}</span>
|
||||||
|
{{ connectionTagLabel(topic.tagSlug) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer support session link -->
|
||||||
|
<a
|
||||||
|
v-if="showPeerSupport(member)"
|
||||||
|
href="#"
|
||||||
|
class="mc-session"
|
||||||
|
@click.prevent="openSlackDM(member)"
|
||||||
|
>
|
||||||
|
Book session
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p class="empty-title">No members found</p>
|
<p class="empty-title">No members found</p>
|
||||||
<p class="empty-sub">Try adjusting your search or filters</p>
|
<p class="empty-sub">Try adjusting your search or filters</p>
|
||||||
<button type="button" class="btn" @click="clearAllFilters">Clear Filters</button>
|
<button type="button" class="btn" @click="clearAllFilters">
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more / count -->
|
||||||
<div v-if="members.length > 0" class="load-more">
|
<div v-if="members.length > 0" class="load-more">
|
||||||
<span>Showing {{ members.length }} of {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }}</span>
|
<span
|
||||||
|
>Showing {{ members.length }} of {{ totalCount }} member{{
|
||||||
|
totalCount === 1 ? "" : "s"
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not Authenticated Notice -->
|
||||||
|
<div v-if="!isAuthenticated && members.length > 0" class="auth-notice">
|
||||||
|
<p>Some member information is visible to members only.</p>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
@click="
|
||||||
|
openLoginModal({
|
||||||
|
title: 'Sign in to see more',
|
||||||
|
description: 'Log in to view full member profiles',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
<NuxtLink to="/join" class="btn btn-primary">Join Ghost Guild</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({ middleware: ['members-auth'] })
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { openLoginModal } = useLoginModal();
|
||||||
|
const { render: renderMarkdown } = useMarkdown();
|
||||||
|
|
||||||
const { render: renderMarkdown } = useMarkdown()
|
// State
|
||||||
|
const members = ref([]);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const loading = ref(true);
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const selectedCircle = ref("all");
|
||||||
|
const peerSupportFilter = ref("all");
|
||||||
|
const selectedCraftTags = ref([]);
|
||||||
|
const selectedConnectionTags = ref([]);
|
||||||
|
const showAllCraftTags = ref(false);
|
||||||
|
const showAllConnectionTags = ref(false);
|
||||||
|
|
||||||
// ---- Directory state ----
|
// Tag options from API
|
||||||
const members = ref([])
|
const craftTagOptions = ref([]);
|
||||||
const totalCount = ref(0)
|
const connectionTagOptions = ref([]);
|
||||||
const loading = ref(true)
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedCircle = ref('all')
|
|
||||||
const directoryCraftTags = ref([])
|
|
||||||
const craftTagOptions = ref([])
|
|
||||||
const showAllTags = ref(false)
|
|
||||||
const showTagsDrawer = ref(false)
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
// State display text mapping
|
||||||
|
const stateLabels = {
|
||||||
|
help: "Can help",
|
||||||
|
interested: "Interested",
|
||||||
|
seeking: "Need help",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateLabel = (state) => stateLabels[state] || state || "";
|
||||||
|
|
||||||
|
// Circle options
|
||||||
const circleOptions = [
|
const circleOptions = [
|
||||||
{ label: 'All Circles', value: 'all' },
|
{ label: "All Circles", value: "all" },
|
||||||
{ label: 'Community', value: 'community' },
|
{ label: "Community", value: "community" },
|
||||||
{ label: 'Founder', value: 'founder' },
|
{ label: "Founder", value: "founder" },
|
||||||
{ label: 'Practitioner', value: 'practitioner' },
|
{ label: "Practitioner", value: "practitioner" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const circleLabels = {
|
const circleLabels = {
|
||||||
community: 'Community',
|
community: "Community",
|
||||||
founder: 'Founder',
|
founder: "Founder",
|
||||||
practitioner: 'Practitioner',
|
practitioner: "Practitioner",
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Tag slug-to-label lookups
|
||||||
const craftTagLabel = (slug) => {
|
const craftTagLabel = (slug) => {
|
||||||
const found = craftTagOptions.value.find((t) => t.slug === slug)
|
const found = craftTagOptions.value.find((t) => t.slug === slug);
|
||||||
return found ? found.label : slug
|
return found ? found.label : slug;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const connectionTagLabel = (slug) => {
|
||||||
|
const found = connectionTagOptions.value.find((t) => t.slug === slug);
|
||||||
|
return found ? found.label : slug;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPeerSupport = (member) =>
|
||||||
|
!!member.communityEcology?.offerPeerSupport;
|
||||||
|
|
||||||
|
// Computed: has active filters
|
||||||
|
const hasActiveFilters = computed(() => {
|
||||||
|
return (
|
||||||
|
(selectedCircle.value && selectedCircle.value !== "all") ||
|
||||||
|
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
||||||
|
selectedCraftTags.value.length > 0 ||
|
||||||
|
selectedConnectionTags.value.length > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get initials from name
|
||||||
const getInitials = (name) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return '?'
|
if (!name) return "?";
|
||||||
return name
|
return name
|
||||||
.split(' ')
|
.split(" ")
|
||||||
.map((w) => w[0])
|
.map((w) => w[0])
|
||||||
.join('')
|
.join("")
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.slice(0, 2)
|
.slice(0, 2);
|
||||||
}
|
};
|
||||||
|
|
||||||
const capitalize = (str) => {
|
// Load members
|
||||||
if (!str) return ''
|
|
||||||
return str
|
|
||||||
.split('-')
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
||||||
.join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Computed ----
|
|
||||||
const visibleTagOptions = computed(() =>
|
|
||||||
showAllTags.value ? craftTagOptions.value : craftTagOptions.value.slice(0, 10)
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasActiveFilters = computed(() =>
|
|
||||||
(selectedCircle.value && selectedCircle.value !== 'all') ||
|
|
||||||
directoryCraftTags.value.length > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Load members ----
|
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {}
|
const params = {};
|
||||||
if (searchQuery.value) params.search = searchQuery.value
|
if (searchQuery.value) params.search = searchQuery.value;
|
||||||
if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value
|
if (selectedCircle.value && selectedCircle.value !== "all")
|
||||||
if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0]
|
params.circle = selectedCircle.value;
|
||||||
|
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
|
||||||
|
params.peerSupport = peerSupportFilter.value;
|
||||||
|
if (selectedCraftTags.value.length === 1)
|
||||||
|
params.craftTag = selectedCraftTags.value[0];
|
||||||
|
if (selectedConnectionTags.value.length === 1)
|
||||||
|
params.connectionTag = selectedConnectionTags.value[0];
|
||||||
|
|
||||||
const data = await $fetch('/api/members/directory', { params })
|
const data = await $fetch("/api/members/directory", { params });
|
||||||
members.value = data.members || []
|
|
||||||
totalCount.value = data.totalCount || 0
|
|
||||||
|
|
||||||
|
members.value = data.members || [];
|
||||||
|
totalCount.value = data.totalCount || 0;
|
||||||
|
|
||||||
|
// Update tag options from API response (only on initial load or if empty)
|
||||||
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
|
if (data.filters?.craftTags && craftTagOptions.value.length === 0) {
|
||||||
craftTagOptions.value = data.filters.craftTags
|
craftTagOptions.value = data.filters.craftTags;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.filters?.cooperativeTags &&
|
||||||
|
connectionTagOptions.value.length === 0
|
||||||
|
) {
|
||||||
|
connectionTagOptions.value = data.filters.cooperativeTags;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load members:', error)
|
console.error("Failed to load members:", error);
|
||||||
members.value = []
|
members.value = [];
|
||||||
totalCount.value = 0
|
totalCount.value = 0;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// ---- Load tag options ----
|
// Fetch tag options from API on mount
|
||||||
const loadTagOptions = async () => {
|
const loadTagOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await $fetch('/api/tags')
|
const data = await $fetch("/api/tags");
|
||||||
const tags = data.tags || []
|
const tags = data.tags || [];
|
||||||
craftTagOptions.value = tags
|
craftTagOptions.value = tags
|
||||||
.filter((t) => t.pool === 'craft')
|
.filter((t) => t.pool === "craft")
|
||||||
.map((t) => ({ slug: t.slug, label: t.label }))
|
.map((t) => ({ slug: t.slug, label: t.label }));
|
||||||
|
connectionTagOptions.value = tags
|
||||||
|
.filter((t) => t.pool === "cooperative")
|
||||||
|
.map((t) => ({ slug: t.slug, label: t.label }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tags:', error)
|
console.error("Failed to load tags:", error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// ---- Filter helpers ----
|
// Toggle peer support checkbox
|
||||||
let searchTimeout
|
const togglePeerSupport = (e) => {
|
||||||
|
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
||||||
|
loadMembers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
let searchTimeout;
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
loadMembers()
|
loadMembers();
|
||||||
}, 300)
|
}, 300);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleDirectoryCraftTag = (slug) => {
|
// Toggle craft tag filter
|
||||||
const idx = directoryCraftTags.value.indexOf(slug)
|
const toggleCraftTag = (slug) => {
|
||||||
if (idx > -1) {
|
const index = selectedCraftTags.value.indexOf(slug);
|
||||||
directoryCraftTags.value.splice(idx, 1)
|
if (index > -1) {
|
||||||
|
selectedCraftTags.value.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
directoryCraftTags.value = [slug]
|
selectedCraftTags.value = [slug]; // single-select for API query param
|
||||||
}
|
}
|
||||||
loadMembers()
|
loadMembers();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Toggle connection tag filter
|
||||||
|
const toggleConnectionTag = (slug) => {
|
||||||
|
const index = selectedConnectionTags.value.indexOf(slug);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedConnectionTags.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedConnectionTags.value = [slug]; // single-select for API query param
|
||||||
|
}
|
||||||
|
loadMembers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear filters
|
||||||
const clearCircleFilter = () => {
|
const clearCircleFilter = () => {
|
||||||
selectedCircle.value = 'all'
|
selectedCircle.value = "all";
|
||||||
loadMembers()
|
loadMembers();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const clearPeerSupportFilter = () => {
|
||||||
|
peerSupportFilter.value = "all";
|
||||||
|
loadMembers();
|
||||||
|
};
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
searchQuery.value = ''
|
searchQuery.value = "";
|
||||||
selectedCircle.value = 'all'
|
selectedCircle.value = "all";
|
||||||
directoryCraftTags.value = []
|
peerSupportFilter.value = "all";
|
||||||
showTagsDrawer.value = false
|
selectedCraftTags.value = [];
|
||||||
loadMembers()
|
selectedConnectionTags.value = [];
|
||||||
}
|
loadMembers();
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
// Slack DM functionality
|
||||||
clearTimeout(searchTimeout)
|
const openSlackDM = async (member) => {
|
||||||
})
|
const username = member.communityEcology?.slackHandle || member.name;
|
||||||
|
|
||||||
useSiteMeta({ title: 'Member Directory', noindex: true })
|
try {
|
||||||
|
await navigator.clipboard.writeText(username);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Could not copy to clipboard:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Init ----
|
alert(
|
||||||
|
`Opening Slack...\n\nSearch for: ${username}\n\n(Username copied to clipboard)`,
|
||||||
|
);
|
||||||
|
window.open("https://gammaspace.slack.com", "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load on mount and handle query params
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadTagOptions()
|
const route = useRoute();
|
||||||
await loadMembers()
|
if (route.query.peerSupport === "true") {
|
||||||
})
|
peerSupportFilter.value = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTagOptions();
|
||||||
|
loadMembers();
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Member Directory - Ghost Guild",
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content:
|
||||||
|
"Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -315,47 +522,48 @@ onMounted(async () => {
|
||||||
border-color: var(--candle-faint);
|
border-color: var(--candle-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Constrain the circle USelectMenu button width so it doesn't stretch. */
|
.filter-select {
|
||||||
:deep(.circle-select) {
|
|
||||||
width: auto !important;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- TAGS DRAWER ---- */
|
|
||||||
.drawer-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
padding: 5px 10px;
|
||||||
background: none;
|
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 3px 10px;
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 26px;
|
||||||
|
}
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: var(--candle-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.15s;
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.drawer-btn:hover {
|
.filter-toggle input {
|
||||||
border-color: var(--candle-faint);
|
accent-color: var(--candle-dim);
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-count-badge {
|
.filter-count {
|
||||||
font-size: 9px;
|
margin-left: auto;
|
||||||
background: var(--candle-faint);
|
font-size: 11px;
|
||||||
color: var(--candle);
|
color: var(--text-faint);
|
||||||
padding: 0 4px;
|
|
||||||
min-width: 14px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-drawer {
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- SKILLS / TOPICS BAR ---- */
|
||||||
.skills-bar {
|
.skills-bar {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -484,7 +692,6 @@ onMounted(async () => {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- DIRECTORY CARD ---- */
|
|
||||||
.mc-head {
|
.mc-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -495,7 +702,8 @@ onMounted(async () => {
|
||||||
.mc-avatar {
|
.mc-avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: transparent;
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -555,7 +763,7 @@ onMounted(async () => {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -563,6 +771,12 @@ onMounted(async () => {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-location {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.mc-tags {
|
.mc-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
@ -584,11 +798,52 @@ onMounted(async () => {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-overflow {
|
.mc-looking {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-topic {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-session {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px dashed var(--candle-faint);
|
||||||
|
color: var(--candle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mc-session:hover {
|
||||||
|
border-color: var(--candle);
|
||||||
|
background: rgba(122, 90, 16, 0.06);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- LOAD MORE ---- */
|
/* ---- LOAD MORE ---- */
|
||||||
.load-more {
|
.load-more {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
|
|
@ -616,8 +871,23 @@ onMounted(async () => {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.empty-sub a {
|
|
||||||
color: var(--candle);
|
/* ---- AUTH NOTICE ---- */
|
||||||
|
.auth-notice {
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 24px;
|
||||||
|
border: 1px dashed var(--candle-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-notice p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
|
|
@ -636,7 +906,7 @@ onMounted(async () => {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
}
|
}
|
||||||
.drawer-btn {
|
.filter-count {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
.skills-bar {
|
.skills-bar {
|
||||||
|
|
@ -648,18 +918,8 @@ onMounted(async () => {
|
||||||
.member-card {
|
.member-card {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
}
|
.auth-notice {
|
||||||
|
margin: 16px;
|
||||||
@media (max-width: 375px) {
|
|
||||||
.filter-bar {
|
|
||||||
padding: 12px 16px;
|
|
||||||
gap: 8px;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.filter-search {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<template>
|
|
||||||
<PageShell :title="policy.title" subtitle="Ghost Guild Policy">
|
|
||||||
<div class="policy-prose">
|
|
||||||
<p class="policy-status">This policy is being finalized.</p>
|
|
||||||
<p>
|
|
||||||
{{ policy.description }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The full text will be published here ahead of launch. In the meantime,
|
|
||||||
the expectations that apply are summarized in our
|
|
||||||
<NuxtLink to="/community-guidelines">Community Guidelines</NuxtLink>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Questions? Contact the Membership Committee at
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// Note: /policies/code-of-conduct and /policies/conflict-resolution are
|
|
||||||
// handled by routeRules in nuxt.config.ts and redirect to external Obsidian
|
|
||||||
// pages. /policies/privacy and /policies/terms have dedicated pages
|
|
||||||
// (privacy.vue, terms.vue) that take precedence over this dynamic route.
|
|
||||||
const POLICIES = {
|
|
||||||
'by-laws': {
|
|
||||||
title: 'By-Laws',
|
|
||||||
description:
|
|
||||||
"Ghost Guild's governing by-laws, including membership classes, voting rights, and the structure of the Membership Committee.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const slug = String(route.params.slug || '')
|
|
||||||
const policy = POLICIES[slug]
|
|
||||||
|
|
||||||
if (!policy) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
useSiteMeta({
|
|
||||||
title: policy.title,
|
|
||||||
description: policy.description,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.policy-prose {
|
|
||||||
max-width: 640px;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-status {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--candle);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-prose p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-prose a {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.policy-prose {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
<template>
|
|
||||||
<PageShell title="Privacy Policy" subtitle="How Ghost Guild handles your data">
|
|
||||||
<div class="policy-prose">
|
|
||||||
<p class="policy-updated">Last updated: April 18, 2026</p>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<p>
|
|
||||||
Ghost Guild is a program of Baby Ghosts, a Canadian non-profit. This
|
|
||||||
policy explains what information we collect when you use
|
|
||||||
ghostguild.org and wiki.ghostguild.org, what we do with it, and what
|
|
||||||
choices you have.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We treat your data as something you've trusted us with, not something
|
|
||||||
we own. If anything here is unclear or feels off, email us at
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>What we collect</h2>
|
|
||||||
<p>
|
|
||||||
<strong>When you apply for membership:</strong> your name, pronouns,
|
|
||||||
location, email, the answers you give in your application, and which
|
|
||||||
membership circle you're applying for.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>When you build a profile:</strong> anything you choose to add
|
|
||||||
(bio, studio affiliation, skills, social links, availability for peer
|
|
||||||
support, Slack handle). Your profile is visible to other Ghost Guild
|
|
||||||
members. It isn't made public or visible to search engines. If you
|
|
||||||
don't want something seen by other members, don't put it in your
|
|
||||||
profile.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>When you contribute to the wiki:</strong> your edits,
|
|
||||||
comments, and authorship are recorded so members can see who wrote
|
|
||||||
what and so we can roll back if needed.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>When you post on the bulletin board:</strong> your posts
|
|
||||||
(needs you have, offers you can make), your name as the poster, and
|
|
||||||
the timestamps. The actual connecting between members happens in
|
|
||||||
Slack, not on our platform.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>When you pay dues:</strong> payment is handled by Helcim. We
|
|
||||||
see the amount, date, and that the payment came from you. We don't
|
|
||||||
see or store card numbers or banking details.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>When you get emails from us:</strong> Resend delivers them.
|
|
||||||
They process your email address and basic delivery metadata.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>For site analytics:</strong> we use Plausible. Plausible
|
|
||||||
doesn't use tracking cookies, doesn't store IP addresses, and doesn't
|
|
||||||
follow you across sites. It tells us aggregate things like which
|
|
||||||
pages get visited and roughly how many people use the site. It
|
|
||||||
doesn't tell us who you are.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We don't use Google Analytics, advertising pixels, or any other
|
|
||||||
tracking. The site uses cookies needed to keep you logged in. That's
|
|
||||||
it.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Why we collect it</h2>
|
|
||||||
<p>
|
|
||||||
<strong>To run the membership program:</strong> review applications,
|
|
||||||
give you access, support your participation, send you things you've
|
|
||||||
signed up for.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>To help members find each other:</strong> making your profile
|
|
||||||
and bulletin board posts visible to the rest of the community.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>To report on impact:</strong> anonymized, aggregated numbers
|
|
||||||
for funders and the community (how many members, what topics come up
|
|
||||||
on the bulletin board, where members are based).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>To meet our obligations</strong> as a non-profit corporation
|
|
||||||
under Canadian law.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Who else sees it</h2>
|
|
||||||
<p>The services that store or process your data on our behalf:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Helcim, payment processing, Canada</li>
|
|
||||||
<li>Resend, transactional email, US</li>
|
|
||||||
<li>Hetzner, server hosting, Germany</li>
|
|
||||||
<li>Outline, wiki software running on our Hetzner server</li>
|
|
||||||
<li>Plausible, anonymous site analytics, EU</li>
|
|
||||||
<li>
|
|
||||||
Slack, if you accept an invitation to the shared Gamma Space
|
|
||||||
workspace
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
<strong>Other Ghost Guild members:</strong> they can see your profile
|
|
||||||
and any bulletin board posts you make. None of this is public or
|
|
||||||
visible to search engines.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Wiki contributions:</strong> anything you post on
|
|
||||||
wiki.ghostguild.org is part of a public knowledge commons (see the
|
|
||||||
<NuxtLink to="/policies/terms">Terms of Service</NuxtLink> for the
|
|
||||||
specifics) and may be visible to anyone on the internet.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Baby Ghosts staff and the Membership Committee:</strong> for
|
|
||||||
purposes related to running the program.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>What we don't do</h2>
|
|
||||||
<p>We don't sell your data.</p>
|
|
||||||
<p>We don't share it with third parties for marketing.</p>
|
|
||||||
<p>
|
|
||||||
We don't feed your messages, profile, applications, bulletin board
|
|
||||||
posts, or other community content into generative AI tools, and we
|
|
||||||
don't allow members to do that with each other's content either. This
|
|
||||||
is a community commitment, not just a policy line.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If we ever want to do something different than what's in this policy,
|
|
||||||
we'll ask first.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>How long we keep it</h2>
|
|
||||||
<p>
|
|
||||||
<strong>While you're a member:</strong> as long as you have an
|
|
||||||
account.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>After you leave:</strong> we keep basic account records for
|
|
||||||
one year for administrative reasons (renewals, returning members,
|
|
||||||
financial records we're required to keep). Your wiki contributions
|
|
||||||
remain in the commons under their license; that's how a commons works
|
|
||||||
(see <NuxtLink to="/policies/terms">Terms of Service</NuxtLink>).
|
|
||||||
Your bulletin board posts are removed when you close your account.
|
|
||||||
Aggregate impact data has no expiry and doesn't identify you.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Financial records:</strong> we keep what we have to keep
|
|
||||||
under tax law (currently six years).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You can ask us to delete your account and personal data at any time.
|
|
||||||
We'll do it unless we're legally required to retain something
|
|
||||||
specific, in which case we'll tell you what and why.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Your rights</h2>
|
|
||||||
<p>You can:</p>
|
|
||||||
<ul>
|
|
||||||
<li>See what we have about you</li>
|
|
||||||
<li>Correct anything that's wrong</li>
|
|
||||||
<li>Download your data</li>
|
|
||||||
<li>Delete your account</li>
|
|
||||||
<li>Withdraw consent for anything you previously agreed to</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
To do any of these, email
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>. We'll
|
|
||||||
respond within 30 days.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you live in a province with its own privacy law (Quebec, British
|
|
||||||
Columbia, Alberta), you may have additional rights under that law.
|
|
||||||
PIPEDA may also apply to some of what we do. We'll respect those
|
|
||||||
rights regardless of which framework technically covers your
|
|
||||||
situation.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Security</h2>
|
|
||||||
<p>
|
|
||||||
We host on a private server, encrypt data in transit, and limit
|
|
||||||
access to staff who need it. Perfect security doesn't exist. If
|
|
||||||
something happens that affects your data, we'll tell you within a
|
|
||||||
reasonable time and explain what we're doing about it.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Children</h2>
|
|
||||||
<p>
|
|
||||||
Ghost Guild is for adults. We don't knowingly collect data from
|
|
||||||
anyone under 18.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Changes</h2>
|
|
||||||
<p>
|
|
||||||
If we change this policy in a way that affects how we handle your
|
|
||||||
data, we'll email members and post the change with a date stamp at
|
|
||||||
least 30 days before it takes effect. Continued use after that means
|
|
||||||
you accept the changes. If you don't, close your account first.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Contact</h2>
|
|
||||||
<p>
|
|
||||||
Questions, requests, complaints:
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>
|
|
||||||
</p>
|
|
||||||
<p class="policy-address">
|
|
||||||
Baby Ghosts<br>
|
|
||||||
3230 Yonge Street #4052<br>
|
|
||||||
Toronto ON M4N 3P6<br>
|
|
||||||
Canada
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
useSiteMeta({
|
|
||||||
title: 'Privacy Policy',
|
|
||||||
description:
|
|
||||||
'How Ghost Guild handles your data: what we collect, why we collect it, and who has access. No Google Analytics, no advertising pixels, no third-party tracking.',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.policy-prose {
|
|
||||||
max-width: 720px;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-updated {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--candle);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section {
|
|
||||||
padding: 28px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.policy-section:first-of-type {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
.policy-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section h2 {
|
|
||||||
font-family: "Brygada 1918", serif;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-bright);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 8px 0 14px;
|
|
||||||
}
|
|
||||||
.policy-section ul li {
|
|
||||||
position: relative;
|
|
||||||
padding: 2px 0 2px 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.policy-section ul li::before {
|
|
||||||
content: "›";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 2px;
|
|
||||||
color: var(--candle-faint);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section a {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section strong {
|
|
||||||
color: var(--text-bright);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-address {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.policy-prose {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
<template>
|
|
||||||
<PageShell title="Refund Policy" subtitle="How Ghost Guild handles refund requests">
|
|
||||||
<div class="policy-prose">
|
|
||||||
<p class="policy-updated">Last updated: April 20, 2026</p>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<p>
|
|
||||||
Ghost Guild is a program of Baby Ghosts, a Canadian non-profit.
|
|
||||||
Contributions and event ticket revenue go directly toward running the
|
|
||||||
program. We handle refund requests on a case-by-case basis rather
|
|
||||||
than by blanket rule.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Membership dues</h2>
|
|
||||||
<p>
|
|
||||||
Membership is pay-what-you-can, and you can change or pause your
|
|
||||||
contribution any time as your situation changes. We don't refund dues
|
|
||||||
that have already been charged. If paying ever becomes a problem, the
|
|
||||||
Solidarity Fund is there for that reason; reach out and we'll work it
|
|
||||||
out.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Event tickets</h2>
|
|
||||||
<p>
|
|
||||||
Paid event registrations can't be cancelled from your account page.
|
|
||||||
Refunds for events are considered case-by-case at admin discretion,
|
|
||||||
taking into account how close to the event you're asking, whether
|
|
||||||
your spot can be filled, and the circumstances behind the request.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you can no longer attend an event you've paid for, email
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a> as
|
|
||||||
early as you can and we'll sort it out with you.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Contact</h2>
|
|
||||||
<p>
|
|
||||||
Refund requests, questions, anything else:
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
useSiteMeta({
|
|
||||||
title: 'Refund Policy',
|
|
||||||
description:
|
|
||||||
'How Ghost Guild handles refund requests for membership dues and event tickets. Pay-what-you-can, case-by-case, run as a non-profit program of Baby Ghosts.',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.policy-prose {
|
|
||||||
max-width: 720px;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-updated {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--candle);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section {
|
|
||||||
padding: 28px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.policy-section:first-of-type {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
.policy-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section h2 {
|
|
||||||
font-family: "Brygada 1918", serif;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-bright);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section a {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section strong {
|
|
||||||
color: var(--text-bright);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.policy-prose {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
<template>
|
|
||||||
<PageShell title="Terms of Service" subtitle="Using ghostguild.org and wiki.ghostguild.org">
|
|
||||||
<div class="policy-prose">
|
|
||||||
<p class="policy-updated">Last updated: April 18, 2026</p>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<p>
|
|
||||||
These terms apply to your use of ghostguild.org and
|
|
||||||
wiki.ghostguild.org, run by Baby Ghosts (a Canadian non-profit). The
|
|
||||||
<NuxtLink to="/community-guidelines">Member Agreement</NuxtLink> and
|
|
||||||
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
|
|
||||||
also apply if you're a member; they sit alongside these terms, and
|
|
||||||
the more specific document wins if there's ever a conflict.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Who can use Ghost Guild</h2>
|
|
||||||
<p>You can browse public pages without an account.</p>
|
|
||||||
<p>
|
|
||||||
To become a member, you have to be 18 or older, complete an
|
|
||||||
application, and be accepted by the Membership Committee. Membership
|
|
||||||
is reviewed against our values and processes; we can decline
|
|
||||||
applications, and we don't always provide detailed reasons.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Your account</h2>
|
|
||||||
<p>
|
|
||||||
You're responsible for what happens under your account. Don't share
|
|
||||||
login credentials. If you suspect unauthorized access, tell us at
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We may suspend or close accounts that violate these terms, the
|
|
||||||
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>,
|
|
||||||
or the
|
|
||||||
<NuxtLink to="/community-guidelines">Member Agreement</NuxtLink>. The
|
|
||||||
process for that lives in our
|
|
||||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We don't suspend or close accounts because a payment didn't go
|
|
||||||
through. If there's a problem with your contribution, we'll reach
|
|
||||||
out.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Membership and dues</h2>
|
|
||||||
<p>
|
|
||||||
Membership runs annually and renews unless you cancel. You can pay
|
|
||||||
your annual dues all at once or in monthly installments; both result
|
|
||||||
in the same annual membership.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Dues are pay-what-you-can: you choose your contribution, and you can
|
|
||||||
change it any time as your situation changes. The full pricing
|
|
||||||
structure is on the membership page.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We don't refund dues. If paying ever becomes a problem, the
|
|
||||||
Solidarity Fund is there for that reason; reach out and we'll work it
|
|
||||||
out.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Acceptable use</h2>
|
|
||||||
<p>
|
|
||||||
The
|
|
||||||
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
|
|
||||||
governs how members behave in Ghost Guild spaces. Some basics that
|
|
||||||
also apply here:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Don't harass, harm, or discriminate against other members</li>
|
|
||||||
<li>
|
|
||||||
Don't share other members' content (Slack messages, profile
|
|
||||||
information, bulletin board posts, application materials, private
|
|
||||||
comments) outside the community without their consent
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Don't feed community content, including other members'
|
|
||||||
contributions, into generative AI tools
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Don't use Ghost Guild for illegal activity, spam, or attempts to
|
|
||||||
compromise the platform
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Don't impersonate others or misrepresent your identity or
|
|
||||||
affiliations
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Don't scrape the site or use automated tools to extract member data
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
If you violate these, we follow the
|
|
||||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
|
|
||||||
Outcomes range from a conversation through to removal from the
|
|
||||||
community.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Your content</h2>
|
|
||||||
<p>
|
|
||||||
There are two different things happening with content on Ghost Guild,
|
|
||||||
and they work differently.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Profiles, bulletin board posts, comments, and other member-only content</h3>
|
|
||||||
<p>
|
|
||||||
You keep ownership of profile information, bulletin board posts,
|
|
||||||
comments, messages, and anything else you post in member-only spaces.
|
|
||||||
By posting these, you give Ghost Guild a non-exclusive license to
|
|
||||||
display them within the platform to other members.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you delete your account, we remove this content from member-facing
|
|
||||||
areas. Backups may persist for a reasonable period before being
|
|
||||||
purged.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Wiki contributions</h3>
|
|
||||||
<p>
|
|
||||||
The wiki at
|
|
||||||
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
|
|
||||||
knowledge commons. When you contribute to it (creating pages, editing
|
|
||||||
existing ones, adding examples, leaving comments on wiki pages), your
|
|
||||||
contribution is automatically and irrevocably licensed under the
|
|
||||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>
|
|
||||||
(CC-BY-SA 4.0).
|
|
||||||
</p>
|
|
||||||
<p>In plain terms:</p>
|
|
||||||
<ul>
|
|
||||||
<li>You still hold the copyright to what you wrote</li>
|
|
||||||
<li>
|
|
||||||
Anyone (members, the public, other cooperatives, organizations
|
|
||||||
adapting the material) can use, share, adapt, and build on your
|
|
||||||
contribution, including for commercial purposes, as long as they
|
|
||||||
credit you and license their derivatives under the same terms
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
You can't pull your contribution back out of the commons later,
|
|
||||||
even if you leave Ghost Guild
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
If we publish wiki material in other places (like
|
|
||||||
<a href="https://coop.love">coop.love</a>), it stays under
|
|
||||||
CC-BY-SA 4.0 and you stay credited
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
This is how a knowledge commons works, and it's central to what Ghost
|
|
||||||
Guild is doing. We're explicit about it because we want you to
|
|
||||||
contribute knowing exactly what's happening to your work. If you have
|
|
||||||
something you'd rather keep private or under a more restrictive
|
|
||||||
license, don't put it in the wiki.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Our content</h2>
|
|
||||||
<p>
|
|
||||||
Educational materials, templates, and resources we publish on
|
|
||||||
ghostguild.org are for member use within the spirit of the
|
|
||||||
cooperative movement. Where a specific license is attached (like
|
|
||||||
Creative Commons), follow that license. We ask for attribution to
|
|
||||||
Ghost Guild or Baby Ghosts.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Third-party services</h2>
|
|
||||||
<p>
|
|
||||||
Ghost Guild relies on Helcim (payments), Resend (email), Hetzner
|
|
||||||
(hosting), Outline (wiki), Plausible (analytics), and Slack (via the
|
|
||||||
shared Gamma Space workspace). Their terms apply when you interact
|
|
||||||
with their services. We've picked them carefully but we can't
|
|
||||||
guarantee they'll never have outages, security issues, or policy
|
|
||||||
changes.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Disclaimers</h2>
|
|
||||||
<p>
|
|
||||||
Ghost Guild is provided as-is. We work to keep things running and
|
|
||||||
resources accurate, but we don't guarantee uptime, completeness, or
|
|
||||||
that information on the site is right for your specific situation.
|
|
||||||
Treat our templates and educational materials as starting points, not
|
|
||||||
legal, financial, or business advice. Talk to qualified professionals
|
|
||||||
before you make decisions that need them.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Limitation of liability</h2>
|
|
||||||
<p>
|
|
||||||
To the extent the law allows, Baby Ghosts isn't liable for indirect,
|
|
||||||
incidental, or consequential damages arising from your use of the
|
|
||||||
site. Our total liability is capped at the amount of dues you've paid
|
|
||||||
in the past 12 months (which may well be zero, given how our pricing
|
|
||||||
works).
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Disputes</h2>
|
|
||||||
<p>
|
|
||||||
For disputes between members, we use the
|
|
||||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
For disputes with Baby Ghosts about these terms or your account,
|
|
||||||
please reach out first. Most things we can sort out by talking. If we
|
|
||||||
can't, the dispute will be governed by the laws of Ontario, Canada.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Changes</h2>
|
|
||||||
<p>
|
|
||||||
We'll update these terms from time to time. For changes that
|
|
||||||
materially affect your rights or our obligations, we'll email members
|
|
||||||
at least 30 days before the new terms take effect. Continued use
|
|
||||||
after that means you accept the changes. If you don't, close your
|
|
||||||
account before they kick in.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="policy-section">
|
|
||||||
<h2>Contact</h2>
|
|
||||||
<p>
|
|
||||||
<a href="mailto:hello@ghostguild.org">hello@ghostguild.org</a>
|
|
||||||
</p>
|
|
||||||
<p class="policy-address">
|
|
||||||
Baby Ghosts<br>
|
|
||||||
3230 Yonge Street #4052<br>
|
|
||||||
Toronto ON M4N 3P6<br>
|
|
||||||
Canada
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
useSiteMeta({
|
|
||||||
title: 'Terms of Service',
|
|
||||||
description:
|
|
||||||
'Terms of service for ghostguild.org and wiki.ghostguild.org, operated by Baby Ghosts. Covers accounts, membership, acceptable use, and what we expect from each other.',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.policy-prose {
|
|
||||||
max-width: 720px;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-updated {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--candle);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section {
|
|
||||||
padding: 28px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.policy-section:first-of-type {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
.policy-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section h2 {
|
|
||||||
font-family: "Brygada 1918", serif;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-bright);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section h3 {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-bright);
|
|
||||||
margin: 20px 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 8px 0 14px;
|
|
||||||
}
|
|
||||||
.policy-section ul li {
|
|
||||||
position: relative;
|
|
||||||
padding: 2px 0 2px 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.policy-section ul li::before {
|
|
||||||
content: "›";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 2px;
|
|
||||||
color: var(--candle-faint);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section a {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-section strong {
|
|
||||||
color: var(--text-bright);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-address {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.policy-prose {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page-fill">
|
<div>
|
||||||
<div v-if="pending" class="loading">Loading series details...</div>
|
<div v-if="pending" class="loading">Loading series details...</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="loading">
|
<div v-else-if="error" class="loading">
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="page-fill">
|
<div v-else>
|
||||||
<!-- BACK LINK -->
|
<!-- BACK LINK -->
|
||||||
<div class="back-link">
|
<div class="back-link">
|
||||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||||
|
|
@ -26,44 +26,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TWO-COLUMN BODY -->
|
<!-- DESCRIPTION -->
|
||||||
<div class="series-body" :class="{ 'has-aside': series.tickets?.enabled }">
|
<div v-if="series.description" class="section">
|
||||||
<!-- LEFT: MAIN CONTENT -->
|
|
||||||
<div class="series-main">
|
|
||||||
<div v-if="series.description" class="section description">
|
|
||||||
<p>{{ series.description }}</p>
|
<p>{{ series.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section" :class="{ 'section-flush': series.events?.length }">
|
<!-- EVENT LIST -->
|
||||||
|
<div class="section">
|
||||||
<div class="section-label">Sessions</div>
|
<div class="section-label">Sessions</div>
|
||||||
<div v-if="series.events?.length" class="sessions-box">
|
<div v-if="series.events?.length">
|
||||||
<div v-for="(event, index) in series.events" :key="event._id || index" class="event-row">
|
<div v-for="(event, index) in series.events" :key="event._id || index" class="event-row">
|
||||||
<span class="event-num">{{ String(index + 1).padStart(2, '0') }}</span>
|
<span class="event-num">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<div class="event-info-head">
|
|
||||||
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
|
<NuxtLink :to="`/events/${event.slug || event._id || event.id}`" class="event-title-link">
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="event-status">{{ getEventStatus(event) }}</span>
|
<span class="event-status">{{ getEventStatus(event) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="event.description" class="event-description">{{ event.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="empty">No sessions scheduled yet.</p>
|
<p v-else class="empty">No sessions scheduled yet.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Questions (inline when no sidebar) -->
|
<!-- PASS PURCHASE -->
|
||||||
<div v-if="!series.tickets?.enabled" class="section">
|
<div v-if="series.tickets?.enabled" class="section">
|
||||||
<div class="section-label">Questions?</div>
|
|
||||||
<p>If you have questions about this series, reach out to us.</p>
|
|
||||||
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT: SIDEBAR -->
|
|
||||||
<aside v-if="series.tickets?.enabled" class="series-aside">
|
|
||||||
<SeriesPassPurchase
|
<SeriesPassPurchase
|
||||||
:series-id="series.id"
|
:series-id="series.id"
|
||||||
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
|
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
|
||||||
|
|
@ -72,13 +59,13 @@
|
||||||
:user-name="memberData?.name"
|
:user-name="memberData?.name"
|
||||||
@purchase-success="handlePurchaseSuccess"
|
@purchase-success="handlePurchaseSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="aside-panel">
|
|
||||||
<div class="box-title">Questions?</div>
|
|
||||||
<p class="aside-detail">Drop us a line.</p>
|
|
||||||
<a class="aside-link" href="mailto:events@ghostguild.org">events@ghostguild.org</a>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
|
<!-- QUESTIONS -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">Questions?</div>
|
||||||
|
<p>If you have questions about this series, reach out to us.</p>
|
||||||
|
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,11 +104,9 @@ const handlePurchaseSuccess = () => {
|
||||||
refreshNuxtData()
|
refreshNuxtData()
|
||||||
}
|
}
|
||||||
|
|
||||||
useSiteMeta(() => ({
|
useHead(() => ({
|
||||||
title: series.value ? `${series.value.title} · Event Series` : 'Event Series',
|
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
|
||||||
description:
|
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
|
||||||
series.value?.description ||
|
|
||||||
(series.value?.title ? `${series.value.title} — a Ghost Guild event series.` : undefined),
|
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -152,105 +137,28 @@ useSiteMeta(() => ({
|
||||||
}
|
}
|
||||||
.meta-text { color: var(--text-faint); }
|
.meta-text { color: var(--text-faint); }
|
||||||
|
|
||||||
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
|
|
||||||
.page-fill {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- TWO-COLUMN BODY ---- */
|
|
||||||
.series-body {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.series-body.has-aside {
|
|
||||||
grid-template-columns: 1fr 280px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.series-main { min-width: 0; }
|
|
||||||
.series-aside {
|
|
||||||
border-left: 1px dashed var(--border);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.series-main .section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
|
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
|
||||||
.section a { font-size: 12px; color: var(--candle); }
|
.section a { font-size: 12px; color: var(--candle); }
|
||||||
.section.description p { font-size: 14px; color: var(--text); }
|
|
||||||
|
|
||||||
.section-flush { padding-bottom: 0; }
|
|
||||||
.sessions-box {
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
margin: 10px -32px 0;
|
|
||||||
}
|
|
||||||
.event-row {
|
.event-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 32px auto 1fr;
|
grid-template-columns: 32px 80px 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 10px 32px;
|
padding: 10px 0;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.event-row:last-child { border-bottom: none; }
|
.event-row:last-child { border-bottom: none; }
|
||||||
.event-num { color: var(--text-faint); font-size: 11px; }
|
.event-num { color: var(--text-faint); font-size: 11px; }
|
||||||
.event-date { color: var(--text-faint); white-space: nowrap; }
|
.event-date { color: var(--text-faint); }
|
||||||
.event-info { min-width: 0; }
|
|
||||||
.event-title-link { color: var(--text); text-decoration: none; font-size: 13px; }
|
.event-title-link { color: var(--text); text-decoration: none; font-size: 13px; }
|
||||||
.event-title-link:hover { color: var(--candle); }
|
.event-title-link:hover { color: var(--candle); }
|
||||||
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; }
|
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; }
|
||||||
.event-description {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 4px 0 0;
|
|
||||||
max-width: 560px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty { font-size: 12px; color: var(--text-faint); }
|
.empty { font-size: 12px; color: var(--text-faint); }
|
||||||
|
|
||||||
/* ---- ASIDE PANELS ---- */
|
|
||||||
.aside-panel {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.aside-panel:last-child { border-bottom: none; }
|
|
||||||
.box-title {
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.aside-detail {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.aside-link {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.series-body.has-aside {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.series-aside {
|
|
||||||
border-left: none;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,214 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Page Header -->
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Event Series"
|
title="Event Series"
|
||||||
subtitle="Multi-session events on cooperative topics"
|
subtitle="Discover our multi-event series designed to take you on a journey of learning and growth"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="pending" class="state-msg">Loading series...</div>
|
<!-- Series Grid -->
|
||||||
|
<section class="py-20 bg-[--ui-bg]">
|
||||||
<div v-else-if="!filteredSeries.length" class="state-msg">
|
<UContainer>
|
||||||
<p>
|
<div v-if="pending" class="text-center py-12">
|
||||||
No series right now. Check back later or browse
|
<div
|
||||||
<NuxtLink to="/events">upcoming events</NuxtLink>.
|
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||||
</p>
|
></div>
|
||||||
|
<p class="text-[--ui-text-muted]">Loading series...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div
|
||||||
<section
|
v-else-if="filteredSeries.length > 0"
|
||||||
|
class="max-w-4xl mx-auto space-y-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
v-for="series in filteredSeries"
|
v-for="series in filteredSeries"
|
||||||
:key="series.id"
|
:key="series.id"
|
||||||
class="series-section"
|
class="border border-[--ui-border] rounded overflow-hidden hover:border-primary transition-colors"
|
||||||
>
|
>
|
||||||
<div class="series-head">
|
<!-- Series Header -->
|
||||||
<h2>{{ series.title }}</h2>
|
<div class="p-6 border-b border-[--ui-border]">
|
||||||
<div class="series-meta-row">
|
<div
|
||||||
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span>
|
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
|
||||||
<span class="meta-text">
|
>
|
||||||
{{ series.eventCount }} sessions<template v-if="series.totalEvents"> of {{ series.totalEvents }} planned</template>
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-3 py-1 rounded text-sm font-medium',
|
||||||
|
getSeriesTypeBadgeClass(series.type),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ formatSeriesType(series.type) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="series.startDate && series.endDate" class="meta-text">
|
<span
|
||||||
{{ formatDateRange(series.startDate, series.endDate) }}
|
:class="[
|
||||||
</span>
|
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
|
||||||
<span v-if="series.totalRegistrations" class="meta-text">
|
series.status === 'active'
|
||||||
{{ series.totalRegistrations }} registered
|
? 'bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30'
|
||||||
|
: series.status === 'upcoming'
|
||||||
|
? 'bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30'
|
||||||
|
: 'bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ series.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="series.description" class="series-desc">
|
<h2 class="text-display-sm font-bold text-[--ui-text] mb-2">
|
||||||
|
{{ series.title }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-[--ui-text-muted] leading-relaxed">
|
||||||
{{ series.description }}
|
{{ series.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center md:text-right flex-shrink-0">
|
||||||
<div v-if="series.events?.length" class="sessions">
|
<div class="text-3xl font-bold text-[--ui-text] mb-1">
|
||||||
|
{{ series.eventCount }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-[--ui-text-muted]">Events</div>
|
||||||
<div
|
<div
|
||||||
v-for="(event, index) in series.events"
|
v-if="series.totalEvents"
|
||||||
:key="event.id"
|
class="text-xs text-[--ui-text-muted] mt-1"
|
||||||
class="event-row"
|
|
||||||
>
|
>
|
||||||
<span class="event-num">
|
of {{ series.totalEvents }} planned
|
||||||
{{ String(event.series?.position || index + 1).padStart(2, '0') }}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events List -->
|
||||||
|
<div class="divide-y divide-[--ui-border]">
|
||||||
|
<div
|
||||||
|
v-for="event in series.events"
|
||||||
|
:key="event.id"
|
||||||
|
class="p-4 hover:bg-[--ui-bg-elevated] transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0 border border-candlelight-700/30"
|
||||||
|
>
|
||||||
|
{{ event.series?.position || "?" }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-medium text-[--ui-text] mb-1">
|
||||||
|
{{ event.title }}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-sm text-[--ui-text-muted] flex-wrap"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon
|
||||||
|
name="heroicons:calendar-days"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
{{ formatEventDate(event.startDate) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon name="heroicons:clock" class="w-4 h-4" />
|
||||||
|
{{ formatEventTime(event.startDate) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="event.registrations?.length"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:users" class="w-4 h-4" />
|
||||||
|
{{ event.registrations.length }} registered
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
|
||||||
|
getEventStatusClass(event),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getEventStatus(event) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
|
|
||||||
<div class="event-info">
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/events/${event.slug || event.id}`"
|
:to="`/events/${event.slug || event.id}`"
|
||||||
class="event-title-link"
|
class="inline-flex items-center px-3 py-1 bg-primary text-white text-sm rounded hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
{{ event.title }}
|
View
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="event-status">{{ getEventStatus(event) }}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="series-foot">
|
<!-- Series Footer -->
|
||||||
<NuxtLink :to="`/series/${series.id}`" class="view-link">
|
<div
|
||||||
View series →
|
class="px-6 py-4 bg-[--ui-bg-elevated] border-t border-[--ui-border]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-sm text-[--ui-text-muted] flex-wrap"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="series.startDate && series.endDate"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
|
||||||
|
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="series.totalRegistrations"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:users" class="w-4 h-4" />
|
||||||
|
{{ series.totalRegistrations }} total registrations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/series/${series.id}`"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm font-medium rounded hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
View Series
|
||||||
|
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-2" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-16">
|
||||||
|
<Icon
|
||||||
|
name="heroicons:squares-2x2"
|
||||||
|
class="w-16 h-16 text-[--ui-text-muted] mx-auto mb-4 opacity-50"
|
||||||
|
/>
|
||||||
|
<h3 class="text-display-sm font-semibold text-[--ui-text] mb-2">
|
||||||
|
No Event Series Available
|
||||||
|
</h3>
|
||||||
|
<p class="text-[--ui-text-muted] max-w-md mx-auto">
|
||||||
|
We're currently planning exciting event series. Check back soon for
|
||||||
|
multi-event learning journeys!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useSiteMeta({
|
// SEO
|
||||||
title: "Event Series",
|
useHead({
|
||||||
description:
|
title: "Event Series - Ghost Guild",
|
||||||
"Multi-session event series on cooperative topics — from foundations courses to practitioner cohorts.",
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content:
|
||||||
|
"Discover our multi-event series designed to take you on a journey of learning and growth in cooperative game development and community building.",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch series data
|
||||||
const { data: seriesData, pending } = await useFetch("/api/series", {
|
const { data: seriesData, pending } = await useFetch("/api/series", {
|
||||||
query: { includeHidden: false },
|
query: { includeHidden: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter for active and upcoming series only
|
||||||
const filteredSeries = computed(() => {
|
const filteredSeries = computed(() => {
|
||||||
if (!seriesData.value) return [];
|
if (!seriesData.value) return [];
|
||||||
return seriesData.value.filter(
|
return seriesData.value.filter(
|
||||||
|
|
@ -89,6 +216,7 @@ const filteredSeries = computed(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
const formatSeriesType = (type) => {
|
const formatSeriesType = (type) => {
|
||||||
const types = {
|
const types = {
|
||||||
workshop_series: "Workshop Series",
|
workshop_series: "Workshop Series",
|
||||||
|
|
@ -100,6 +228,25 @@ const formatSeriesType = (type) => {
|
||||||
return types[type] || type;
|
return types[type] || type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSeriesTypeBadgeClass = (type) => {
|
||||||
|
const classes = {
|
||||||
|
workshop_series:
|
||||||
|
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
|
||||||
|
recurring_meetup:
|
||||||
|
"bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30",
|
||||||
|
multi_day:
|
||||||
|
"bg-ember-900/20 text-ember-500 dark:text-ember-400 border border-ember-700/30",
|
||||||
|
course:
|
||||||
|
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
|
||||||
|
tournament:
|
||||||
|
"bg-ember-900/20 text-ember-500 dark:text-ember-400 border border-ember-700/30",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
classes[type] ||
|
||||||
|
"bg-earth-900/20 text-earth-400 dark:text-earth-400 border border-earth-700/30"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatEventDate = (date) => {
|
const formatEventDate = (date) => {
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|
@ -108,133 +255,50 @@ const formatEventDate = (date) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatEventTime = (date) => {
|
||||||
|
return new Date(date).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateRange = (startDate, endDate) => {
|
const formatDateRange = (startDate, endDate) => {
|
||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
|
|
||||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
return `${formatter.format(start)} – ${formatter.format(end)}`;
|
|
||||||
|
return `${formatter.format(start)} to ${formatter.format(end)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEventStatus = (event) => {
|
const getEventStatus = (event) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startDate = new Date(event.startDate);
|
const startDate = new Date(event.startDate);
|
||||||
const endDate = new Date(event.endDate);
|
const endDate = new Date(event.endDate);
|
||||||
|
|
||||||
if (now < startDate) return "Upcoming";
|
if (now < startDate) return "Upcoming";
|
||||||
if (now >= startDate && now <= endDate) return "Ongoing";
|
if (now >= startDate && now <= endDate) return "Ongoing";
|
||||||
return "Completed";
|
return "Completed";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getEventStatusClass = (event) => {
|
||||||
|
const status = getEventStatus(event);
|
||||||
|
const classes = {
|
||||||
|
Upcoming:
|
||||||
|
"bg-guild-500/10 text-guild-300 dark:text-guild-300 border border-guild-500/30",
|
||||||
|
Ongoing:
|
||||||
|
"bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30",
|
||||||
|
Completed:
|
||||||
|
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
classes[status] ||
|
||||||
|
"bg-guild-500/10 text-guild-400 dark:text-guild-400 border border-guild-500/30"
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.state-msg {
|
|
||||||
padding: 32px 28px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.state-msg p { max-width: 560px; }
|
|
||||||
|
|
||||||
.series-section {
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-head {
|
|
||||||
padding: 24px 28px 16px;
|
|
||||||
}
|
|
||||||
.series-head h2 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-bright);
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.series-meta-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.meta-text {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.series-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.7;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions {
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.event-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 28px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.event-row:last-child { border-bottom: none; }
|
|
||||||
.event-num {
|
|
||||||
flex: 0 0 24px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 11px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.event-date {
|
|
||||||
flex: 0 0 110px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
white-space: nowrap;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.event-info { flex: 1 1 0; min-width: 0; }
|
|
||||||
.event-title-link {
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.event-title-link:hover { color: var(--candle); }
|
|
||||||
.event-status {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-foot {
|
|
||||||
padding: 14px 28px 24px;
|
|
||||||
}
|
|
||||||
.view-link {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--candle);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.view-link:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.series-head,
|
|
||||||
.series-foot {
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
.event-row {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
row-gap: 2px;
|
|
||||||
}
|
|
||||||
.event-info {
|
|
||||||
flex-basis: 100%;
|
|
||||||
margin-left: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({ layout: false })
|
definePageMeta({ layout: false })
|
||||||
useSiteMeta({ title: 'Verifying', noindex: true })
|
|
||||||
|
|
||||||
const state = ref('verifying')
|
const state = ref('verifying')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,280 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Welcome to Ghost Guild"
|
||||||
|
subtitle="You're officially part of the community!"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="py-16 bg-guild-900">
|
||||||
|
<UContainer>
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- Welcome Message -->
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="w-24 h-24 mx-auto mb-6">
|
||||||
|
<img
|
||||||
|
v-if="memberData?.avatar"
|
||||||
|
:src="`/ghosties/Ghost-${memberData.avatar.charAt(0).toUpperCase() + memberData.avatar.slice(1)}.png`"
|
||||||
|
:alt="memberData.name"
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
src="/ghosties/Ghost-Sweet.png"
|
||||||
|
alt="Ghost Guild"
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-display font-bold text-guild-100 mb-4">
|
||||||
|
Hey {{ memberData?.name || "there" }}!
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-guild-300 max-w-2xl mx-auto">
|
||||||
|
You've joined a an awesome community!!👻 Welcome to Ghost guild…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Getting Started Steps -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
|
||||||
|
<div class="p-6 bg-guild-800/50 rounded-xl border border-guild-700">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-candlelight-500/20 rounded-lg flex items-center justify-center mb-4"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="heroicons:user-circle"
|
||||||
|
class="w-6 h-6 text-candlelight-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-guild-100 mb-2">
|
||||||
|
<span class="text-ui-label text-candlelight-400 mr-2">1.</span>Complete Your Profile
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-guild-400 mb-4">
|
||||||
|
Tell the community about yourself, your skills, and what you're
|
||||||
|
looking for.
|
||||||
|
</p>
|
||||||
|
<UButton to="/member/profile" variant="outline" size="sm">
|
||||||
|
Edit Profile
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-guild-800/50 rounded-xl border border-guild-700">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-candlelight-500/20 rounded-lg flex items-center justify-center mb-4"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="heroicons:calendar-days"
|
||||||
|
class="w-6 h-6 text-candlelight-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-guild-100 mb-2">
|
||||||
|
<span class="text-ui-label text-candlelight-400 mr-2">2.</span>Join an Event
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-guild-400 mb-4">
|
||||||
|
From workshops to game nights, events are the heart of our
|
||||||
|
community.
|
||||||
|
</p>
|
||||||
|
<UButton to="/events" variant="outline" size="sm">
|
||||||
|
Browse Events
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-guild-800/50 rounded-xl border border-guild-700">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-candlelight-500/20 rounded-lg flex items-center justify-center mb-4"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:users" class="w-6 h-6 text-candlelight-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-guild-100 mb-2">
|
||||||
|
<span class="text-ui-label text-candlelight-400 mr-2">3.</span>Meet the Community
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-guild-400 mb-4">
|
||||||
|
Connect with other members and find peers for support and
|
||||||
|
collaboration.
|
||||||
|
</p>
|
||||||
|
<UButton to="/members" variant="outline" size="sm">
|
||||||
|
View Members
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Circles -->
|
||||||
|
<div
|
||||||
|
class="p-8 bg-guild-800/30 rounded-2xl border border-guild-700 mb-16"
|
||||||
|
>
|
||||||
|
<h3 class="text-display-sm font-bold text-guild-100 mb-4">
|
||||||
|
Understanding Circles
|
||||||
|
</h3>
|
||||||
|
<p class="text-guild-300 mb-6">
|
||||||
|
Ghost Guild is organized into three circles based on where you are
|
||||||
|
in your journey. Your circle helps us tailor events and resources
|
||||||
|
to your needs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg"
|
||||||
|
:class="
|
||||||
|
memberData?.circle === 'community'
|
||||||
|
? 'circle-surface-community border border-[var(--color-circle-community)]/50'
|
||||||
|
: 'bg-guild-800/50'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h4 class="font-semibold text-guild-100 mb-2">
|
||||||
|
Community Circle
|
||||||
|
<span
|
||||||
|
v-if="memberData?.circle === 'community'"
|
||||||
|
class="text-candlelight-400 text-sm ml-2"
|
||||||
|
>← You're here</span
|
||||||
|
>
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-guild-400">
|
||||||
|
For those exploring solidarity economics and alternative
|
||||||
|
studio models.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg"
|
||||||
|
:class="
|
||||||
|
memberData?.circle === 'founder'
|
||||||
|
? 'circle-surface-founder border border-[var(--color-circle-founder)]/50'
|
||||||
|
: 'bg-guild-800/50'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h4 class="font-semibold text-guild-100 mb-2">
|
||||||
|
Founder Circle
|
||||||
|
<span
|
||||||
|
v-if="memberData?.circle === 'founder'"
|
||||||
|
class="text-candlelight-400 text-sm ml-2"
|
||||||
|
>← You're here</span
|
||||||
|
>
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-guild-400">
|
||||||
|
For those actively building or running a cooperative or
|
||||||
|
solidarity-based studio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-lg"
|
||||||
|
:class="
|
||||||
|
memberData?.circle === 'practitioner'
|
||||||
|
? 'circle-surface-practitioner border border-[var(--color-circle-practitioner)]/50'
|
||||||
|
: 'bg-guild-800/50'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h4 class="font-semibold text-guild-100 mb-2">
|
||||||
|
Practitioner Circle
|
||||||
|
<span
|
||||||
|
v-if="memberData?.circle === 'practitioner'"
|
||||||
|
class="text-candlelight-400 text-sm ml-2"
|
||||||
|
>← You're here</span
|
||||||
|
>
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-guild-400">
|
||||||
|
For consultants, advisors, and professionals supporting
|
||||||
|
cooperative game studios.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resources -->
|
||||||
|
<div
|
||||||
|
class="p-8 bg-guild-800/30 rounded-2xl border border-guild-700 mb-16"
|
||||||
|
>
|
||||||
|
<h3 class="text-display-sm font-bold text-guild-100 mb-4">
|
||||||
|
Resources & Support
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-candlelight-500/20 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="heroicons:book-open"
|
||||||
|
class="w-5 h-5 text-candlelight-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-guild-100 mb-1">
|
||||||
|
Resource Library
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-guild-400 mb-2">
|
||||||
|
Templates, guides, and tools for building solidarity-based
|
||||||
|
studios.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
to="https://wiki.ghostguild.org"
|
||||||
|
target="_blank"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
class="p-0"
|
||||||
|
>
|
||||||
|
Browse Resources →
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-ember-500/20 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="heroicons:chat-bubble-left-right"
|
||||||
|
class="w-5 h-5 text-ember-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-guild-100 mb-1">
|
||||||
|
Community Ecology
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-guild-400 mb-2">
|
||||||
|
Connect with community members through shared interests and
|
||||||
|
cooperative topics.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
to="/ecology"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
class="p-0"
|
||||||
|
>
|
||||||
|
Browse Community →
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="text-center">
|
||||||
|
<UButton to="/member/dashboard" size="xl" class="px-12">
|
||||||
|
Go to Your Dashboard
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
await navigateTo('/member/dashboard?welcome=1', { redirectCode: 301 })
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
|
|
||||||
|
// Ensure user is authenticated
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ["auth"],
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkMemberStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Welcome - Ghost Guild",
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Welcome to Ghost Guild! Get started with your membership.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ const formatters = {
|
||||||
icon: 'i-lucide-user-pen'
|
icon: 'i-lucide-user-pen'
|
||||||
}),
|
}),
|
||||||
subscription_created: (m) => ({
|
subscription_created: (m) => ({
|
||||||
text: m.amount != null ? `Started $${m.amount}/mo subscription` : 'Started subscription',
|
text: m.tier ? `Started $${m.tier}/mo subscription` : 'Started subscription',
|
||||||
icon: 'i-lucide-credit-card'
|
icon: 'i-lucide-credit-card'
|
||||||
}),
|
}),
|
||||||
subscription_cancelled: () => ({
|
subscription_cancelled: () => ({
|
||||||
|
|
@ -76,8 +76,8 @@ const formatters = {
|
||||||
text: 'Updated community connections',
|
text: 'Updated community connections',
|
||||||
icon: 'i-lucide-users'
|
icon: 'i-lucide-users'
|
||||||
}),
|
}),
|
||||||
board_updated: () => ({
|
community_ecology_updated: () => ({
|
||||||
text: 'Updated board',
|
text: 'Updated community ecology',
|
||||||
icon: 'i-lucide-users'
|
icon: 'i-lucide-users'
|
||||||
}),
|
}),
|
||||||
connection_requested: (m) => ({
|
connection_requested: (m) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
// Convert a datetime-local string ("YYYY-MM-DDTHH:MM") to a UTC Date,
|
|
||||||
// interpreting the wall-clock time in the given IANA timezone.
|
|
||||||
export function zonedLocalToUTC(localStr, tz) {
|
|
||||||
if (!localStr || !tz) return null;
|
|
||||||
const [datePart, timePart] = String(localStr).split("T");
|
|
||||||
if (!datePart || !timePart) return null;
|
|
||||||
const [y, mo, d] = datePart.split("-").map(Number);
|
|
||||||
const [h, mi] = timePart.split(":").map(Number);
|
|
||||||
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
|
|
||||||
|
|
||||||
// Treat the components as if they are already UTC. The result's wall-clock
|
|
||||||
// in the target TZ will differ from what we want by exactly the TZ offset
|
|
||||||
// for that moment, so we measure that offset and subtract it.
|
|
||||||
const asUTC = new Date(Date.UTC(y, mo - 1, d, h, mi));
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: tz,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(asUTC);
|
|
||||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
||||||
const observed = Date.UTC(
|
|
||||||
get("year"),
|
|
||||||
get("month") - 1,
|
|
||||||
get("day"),
|
|
||||||
get("hour") % 24,
|
|
||||||
get("minute"),
|
|
||||||
get("second"),
|
|
||||||
);
|
|
||||||
const offsetMs = observed - asUTC.getTime();
|
|
||||||
return new Date(asUTC.getTime() - offsetMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a UTC Date (or ISO string) to a datetime-local string
|
|
||||||
// ("YYYY-MM-DDTHH:MM") rendered in the given IANA timezone.
|
|
||||||
export function utcToZonedLocal(utc, tz) {
|
|
||||||
if (!utc || !tz) return "";
|
|
||||||
const d = utc instanceof Date ? utc : new Date(utc);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: tz,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(d);
|
|
||||||
const get = (type) => parts.find((p) => p.type === type)?.value;
|
|
||||||
const year = get("year");
|
|
||||||
const month = get("month");
|
|
||||||
const day = get("day");
|
|
||||||
let hour = get("hour");
|
|
||||||
const minute = get("minute");
|
|
||||||
if (hour === "24") hour = "00";
|
|
||||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short timezone label (e.g., "EDT", "PDT") for a Date in a given IANA TZ.
|
|
||||||
export function shortTimezoneName(date, tz) {
|
|
||||||
if (!date || !tz) return "";
|
|
||||||
const d = date instanceof Date ? date : new Date(date);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
try {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: tz,
|
|
||||||
timeZoneName: "short",
|
|
||||||
}).formatToParts(d);
|
|
||||||
return parts.find((p) => p.type === "timeZoneName")?.value || "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
124
docs/BACKLOG.md
|
|
@ -1,124 +0,0 @@
|
||||||
# Ghost Guild — Open Backlog
|
|
||||||
|
|
||||||
_Last consolidated: 2026-05-18. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
|
|
||||||
|
|
||||||
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
|
|
||||||
|
|
||||||
**Launch shape (2026-05-18):** site live with events ASAP, applications open immediately, Slack invites delivered in waves. Entire waitlist invited to apply at launch. See `LAUNCH_READINESS.md` for the full shape, the activation steps, and the open product decisions that gate the launch comms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-cutover (do once)
|
|
||||||
|
|
||||||
Operational steps that have to run during cutover. Full details + env-var list in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
|
|
||||||
|
|
||||||
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
|
|
||||||
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
|
|
||||||
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
|
|
||||||
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
|
|
||||||
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
|
|
||||||
- [ ] Push local `main` to `origin/main`.
|
|
||||||
- [ ] Deploy.
|
|
||||||
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic.**
|
|
||||||
- [ ] Audit prod for pre-fix series-pass bypass registrations (registrations on pass-only series children with `registeredAt < 2026-04-20` from non-pass-holders). Decide per case.
|
|
||||||
- [ ] In Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303 (we send our own CRA-safe version via Resend).
|
|
||||||
- [ ] Run one real test charge and verify (a) Payment doc in Mongo and (b) exactly one CRA-compliant confirmation email.
|
|
||||||
- [ ] Rotate `HELCIM_API_TOKEN` in the Helcim merchant portal and update the Dokploy env var.
|
|
||||||
- [ ] Trigger the daily reconcile task once manually in Dokploy to confirm it's wired correctly.
|
|
||||||
|
|
||||||
## Pilot smoke walks (before first wave)
|
|
||||||
|
|
||||||
Once cutover lands, before the first Slack onboarding wave goes out:
|
|
||||||
|
|
||||||
- [ ] **Pilot smoke walk for Slack-invited workflow.** One admin manually clicks "Mark as Slack invited" against a real test member in production, confirms the row updates in place, and confirms the dashboard "Slack coming" note disappears for that member. Unit tests cover the pieces; nothing covers the live admin-to-member round-trip.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bylaws-decoupling (waiting on amendment ratification)
|
|
||||||
|
|
||||||
Membership status is being decoupled from payment status. Copy + UI gates already align; behavioral changes below remain.
|
|
||||||
|
|
||||||
- ~~B1 cancel-subscription leaves status `active`.~~ Verified shipped 2026-05-18: `server/api/members/cancel-subscription.post.js:31,50` writes `status: 'active'`. Test coverage in `tests/server/api/cancel-subscription.test.js` (Fix #9 in LAUNCH_READINESS).
|
|
||||||
- ~~B3 cancelled.~~ `pending_payment` stays.
|
|
||||||
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known gotchas (post-launch)
|
|
||||||
|
|
||||||
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
|
|
||||||
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
|
|
||||||
- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
|
|
||||||
- **`/admin/series-management` "Delete" button doesn't actually delete.** Click handler iterates events to PUT-unlink each from the series, never calls `DELETE /api/admin/series/:id`. For an empty series the button is a no-op; for a series with events it just orphans them. Either rename to "Unlink events" or add the actual DELETE call. Surfaced by `e2e/admin-series.spec.js` (delete test skipped). Flagged 2026-04-30.
|
|
||||||
- **Past-deadline events and sold-out events render identically.** `EventTicketPurchase.vue` falls through to "Event Sold Out" panel for both `tickets.available.reason === 'Registration deadline has passed'` and zero-stock cases. If "Registration closed" is meant to read differently from "Sold out," add a distinct branch. Flagged 2026-04-30 (no e2e written — gated on this UX decision).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility / a11y
|
|
||||||
|
|
||||||
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
|
|
||||||
- [ ] **`/board` color-contrast violations (WCAG AA).** `.block-label` ("Offering" tag) and `.slack-handle` use `#746a58` on `#e8dfc8` → 4.01:1; AA needs 4.5:1 for small text. Surfaced by `e2e/a11y.spec.js` (the `/board` route fails; test is intentionally left red until fixed). Likely a single CSS variable adjustment. Flagged 2026-04-30.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deferred features (own session each)
|
|
||||||
|
|
||||||
- [ ] **Email automation system.** Patterned after Tranzac's implementation (separate project, already built). HTML email bodies with template management and drip sequences. Deferred 2026-04-20 — ruled wasted work given the larger system is designed elsewhere. Current transactional email lives in `server/utils/resend.js` + inline in `server/api/auth/login.post.js`, `server/routes/oidc/interaction/login.post.ts`, `server/api/admin/{members,pre-registrants}/invite.post.js`. Copy dump at `docs/email-copy-dump.md`. See memory: `project_email_automation_future`.
|
|
||||||
- [ ] **Receipts for event ticket purchases (Phase 2).** Phase 1 receipts only cover membership payments. Event tickets — especially guest purchases without member accounts — need a receipt flow. Likely an emailed PDF/HTML receipt at purchase time. Build target: June–Oct 2026, live Jan 2027. See memory: `project_receipts`.
|
|
||||||
- [ ] **Series/event waitlist.** Admin can configure `tickets.waitlist.enabled` and `maxSize`; `server/utils/tickets.js` returns `waitlistAvailable: true` when full; `app/components/SeriesPassPurchase.vue:341` and `EventTicketPurchase.vue` have stub `handleJoinWaitlist` that toasts "Waitlist Coming Soon." No server endpoint, no confirmation email, no `event_waitlisted` activity hook. Either implement end-to-end or hide the button by removing the `v-if="availability?.waitlistAvailable"` branches in `EventSeriesTicketCard.vue:175` and `EventTicketCard.vue:73`.
|
|
||||||
- [ ] **ASVS Phase 4.** File-upload validation pipeline, granular RBAC, credential encryption.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Wave-Slack pilot follow-ups
|
|
||||||
|
|
||||||
- [ ] **`/api/auth/member` doesn't return `slackInvited`.** Dashboard's Slack-coming note is gated on `memberData.slackInvited`, which is always `undefined` client-side, so the note shows for *every* active member regardless of state. Real bug. Add `slackInvited` (and `slackInvitedAt`) to the auth/member response. Surfaced by wave-slack §7.2 e2e (skipped pending this fix). Flagged 2026-04-30.
|
|
||||||
- [ ] **Admin members list row mutation isn't reactive.** `markSlackInvited` in `app/pages/admin/members/index.vue` does `Object.assign(member, res.member)` on a plain object inside a `useFetch` array; Vue doesn't react, so the "Mark as Slack invited" button stays visible until a manual reload. Fix: `members.value[i] = { ...members.value[i], ...res.member }` or `splice`. Detail page uses the right pattern (covered by §6.6). Surfaced by wave-slack §6.2 e2e (skipped pending this fix). Flagged 2026-04-30.
|
|
||||||
- [ ] **Deprecated `slackInviteStatus` field still serialized.** Removed from UI but still on `Member` documents and the `/api/admin/members` payload. Project it away in the API response and run a one-shot `$unset` cleanup. Surfaced by wave-slack §6.7 e2e. Flagged 2026-04-30.
|
|
||||||
- [ ] **Spec vs shipped-UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 asserts "no wave/cohort/batch language" in the dashboard note, but the shipped welcome-email and dashboard copy say "monthly onboarding waves." Decide which side wins; update the other.
|
|
||||||
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 9 of 16 scaffolded tests now passing (admin Slack-invited button + non-trivial dashboard cases). 7 remain skipped pending the bugs above (7.2, 6.2), seeding gaps (7.4 — no dev endpoint to mint members of arbitrary status), Open Questions (7.8, 6.9), or spec-vs-UI conflicts (7.5, 6.7).
|
|
||||||
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
|
|
||||||
- [ ] **`slack_invite_failed` enum slug cleanup.** Detector and alert removed in `d15458b`, but the slug remains in `server/models/adminAlertDismissal.js` enum so historical dismissal rows continue to validate. Full removal needs a one-shot cleanup of stale dismissal rows in the DB. Roll into a future schema-tidy pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Simplify-pass follow-ups (still open)
|
|
||||||
|
|
||||||
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
|
|
||||||
|
|
||||||
- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
|
|
||||||
- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
|
|
||||||
- [ ] **STATUS_LABELS dedup — verify.** The 2026-04-30 small-wins batch claimed STATUS_LABELS dedup, but `e2e/admin-members.spec.js` expansion found an inline copy still at `app/pages/admin/members/index.vue:491` and another at `app/pages/member/account.vue:420`. Either the previous dedup was partial or a new copy was reintroduced — confirm and finish dedup into a shared constants module.
|
|
||||||
- [ ] **`app/pages/admin/members/[id].vue` status select still hand-written.** Commit `441a5f5` aligned the index page's status `<select>` to `STATUS_LABELS`, but the detail page (`[id].vue`) still hand-codes raw status options. Refactor to drive from the same constant.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Optional / low-priority
|
|
||||||
|
|
||||||
- [ ] **Welcome-email Slack-timing mention.** Currently the welcome email doesn't mention Slack timing — the dashboard carries that note. Could add a one-line "Slack invitation comes in monthly waves — there may be a short wait" if the dashboard turns out not to be enough signal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E2e infrastructure gaps
|
|
||||||
|
|
||||||
Surfaced during the 2026-04-30 e2e expansion. None block a green suite, but each blocks specific coverage from being added.
|
|
||||||
|
|
||||||
- [ ] **Other email routes still send real emails in dev mode.** The `ALLOW_DEV_TEST_ENDPOINTS` short-circuit was added to `server/api/admin/pre-registrants/invite.post.js` (which calls `new Resend(...)` directly), but the five wrapper functions in `server/utils/resend.js` (event registration, cancellation, waitlist, series pass, welcome) still dispatch live. Either add the same gate to each wrapper, or refactor the wrappers into a single `sendEmail({ from, to, subject, text, html })` helper holding the gate centrally — would also dedupe ~5 near-identical try/catch blocks.
|
|
||||||
- [ ] **No dev endpoint to seed members of arbitrary status.** Wave-slack §7.4 (note hidden for suspended/cancelled/guest) is gated on this. `/api/dev/test-login` only mints an `active` admin. A minimal `/api/dev/members.post` accepting `{ email, status, slackInvited, ... }` would unblock many more dashboard-state e2e tests.
|
|
||||||
- [ ] **SSR `useFetch` blocks `page.route` mocking.** Page-level fetches in `[slug].vue` files run during SSR and can't be intercepted client-side. Affects: hidden-event 404 e2e, any test that needs a mocked event payload before client hydration. Either expose a client-side fetch alternative, add a server-side test mock layer, or accept that DB seeding is required for these cases.
|
|
||||||
- [ ] **Self-cancel block on paid event registrations not e2e-tested.** Requires seeding a logged-in member with a paid registration row. Out of scope for this round.
|
|
||||||
- [ ] **Visual snapshot for `join — desktop` is stale.** 12,676px diff (2% of image) from layout drift. Regenerate via `npx playwright test --update-snapshots e2e/visual/pages.spec.js` once a designer eyeballs the diff.
|
|
||||||
- [ ] **E2e cross-file races on admin specs.** With `fullyParallel: false` + `workers: 4` + `retries: 1`, ~1 admin CRUD test still fails per full-suite run (rotates between `admin-events` CRUD, `board` page-loads, and wave-slack §6.4). Each passes 100% in isolation. Root cause: tests anchor on "first row" / "any visible button" rather than uniquely-identified data, so they race when other admin specs mutate the shared dev DB. Proper fix is per-test data isolation: each test creates its own scoped record with a `Date.now()` suffix and queries by that exact identifier. Out of scope for the e2e expansion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deeplink memories
|
|
||||||
|
|
||||||
- `project_post_launch_backlog.md` — high-level digest of this file
|
|
||||||
- `project_launch_readiness.md` — cutover status (NOT YET happened)
|
|
||||||
- `project_launch_flow_map.md` — onboarding flow + Slack wave model
|
|
||||||
- `project_pre_registrants.md` — invitation system + pre-reg lifecycle
|
|
||||||
- `project_helcim_plan_model.md` — cadence-keyed plan model
|
|
||||||
- `project_contribution_amount_redesign.md` — arbitrary $ amount + guidance presets
|
|
||||||
- `project_receipts.md` — Phase 1 done, Phase 2 pending
|
|
||||||
- `project_email_automation_future.md` — Tranzac reference for full system
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
# Launch Readiness
|
|
||||||
|
|
||||||
**Status as of 2026-05-18. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
|
|
||||||
|
|
||||||
Pre-cutover deploy checklist is the live content on this page. Everything else (post-launch work, bylaws decoupling, deferred features, simplify follow-ups, a11y) lives in [`BACKLOG.md`](./BACKLOG.md). Completed launch-blocker items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Launch shape (2026-05-18)
|
|
||||||
|
|
||||||
The launch decision: **site live with events ASAP, applications open immediately, Slack invitations sent later in waves.**
|
|
||||||
|
|
||||||
- Anyone can hit the site, see events, buy a ticket (members and guests both supported on `main`).
|
|
||||||
- Anyone can join — `/join` (anonymous) and `/accept-invite` (waitlist pre-registrants) both render the same `SignupFlowOverlay` and call the same Helcim signup path. New members become `active` immediately on payment; `slackInvited=false` until an admin marks them in a wave.
|
|
||||||
- The entire waitlist is invited to apply at launch via the pre-registrant invitation tool. They go through the same flow as anonymous signups, just with email pre-filled and a token-bound pre-reg.
|
|
||||||
|
|
||||||
Open decisions that gate the launch comms — see [Open decisions](#open-decisions-before-launch-comms) below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign + migration script, cadence UX unification, receipts Phase 1, and `feature/guest-event-accounts` (merged in `e96d493`). Not pushed — site is not deployed yet.
|
|
||||||
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
|
|
||||||
- Contribution-amount migration has **NOT** yet been run against prod.
|
|
||||||
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
|
|
||||||
- `cancel-subscription` correctly keeps status `active` per ratified bylaws (Fix #9 in this doc; the stale B1 entry in BACKLOG was marked done 2026-05-18).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0 — Must fix before launch
|
|
||||||
|
|
||||||
None outstanding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1 — Strongly preferred before launch
|
|
||||||
|
|
||||||
None outstanding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy checklist
|
|
||||||
|
|
||||||
Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-repo `Dockerfile` (`node:20-alpine`, runs `node .output/server/index.mjs` on port 3000); Dokploy autodetects it. Traefik (Dokploy's reverse proxy) handles SSL; `oidc-provider.ts:194` and the rate-limit middleware already trust `X-Forwarded-Proto` / `X-Forwarded-For`.
|
|
||||||
|
|
||||||
### One-time host setup
|
|
||||||
|
|
||||||
- [ ] **Provision the Dokploy app** pointing at this repo. Build context: repo root. Default Dockerfile. Container port: `3000`.
|
|
||||||
- [ ] **Set env vars in the Dokploy UI** (full list below). The `validate-env.js` Nitro plugin fails fast at boot if `MONGODB_URI` / `JWT_SECRET` / `RESEND_API_KEY` / `HELCIM_API_TOKEN` are missing — container refuses to start, so misconfig surfaces immediately in logs.
|
|
||||||
- [ ] **`BASE_URL` must exactly match the public origin** (e.g. `https://ghostguild.org`, no trailing slash). The `/api/helcim/customer` origin check at `server/api/helcim/customer.post.js:11-15` does exact-match comparison against the `Origin` header — if `BASE_URL` is wrong or unset, signup 403s.
|
|
||||||
- [ ] **`NODE_ENV=production`** must be set. Without it: `Secure` cookie flag, HSTS, and CSP all silently no-op.
|
|
||||||
- [ ] **Add a Dokploy Scheduled Task** for daily reconciliation. Command:
|
|
||||||
```
|
|
||||||
curl -fsS -X POST "$BASE_URL/api/internal/reconcile-payments" -H "X-Reconcile-Token: $NUXT_RECONCILE_TOKEN"
|
|
||||||
```
|
|
||||||
Schedule: `0 4 * * *` (or any time of day). The Nitro route does the heavy lifting (Mongo iteration, Helcim API, retries) — the scheduler just wakes it up.
|
|
||||||
|
|
||||||
### Cutover
|
|
||||||
|
|
||||||
- [ ] Push local `main` to `origin/main`.
|
|
||||||
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.** Idempotent; dry-run on local counted 34 members. Requires `MONGODB_URI` in env. The script writes `contributionAmount` (Number) derived from existing `contributionTier` (String) on every Member doc; the old field is left intact for a window.
|
|
||||||
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in Dokploy env.
|
|
||||||
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy env.
|
|
||||||
- [ ] **Set `NUXT_RECONCILE_TOKEN`** to any 32+ char random string. Shared secret between the Dokploy scheduled task and `/api/internal/reconcile-payments`.
|
|
||||||
- [ ] Deploy.
|
|
||||||
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); the daily Dokploy cron picks it up from there.
|
|
||||||
- [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched.
|
|
||||||
- [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails.
|
|
||||||
- [ ] **Run one real test charge against the deployed app** and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
|
|
||||||
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
|
|
||||||
- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
|
|
||||||
|
|
||||||
### Activation (after Cutover passes)
|
|
||||||
|
|
||||||
The site is deployed but not yet public. These are the steps that flip the switch.
|
|
||||||
|
|
||||||
- [ ] **Disable the coming-soon gate.** Set `NUXT_PUBLIC_COMING_SOON=false` (or remove the var) in Dokploy and redeploy. The gate lives in `app/middleware/coming-soon.global.js:4` and is purely env-driven. Verify `/`, `/about`, `/events`, `/board` all render without a redirect when logged out.
|
|
||||||
- [ ] **Publish first event(s).** Confirm at least one event or series is live and visible publicly. Walk through the guest ticket-purchase flow end-to-end (anonymous → buy ticket → registered → confirmation email).
|
|
||||||
- [ ] **Pre-flight real-money signup test on prod.** Have one trusted person (ideally outside the immediate build team) go through `/join` from scratch: choose a small contribution, pay, receive welcome email, land on dashboard, see "Slack coming" note. This catches end-to-end issues that no internal test reproduces.
|
|
||||||
- [ ] **Send waitlist invitation batch** via the pre-registrant admin tool. Decide cadence first (see [Open decisions](#open-decisions-before-launch-comms)). Smoke-test by inviting yourself or one friend first; only fan out once that round-trip is clean.
|
|
||||||
|
|
||||||
### Open decisions before launch comms
|
|
||||||
|
|
||||||
These do not block deploy but need answers before the waitlist invite goes out. Each carries a small amount of work depending on the answer.
|
|
||||||
|
|
||||||
- [ ] **Apply-framing decision.** Today's CTAs say "Join Ghost Guild" / "Become a member"; there is no "Apply" copy in the codebase. Both `/join` and `/accept-invite` use the same `SignupFlowOverlay`, so the mechanical flow is single-source. Pick one:
|
|
||||||
- **A (no code work).** Keep "Join" everywhere on-site; use "apply" only in external comms (waitlist email, social, etc.).
|
|
||||||
- **B (small code work).** Rename to "Apply" across CTAs + page copy. Touches `app/pages/index.vue:11`, `app/pages/about.vue:86`, `app/pages/join.vue:5,109,111,301`, `app/components/LoginModal.vue:66`, and at least the waitlist invite + welcome email copy. Likely ~30 min of search-and-replace + screenshot review.
|
|
||||||
- [ ] **First Slack wave date.** A publicly-stated date or cadence rule (e.g. "end of each month"). Used in three places: waitlist invite email, welcome email, dashboard "Slack coming" note. Without this, every new member emails support asking when Slack is coming.
|
|
||||||
- [ ] **Non-member event CTA — ticket-first or membership-first?** Event pages render to anonymous visitors with both paths viable. Pick which one is primary: "Buy ticket" lowers friction, "Apply for membership" protects the funnel. Write the CTA copy once and use consistently across events.
|
|
||||||
- [ ] **Receipts for guest ticket purchases.** Phase 1 receipts cover membership payments only. Guest ticket buyers will get no CRA-compliant receipt at launch. Options: (a) ship a basic transactional receipt for tickets pre-launch, (b) accept the gap until Phase 2 (build June–Oct 2026, live Jan 2027).
|
|
||||||
- [ ] **Waitlist invite cadence.** Single blast vs staggered (e.g., 50/day over 4 days). Trade-off is Day-1 support load — a stagger gives you time to catch real issues from early batches before the rest of the list hits.
|
|
||||||
|
|
||||||
### Pre-launch code cleanup (recommended, not blocking)
|
|
||||||
|
|
||||||
Items from [`BACKLOG.md`](./BACKLOG.md) that materially affect the launch-window experience. None are deploy blockers, but each shows up to real users:
|
|
||||||
|
|
||||||
- [ ] **`/api/auth/member` returns `slackInvited`.** Without this, the dashboard "Slack coming" note shows for every active member regardless of state. Highest-priority of the wave-Slack bugs because every new member sees the broken case.
|
|
||||||
- [ ] **Admin members-list row reactivity** on "Mark as Slack invited" — admin has to manually reload after clicking. Hits operators, not members, but operators are us.
|
|
||||||
- [ ] **`/board` color-contrast fix** (`.block-label`, `.slack-handle` — `#746a58` on `#e8dfc8` → 4.01:1, needs ≥4.5:1). Single CSS-var change, currently the only red item in `e2e/a11y.spec.js`.
|
|
||||||
- [ ] **Spec vs UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 says "no wave/cohort/batch language" but shipped copy uses "monthly onboarding waves." Pick a side and align before launch comms go out.
|
|
||||||
|
|
||||||
**Env vars required in Dokploy (reference):**
|
|
||||||
- `NODE_ENV=production`
|
|
||||||
- `BASE_URL` (exact public origin, no trailing slash)
|
|
||||||
- `MONGODB_URI`
|
|
||||||
- `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins)
|
|
||||||
- `RESEND_API_KEY`
|
|
||||||
- `HELCIM_API_TOKEN`
|
|
||||||
- `NUXT_HELCIM_MONTHLY_PLAN_ID=50302`
|
|
||||||
- `NUXT_HELCIM_ANNUAL_PLAN_ID=50303`
|
|
||||||
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
|
|
||||||
- `NUXT_RECONCILE_TOKEN` (32+ char random string)
|
|
||||||
- `SLACK_BOT_TOKEN`
|
|
||||||
- `OIDC_COOKIE_SECRET`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fixed 2026-04-25
|
|
||||||
|
|
||||||
Day-of-launch security and correctness audit. All commit shas TBD until Phase 5.
|
|
||||||
|
|
||||||
### CRITICAL (security)
|
|
||||||
- **Fix #1** — `HELCIM_API_TOKEN` removed from public runtime config + dead `useHelcim.js` deleted. **Token must be rotated post-deploy** (was previously exposed via `window.__NUXT__`).
|
|
||||||
- **Fix #2** — `/api/helcim/customer` gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated `setAuthCookie`).
|
|
||||||
- **Fix #3** — `/api/events/[id]/payment` deleted (dead code with auth bypass). `processHelcimPayment` stub + `eventPaymentSchema` removed.
|
|
||||||
- **Fix #4** — `/api/helcim/initialize-payment` re-derives ticket amount server-side via `calculateTicketPrice`; new `series_ticket` metadata type.
|
|
||||||
- **Fix #5** — `/api/helcim/customer` upgrades existing `status:guest` members in place rather than rejecting with 409.
|
|
||||||
|
|
||||||
### HIGH (correctness)
|
|
||||||
- **Fix #6** — Recurring reconciliation: Netlify scheduled function calls `/api/internal/reconcile-payments` daily. Requires `NUXT_RECONCILE_TOKEN` env var.
|
|
||||||
- **Fix #7** — `validateBeforeSave: false` added to event subdoc saves (waitlist endpoints) to dodge legacy location validators.
|
|
||||||
- **Fix #8** — Series-pass purchase always creates a guest Member when caller is unauthenticated, mirroring event-ticket flow.
|
|
||||||
- **Fix #9** — `cancel-subscription` leaves status `active` (per ratified bylaws); adds `lastCancelledAt` audit field.
|
|
||||||
- **Fix #10** — `/api/auth/verify` uses `validateBody` with `.strict()` Zod schema.
|
|
||||||
- **Fix #11** — Added 8 vitest cases for `cancel-subscription.post.js` (was uncovered).
|
|
||||||
|
|
||||||
### Side-quests
|
|
||||||
- Visual audit Phase 4 changes (events/series surface)
|
|
||||||
- Per-fix branch verification: see `docs/superpowers/specs/2026-04-25-fix-*.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual browser tests still needed
|
|
||||||
|
|
||||||
None outstanding. All launch-blocking flows verified via local dev or cloudflared tunnel with real Helcim test card + real email (see archive for the full log). The one remaining browser verification is the staging test charge bundled into the Deploy checklist above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-launch & deferred work
|
|
||||||
|
|
||||||
Bylaws decoupling, post-launch a11y, ASVS Phase 4, deferred features, simplify-pass follow-ups, known gotchas, wave-Slack pilot follow-ups — **everything that isn't a deploy step has moved to [`BACKLOG.md`](./BACKLOG.md).**
|
|
||||||
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# Board Classifieds Redesign
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Replace the Board's passive tag-matching system with an active classifieds board where members post what they're seeking and offering. Posts are the single source of truth for board presence. The UI follows a corkboard/zine card layout. All communication happens on Slack via curated topic channels.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Give members a reason to browse and return to the Board
|
|
||||||
- Make the Board feel like a BBS — fun, personal, alive
|
|
||||||
- Push all conversation to Slack (no in-app messaging)
|
|
||||||
- Replace the abstract tag-state system with concrete, human-readable posts
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|----------|--------|-----------|
|
|
||||||
| Visual style | Corkboard / zine cards | Fits existing design language, gives each post personality |
|
|
||||||
| Posts vs matching | Posts replace tag-matching entirely | Single source of truth, simpler mental model |
|
|
||||||
| Post lifecycle | Evergreen until removed by author | Simple, member-managed |
|
|
||||||
| Posts per member | Unlimited | Community will self-regulate |
|
|
||||||
| Slack integration | Web URL links to curated topic channels | `gammaspace.slack.com/archives/{channelId}` — tested, works reliably |
|
|
||||||
| Slack deep links | Protocol (`slack://`) and app links do not work | Tested — only web URL format opens the correct channel |
|
|
||||||
| Channel management | Admin-managed, curated set with tag mapping | Admin UI to map cooperative tags to Slack channels |
|
|
||||||
| Unmapped tags | No Slack link shown | No fallback channel |
|
|
||||||
| Visibility | All members see all posts | Behind `members-auth` middleware |
|
|
||||||
| Migration | None needed | Pre-launch, test data only |
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### New: `BoardPost`
|
|
||||||
|
|
||||||
```
|
|
||||||
author ObjectId, ref Member, required
|
|
||||||
title String, required, max 120
|
|
||||||
seeking String, optional, max 500
|
|
||||||
offering String, optional, max 500
|
|
||||||
note String, optional, max 300
|
|
||||||
tags [String] — slugs from cooperative tag pool
|
|
||||||
createdAt Date
|
|
||||||
updatedAt Date
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation: at least one of `seeking` or `offering` is required.
|
|
||||||
|
|
||||||
### New: `BoardChannel`
|
|
||||||
|
|
||||||
```
|
|
||||||
name String, required — display name (e.g. "Structure & Governance")
|
|
||||||
slackChannelId String, required — Slack channel ID (e.g. "C09DDGZGXAP")
|
|
||||||
tagSlugs [String] — cooperative tag slugs mapped to this channel
|
|
||||||
createdAt Date
|
|
||||||
updatedAt Date
|
|
||||||
```
|
|
||||||
|
|
||||||
### Member Model Changes
|
|
||||||
|
|
||||||
**Remove:**
|
|
||||||
- `board.topics` (array of tag/state objects)
|
|
||||||
- `board.details`
|
|
||||||
- `board.offerPeerSupport`
|
|
||||||
- `board.availability`
|
|
||||||
- `board.personalMessage`
|
|
||||||
|
|
||||||
**Keep:**
|
|
||||||
- `board.slackHandle` — author's Slack identity, shown on posts
|
|
||||||
- Privacy toggle for Slack handle visibility
|
|
||||||
|
|
||||||
## Board Page
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
Corkboard card grid. 2 columns on desktop, 1 column at ≤1024px. Newest posts first.
|
|
||||||
|
|
||||||
### Header
|
|
||||||
|
|
||||||
- Page title "Board" with post count subtitle
|
|
||||||
- "+ New Post" button
|
|
||||||
- Tag filter drawer (existing pattern — toggleable, filters posts by tag)
|
|
||||||
|
|
||||||
### Post Card
|
|
||||||
|
|
||||||
Each card displays:
|
|
||||||
|
|
||||||
- **Type indicator:** SEEKING (gold) / OFFERING (green) / SEEKING ↔ OFFERING (ember) — derived from which fields are filled
|
|
||||||
- **Title** — prominent, Brygada 1918
|
|
||||||
- **Seeking text** — if present
|
|
||||||
- **Offering text** — if present
|
|
||||||
- **Note** — personal touch, slightly different visual treatment
|
|
||||||
- **Tags** — dashed-border pills
|
|
||||||
- **Footer:** author avatar + name + circle badge
|
|
||||||
- **Slack link:** "Discuss on Slack →" linking to mapped channel. Only shown if post's tags map to a channel. Uses `https://gammaspace.slack.com/archives/{channelId}`. If tags map to multiple channels, use the first match.
|
|
||||||
|
|
||||||
### New Post Form
|
|
||||||
|
|
||||||
Inline at top of the Board page (not a modal). Fields:
|
|
||||||
|
|
||||||
- Title (required, 120 chars)
|
|
||||||
- Seeking (optional, 500 chars)
|
|
||||||
- Offering (optional, 500 chars)
|
|
||||||
- Note (optional, 300 chars)
|
|
||||||
- Tags (multi-select from cooperative tag pool)
|
|
||||||
|
|
||||||
Validation: at least one of seeking/offering required. Form appears on "+ New Post" click, collapses after submission.
|
|
||||||
|
|
||||||
### Empty State
|
|
||||||
|
|
||||||
Friendly prompt to be the first to post, with link to create.
|
|
||||||
|
|
||||||
## Profile Board Section
|
|
||||||
|
|
||||||
Replaces the current cooperative tag selector, details textarea, and peer support section.
|
|
||||||
|
|
||||||
### Shows
|
|
||||||
|
|
||||||
- List of member's active posts (compact card previews)
|
|
||||||
- Edit and delete actions per post
|
|
||||||
- "+ New Post" button (navigates to Board page or opens same inline form)
|
|
||||||
- Slack handle setting (identity-level, not per-post)
|
|
||||||
- Privacy toggle for Slack handle
|
|
||||||
|
|
||||||
### Removes
|
|
||||||
|
|
||||||
- `CooperativeTagSelector` three-state tag picker
|
|
||||||
- Details textarea
|
|
||||||
- Offer Peer Support toggle + conditional fields (availability, personal message)
|
|
||||||
|
|
||||||
### No Posts State
|
|
||||||
|
|
||||||
Prompt to visit the Board and post something.
|
|
||||||
|
|
||||||
## Admin: Board Channels
|
|
||||||
|
|
||||||
New admin page for managing Slack channel mappings.
|
|
||||||
|
|
||||||
### UI
|
|
||||||
|
|
||||||
- List of board channels showing: display name, Slack channel ID, mapped tags
|
|
||||||
- Add / edit / remove channels
|
|
||||||
- Tag mapping: multi-select from cooperative tags
|
|
||||||
- Unmapped tag indicator: shows cooperative tags not yet assigned to any channel
|
|
||||||
|
|
||||||
### Behavior
|
|
||||||
|
|
||||||
- Admins create channels in Slack manually, then register them here by pasting the channel ID
|
|
||||||
- Frontend uses the channel list to build Slack links on post cards
|
|
||||||
|
|
||||||
## API Routes
|
|
||||||
|
|
||||||
### New
|
|
||||||
|
|
||||||
| Method | Path | Auth | Purpose |
|
|
||||||
|--------|------|------|---------|
|
|
||||||
| GET | `/api/board/posts` | member | List all posts, newest first. Tag filtering via query params. Populates author name/avatar/circle/slackHandle. |
|
|
||||||
| POST | `/api/board/posts` | member | Create a post. Validates at least one of seeking/offering. |
|
|
||||||
| PATCH | `/api/board/posts/:id` | member (own post) | Edit a post. |
|
|
||||||
| DELETE | `/api/board/posts/:id` | member (own post) | Delete a post. |
|
|
||||||
| GET | `/api/board/channels` | member | List channels with tag mappings (for building Slack links). |
|
|
||||||
| GET | `/api/admin/board-channels` | admin | List channels for admin UI. |
|
|
||||||
| POST | `/api/admin/board-channels` | admin | Create channel mapping. |
|
|
||||||
| PATCH | `/api/admin/board-channels/:id` | admin | Update channel mapping. |
|
|
||||||
| DELETE | `/api/admin/board-channels/:id` | admin | Remove channel mapping. |
|
|
||||||
|
|
||||||
### Remove
|
|
||||||
|
|
||||||
| Path | Reason |
|
|
||||||
|------|--------|
|
|
||||||
| `GET /api/board/suggestions` | Replaced by posts |
|
|
||||||
| `PATCH /api/members/me/board` | Board profile fields removed (slackHandle stays on existing member profile patch) |
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Stays (repurposed)
|
|
||||||
|
|
||||||
- `CooperativeTagSelector` — simplified to a plain tag picker (no three-state toggle) for use in post creation form
|
|
||||||
|
|
||||||
### Goes
|
|
||||||
|
|
||||||
- Match-card UI on Board page
|
|
||||||
- Peer support section on profile page
|
|
||||||
|
|
||||||
### New
|
|
||||||
|
|
||||||
- `BoardPostCard` — the corkboard card component
|
|
||||||
- `BoardPostForm` — inline creation/edit form
|
|
||||||
- `BoardPostList` — grid layout for post cards (used on Board page and profile)
|
|
||||||
- Admin channel management components
|
|
||||||
|
|
||||||
## Composables
|
|
||||||
|
|
||||||
### Remove
|
|
||||||
|
|
||||||
- `useBoard` (the old `getSuggestions` wrapper)
|
|
||||||
|
|
||||||
### New
|
|
||||||
|
|
||||||
- `useBoardPosts` — CRUD for posts, tag filtering
|
|
||||||
- `useBoardChannels` — fetch channel mappings, resolve tag→channel for Slack links
|
|
||||||
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 18 KiB |