Compare commits
141 commits
fix/events
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a361b6857 | |||
| cc89c28f03 | |||
| 384d3197ce | |||
| 4a05e91715 | |||
| 622cc8e53b | |||
| 2ffaf0ef09 | |||
| 31144617d7 | |||
| 877ef1a220 | |||
| 9e18560ebf | |||
| 96470a604a | |||
| 49f4eae11c | |||
| 75b1f84d18 | |||
| 9dd007657a | |||
| acbd3c0737 | |||
| a76ba2f8c7 | |||
| e6f05b5471 | |||
| 9e4030ccfd | |||
| f5b7a3eeba | |||
| 6fa3e08fe0 | |||
| e1d224e260 | |||
| 2a66b0eb8a | |||
| 397c00125a | |||
| 050d117abf | |||
| 94b242100c | |||
| 790f44b4e9 | |||
| 13c72b5ee0 | |||
| 9858316b30 | |||
| 0927b66b4f | |||
| 84aea08a5f | |||
| 73e67d02bb | |||
| c3695de5ca | |||
| b45f92a574 | |||
| b7d9d91b1a | |||
| 47e106171e | |||
| 6bfb078e45 | |||
| f66189cfd6 | |||
| 1578055a27 | |||
| 6e98720310 | |||
| f428cbb219 | |||
| f05c1f6d40 | |||
| 0985f6acb1 | |||
| 43eda6db04 | |||
| 386cb7e4b2 | |||
| a797f8e17c | |||
| 16aaeddcee | |||
| d1b5107478 | |||
| 9ddb45c4d8 | |||
| f62fd4f586 | |||
| ba84429917 | |||
| 593b1238f9 | |||
| 8dd55ccc09 | |||
| 03dfdab20e | |||
| 6a6f036877 | |||
| 1c8f30fe6f | |||
| 7f0a586311 | |||
| b9fa9f603c | |||
| 33ba082b82 | |||
| a949252915 | |||
| 9b79ae6bf4 | |||
| c6a5e25d06 | |||
| 441a5f5608 | |||
| d9444b022b | |||
| da5e7efcb7 | |||
| d4000c18cf | |||
| 313b8598df | |||
| d06c83cfc4 | |||
| 9c7d6fa446 | |||
| 07943266b7 | |||
| 5a69d6ab75 | |||
| d6cdf45838 | |||
| cb93f14160 | |||
| d93c16fbf7 | |||
| cad57b0083 | |||
| 1c2d1537a8 | |||
| 26791cc0e3 | |||
| 6527bbbe4e | |||
| 90acc35792 | |||
| dbd46cc157 | |||
| a9acc4c2dc | |||
| dadec1a273 | |||
| f85f284ea5 | |||
| 55c57d263d | |||
| 1da76b11cb | |||
| 350d6c219c | |||
| 05c47c4499 | |||
| 59d2be2df8 | |||
| 23154ff232 | |||
| a69c9d9b49 | |||
| dc2becf63e | |||
| e19b16a5cc | |||
| e756170884 | |||
| 7e44809a83 | |||
| f66455eda5 | |||
| 955217a941 | |||
| d15458b30a | |||
| 7b326f879d | |||
| c2999810c6 | |||
| 0981596ea2 | |||
| 55029e7eb7 | |||
| b1d8cb1966 | |||
| 2f6a92ac61 | |||
| 3c49317437 | |||
| be24ae32fb | |||
| cf59931814 | |||
| 3c38333dd1 | |||
| 4d44e7045c | |||
| c1367ebd29 | |||
| ac5e979c78 | |||
| 0a41b30db7 | |||
| 5f93d4c2e3 | |||
| bd4561fea7 | |||
| 2611a2a973 | |||
| 5432dfe8f2 | |||
| 0eeb3c351f | |||
| bafe24b778 | |||
| 00073ec52c | |||
| edef1b86be | |||
| 0d83003f87 | |||
| 521efb0890 | |||
| bb0dbfe53e | |||
| 3f42307c64 | |||
| 0c489cf2c3 | |||
| 0f841912e2 | |||
| e3410c52a5 | |||
| 210a8d588f | |||
| 04eb33df6e | |||
| 1083a1d260 | |||
| a2a8d945c6 | |||
| 0369992cdd | |||
| 7a626b0a82 | |||
| c149fba13a | |||
| 8e76ce9366 | |||
| 51230e5151 | |||
| 208638e374 | |||
| 0f2f1d1cbf | |||
| 8f0648de57 | |||
| 53331cc190 | |||
| dc9c868f75 | |||
| 886c62e7b1 | |||
| b222b14e61 | |||
| e227f29bcd |
|
|
@ -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://localhost:27017/ghostguild-test
|
MONGODB_URI: mongodb://mongo-ci: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,15 +39,35 @@ 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 &
|
run: node .output/server/index.mjs > /tmp/server.log 2>&1 &
|
||||||
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'
|
||||||
- run: npx playwright test --ignore-snapshots
|
- name: Server log on failure
|
||||||
- uses: actions/upload-artifact@v4
|
if: failure()
|
||||||
|
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
|
||||||
|
|
@ -68,39 +88,3 @@ 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
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
|
|
@ -40,3 +40,4 @@ e2e/.auth/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
.claude
|
.claude
|
||||||
|
scripts/dump-babyghosts-preregistrations.mjs
|
||||||
|
|
|
||||||
0
.husky/pre-push
Normal file → Executable file
|
|
@ -3,21 +3,26 @@ 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 bash clojure cpp csharp
|
# al angular ansible bash clojure
|
||||||
# csharp_omnisharp dart elixir elm erlang
|
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||||
# fortran fsharp go groovy haskell
|
# dart elixir elm erlang fortran
|
||||||
# java julia kotlin lua markdown
|
# fsharp go groovy haskell haxe
|
||||||
# matlab nix pascal perl php
|
# hlsl html java json julia
|
||||||
# php_phpactor powershell python python_jedi r
|
# kotlin lean4 lua luau markdown
|
||||||
# rego ruby ruby_solargraph rust scala
|
# matlab msl nix ocaml pascal
|
||||||
# swift terraform toml typescript typescript_vts
|
# perl php php_phpactor powershell python
|
||||||
# vue yaml zig
|
# python_jedi python_ty r rego ruby
|
||||||
|
# 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.
|
||||||
|
|
@ -65,53 +70,17 @@ 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
|
||||||
|
|
@ -122,11 +91,14 @@ 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.
|
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||||
# The full set of modes to be activated is base_modes + default_modes.
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
# If the setting is undefined/empty, 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
|
||||||
|
|
@ -150,3 +122,19 @@ 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:20-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
@ -7,8 +7,11 @@ 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.
|
||||||
FROM node:20-alpine
|
# bash + curl are added so Dokploy scheduled tasks (which wrap commands in
|
||||||
|
# `bash -c "..."`) can run; alpine ships only ash and has no curl by default.
|
||||||
|
FROM node:22-alpine
|
||||||
|
RUN apk add --no-cache bash curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/.output .output
|
COPY --from=builder /app/.output .output
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@
|
||||||
--text: #2a2015;
|
--text: #2a2015;
|
||||||
--text-bright: #1a1008;
|
--text-bright: #1a1008;
|
||||||
--text-dim: #5a5040;
|
--text-dim: #5a5040;
|
||||||
--text-faint: #746a58;
|
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
|
||||||
|
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
|
||||||
|
--text-dim (5.80:1) while meeting AA for small text. */
|
||||||
|
--text-faint: #665c4b;
|
||||||
--parch: #2a2015;
|
--parch: #2a2015;
|
||||||
--parch-hover: #3a3025;
|
--parch-hover: #3a3025;
|
||||||
--parch-text: #ede4d0;
|
--parch-text: #ede4d0;
|
||||||
|
|
@ -273,6 +276,14 @@ 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 ----
|
/* ---- SHARED USelectMenu STYLES ----
|
||||||
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
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. */
|
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ const slackLinks = computed(() => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.board-post {
|
.board-post {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 18px 22px;
|
padding: 20px 24px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
-webkit-column-break-inside: avoid;
|
-webkit-column-break-inside: avoid;
|
||||||
|
|
@ -178,7 +178,8 @@ const slackLinks = computed(() => {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-faint);
|
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||||
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-actions {
|
.post-actions {
|
||||||
|
|
@ -219,7 +220,7 @@ const slackLinks = computed(() => {
|
||||||
|
|
||||||
.post-title {
|
.post-title {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 19px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
|
|
@ -233,7 +234,8 @@ const slackLinks = computed(() => {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-faint);
|
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||||
|
color: var(--text-dim);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.block-text {
|
.block-text {
|
||||||
|
|
@ -244,7 +246,8 @@ const slackLinks = computed(() => {
|
||||||
|
|
||||||
.post-note {
|
.post-note {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||||
|
color: var(--text-dim);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
@ -293,7 +296,8 @@ const slackLinks = computed(() => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-faint);
|
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||||
|
color: var(--text-dim);
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
}
|
}
|
||||||
.author-name {
|
.author-name {
|
||||||
|
|
@ -308,7 +312,8 @@ const slackLinks = computed(() => {
|
||||||
}
|
}
|
||||||
.slack-handle {
|
.slack-handle {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||||
|
color: var(--text-dim);
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.post-form {
|
.post-form {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 14px 16px;
|
padding: 16px 16px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +147,7 @@ function handleSubmit() {
|
||||||
}
|
}
|
||||||
.form-title {
|
.form-title {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +183,7 @@ function handleSubmit() {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
.circle-option {
|
.circle-option {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 14px 12px;
|
padding: 12px 12px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
@ -83,7 +83,7 @@ defineEmits(['update:modelValue'])
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-tag {
|
.circle-tag {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,14 @@ const props = defineProps({
|
||||||
limit: { type: Number, default: 3 },
|
limit: { type: Number, default: 3 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const upcomingEvents = ref([])
|
let 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.value = data.value || []
|
upcomingEvents = computed(() => data.value || [])
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<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,37 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
|
||||||
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
|
||||||
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
|
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
|
||||||
name="heroicons:ticket"
|
<span class="text-sm font-semibold" style="color: var(--parch-text)">
|
||||||
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="text-xl font-bold text-white mb-1">
|
<h3 class="font-display text-xl font-bold mb-1" style="color: var(--parch-text)">
|
||||||
{{ ticket.name }}
|
{{ ticket.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="ticket.description" class="text-sm text-candlelight-900 dark:text-candlelight-200">
|
<p v-if="ticket.description" class="text-sm" style="color: var(--parch-text); opacity: 0.85">
|
||||||
{{ 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 text-white text-ui-mono">
|
<div class="text-3xl font-bold" style="color: var(--parch-text)">
|
||||||
{{ formatPrice(ticket.price, ticket.currency) }}
|
{{ formatPrice(ticket.price, ticket.currency) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
|
||||||
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>
|
||||||
|
|
@ -39,29 +29,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="p-6 bg-guild-800/50 dark:bg-guild-700/30">
|
<div class="p-6" style="background: var(--surface)">
|
||||||
<!-- What's Included -->
|
<!-- What's Included -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
||||||
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 text-guild-300 dark:text-guild-300">
|
<div class="flex items-center gap-2" style="color: var(--text)">
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||||
<span>Access to all {{ totalEvents }} events in the series</span>
|
<span>Access to all {{ totalEvents }} events in the series</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
|
||||||
v-if="ticket.isFree && !isMember"
|
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||||
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
|
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
|
||||||
v-if="memberSavings > 0"
|
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||||
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>
|
||||||
|
|
@ -69,33 +53,31 @@
|
||||||
|
|
||||||
<!-- 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 text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
||||||
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 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
|
class="flex items-start gap-3 p-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full bg-candlelight-600/20 border border-candlelight-500/30 flex items-center justify-center flex-shrink-0"
|
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||||
|
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-bold text-candlelight-300">{{ index + 1 }}</span>
|
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-guild-100 dark:text-guild-100 text-sm">
|
<div class="font-medium text-sm" style="color: var(--text)">
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-guild-400 dark:text-guild-400 mt-1">
|
<div class="text-xs mt-1" style="color: var(--text-faint)">
|
||||||
{{ formatEventDate(event.startDate) }}
|
{{ formatEventDate(event.startDate) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
|
||||||
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>
|
||||||
|
|
@ -104,13 +86,14 @@
|
||||||
<!-- Member Benefit Callout -->
|
<!-- Member Benefit Callout -->
|
||||||
<div
|
<div
|
||||||
v-if="ticket.isFree && isMember"
|
v-if="ticket.isFree && isMember"
|
||||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
class="p-4 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 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
<Icon name="heroicons:sparkles" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
|
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
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>
|
||||||
|
|
@ -120,13 +103,14 @@
|
||||||
<!-- 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 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
class="p-4 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 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
<Icon name="heroicons:tag" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">Member Savings</div>
|
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
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>
|
||||||
|
|
@ -136,22 +120,15 @@
|
||||||
|
|
||||||
<!-- Availability -->
|
<!-- Availability -->
|
||||||
<div v-if="availability" class="mb-6">
|
<div v-if="availability" class="mb-6">
|
||||||
<div
|
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
|
||||||
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="[
|
class="w-5 h-5"
|
||||||
'w-5 h-5',
|
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
||||||
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
:class="[
|
class="text-sm font-medium"
|
||||||
'text-sm font-medium',
|
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
||||||
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>
|
||||||
|
|
@ -160,12 +137,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 bg-ember-900/20 border border-ember-700/30 rounded-lg">
|
<div class="p-4" style="background: var(--ember-bg); border: 1px solid var(--ember)">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 flex-shrink-0 mt-0.5" />
|
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
|
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
|
||||||
<div class="text-sm text-ember-400">
|
<div class="text-sm" style="color: var(--ember)">
|
||||||
All series passes have been claimed.
|
All series passes have been claimed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,7 +151,7 @@
|
||||||
<UButton
|
<UButton
|
||||||
v-if="availability?.waitlistAvailable"
|
v-if="availability?.waitlistAvailable"
|
||||||
block
|
block
|
||||||
color="gray"
|
color="neutral"
|
||||||
size="lg"
|
size="lg"
|
||||||
@click="$emit('join-waitlist')"
|
@click="$emit('join-waitlist')"
|
||||||
>
|
>
|
||||||
|
|
@ -183,12 +160,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Already Registered -->
|
<!-- Already Registered -->
|
||||||
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
|
<div
|
||||||
|
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 text-candlelight-400 flex-shrink-0" />
|
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
|
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ const formatPrice = (amount) => {
|
||||||
|
|
||||||
.early-bird {
|
.early-bird {
|
||||||
color: var(--candle-dim);
|
color: var(--candle-dim);
|
||||||
border-color: rgba(122, 90, 16, 0.35);
|
border-color: var(--candle-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket-savings {
|
.ticket-savings {
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,14 @@
|
||||||
|
|
||||||
<!-- Already Registered -->
|
<!-- Already Registered -->
|
||||||
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
||||||
<div class="box-title">Registration</div>
|
|
||||||
<p class="ticket-status" style="color: var(--green)">
|
<p class="ticket-status" style="color: var(--green)">
|
||||||
You're Registered!
|
You're Registered!
|
||||||
</p>
|
</p>
|
||||||
<p class="ticket-detail">
|
<p class="ticket-detail">
|
||||||
<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
|
||||||
|
>.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
You're all set for this event. Check your email for confirmation
|
You're all set for this event. Check your email for confirmation
|
||||||
|
|
@ -70,13 +70,11 @@
|
||||||
|
|
||||||
<!-- Registration (logged-in member) -->
|
<!-- Registration (logged-in member) -->
|
||||||
<div
|
<div
|
||||||
v-if="ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn"
|
v-if="
|
||||||
|
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
|
||||||
|
"
|
||||||
class="ticket-panel"
|
class="ticket-panel"
|
||||||
>
|
>
|
||||||
<div class="box-title">
|
|
||||||
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
||||||
class="ticket-notice"
|
class="ticket-notice"
|
||||||
|
|
@ -90,8 +88,7 @@
|
||||||
class="ticket-notice"
|
class="ticket-notice"
|
||||||
style="color: var(--candle)"
|
style="color: var(--candle)"
|
||||||
>
|
>
|
||||||
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
Payment of {{ ticketInfo.formattedPrice }} will be processed securely
|
||||||
securely
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -129,7 +126,7 @@
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
required
|
required
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -142,7 +139,7 @@
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
@ -154,17 +151,23 @@
|
||||||
securely
|
securely
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="consent-block">
|
||||||
<label class="consent-field">
|
<label class="consent-field">
|
||||||
<input
|
<input
|
||||||
v-model="form.createAccount"
|
v-model="form.createAccount"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
>Create a free guest account so I can manage my
|
||||||
|
registration</span
|
||||||
>
|
>
|
||||||
<span>Create a free guest account so I can manage my registration</span>
|
|
||||||
</label>
|
</label>
|
||||||
<p class="field-hint consent-hint">
|
<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.
|
Guest accounts let you view your tickets and register faster next
|
||||||
|
time. We won't add you to member communications.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -188,24 +191,18 @@
|
||||||
class="ticket-panel"
|
class="ticket-panel"
|
||||||
>
|
>
|
||||||
<div class="box-title">Waitlist</div>
|
<div class="box-title">Waitlist</div>
|
||||||
<p class="ticket-status" style="color: var(--ember)">
|
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||||
Event Sold Out
|
|
||||||
</p>
|
|
||||||
<p class="ticket-detail">
|
<p class="ticket-detail">
|
||||||
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">
|
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
|
||||||
Join Waitlist
|
|
||||||
</button>
|
|
||||||
</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="ticket-panel">
|
||||||
<div class="box-title">Tickets</div>
|
<div class="box-title">Tickets</div>
|
||||||
<p class="ticket-status" style="color: var(--ember)">
|
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||||
Event Sold Out
|
|
||||||
</p>
|
|
||||||
<p class="ticket-detail">
|
<p class="ticket-detail">
|
||||||
Unfortunately, this event is at capacity and no longer accepting
|
Unfortunately, this event is at capacity and no longer accepting
|
||||||
registrations.
|
registrations.
|
||||||
|
|
@ -222,13 +219,17 @@ const props = defineProps({
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
eventStartDate: {
|
eventStartDate: {
|
||||||
type: Date,
|
type: [String, 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,
|
||||||
|
|
@ -305,7 +306,9 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular ticket availability check
|
// Regular ticket availability check
|
||||||
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : "";
|
const params = effectiveEmail
|
||||||
|
? `?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}`,
|
||||||
);
|
);
|
||||||
|
|
@ -330,7 +333,6 @@ const handleSubmit = async () => {
|
||||||
await initializeTicketPayment(
|
await initializeTicketPayment(
|
||||||
props.eventId,
|
props.eventId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
ticketInfo.value.price,
|
|
||||||
props.eventTitle,
|
props.eventTitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -414,6 +416,7 @@ const formatEventDate = (date) => {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
timeZone: props.eventTimezone || "America/Toronto",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -451,21 +454,26 @@ const formatEventDate = (date) => {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consent-field {
|
.consent-block {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.consent-field {
|
||||||
|
display: contents;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-bottom: 4px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.consent-field input[type="checkbox"] {
|
.consent-field input[type="checkbox"] {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
flex-shrink: 0;
|
accent-color: var(--candle);
|
||||||
}
|
}
|
||||||
.consent-hint {
|
.consent-hint {
|
||||||
margin-bottom: 14px;
|
grid-column: 2;
|
||||||
padding-left: 24px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</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.startDate) }}</span>
|
<span class="em-date">{{ formatDate(event) }}</span>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/events/${event.slug || event._id}`"
|
:to="`/events/${event.slug || event._id}`"
|
||||||
class="em-title"
|
class="em-title"
|
||||||
|
|
@ -37,10 +37,13 @@ defineProps({
|
||||||
events: { type: Array, default: () => [] },
|
events: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (event) => {
|
||||||
if (!dateStr) return "";
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr);
|
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -104,7 +107,7 @@ const formatDate = (dateStr) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.em-circle {
|
.em-circle {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
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: 14px 32px;
|
padding: 16px 28px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@
|
||||||
<img
|
<img
|
||||||
:src="transformedImageUrl"
|
:src="transformedImageUrl"
|
||||||
:alt="modelValue.alt || 'Event image'"
|
:alt="modelValue.alt || 'Event image'"
|
||||||
class="w-full h-48 object-cover rounded-lg border border-guild-700"
|
class="w-full h-48 object-cover"
|
||||||
|
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
|
||||||
@click="removeImage"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors"
|
class="absolute top-2 right-2 p-1 rounded-full transition-colors"
|
||||||
|
style="background: var(--ember); color: var(--parch-text)"
|
||||||
|
@click="removeImage"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -21,67 +23,84 @@
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
<div
|
<div
|
||||||
v-if="!modelValue?.url"
|
v-if="!modelValue?.url"
|
||||||
class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors"
|
class="border-2 border-dashed p-6 text-center transition-colors"
|
||||||
|
:style="
|
||||||
|
isDragging
|
||||||
|
? 'border-color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent)'
|
||||||
|
: 'border-color: var(--border)'
|
||||||
|
"
|
||||||
@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/*"
|
||||||
@change="handleFileSelect"
|
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
@change="handleFileSelect"
|
||||||
|
>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" />
|
<Icon
|
||||||
|
name="heroicons:photo"
|
||||||
|
class="w-12 h-12 mx-auto"
|
||||||
|
style="color: var(--text-dim)"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-guild-400">
|
<p style="color: var(--text-dim)">
|
||||||
<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 text-guild-500">PNG, JPG, GIF up to 10MB</p>
|
<p class="text-sm" style="color: var(--text-faint)">
|
||||||
|
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 class="block text-sm font-medium text-guild-100 mb-1">
|
<label
|
||||||
|
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 || ''"
|
||||||
@input="updateAltText($event.target.value)"
|
|
||||||
placeholder="Describe this image..."
|
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"
|
class="w-full px-3 py-2 alt-text-input"
|
||||||
/>
|
@input="updateAltText($event.target.value)"
|
||||||
|
>
|
||||||
</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 class="text-guild-400">Uploading...</span>
|
<span style="color: var(--text-dim)">Uploading...</span>
|
||||||
<span class="text-guild-400">{{ uploadProgress }}%</span>
|
<span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-guild-800 rounded-full h-2">
|
|
||||||
<div
|
<div
|
||||||
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
|
class="w-full rounded-full h-2"
|
||||||
:style="`width: ${uploadProgress}%`"
|
style="background: var(--surface)"
|
||||||
|
>
|
||||||
|
<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 text-ember-400">
|
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,3 +220,16 @@ 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: 14px;
|
font-size: 13px;
|
||||||
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: 10px 14px;
|
padding: 12px 16px;
|
||||||
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,67 +1,40 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-2">
|
<div class="natural-date-input">
|
||||||
<div class="relative">
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="naturalInput"
|
:model-value="rawInput"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:color="
|
:color="trailingState"
|
||||||
hasError && naturalInput.trim()
|
@update:model-value="onInputChange"
|
||||||
? 'error'
|
|
||||||
: isValidParse && naturalInput.trim()
|
|
||||||
? 'success'
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
@input="parseNaturalInput"
|
|
||||||
@blur="onBlur"
|
|
||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="isValidParse && naturalInput.trim()"
|
v-if="isValid && rawInput.trim()"
|
||||||
name="heroicons:check-circle"
|
name="heroicons:check-circle"
|
||||||
class="w-5 h-5 text-candlelight-500"
|
class="w-5 h-5"
|
||||||
|
style="color: var(--candle)"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
v-else-if="hasError && naturalInput.trim()"
|
v-else-if="hasError && rawInput.trim()"
|
||||||
name="heroicons:exclamation-circle"
|
name="heroicons:exclamation-circle"
|
||||||
class="w-5 h-5 text-ember-500"
|
class="w-5 h-5"
|
||||||
|
style="color: var(--ember)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</div>
|
<p
|
||||||
|
v-if="rawInput.trim() && isValid"
|
||||||
<div
|
class="preview-line"
|
||||||
v-if="parsedDate && isValidParse"
|
style="color: var(--candle)"
|
||||||
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
→ {{ previewText }}
|
||||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
</p>
|
||||||
<span>{{ formatParsedDate(parsedDate) }}</span>
|
<p
|
||||||
</div>
|
v-else-if="rawInput.trim() && hasError"
|
||||||
</div>
|
class="preview-line"
|
||||||
|
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"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
{{ errorMessage }}
|
||||||
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
|
</p>
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -69,176 +42,197 @@
|
||||||
import * as chrono from "chrono-node";
|
import * as chrono from "chrono-node";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: String, default: "" },
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
|
default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
|
||||||
},
|
|
||||||
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 naturalInput = ref("");
|
const rawInput = ref("");
|
||||||
const parsedDate = ref(null);
|
const isValid = ref(false);
|
||||||
const isValidParse = ref(false);
|
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const datetimeValue = ref("");
|
// previewDate holds the parsed value as a UTC Date so we can format it in
|
||||||
|
// arbitrary timezones without re-parsing. Source of truth for the preview.
|
||||||
|
const previewDate = ref(null);
|
||||||
|
|
||||||
// Initialize with current value
|
const trailingState = computed(() => {
|
||||||
onMounted(() => {
|
if (!rawInput.value.trim()) return undefined;
|
||||||
if (props.modelValue) {
|
if (hasError.value) return "error";
|
||||||
const date = new Date(props.modelValue);
|
if (isValid.value) return "success";
|
||||||
if (!isNaN(date.getTime())) {
|
return undefined;
|
||||||
parsedDate.value = date;
|
|
||||||
datetimeValue.value = formatForDatetimeLocal(date);
|
|
||||||
isValidParse.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for external changes to modelValue
|
const previewText = computed(() => {
|
||||||
|
if (!previewDate.value) return "";
|
||||||
|
const tz = activeTZ();
|
||||||
|
const date = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: tz,
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
}).format(previewDate.value);
|
||||||
|
const abbr = shortTimezoneName(previewDate.value, tz);
|
||||||
|
return abbr ? `${date} ${abbr}` : date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTZ = () =>
|
||||||
|
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// Seed the input from modelValue without triggering chrono. The parent's
|
||||||
|
// value is canonical — we just render it as a chrono-friendly readable
|
||||||
|
// string so the user can backspace and tweak in place.
|
||||||
|
const seedFromModelValue = () => {
|
||||||
|
if (!props.modelValue) {
|
||||||
|
rawInput.value = "";
|
||||||
|
isValid.value = false;
|
||||||
|
hasError.value = false;
|
||||||
|
errorMessage.value = "";
|
||||||
|
previewDate.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newValue) => {
|
(next) => {
|
||||||
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
|
const tz = activeTZ();
|
||||||
const date = new Date(newValue);
|
const expected = previewDate.value
|
||||||
if (!isNaN(date.getTime())) {
|
? utcToZonedLocal(previewDate.value, tz)
|
||||||
parsedDate.value = date;
|
: "";
|
||||||
datetimeValue.value = formatForDatetimeLocal(date);
|
if (next === expected) return;
|
||||||
isValidParse.value = true;
|
seedFromModelValue();
|
||||||
naturalInput.value = ""; // Clear natural input when set externally
|
|
||||||
}
|
|
||||||
} else if (!newValue) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const parseNaturalInput = () => {
|
watch(
|
||||||
const input = naturalInput.value.trim();
|
() => props.displayTimezone,
|
||||||
|
() => {
|
||||||
|
// Re-interpret the current input under the new TZ so the preview and
|
||||||
|
// emitted value reflect the new timezone semantics.
|
||||||
|
if (rawInput.value.trim()) parse(rawInput.value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!input) {
|
const onInputChange = (value) => {
|
||||||
reset();
|
rawInput.value = value;
|
||||||
return;
|
parse(value);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse with chrono-node
|
|
||||||
const results = chrono.parse(input);
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
const result = results[0];
|
|
||||||
const date = result.date();
|
|
||||||
|
|
||||||
// Validate the parsed date
|
|
||||||
if (date && !isNaN(date.getTime())) {
|
|
||||||
parsedDate.value = date;
|
|
||||||
isValidParse.value = true;
|
|
||||||
hasError.value = false;
|
|
||||||
datetimeValue.value = formatForDatetimeLocal(date);
|
|
||||||
emit("update:modelValue", formatForDatetimeLocal(date));
|
|
||||||
} else {
|
|
||||||
setError("Could not parse this date format");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(
|
|
||||||
'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError("Error parsing date");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBlur = () => {
|
const parse = (input) => {
|
||||||
// If we have a valid parse but the input changed, try to parse again
|
const trimmed = input.trim();
|
||||||
if (naturalInput.value.trim() && !isValidParse.value) {
|
if (!trimmed) {
|
||||||
parseNaturalInput();
|
isValid.value = false;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
hasError.value = false;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
previewDate.value = null;
|
||||||
|
emit("update:modelValue", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tz = activeTZ();
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = chrono.parse(trimmed, referenceNowInTZ(tz));
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!results.length) {
|
||||||
|
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const date = results[0].date();
|
||||||
|
if (!date || Number.isNaN(date.getTime())) {
|
||||||
|
setError("Couldn't read that date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// chrono returned a Date whose browser-local components match what the
|
||||||
|
// user typed in the event timezone (because we shifted the reference).
|
||||||
|
// Read those components as wall-clock in displayTimezone.
|
||||||
|
const localStr = browserComponentsToString(date);
|
||||||
|
const utc = zonedLocalToUTC(localStr, tz);
|
||||||
|
if (!utc) {
|
||||||
|
setError("Couldn't parse this date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isValid.value = true;
|
||||||
|
hasError.value = false;
|
||||||
|
errorMessage.value = "";
|
||||||
|
previewDate.value = utc;
|
||||||
|
emit("update:modelValue", localStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (msg) => {
|
||||||
|
isValid.value = false;
|
||||||
|
hasError.value = true;
|
||||||
|
errorMessage.value = msg;
|
||||||
|
previewDate.value = null;
|
||||||
emit("update:modelValue", "");
|
emit("update:modelValue", "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const setError = (message) => {
|
// Build a Date object whose browser-local components equal the current
|
||||||
isValidParse.value = false;
|
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
|
||||||
hasError.value = true;
|
// Friday" anchor to the event TZ rather than the editor's browser TZ.
|
||||||
errorMessage.value = message;
|
const referenceNowInTZ = (tz) => {
|
||||||
parsedDate.value = null;
|
const nowStr = utcToZonedLocal(new Date(), tz);
|
||||||
|
if (!nowStr) return new Date();
|
||||||
|
const [d, t] = nowStr.split("T");
|
||||||
|
const [y, mo, day] = d.split("-").map(Number);
|
||||||
|
const [h, mi] = t.split(":").map(Number);
|
||||||
|
return new Date(y, mo - 1, day, h, mi);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatForDatetimeLocal = (date) => {
|
const browserComponentsToString = (date) => {
|
||||||
if (!date) return "";
|
const y = date.getFullYear();
|
||||||
// Format as YYYY-MM-DDTHH:MM for datetime-local input
|
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const year = date.getFullYear();
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const h = String(date.getHours()).padStart(2, "0");
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
return `${y}-${mo}-${d}T${h}:${mi}`;
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatParsedDate = (date) => {
|
const readableSeed = (utc, tz) => {
|
||||||
if (!date) return "";
|
// Format chosen to round-trip cleanly through chrono.parse.
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
const now = new Date();
|
timeZone: tz,
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
month: "short",
|
||||||
const tomorrow = new Date(now);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const isTomorrow = date.toDateString() === tomorrow.toDateString();
|
|
||||||
|
|
||||||
const timeStr = date.toLocaleString("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
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",
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
});
|
}).format(utc);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.natural-date-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-line {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border: 1px dashed rgba(237, 228, 208, 0.25);
|
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
|
||||||
color: var(--parch-accent);
|
color: var(--parch-accent);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -134,7 +134,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
|
||||||
.ow-progress {
|
.ow-progress {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px dashed rgba(237, 228, 208, 0.12);
|
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--parch-text-dim);
|
color: var(--parch-text-dim);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -153,7 +153,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-bar-empty {
|
.ow-bar-empty {
|
||||||
color: rgba(237, 228, 208, 0.2);
|
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ow-skip {
|
.ow-skip {
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div
|
<div v-else-if="error" class="error-state p-6">
|
||||||
v-else-if="error"
|
<h3 class="error-state__heading text-lg font-semibold mb-2">
|
||||||
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="text-ember-400">{{ error }}</p>
|
<p class="error-state__body">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
|
|
@ -48,7 +45,7 @@
|
||||||
<!-- Registration Form -->
|
<!-- Registration Form -->
|
||||||
<div
|
<div
|
||||||
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
||||||
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
|
class="registration-form p-6"
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
||||||
{{
|
{{
|
||||||
|
|
@ -103,18 +100,20 @@
|
||||||
<!-- 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 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
|
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
|
<Icon
|
||||||
name="heroicons:sparkles"
|
name="heroicons:sparkles"
|
||||||
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
|
class="w-5 h-5 flex-shrink-0 mt-0.5"
|
||||||
|
style="color: var(--candle)"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">
|
<div class="font-semibold mb-1" style="color: var(--candle)">
|
||||||
Member Benefit
|
Member Benefit
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
This series pass is free for Ghost Guild members!
|
This series pass is free for Ghost Guild members!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,6 +143,7 @@
|
||||||
<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 { initializeTicketPayment, verifyPayment } = useHelcimPay();
|
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
@ -264,10 +264,9 @@ const handleSubmit = async () => {
|
||||||
paymentProcessing.value = true;
|
paymentProcessing.value = true;
|
||||||
|
|
||||||
// Initialize Helcim payment for series pass
|
// Initialize Helcim payment for series pass
|
||||||
await initializeTicketPayment(
|
await initializeSeriesTicketPayment(
|
||||||
props.seriesId,
|
props.seriesId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
passInfo.value.ticket.price,
|
|
||||||
props.seriesInfo.title,
|
props.seriesInfo.title,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -298,12 +297,17 @@ 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",
|
||||||
timeout: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit success event
|
// Emit success event
|
||||||
|
|
@ -323,7 +327,7 @@ const handleSubmit = async () => {
|
||||||
title: "Purchase Failed",
|
title: "Purchase Failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
color: "red",
|
color: "red",
|
||||||
timeout: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("purchase-error", errorMessage);
|
emit("purchase-error", errorMessage);
|
||||||
|
|
@ -350,3 +354,18 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,9 @@
|
||||||
</dl>
|
</dl>
|
||||||
</DashedBox>
|
</DashedBox>
|
||||||
<p class="signup-flow-body" style="margin-top: 16px">
|
<p class="signup-flow-body" style="margin-top: 16px">
|
||||||
We've sent a confirmation email to {{ summary?.email }}. Redirecting
|
Check {{ summary?.email }} for a sign-in link to finish setting up
|
||||||
you to your dashboard...
|
your account. The link expires in 15 minutes.
|
||||||
</p>
|
</p>
|
||||||
<div class="button-row" style="margin-top: 20px">
|
|
||||||
<NuxtLink :to="dashboardHref" class="btn btn-primary">
|
|
||||||
Go to Dashboard Now
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="state === 'error'">
|
<template v-if="state === 'error'">
|
||||||
|
|
@ -113,7 +108,7 @@ const stepLabel = computed(() => {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: rgba(42, 32, 21, 0.72);
|
background: color-mix(in srgb, var(--parch) 72%, transparent);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="tier-picker">
|
|
||||||
<div
|
|
||||||
v-for="tier in tiers"
|
|
||||||
:key="tier.amount"
|
|
||||||
class="tier-option"
|
|
||||||
:class="{ current: modelValue === tier.amount }"
|
|
||||||
@click="$emit('update:modelValue', tier.amount)"
|
|
||||||
>
|
|
||||||
<span class="tier-amount">{{ tier.display }}</span>
|
|
||||||
<span v-if="tier.subtitle" class="tier-subtitle">{{ tier.subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: { type: Number, default: 0 },
|
|
||||||
tiers: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [
|
|
||||||
{ amount: 0, display: "$0", label: "Free" },
|
|
||||||
{ amount: 5, display: "$5", label: "/month" },
|
|
||||||
{ amount: 15, display: "$15", label: "/month" },
|
|
||||||
{ amount: 30, display: "$30", label: "/month" },
|
|
||||||
{ amount: 50, display: "$50", label: "/month" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(["update:modelValue"]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tier-picker {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-option {
|
|
||||||
flex: 1;
|
|
||||||
padding: 18px 8px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overlap adjacent borders so dashed lines collapse into one */
|
|
||||||
.tier-option + .tier-option {
|
|
||||||
margin-left: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-option:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active item paints its solid border on top of any neighbor */
|
|
||||||
.tier-option.current {
|
|
||||||
border-color: var(--candle);
|
|
||||||
border-style: solid;
|
|
||||||
background: var(--surface);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-amount {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: "Brygada 1918", serif;
|
|
||||||
display: block;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-option.current .tier-amount {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tier-picker {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.tier-option {
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -12,7 +12,12 @@
|
||||||
class="breadcrumb-link"
|
class="breadcrumb-link"
|
||||||
>{{ crumb.label }}</NuxtLink
|
>{{ crumb.label }}</NuxtLink
|
||||||
>
|
>
|
||||||
<span v-else class="breadcrumb-current">{{ crumb.label }}</span>
|
<ClientOnly v-else>
|
||||||
|
<span class="breadcrumb-current">{{ crumb.label }}</span>
|
||||||
|
<template #fallback>
|
||||||
|
<span class="breadcrumb-current"> </span>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,98 @@
|
||||||
// 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 TIMEZONE = "America/Toronto";
|
const DEFAULT_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);
|
||||||
const { month = "short", day = "numeric", year = "numeric" } = options;
|
if (isNaN(dateObj.getTime())) return "";
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format event date range
|
const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
|
||||||
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 startMonth = start.toLocaleDateString("en-US", { month: "short" });
|
const tzOpts = timeZone ? { timeZone } : {};
|
||||||
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
|
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||||
const startDay = start.getDate();
|
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||||
const endDay = end.getDate();
|
const startDay = Number(
|
||||||
const year = end.getFullYear();
|
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
const endDay = Number(
|
||||||
|
end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
const year = Number(
|
||||||
|
end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
const startMonthIdx = startMonth; // compared as label string
|
||||||
|
const endMonthIdx = endMonth;
|
||||||
|
const startYear = Number(
|
||||||
|
start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
if (
|
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||||
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 (
|
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||||
start.getMonth() === end.getMonth() &&
|
|
||||||
start.getFullYear() === end.getFullYear()
|
|
||||||
) {
|
|
||||||
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||||
} else if (start.getFullYear() === end.getFullYear()) {
|
} else if (startYear === year) {
|
||||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||||
} else {
|
} else {
|
||||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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);
|
||||||
const now = new Date();
|
return dateObj < new Date();
|
||||||
return dateObj < now;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if a date is today
|
const isToday = (date, timeZone) => {
|
||||||
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.getDate() === today.getDate() &&
|
dateObj.toLocaleDateString("en-US", opts) ===
|
||||||
dateObj.getMonth() === today.getMonth() &&
|
today.toLocaleDateString("en-US", opts)
|
||||||
dateObj.getFullYear() === today.getFullYear()
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get a readable time string
|
const formatTime = (date, includeSeconds = false, timeZone) => {
|
||||||
const formatTime = (date, includeSeconds = false) => {
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
const options = {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
...(includeSeconds && { second: "2-digit" }),
|
...(includeSeconds && { second: "2-digit" }),
|
||||||
};
|
...(timeZone && { timeZone }),
|
||||||
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
|
}).format(dateObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
TIMEZONE,
|
DEFAULT_TIMEZONE,
|
||||||
|
// Legacy alias for callers that hard-coded the constant.
|
||||||
|
TIMEZONE: DEFAULT_TIMEZONE,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
isPastDate,
|
isPastDate,
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
// 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
|
// Initialize HelcimPay.js session (membership signup flow)
|
||||||
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,6 +12,7 @@ export const useHelcimPay = () => {
|
||||||
customerId,
|
customerId,
|
||||||
customerCode,
|
customerCode,
|
||||||
amount,
|
amount,
|
||||||
|
metadata: { type: "membership_signup" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -28,26 +29,14 @@ export const useHelcimPay = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize payment for event ticket purchase
|
const _initializeTicket = async (metadata, errorPrefix) => {
|
||||||
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: email, // Use email as customer code for event tickets
|
customerCode: metadata.email,
|
||||||
amount,
|
metadata,
|
||||||
metadata: {
|
|
||||||
type: "event_ticket",
|
|
||||||
eventId,
|
|
||||||
email,
|
|
||||||
eventTitle,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,16 +46,29 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
checkoutToken: response.checkoutToken,
|
checkoutToken: response.checkoutToken,
|
||||||
|
amount: response.amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Failed to initialize ticket payment session");
|
throw new Error(`Failed to initialize ${errorPrefix} session`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Ticket payment initialization error:", error);
|
console.error(`${errorPrefix} 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) => {
|
||||||
|
|
@ -139,6 +141,7 @@ 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);
|
||||||
|
|
@ -148,6 +151,8 @@ 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") {
|
||||||
|
|
@ -237,10 +242,10 @@ export const useHelcimPay = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up observer after a timeout
|
// Clean up observer after a timeout
|
||||||
setTimeout(() => observer.disconnect(), 5000);
|
observerTimer = 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)
|
||||||
setTimeout(() => {
|
paymentTimer = 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"));
|
||||||
|
|
@ -272,6 +277,7 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
initializeHelcimPay,
|
initializeHelcimPay,
|
||||||
initializeTicketPayment,
|
initializeTicketPayment,
|
||||||
|
initializeSeriesTicketPayment,
|
||||||
verifyPayment,
|
verifyPayment,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,25 +25,59 @@ export const useMemberPayment = () => {
|
||||||
paymentSuccess.value = false
|
paymentSuccess.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get or create Helcim customer
|
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||||
await getOrCreateCustomer()
|
// AND a card's on file, we can skip the paid getOrCreateCustomer round
|
||||||
|
// trip entirely and go straight to subscription creation.
|
||||||
|
const hasCachedHelcimIds = Boolean(
|
||||||
|
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
||||||
|
)
|
||||||
|
|
||||||
// Step 2: Initialize Helcim payment with $0 for card verification
|
let existing = null
|
||||||
|
let probedExistingCard = false
|
||||||
|
let cardToken = null
|
||||||
|
|
||||||
|
if (hasCachedHelcimIds) {
|
||||||
|
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
|
||||||
|
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
probedExistingCard = true
|
||||||
|
if (existing?.cardToken) {
|
||||||
|
customerId.value = memberData.value.helcimCustomerId
|
||||||
|
customerCode.value = memberData.value.helcimCustomerCode
|
||||||
|
cardToken = existing.cardToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardToken) {
|
||||||
|
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||||
|
// to re-save it, breaking retries after a partial-failed signup.
|
||||||
|
const [, existingFromFull] = await Promise.all([
|
||||||
|
getOrCreateCustomer(),
|
||||||
|
probedExistingCard
|
||||||
|
? Promise.resolve(existing)
|
||||||
|
: $fetch('/api/helcim/existing-card').catch((err) => {
|
||||||
|
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
cardToken = existingFromFull?.cardToken || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardToken) {
|
||||||
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: {
|
||||||
|
|
@ -56,14 +90,16 @@ export const useMemberPayment = () => {
|
||||||
throw new Error('Payment verification failed on backend')
|
throw new Error('Payment verification failed on backend')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Create subscription with proper contribution tier
|
cardToken = paymentResult.cardToken
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
contributionAmount: memberData.value?.contributionAmount ?? 5,
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -71,7 +107,6 @@ 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()
|
||||||
|
|
||||||
|
|
|
||||||
58
app/composables/useSiteMeta.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* 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
app/config/eventTypes.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// 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 || "";
|
||||||
|
}
|
||||||
8
app/config/memberStatus.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const STATUS_LABELS = {
|
||||||
|
active: "Active",
|
||||||
|
pending_payment: "Payment setup incomplete",
|
||||||
|
suspended: "Paused",
|
||||||
|
cancelled: "Closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
|
||||||
|
|
@ -217,6 +217,8 @@
|
||||||
</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();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
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");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,16 @@
|
||||||
<div class="section-label">The Circles</div>
|
<div class="section-label">The Circles</div>
|
||||||
<div class="circles-grid">
|
<div class="circles-grid">
|
||||||
<div id="community" class="circle-cell">
|
<div id="community" class="circle-cell">
|
||||||
<h3 style="color: var(--c-community)">Community</h3>
|
<h2 style="color: var(--c-community)">Community</h2>
|
||||||
|
|
||||||
<p>For anyone exploring cooperative models.</p>
|
<p>For anyone exploring cooperative models.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="founder" class="circle-cell">
|
<div id="founder" class="circle-cell">
|
||||||
<h3 style="color: var(--c-founder)">Founder</h3>
|
<h2 style="color: var(--c-founder)">Founder</h2>
|
||||||
<p>For people actively building cooperatives.</p>
|
<p>For people actively building cooperatives.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="practitioner" class="circle-cell">
|
<div id="practitioner" class="circle-cell">
|
||||||
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
|
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
|
||||||
<p>For experienced practitioners sharing what they know.</p>
|
<p>For experienced practitioners sharing what they know.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,7 +104,13 @@
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup></script>
|
<script setup>
|
||||||
|
useSiteMeta({
|
||||||
|
title: 'About',
|
||||||
|
description:
|
||||||
|
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ---- ABOUT HERO ---- */
|
/* ---- ABOUT HERO ---- */
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ import {
|
||||||
} from "~/config/contributions";
|
} 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 { initializeHelcimPay, verifyPayment } = useHelcimPay();
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
</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>
|
||||||
|
|
@ -38,6 +40,7 @@
|
||||||
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">
|
||||||
|
|
@ -60,7 +63,8 @@
|
||||||
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="4"
|
:rows="8"
|
||||||
|
autoresize
|
||||||
:color="fieldErrors.description ? 'error' : undefined"
|
:color="fieldErrors.description ? 'error' : undefined"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
@ -77,7 +81,8 @@
|
||||||
<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="6"
|
:rows="12"
|
||||||
|
autoresize
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
|
|
@ -85,6 +90,21 @@
|
||||||
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 -->
|
||||||
|
|
@ -97,12 +117,7 @@
|
||||||
<USelect
|
<USelect
|
||||||
v-model="eventForm.eventType"
|
v-model="eventForm.eventType"
|
||||||
aria-label="Event type"
|
aria-label="Event type"
|
||||||
:items="[
|
:items="EVENT_TYPES"
|
||||||
{ 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">
|
||||||
|
|
@ -111,19 +126,32 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label> Location <span class="required">*</span> </label>
|
<label> Event Timezone <span class="required">*</span> </label>
|
||||||
<UInput
|
<USelectMenu
|
||||||
v-model="eventForm.location"
|
v-model="eventForm.displayTimezone"
|
||||||
placeholder="e.g., https://zoom.us/j/123... or #channel-name"
|
:items="timezoneItems"
|
||||||
required
|
value-key="value"
|
||||||
:color="fieldErrors.location ? 'error' : undefined"
|
searchable
|
||||||
|
searchable-placeholder="Search timezones..."
|
||||||
|
placeholder="Select a timezone"
|
||||||
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">
|
||||||
Enter a video conference link or Slack channel (starting with #)
|
Dates below are interpreted in this timezone. Attendees see the
|
||||||
|
event time in this zone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Location</label>
|
||||||
|
<UInput
|
||||||
|
v-model="eventForm.location"
|
||||||
|
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="help-text">
|
||||||
|
Video conference link, Slack channel (#channel-name), or 'TBD' if
|
||||||
|
the platform is undecided
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -131,6 +159,7 @@
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,6 +172,7 @@
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -169,6 +199,7 @@
|
||||||
<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">
|
||||||
|
|
@ -178,6 +209,87 @@
|
||||||
</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>
|
||||||
|
|
@ -190,39 +302,24 @@
|
||||||
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">
|
||||||
|
|
@ -243,11 +340,30 @@
|
||||||
:items="tagOptions"
|
:items="tagOptions"
|
||||||
value-key="value"
|
value-key="value"
|
||||||
multiple
|
multiple
|
||||||
placeholder="Select tags..."
|
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"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
Tag this event to help with discovery and recommendations
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -257,7 +373,7 @@
|
||||||
<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>
|
||||||
|
|
@ -269,7 +385,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">
|
||||||
|
|
@ -278,6 +394,12 @@
|
||||||
</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">
|
||||||
|
|
@ -345,6 +467,7 @@
|
||||||
<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">
|
||||||
|
|
@ -353,11 +476,6 @@
|
||||||
</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 -->
|
||||||
|
|
@ -365,7 +483,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">
|
||||||
|
|
@ -381,7 +499,6 @@
|
||||||
<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)`,
|
||||||
|
|
@ -391,6 +508,7 @@
|
||||||
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
|
||||||
|
|
@ -448,113 +566,7 @@
|
||||||
</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 -->
|
||||||
|
|
@ -565,9 +577,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>
|
||||||
|
|
@ -589,6 +601,9 @@
|
||||||
</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",
|
||||||
|
|
@ -607,9 +622,32 @@ const availableSeries = ref([]);
|
||||||
const availableTags = ref([]);
|
const availableTags = ref([]);
|
||||||
|
|
||||||
const tagOptions = computed(() =>
|
const tagOptions = computed(() =>
|
||||||
availableTags.value.map((t) => ({ label: t.label, value: t.slug }))
|
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: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -617,11 +655,13 @@ const eventForm = reactive({
|
||||||
featureImage: null,
|
featureImage: null,
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
eventType: "community",
|
eventType: "community-meetup",
|
||||||
|
displayTimezone: "America/Toronto",
|
||||||
location: "",
|
location: "",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
|
membersOnly: false,
|
||||||
cancellationMessage: "",
|
cancellationMessage: "",
|
||||||
targetCircles: [],
|
targetCircles: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
@ -649,15 +689,57 @@ const eventForm = reactive({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agenda management functions
|
// Format a Date/ISO value into a datetime-local string using local-time components.
|
||||||
const addAgendaItem = () => {
|
// `toISOString().slice(0,16)` drifts by the browser's UTC offset on edit round-trip.
|
||||||
eventForm.agenda.push("");
|
const formatForDatetimeLocal = (value) => {
|
||||||
|
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())}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAgendaItem = (index) => {
|
// Render the form's datetime fields in the event's display timezone.
|
||||||
eventForm.agenda.splice(index, 1);
|
const formatForEventTZ = (value) => {
|
||||||
|
if (!value) return "";
|
||||||
|
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const utcOffsetLabel = (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 || "";
|
||||||
|
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
|
// Load available series and tags
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -698,34 +780,32 @@ const onSeriesSelect = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if we're editing an event
|
function populateEditForm(payload) {
|
||||||
if (route.query.edit) {
|
const event = payload?.data;
|
||||||
try {
|
if (!event) return;
|
||||||
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: new Date(event.startDate).toISOString().slice(0, 16),
|
startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
|
||||||
endDate: new Date(event.endDate).toISOString().slice(0, 16),
|
endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
|
||||||
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 || [],
|
tags: event.tags || [],
|
||||||
maxAttendees: event.maxAttendees || "",
|
maxAttendees: event.maxAttendees || "",
|
||||||
registrationRequired: event.registrationRequired,
|
registrationRequired: event.registrationRequired,
|
||||||
registrationDeadline: event.registrationDeadline
|
registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
|
||||||
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
|
|
||||||
: "",
|
|
||||||
agenda: event.agenda || [],
|
agenda: event.agenda || [],
|
||||||
tickets: event.tickets || {
|
tickets: event.tickets || {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
@ -746,22 +826,30 @@ if (route.query.edit) {
|
||||||
description: "",
|
description: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Handle early bird deadline formatting
|
|
||||||
if (event.tickets?.public?.earlyBirdDeadline) {
|
if (event.tickets?.public?.earlyBirdDeadline) {
|
||||||
eventForm.tickets.public.earlyBirdDeadline = new Date(
|
eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
|
||||||
event.tickets.public.earlyBirdDeadline,
|
event.tickets.public.earlyBirdDeadline,
|
||||||
)
|
eventForm.displayTimezone,
|
||||||
.toISOString()
|
);
|
||||||
.slice(0, 16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load event for editing:", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
watch(editEvent, populateEditForm, { immediate: false });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we're duplicating an event
|
// Check if we're duplicating an event
|
||||||
if (route.query.duplicate && process.client) {
|
if (route.query.duplicate && import.meta.client) {
|
||||||
const duplicateData = sessionStorage.getItem("duplicateEventData");
|
const duplicateData = sessionStorage.getItem("duplicateEventData");
|
||||||
if (duplicateData) {
|
if (duplicateData) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -775,7 +863,7 @@ if (route.query.duplicate && process.client) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're creating a series event
|
// Check if we're creating a series event
|
||||||
if (route.query.series && process.client) {
|
if (route.query.series && import.meta.client) {
|
||||||
const seriesData = sessionStorage.getItem("seriesEventData");
|
const seriesData = sessionStorage.getItem("seriesEventData");
|
||||||
if (seriesData) {
|
if (seriesData) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -814,12 +902,6 @@ 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);
|
||||||
|
|
@ -836,23 +918,6 @@ 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);
|
||||||
|
|
@ -887,15 +952,40 @@ 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: eventForm,
|
body: payload,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await $fetch("/api/admin/events", {
|
await $fetch("/api/admin/events", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: eventForm,
|
body: payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -934,11 +1024,13 @@ const saveAndCreateAnother = async () => {
|
||||||
featureImage: null,
|
featureImage: null,
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
eventType: "community",
|
eventType: "community-meetup",
|
||||||
|
displayTimezone: "America/Toronto",
|
||||||
location: "",
|
location: "",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
isCancelled: false,
|
isCancelled: false,
|
||||||
|
membersOnly: false,
|
||||||
cancellationMessage: "",
|
cancellationMessage: "",
|
||||||
targetCircles: [],
|
targetCircles: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
@ -978,7 +1070,42 @@ const saveAndCreateAnother = async () => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.create-form {
|
.create-form {
|
||||||
max-width: 800px;
|
display: flex;
|
||||||
|
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 {
|
||||||
|
|
@ -1015,7 +1142,21 @@ const saveAndCreateAnother = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-body {
|
.form-body {
|
||||||
padding: 24px 28px;
|
display: flex;
|
||||||
|
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 {
|
||||||
|
|
@ -1023,7 +1164,9 @@ const saveAndCreateAnother = async () => {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
padding-bottom: 10px;
|
margin-left: -28px;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -1121,7 +1264,7 @@ const saveAndCreateAnother = async () => {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 20px;
|
padding: 20px 28px;
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1154,59 +1297,50 @@ 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-body {
|
.form-main,
|
||||||
padding: 20px;
|
.form-aside,
|
||||||
|
.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,15 +16,12 @@
|
||||||
<!-- 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 value="community">Community</option>
|
<option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</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;">
|
||||||
|
|
@ -71,7 +68,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>
|
||||||
|
|
@ -89,11 +86,11 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-date">
|
<td class="col-date">
|
||||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
<span class="date-main">{{ formatDate(event) }}</span>
|
||||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
<span class="date-time">{{ formatTime(event) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
|
|
@ -128,9 +125,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 @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -169,7 +166,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>
|
||||||
|
|
@ -187,11 +184,11 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-date">
|
<td class="col-date">
|
||||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
<span class="date-main">{{ formatDate(event) }}</span>
|
||||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
<span class="date-time">{{ formatTime(event) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
|
|
@ -226,9 +223,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 @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -267,6 +264,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
middleware: 'admin',
|
middleware: 'admin',
|
||||||
|
|
@ -349,19 +348,23 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
|
||||||
pastPage.value = 1
|
pastPage.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (event) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
if (!event?.startDate) return ''
|
||||||
|
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 = (dateString) => {
|
const formatTime = (event) => {
|
||||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
if (!event?.startDate) return ''
|
||||||
|
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',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,7 +573,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 rgba(138, 68, 32, 0.3);
|
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,7 +586,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 rgba(138, 68, 32, 0.4);
|
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,12 +635,12 @@ tbody td {
|
||||||
|
|
||||||
.status-upcoming {
|
.status-upcoming {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
border-color: rgba(122, 90, 16, 0.3);
|
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-ongoing {
|
.status-ongoing {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: rgba(74, 106, 56, 0.3);
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-past {
|
.status-past {
|
||||||
|
|
@ -647,7 +650,7 @@ tbody td {
|
||||||
|
|
||||||
.status-cancelled {
|
.status-cancelled {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
border-color: rgba(138, 68, 32, 0.3);
|
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
|
||||||
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">
|
||||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
<CircleBadge :circle="member.circle" />
|
||||||
<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">{{ event.eventType }}</span>
|
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||||
<span class="item-date">{{ event.location || 'Online' }}</span>
|
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,6 +106,8 @@
|
||||||
</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">
|
||||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
<CircleBadge :circle="member.circle" />
|
||||||
<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>
|
||||||
|
|
@ -56,14 +56,18 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Contribution ($/mo)</label>
|
<label>Contribution ($/mo)</label>
|
||||||
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
|
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
|
||||||
|
<p class="field-hint field-hint--warn">
|
||||||
|
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard — this form does not sync.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
<select v-model="form.status">
|
<select v-model="form.status">
|
||||||
<option value="pending_payment">pending_payment</option>
|
<option
|
||||||
<option value="active">active</option>
|
v-for="(label, value) in STATUS_LABELS"
|
||||||
<option value="suspended">suspended</option>
|
:key="value"
|
||||||
<option value="cancelled">cancelled</option>
|
:value="value"
|
||||||
|
>{{ label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -106,8 +110,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<dt>Slack invite</dt>
|
<dt>Slack invite</dt>
|
||||||
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
<dd v-if="member.slackInvited" class="status-ok">
|
||||||
{{ member.slackInvited ? "Invited" : "Pending" }}
|
Invited {{ formatDate(member.slackInvitedAt) }}
|
||||||
|
</dd>
|
||||||
|
<dd v-else class="meta-action">
|
||||||
|
<span class="status-dim">Not yet invited</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-btn"
|
||||||
|
:disabled="markingSlackInvited"
|
||||||
|
@click="markSlackInvited"
|
||||||
|
>
|
||||||
|
{{ markingSlackInvited ? "Marking…" : "Mark as Slack invited" }}
|
||||||
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member.helcimCustomerId" class="meta-row">
|
<div v-if="member.helcimCustomerId" class="meta-row">
|
||||||
|
|
@ -155,12 +170,6 @@
|
||||||
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
|
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
|
||||||
<dt>Slack status</dt>
|
|
||||||
<dd :class="slackStatusClass">
|
|
||||||
{{ member.slackInviteStatus || 'none' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -234,6 +243,7 @@
|
||||||
|
|
||||||
<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",
|
||||||
|
|
@ -356,12 +366,31 @@ const hasBoardEngaged = computed(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const slackStatusClass = computed(() => {
|
const markingSlackInvited = ref(false)
|
||||||
const status = member.value?.slackInviteStatus
|
|
||||||
if (status === 'joined') return 'status-ok'
|
async function markSlackInvited() {
|
||||||
if (status === 'invited') return 'status-dim'
|
if (!member.value || markingSlackInvited.value) return
|
||||||
return 'status-dim'
|
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([])
|
||||||
|
|
@ -510,6 +539,24 @@ 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;
|
||||||
|
|
@ -553,6 +600,32 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,11 @@
|
||||||
<div class="field" style="margin-bottom: 0">
|
<div class="field" style="margin-bottom: 0">
|
||||||
<select v-model="statusFilter" aria-label="Filter by status">
|
<select v-model="statusFilter" aria-label="Filter by status">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="active">Active</option>
|
<option
|
||||||
<option value="pending_payment">Pending Payment</option>
|
v-for="(label, value) in STATUS_LABELS"
|
||||||
<option value="suspended">Suspended</option>
|
:key="value"
|
||||||
<option value="cancelled">Cancelled</option>
|
:value="value"
|
||||||
|
>{{ label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,9 +109,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="col-email">{{ member.email }}</td>
|
<td class="col-email">{{ member.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="member.circle">{{
|
<CircleBadge :circle="member.circle" />
|
||||||
member.circle
|
|
||||||
}}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
|
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -124,8 +123,11 @@
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
<span v-if="member.slackInvited" class="status-ok">
|
||||||
{{ member.slackInvited ? "Invited" : "Pending" }}
|
Invited {{ formatDate(member.slackInvitedAt) }}
|
||||||
|
</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">
|
||||||
|
|
@ -135,8 +137,12 @@
|
||||||
<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 class="link-btn" @click.stop="sendSlackInvite(member)">
|
<button
|
||||||
Slack
|
v-if="!member.slackInvited"
|
||||||
|
class="link-btn"
|
||||||
|
@click.stop="markSlackInvited(member)"
|
||||||
|
>
|
||||||
|
Mark as Slack invited
|
||||||
</button>
|
</button>
|
||||||
<button class="link-btn" @click.stop="editMember(member)">Edit</button>
|
<button class="link-btn" @click.stop="editMember(member)">Edit</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -262,7 +268,7 @@
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Circle</th>
|
<th>Circle</th>
|
||||||
<th>Tier</th>
|
<th>Contribution</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -366,10 +372,11 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
<select v-model="editingMember.status">
|
<select v-model="editingMember.status">
|
||||||
<option value="pending_payment">Pending Payment</option>
|
<option
|
||||||
<option value="active">Active</option>
|
v-for="(label, value) in STATUS_LABELS"
|
||||||
<option value="suspended">Suspended</option>
|
:key="value"
|
||||||
<option value="cancelled">Cancelled</option>
|
:value="value"
|
||||||
|
>{{ label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|
@ -461,6 +468,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
middleware: "admin",
|
middleware: "admin",
|
||||||
|
|
@ -481,14 +490,6 @@ const statusFilter = ref("");
|
||||||
const sortKey = ref("createdAt");
|
const sortKey = ref("createdAt");
|
||||||
const sortDir = ref("desc");
|
const sortDir = ref("desc");
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
|
||||||
active: "Active",
|
|
||||||
pending_payment: "Pending",
|
|
||||||
suspended: "Suspended",
|
|
||||||
cancelled: "Cancelled",
|
|
||||||
};
|
|
||||||
const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
|
|
||||||
|
|
||||||
const toggleSort = (key) => {
|
const toggleSort = (key) => {
|
||||||
if (sortKey.value === key) {
|
if (sortKey.value === key) {
|
||||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||||
|
|
@ -829,8 +830,25 @@ const submitInvites = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Existing actions ---
|
// --- Existing actions ---
|
||||||
const sendSlackInvite = (member) => {
|
const markSlackInvited = async (member) => {
|
||||||
console.log("Send Slack invite to:", member.email);
|
try {
|
||||||
|
const res = await $fetch(
|
||||||
|
`/api/admin/members/${member._id}/slack-status`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: { slackInvited: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const idx = members.value.findIndex((m) => m._id === member._id);
|
||||||
|
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
|
||||||
|
toast.add({ title: "Marked as Slack invited", color: "success" });
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: "Failed to mark Slack invited",
|
||||||
|
description: err.data?.statusMessage || err.message,
|
||||||
|
color: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Edit Member ---
|
// --- Edit Member ---
|
||||||
|
|
@ -1126,7 +1144,7 @@ th.sortable:hover {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.badge.status-active {
|
.badge.status-active {
|
||||||
color: var(--green, #3a6b3a);
|
color: var(--green);
|
||||||
border-color: rgba(58, 107, 58, 0.45);
|
border-color: rgba(58, 107, 58, 0.45);
|
||||||
}
|
}
|
||||||
.badge.status-pending_payment {
|
.badge.status-pending_payment {
|
||||||
|
|
@ -1135,7 +1153,7 @@ th.sortable:hover {
|
||||||
}
|
}
|
||||||
.badge.status-suspended {
|
.badge.status-suspended {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
border-color: rgba(138, 68, 32, 0.45);
|
border-color: color-mix(in srgb, var(--ember) 45%, transparent);
|
||||||
}
|
}
|
||||||
.badge.status-cancelled {
|
.badge.status-cancelled {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
@ -1283,7 +1301,7 @@ th.sortable:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-error {
|
.row-error {
|
||||||
background: rgba(138, 68, 32, 0.04);
|
background: color-mix(in srgb, var(--ember) 4%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- PREVIEW BOX ---- */
|
/* ---- PREVIEW BOX ---- */
|
||||||
|
|
|
||||||
|
|
@ -643,8 +643,8 @@ tbody td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-accepted {
|
.status-accepted {
|
||||||
color: var(--green, #4a7);
|
color: var(--green);
|
||||||
border-color: var(--green, #4a7);
|
border-color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-expired {
|
.status-expired {
|
||||||
|
|
@ -671,7 +671,7 @@ tbody td {
|
||||||
|
|
||||||
/* ---- STATUS INDICATORS ---- */
|
/* ---- STATUS INDICATORS ---- */
|
||||||
.status-ok {
|
.status-ok {
|
||||||
color: var(--green, #4a7);
|
color: var(--green);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -850,7 +850,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 rgba(138, 68, 32, 0.4);
|
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -931,12 +931,12 @@ const exportSeriesData = () => {
|
||||||
|
|
||||||
.status-active {
|
.status-active {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: rgba(74, 106, 56, 0.3);
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-upcoming {
|
.status-upcoming {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
border-color: rgba(122, 90, 16, 0.3);
|
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-completed {
|
.status-completed {
|
||||||
|
|
@ -946,7 +946,7 @@ const exportSeriesData = () => {
|
||||||
|
|
||||||
.status-ongoing {
|
.status-ongoing {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: rgba(74, 106, 56, 0.3);
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- LINK BUTTONS ---- */
|
/* ---- LINK BUTTONS ---- */
|
||||||
|
|
|
||||||
|
|
@ -954,8 +954,8 @@ const applyBatchVisibility = async (hidden) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-created {
|
.sync-created {
|
||||||
color: var(--green, #4a7);
|
color: var(--green);
|
||||||
border-color: var(--green, #4a7);
|
border-color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-updated {
|
.sync-updated {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ layout: false });
|
definePageMeta({ layout: false });
|
||||||
useHead({ title: "Sign Out — Ghost Guild" });
|
useSiteMeta({ title: "Sign Out", noindex: true });
|
||||||
|
|
||||||
// The xsrf token comes from a short-lived httpOnly cookie set by
|
// The xsrf token comes from a short-lived httpOnly cookie set by
|
||||||
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).
|
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).
|
||||||
|
|
@ -82,7 +82,7 @@ if (import.meta.server && !xsrf.value) {
|
||||||
.auth-title {
|
.auth-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ layout: false });
|
definePageMeta({ layout: false });
|
||||||
useHead({ title: "Signed Out — Ghost Guild" });
|
useSiteMeta({ title: "Signed Out", noindex: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -46,7 +46,7 @@ useHead({ title: "Signed Out — Ghost Guild" });
|
||||||
.auth-title {
|
.auth-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ layout: false });
|
definePageMeta({ layout: false });
|
||||||
useHead({ title: "Sign-In Error — Ghost Guild" });
|
useSiteMeta({ title: "Sign-In Error", noindex: true });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ const hasDetail = computed(
|
||||||
.auth-title {
|
.auth-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
|
@ -97,7 +97,7 @@ const hasDetail = computed(
|
||||||
|
|
||||||
.auth-detail-code {
|
.auth-detail-code {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
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;
|
||||||
|
|
@ -172,8 +173,8 @@ function resetForm() {
|
||||||
|
|
||||||
.wiki-login-title {
|
.wiki-login-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 32px;
|
font-size: 36px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
|
@ -240,7 +241,7 @@ function resetForm() {
|
||||||
.wiki-login-sent-heading {
|
.wiki-login-sent-heading {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,14 +192,10 @@ const loadTags = async () => {
|
||||||
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
|
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({
|
useSiteMeta({
|
||||||
title: 'Board - Ghost Guild',
|
title: 'Bulletin Board',
|
||||||
meta: [
|
description:
|
||||||
{
|
'The Ghost Guild bulletin board. Members post offers and requests around shared interests and cooperative topics.',
|
||||||
name: 'description',
|
|
||||||
content: 'Share what you are seeking and offering with the Ghost Guild community.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -357,13 +353,13 @@ onMounted(async () => {
|
||||||
|
|
||||||
/* ---- LOADING / EMPTY ---- */
|
/* ---- LOADING / EMPTY ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
padding: 60px 24px;
|
padding: 64px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 60px 24px;
|
padding: 64px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.empty-title {
|
.empty-title {
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ const handleLogout = async () => {
|
||||||
.coming-soon-title {
|
.coming-soon-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
`
|
||||||
<template>
|
<template>
|
||||||
<PageShell title="Community Guidelines" subtitle="What you're agreeing to when you join Ghost Guild">
|
<PageShell
|
||||||
|
title="Community Guidelines"
|
||||||
|
subtitle="What you're agreeing to when you join Ghost Guild"
|
||||||
|
>
|
||||||
<div class="guidelines-prose">
|
<div class="guidelines-prose">
|
||||||
<section class="guidelines-section">
|
<section class="guidelines-section">
|
||||||
<h2>Welcome</h2>
|
<h2>Welcome</h2>
|
||||||
|
|
@ -24,12 +28,12 @@
|
||||||
contribute financially.
|
contribute financially.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
When you join Ghost Guild, you become a Class B member of Baby
|
When you join Ghost Guild, you become a Class B member of Baby Ghosts,
|
||||||
Ghosts, our parent charity. Class A membership is held by a small
|
our parent charity. Class A membership is held by a small group
|
||||||
group involved in governance, mainly our directors. Class A and
|
involved in governance, mainly our directors. Class A and Class B have
|
||||||
Class B have equal access to resources, community, events, and the
|
equal access to resources, community, events, and the Solidarity Fund.
|
||||||
Solidarity Fund. Voting at the Annual General Meeting is limited
|
Voting at the Annual General Meeting is limited to Class A members, as
|
||||||
to Class A members, as set out in our
|
set out in our
|
||||||
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
|
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -82,7 +86,9 @@
|
||||||
Equal access to resources, events, community spaces, and the
|
Equal access to resources, events, community spaces, and the
|
||||||
Solidarity Fund, regardless of circle or contribution level
|
Solidarity Fund, regardless of circle or contribution level
|
||||||
</li>
|
</li>
|
||||||
<li>Support from the Solidarity Fund if you face financial barriers</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>The ability to move between circles as your journey evolves</li>
|
||||||
<li>
|
<li>
|
||||||
Privacy protection in line with our
|
Privacy protection in line with our
|
||||||
|
|
@ -105,8 +111,8 @@
|
||||||
at all times
|
at all times
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Participating within your capacity. This is a community of
|
Participating within your capacity. This is a community of practice.
|
||||||
practice. Show up in whatever way works for you.
|
Show up in whatever way works for you.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Contributing dues in line with your ability, or working with the
|
Contributing dues in line with your ability, or working with the
|
||||||
|
|
@ -114,7 +120,9 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Approaching disagreements with openness and using our
|
Approaching disagreements with openness and using our
|
||||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>
|
<NuxtLink to="/policies/conflict-resolution"
|
||||||
|
>Conflict Resolution Policy</NuxtLink
|
||||||
|
>
|
||||||
when conflicts arise
|
when conflicts arise
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -126,14 +134,13 @@
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Don't share screenshots, message content, or other community
|
Don't share screenshots, message content, or other community content
|
||||||
content externally without the explicit consent of everyone
|
externally without the explicit consent of everyone involved
|
||||||
involved
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Don't contribute community conversations, messages, or member
|
Don't contribute community conversations, messages, or member
|
||||||
content to generative AI tools like ChatGPT or Claude. This
|
content to generative AI tools like ChatGPT or Claude. This protects
|
||||||
protects everyone's privacy and contributions.
|
everyone's privacy and contributions.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Violations of these privacy norms can result in removal from the
|
Violations of these privacy norms can result in removal from the
|
||||||
|
|
@ -149,7 +156,10 @@
|
||||||
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
|
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
|
||||||
knowledge commons. Anything you contribute to it is automatically and
|
knowledge commons. Anything you contribute to it is automatically and
|
||||||
irrevocably licensed under the
|
irrevocably licensed under the
|
||||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>
|
<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.
|
(CC-BY-SA 4.0) at the moment you post it.
|
||||||
</p>
|
</p>
|
||||||
<p>In plain terms:</p>
|
<p>In plain terms:</p>
|
||||||
|
|
@ -162,13 +172,13 @@
|
||||||
credit you and release their derivatives under the same license
|
credit you and release their derivatives under the same license
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
You can't withdraw your contribution from the commons later, even
|
You can't withdraw your contribution from the commons later, even if
|
||||||
if you leave Ghost Guild
|
you leave Ghost Guild
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If wiki material gets republished elsewhere (like on
|
If wiki material gets republished elsewhere (like on
|
||||||
<a href="https://coop.love">coop.love</a>), it stays under
|
<a href="https://coop.love">coop.love</a>), it stays under CC-BY-SA
|
||||||
CC-BY-SA 4.0 and you stay credited
|
4.0 and you stay credited
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -188,8 +198,8 @@
|
||||||
<section class="guidelines-section">
|
<section class="guidelines-section">
|
||||||
<h2>Our Privacy Commitments</h2>
|
<h2>Our Privacy Commitments</h2>
|
||||||
<p>
|
<p>
|
||||||
Your personal information is used to administer your membership and
|
Your personal information is used to administer your membership and to
|
||||||
to communicate with you about Ghost Guild.
|
communicate with you about Ghost Guild.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We use a small number of third-party services to run the platform
|
We use a small number of third-party services to run the platform
|
||||||
|
|
@ -220,8 +230,9 @@
|
||||||
You can end your membership at any time by contacting the Membership
|
You can end your membership at any time by contacting the Membership
|
||||||
Committee. In rare cases, membership may be ended for serious
|
Committee. In rare cases, membership may be ended for serious
|
||||||
violations of these guidelines, following the process in our
|
violations of these guidelines, following the process in our
|
||||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
|
<NuxtLink to="/policies/conflict-resolution"
|
||||||
Dues are not refunded.
|
>Conflict Resolution Policy</NuxtLink
|
||||||
|
>. Dues are not refunded.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If you leave, your wiki contributions remain in the commons under
|
If you leave, your wiki contributions remain in the commons under
|
||||||
|
|
@ -235,8 +246,14 @@
|
||||||
<h2>Related Policies</h2>
|
<h2>Related Policies</h2>
|
||||||
<p>These policies are part of what you agree to by joining:</p>
|
<p>These policies are part of what you agree to by joining:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink></li>
|
<li>
|
||||||
<li><NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink></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/privacy">Privacy Policy</NuxtLink></li>
|
||||||
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
|
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -256,9 +273,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useHead({
|
useSiteMeta({
|
||||||
title: 'Community Guidelines · Ghost Guild',
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -309,7 +328,7 @@ useHead({
|
||||||
}
|
}
|
||||||
.guidelines-section ul li {
|
.guidelines-section ul li {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 2px 0 2px 18px;
|
padding: 2px 0 2px 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|
@ -365,7 +384,7 @@ useHead({
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,3 +394,4 @@ useHead({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
`
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else class="page-fill">
|
||||||
<!-- EVENT HEADER -->
|
<!-- EVENT HEADER -->
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
<h1>{{ event.title }}</h1>
|
<h1>{{ event.title }}</h1>
|
||||||
|
|
@ -22,15 +22,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="event-meta-item">
|
<div class="event-meta-item">
|
||||||
<span class="meta-label">Location</span>
|
<span class="meta-label">Location</span>
|
||||||
{{ event.location }}
|
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
@ -48,7 +47,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 -->
|
||||||
|
|
@ -82,7 +81,7 @@
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>About This Event</h2>
|
<h2>About This Event</h2>
|
||||||
<p>{{ event.description }}</p>
|
<div class="prose" v-html="renderMarkdown(event.description)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Description -->
|
<!-- Series Description -->
|
||||||
|
|
@ -91,17 +90,23 @@
|
||||||
class="section"
|
class="section"
|
||||||
>
|
>
|
||||||
<h2>About the {{ event.series.title }} Series</h2>
|
<h2>About the {{ event.series.title }} Series</h2>
|
||||||
<p>{{ event.series.description }}</p>
|
<div class="prose" v-html="renderMarkdown(event.series.description)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Information -->
|
||||||
|
<div v-if="event.content" class="section">
|
||||||
|
<h2>Additional Information</h2>
|
||||||
|
<div class="prose" v-html="renderMarkdown(event.content)" />
|
||||||
</div>
|
</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>
|
||||||
<ol class="agenda-list">
|
<ul 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>
|
||||||
</ol>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Speakers -->
|
<!-- Speakers -->
|
||||||
|
|
@ -128,6 +133,7 @@
|
||||||
: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"
|
:user-name="memberData?.name"
|
||||||
@success="handleTicketSuccess"
|
@success="handleTicketSuccess"
|
||||||
|
|
@ -139,7 +145,7 @@
|
||||||
<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">{{ event.eventType }}</span>
|
<span class="detail-val">{{ eventTypeLabel(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>
|
||||||
|
|
@ -165,6 +171,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { eventTypeLabel } from "~/config/eventTypes";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
|
@ -186,6 +194,7 @@ if (error.value?.statusCode === 404) {
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { trackGoal, isComplete } = useOnboarding();
|
const { trackGoal, isComplete } = useOnboarding();
|
||||||
|
const { render: renderMarkdown } = useMarkdown();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkMemberStatus();
|
await checkMemberStatus();
|
||||||
|
|
@ -194,21 +203,29 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const eventTimeZone = computed(
|
||||||
|
() => event.value?.displayTimezone || "America/Toronto",
|
||||||
|
);
|
||||||
|
|
||||||
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))}`;
|
||||||
};
|
};
|
||||||
|
|
@ -220,16 +237,12 @@ const handleTicketError = (err) => {
|
||||||
console.error("Ticket purchase failed:", err);
|
console.error("Ticket purchase failed:", err);
|
||||||
};
|
};
|
||||||
|
|
||||||
useHead(() => ({
|
useSiteMeta(() => ({
|
||||||
title: event.value
|
title: event.value ? `${event.value.title} · Events` : "Event",
|
||||||
? `${event.value.title} - Ghost Guild Events`
|
description:
|
||||||
: "Event - Ghost Guild",
|
event.value?.description || "View event details and register.",
|
||||||
meta: [
|
type: "article",
|
||||||
{
|
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
|
||||||
name: "description",
|
|
||||||
content: event.value?.description || "View event details and register",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -294,10 +307,19 @@ useHead(() => ({
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
|
||||||
|
.page-fill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- TWO-COLUMN BODY ---- */
|
/* ---- TWO-COLUMN BODY ---- */
|
||||||
.event-body {
|
.event-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 280px;
|
grid-template-columns: 1fr 280px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.event-main {
|
.event-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -328,12 +350,79 @@ useHead(() => ({
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.section p {
|
.section p {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
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;
|
||||||
|
|
@ -346,10 +435,27 @@ useHead(() => ({
|
||||||
}
|
}
|
||||||
|
|
||||||
.agenda-list {
|
.agenda-list {
|
||||||
padding-left: 20px;
|
list-style: none;
|
||||||
font-size: 12px;
|
padding: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 2;
|
line-height: 1.7;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.agenda-list li {
|
||||||
|
position: relative;
|
||||||
|
padding: 2px 0 2px 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.agenda-list li::before {
|
||||||
|
content: "›";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 2px;
|
||||||
|
color: var(--candle-faint);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker {
|
.speaker {
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,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.startDate) }}</span>
|
<span class="event-date">{{ formatDate(event) }}</span>
|
||||||
<span class="event-time">{{ formatTime(event.startDate) }}</span>
|
<span class="event-time">{{ formatTime(event) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<div class="event-title">
|
<div class="event-title">
|
||||||
|
|
@ -45,34 +45,21 @@
|
||||||
<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">{{
|
||||||
event.eventType
|
eventTypeLabel(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>
|
|
||||||
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
|
|
||||||
>Sold out</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-else-if="isAlmostFull(event)"
|
|
||||||
class="capacity-badge limited"
|
|
||||||
>Limited tickets</span
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
<template 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" />
|
||||||
|
|
@ -88,8 +75,8 @@
|
||||||
<div class="series-grid">
|
<div class="series-grid">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="series in activeSeries"
|
v-for="series in activeSeries"
|
||||||
:key="series._id"
|
:key="series.id"
|
||||||
:to="`/series/${series._id}`"
|
:to="`/series/${series.id}`"
|
||||||
class="series-box"
|
class="series-box"
|
||||||
>
|
>
|
||||||
<h2>{{ series.title }}</h2>
|
<h2>{{ series.title }}</h2>
|
||||||
|
|
@ -107,6 +94,11 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<div
|
||||||
|
v-if="activeSeries.length % 2"
|
||||||
|
class="series-box series-box-filler"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -114,23 +106,27 @@
|
||||||
</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" },
|
||||||
{ label: "Workshops", value: "workshop" },
|
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
|
||||||
{ 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)
|
||||||
|
|
@ -148,18 +144,25 @@ const activeSeries = computed(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (event) => {
|
||||||
if (!dateStr) return "";
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr);
|
const tz = event.displayTimezone || "America/Toronto";
|
||||||
const opts = { month: "short", day: "numeric" };
|
const d = new Date(event.startDate);
|
||||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
|
const opts = { month: "short", day: "numeric", timeZone: tz };
|
||||||
|
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||||
|
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||||
|
if (dYear !== nowYear) opts.year = "numeric";
|
||||||
return d.toLocaleDateString("en-US", opts);
|
return d.toLocaleDateString("en-US", opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (dateStr) => {
|
const formatTime = (event) => {
|
||||||
if (!dateStr) return "";
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr);
|
return new Date(event.startDate).toLocaleTimeString("en-US", {
|
||||||
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZoneName: "short",
|
||||||
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLocation = (event) => {
|
const formatLocation = (event) => {
|
||||||
|
|
@ -171,16 +174,6 @@ const formatLocation = (event) => {
|
||||||
return event.location;
|
return event.location;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSoldOut = (event) => {
|
|
||||||
if (!event.maxAttendees) return false;
|
|
||||||
return (event.registeredCount || 0) >= event.maxAttendees;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAlmostFull = (event) => {
|
|
||||||
if (!event.maxAttendees) return false;
|
|
||||||
if (isSoldOut(event)) return false;
|
|
||||||
return (event.registeredCount || 0) / event.maxAttendees >= 0.8;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -211,7 +204,7 @@ const isAlmostFull = (event) => {
|
||||||
|
|
||||||
.event-row {
|
.event-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 90px 1fr auto auto;
|
grid-template-columns: 90px 1fr auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
|
|
@ -228,8 +221,12 @@ const isAlmostFull = (event) => {
|
||||||
.event-row:hover {
|
.event-row:hover {
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
.event-row.is-cancelled {
|
.event-row.is-cancelled .event-title a {
|
||||||
opacity: 0.5;
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
}
|
||||||
|
.event-row.is-cancelled .event-tagline {
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-col {
|
.event-date-col {
|
||||||
|
|
@ -279,6 +276,16 @@ const isAlmostFull = (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;
|
||||||
|
|
@ -307,35 +314,6 @@ const isAlmostFull = (event) => {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-capacity {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-top: 2px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.seats-warn {
|
|
||||||
color: var(--ember);
|
|
||||||
}
|
|
||||||
.capacity-badge {
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border: 1px dashed currentColor;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.capacity-badge.limited {
|
|
||||||
color: var(--ember);
|
|
||||||
}
|
|
||||||
.capacity-badge.sold-out {
|
|
||||||
color: var(--text-faint);
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-badges {
|
.event-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -368,14 +346,21 @@ const isAlmostFull = (event) => {
|
||||||
}
|
}
|
||||||
.series-box {
|
.series-box {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.series-box:last-child {
|
.series-box:nth-child(2n) {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
.series-box:hover {
|
.series-box:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.series-box-filler {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.series-box:not(.series-box-filler):hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
.series-box h2 {
|
.series-box h2 {
|
||||||
|
|
@ -419,6 +404,10 @@ const isAlmostFull = (event) => {
|
||||||
border-color: var(--candle-faint);
|
border-color: var(--candle-faint);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
.past-toggle:focus-visible {
|
||||||
|
outline: 2px dashed var(--candle);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
.past-toggle.active {
|
.past-toggle.active {
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
|
@ -456,7 +445,6 @@ const isAlmostFull = (event) => {
|
||||||
grid-template-columns: 70px 1fr;
|
grid-template-columns: 70px 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.event-capacity,
|
|
||||||
.event-badges {
|
.event-badges {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -467,8 +455,17 @@ const isAlmostFull = (event) => {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
.series-box:nth-child(2n) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.series-box:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
.series-box:last-child {
|
.series-box:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
.series-box-filler {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,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.startDate) }}</span>
|
<span class="event-date">{{ formatDate(event) }}</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
|
||||||
|
|
@ -117,6 +117,33 @@ 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: () => [],
|
||||||
|
|
@ -131,12 +158,10 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
|
||||||
|
|
||||||
const { data: wikiFeature } = await useFetch(
|
const { data: wikiFeature } = await useFetch(
|
||||||
"/api/site-content/homepage.wiki_feature",
|
"/api/site-content/homepage.wiki_feature",
|
||||||
{ default: () => ({ title: "", body: "" }) }
|
{ default: () => ({ title: "", body: "" }) },
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCustomWikiFeature = computed(
|
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
|
||||||
() => !!wikiFeature.value?.body?.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
const customWikiParagraphs = computed(() => {
|
const customWikiParagraphs = computed(() => {
|
||||||
const body = wikiFeature.value?.body?.trim() || "";
|
const body = wikiFeature.value?.body?.trim() || "";
|
||||||
|
|
@ -166,14 +191,17 @@ const circleData = [
|
||||||
label: "Practitioner",
|
label: "Practitioner",
|
||||||
metaphor: "The alcove",
|
metaphor: "The alcove",
|
||||||
blurb:
|
blurb:
|
||||||
"Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.",
|
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (event) => {
|
||||||
if (!dateStr) return "";
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr);
|
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,26 +64,37 @@
|
||||||
<!-- Left: Monthly Contribution -->
|
<!-- Left: Monthly Contribution -->
|
||||||
<div class="join-col">
|
<div class="join-col">
|
||||||
<div class="section-label" style="margin-bottom: 12px">
|
<div class="section-label" style="margin-bottom: 12px">
|
||||||
{{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
|
{{
|
||||||
|
cadence === "annual"
|
||||||
|
? "Annual Contribution"
|
||||||
|
: "Monthly Contribution"
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<h2>Pay what you can</h2>
|
<h2>Pay what you can</h2>
|
||||||
<ul class="tier-list">
|
<ul class="tier-list">
|
||||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
<li><span class="tier-amt">$0</span> I need support right now</li>
|
||||||
<li><span class="tier-amt">{{ formatContributionAmount(5) }}</span> I can contribute</li>
|
|
||||||
<li>
|
<li>
|
||||||
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
|
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I
|
||||||
(suggested)
|
can contribute
|
||||||
</li>
|
</li>
|
||||||
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
|
|
||||||
<li>
|
<li>
|
||||||
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
|
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I
|
||||||
members
|
can sustain the community (suggested)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="tier-amt">{{ formatContributionAmount(30) }}</span> I
|
||||||
|
can support others too
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I
|
||||||
|
want to sponsor multiple members
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="charity-note">
|
<p class="charity-note">
|
||||||
Baby Ghosts Studio Development Fund is a registered Canadian charity.
|
Baby Ghosts Studio Development Fund is a registered Canadian
|
||||||
Members who file Canadian taxes can claim their contributions.
|
charity. Members who file Canadian taxes can claim their
|
||||||
We'll help you set up tax receipts once you've joined.
|
contributions. We'll help you set up tax receipts once you've
|
||||||
|
joined.
|
||||||
</p>
|
</p>
|
||||||
<p class="solidarity-note">
|
<p class="solidarity-note">
|
||||||
Pay what you can. If you can pay more, you're making room for
|
Pay what you can. If you can pay more, you're making room for
|
||||||
|
|
@ -118,7 +129,7 @@
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="join-email">Email Address</label>
|
<label class="form-label" for="join-email">Email Address</label>
|
||||||
|
|
@ -129,7 +140,7 @@
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Circle</label>
|
<label class="form-label">Circle</label>
|
||||||
|
|
@ -141,7 +152,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="community"
|
value="community"
|
||||||
>
|
/>
|
||||||
<label for="circle-community">
|
<label for="circle-community">
|
||||||
<span
|
<span
|
||||||
class="circle-label-name"
|
class="circle-label-name"
|
||||||
|
|
@ -158,7 +169,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="founder"
|
value="founder"
|
||||||
>
|
/>
|
||||||
<label for="circle-founder">
|
<label for="circle-founder">
|
||||||
<span
|
<span
|
||||||
class="circle-label-name"
|
class="circle-label-name"
|
||||||
|
|
@ -175,7 +186,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="practitioner"
|
value="practitioner"
|
||||||
>
|
/>
|
||||||
<label for="circle-practitioner">
|
<label for="circle-practitioner">
|
||||||
<span
|
<span
|
||||||
class="circle-label-name"
|
class="circle-label-name"
|
||||||
|
|
@ -197,7 +208,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="cadence"
|
name="cadence"
|
||||||
value="monthly"
|
value="monthly"
|
||||||
>
|
/>
|
||||||
<label for="cadence-monthly">
|
<label for="cadence-monthly">
|
||||||
<span class="circle-label-name">Per Month</span>
|
<span class="circle-label-name">Per Month</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -209,7 +220,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="cadence"
|
name="cadence"
|
||||||
value="annual"
|
value="annual"
|
||||||
>
|
/>
|
||||||
<label for="cadence-annual">
|
<label for="cadence-annual">
|
||||||
<span class="circle-label-name">Per Year</span>
|
<span class="circle-label-name">Per Year</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -230,9 +241,13 @@
|
||||||
step="1"
|
step="1"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
class="contribution-input"
|
class="contribution-input"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
|
<div
|
||||||
|
class="contribution-presets"
|
||||||
|
role="group"
|
||||||
|
aria-label="Suggested amounts"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="preset in CONTRIBUTION_PRESETS"
|
v-for="preset in CONTRIBUTION_PRESETS"
|
||||||
:key="preset.amount"
|
:key="preset.amount"
|
||||||
|
|
@ -243,24 +258,30 @@
|
||||||
${{ preset.amount }}
|
${{ preset.amount }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
<p v-if="guidanceLabel" class="contribution-guidance">
|
||||||
|
{{ guidanceLabel }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="form.contributionAmount > 0" class="form-group">
|
<div v-if="form.contributionAmount > 0" class="form-group">
|
||||||
<div class="billing-summary">
|
<div class="billing-summary">
|
||||||
<p class="billing-summary-line">
|
<p class="billing-summary-line">
|
||||||
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month × 12)</span>.
|
You'll be charged <strong>${{ firstCharge }} today</strong
|
||||||
|
><span v-if="cadence === 'annual'">
|
||||||
|
(${{ form.contributionAmount }}/month × 12)</span
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p class="billing-summary-line">
|
<p class="billing-summary-line">
|
||||||
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
Then
|
||||||
|
<strong
|
||||||
|
>${{ firstCharge }} every
|
||||||
|
{{ cadence === "annual" ? "year" : "month" }}</strong
|
||||||
|
>, until you cancel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />
|
||||||
v-model="form.agreedToGuidelines"
|
|
||||||
type="checkbox"
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
I agree to the Ghost Guild
|
I agree to the Ghost Guild
|
||||||
<NuxtLink to="/community-guidelines" target="_blank"
|
<NuxtLink to="/community-guidelines" target="_blank"
|
||||||
|
|
@ -296,13 +317,17 @@
|
||||||
<ParchmentInset>
|
<ParchmentInset>
|
||||||
<h2>How membership works</h2>
|
<h2>How membership works</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Full access to the knowledge commons, Slack, and peer support</li>
|
<li>Full access to the knowledge commons, events and workshops, and community</li>
|
||||||
<li>Free access to all Ghost Guild events</li>
|
<li>Free access to all Ghost Guild events</li>
|
||||||
<li>Equal access for every member, regardless of contribution</li>
|
<li>Equal access for every member, regardless of contribution</li>
|
||||||
<li>Your circle reflects where you are, not rank</li>
|
<li>Your circle reflects where you are, not rank</li>
|
||||||
<li>Pay what you can ($0–$50+/month, separate from circle)</li>
|
<li>Pay what you can ($0–$50+/month, separate from circle)</li>
|
||||||
<li>Higher contributions create solidarity spots for others</li>
|
<li>Higher contributions create solidarity spots for others</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>
|
||||||
|
Community connection happens in our Slack workspace, joined in monthly
|
||||||
|
onboarding waves — there may be a short wait after you join.
|
||||||
|
</p>
|
||||||
</ParchmentInset>
|
</ParchmentInset>
|
||||||
|
|
||||||
<!-- THREE CIRCLES -->
|
<!-- THREE CIRCLES -->
|
||||||
|
|
@ -338,12 +363,11 @@
|
||||||
<h2>Practicing</h2>
|
<h2>Practicing</h2>
|
||||||
<p>
|
<p>
|
||||||
For those already running cooperative studios or with deep
|
For those already running cooperative studios or with deep
|
||||||
experience in cooperative practice. You are here to teach, advise,
|
experience in cooperative practice. You're here to support newcomers
|
||||||
mentor, and help shape the program itself. Alumni.
|
and help shape the Cooperative Foundations program.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Flow overlay: covers the page from form submit through redirect.
|
<!-- Flow overlay: covers the page from form submit through redirect.
|
||||||
|
|
@ -367,6 +391,12 @@ import {
|
||||||
getGuidanceLabel,
|
getGuidanceLabel,
|
||||||
} from "~/config/contributions";
|
} from "~/config/contributions";
|
||||||
|
|
||||||
|
useSiteMeta({
|
||||||
|
title: "Join",
|
||||||
|
description:
|
||||||
|
"Join Ghost Guild — a membership community for game developers exploring cooperative models. Everyone gets everything. Pay what you can, $0 to $50 per month.",
|
||||||
|
});
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
|
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
|
||||||
|
|
||||||
|
|
@ -434,7 +464,8 @@ const isFormValid = computed(() => {
|
||||||
form.name &&
|
form.name &&
|
||||||
form.email &&
|
form.email &&
|
||||||
form.circle &&
|
form.circle &&
|
||||||
Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
|
Number.isInteger(form.contributionAmount) &&
|
||||||
|
form.contributionAmount >= 0 &&
|
||||||
form.agreedToGuidelines
|
form.agreedToGuidelines
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -555,10 +586,9 @@ const createSubscription = async (cardToken = null) => {
|
||||||
flowState.value = "success";
|
flowState.value = "success";
|
||||||
successMessage.value = "Your membership is active.";
|
successMessage.value = "Your membership is active.";
|
||||||
|
|
||||||
// Check member status to ensure user is properly authenticated
|
// Sign-in cookie is now issued by the email-verify magic link
|
||||||
await checkMemberStatus();
|
// (see /api/helcim/customer). Don't auto-navigate to a gated page —
|
||||||
|
// the success state instructs the user to check their inbox.
|
||||||
navigateTo("/welcome");
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Subscription creation failed - response not successful");
|
throw new Error("Subscription creation failed - response not successful");
|
||||||
}
|
}
|
||||||
|
|
@ -727,7 +757,7 @@ onUnmounted(() => {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.tier-list li {
|
.tier-list li {
|
||||||
padding: 5px 0;
|
padding: 4px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
@ -831,7 +861,7 @@ onUnmounted(() => {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
border: 1px solid var(--parch);
|
border: 1px solid var(--parch);
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.contribution-input:focus {
|
.contribution-input:focus {
|
||||||
|
|
@ -848,7 +878,7 @@ onUnmounted(() => {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px dashed var(--parch);
|
border: 1px dashed var(--parch);
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -1018,6 +1048,7 @@ onUnmounted(() => {
|
||||||
.checkbox-label a,
|
.checkbox-label a,
|
||||||
.checkbox-label :deep(a) {
|
.checkbox-label :deep(a) {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- ERROR & SUCCESS BOXES ---- */
|
/* ---- ERROR & SUCCESS BOXES ---- */
|
||||||
|
|
@ -1127,5 +1158,4 @@ onUnmounted(() => {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@
|
||||||
form.contributionAmount === Number(memberData.contributionAmount || 0) ||
|
form.contributionAmount === Number(memberData.contributionAmount || 0) ||
|
||||||
isUpdating
|
isUpdating
|
||||||
"
|
"
|
||||||
@click="handleUpdateTier"
|
@click="handleUpdateContribution"
|
||||||
>
|
>
|
||||||
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -315,6 +315,9 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
|
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",
|
||||||
|
|
@ -417,13 +420,6 @@ const circleOptions = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
|
||||||
active: "Active",
|
|
||||||
pending_payment: "Setting up payment",
|
|
||||||
suspended: "Paused",
|
|
||||||
cancelled: "Closed",
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -482,7 +478,7 @@ const refreshNextBillingIfStale = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateTier = async () => {
|
const handleUpdateContribution = async () => {
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/members/update-contribution", {
|
await $fetch("/api/members/update-contribution", {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@
|
||||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
<span>${{ memberData?.contributionAmount ?? 0 }} 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 -->
|
||||||
|
|
@ -56,9 +60,7 @@
|
||||||
:to="`/events/${evt.slug || evt._id}`"
|
:to="`/events/${evt.slug || evt._id}`"
|
||||||
class="event-item"
|
class="event-item"
|
||||||
>
|
>
|
||||||
<span class="event-date">{{
|
<span class="event-date">{{ formatEventDate(evt) }}</span>
|
||||||
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>
|
||||||
|
|
@ -218,12 +220,18 @@
|
||||||
</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 route = useRoute();
|
||||||
const isNewSignup = computed(() => route.query.welcome === "1");
|
const isNewSignup = computed(() => route.query.welcome === "1");
|
||||||
|
const showSlackComingNote = computed(
|
||||||
|
() =>
|
||||||
|
memberData.value?.status === "active" && !memberData.value?.slackInvited,
|
||||||
|
);
|
||||||
const welcomeTitle = computed(() => {
|
const welcomeTitle = computed(() => {
|
||||||
const name = memberData.value?.name || "";
|
const name = memberData.value?.name || "";
|
||||||
return isNewSignup.value
|
return isNewSignup.value
|
||||||
|
|
@ -357,20 +365,22 @@ const getEventImageUrl = (featureImage) => {
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventDate = (dateString) => {
|
const formatEventDate = (event) => {
|
||||||
const date = new Date(dateString);
|
if (!event?.startDate) return "";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(date);
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
}).format(new Date(event.startDate));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventTime = (dateString) => {
|
const formatEventTime = (event) => {
|
||||||
const date = new Date(dateString);
|
if (!event?.startDate) return "";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(date);
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
}).format(new Date(event.startDate));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMemberSince = (dateString) => {
|
const formatMemberSince = (dateString) => {
|
||||||
|
|
@ -468,6 +478,13 @@ 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));
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({ middleware: 'auth' });
|
definePageMeta({ middleware: 'auth' });
|
||||||
|
|
||||||
|
useSiteMeta({ title: 'Payment Setup', noindex: true });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
@ -72,6 +74,7 @@ const errorMessage = ref('');
|
||||||
const isProcessing = ref(false);
|
const isProcessing = ref(false);
|
||||||
const customerId = ref('');
|
const customerId = ref('');
|
||||||
const customerCode = ref('');
|
const customerCode = ref('');
|
||||||
|
const hasExistingCard = ref(false);
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
@ -84,13 +87,47 @@ const initialize = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const customer = await $fetch('/api/helcim/get-or-create-customer', {
|
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||||
method: 'POST',
|
// 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;
|
customerId.value = customer.customerId;
|
||||||
customerCode.value = customer.customerCode;
|
customerCode.value = customer.customerCode;
|
||||||
|
hasExistingCard.value = Boolean(existingFromFull?.cardToken);
|
||||||
|
|
||||||
|
if (!hasExistingCard.value) {
|
||||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
step.value = 'ready';
|
step.value = 'ready';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Payment setup init failed:', err);
|
console.error('Payment setup init failed:', err);
|
||||||
|
|
@ -106,6 +143,7 @@ const openModal = async () => {
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!hasExistingCard.value) {
|
||||||
const result = await verifyPayment();
|
const result = await verifyPayment();
|
||||||
if (!result?.success) throw new Error('Payment was not completed.');
|
if (!result?.success) throw new Error('Payment was not completed.');
|
||||||
|
|
||||||
|
|
@ -116,6 +154,7 @@ const openModal = async () => {
|
||||||
customerId: customerId.value,
|
customerId: customerId.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update circle first if it changed — update-contribution only touches tier.
|
// Update circle first if it changed — update-contribution only touches tier.
|
||||||
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
|
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,8 @@ import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
||||||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
||||||
import { formatActivity } from "~/utils/activityText";
|
import { formatActivity } from "~/utils/activityText";
|
||||||
|
|
||||||
|
useSiteMeta({ title: "Profile", noindex: true });
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
middleware: "auth",
|
||||||
});
|
});
|
||||||
|
|
@ -712,10 +714,6 @@ useHead({
|
||||||
|
|
||||||
.posts-empty-link {
|
.posts-empty-link {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.posts-empty-link:hover {
|
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@
|
||||||
<span class="profile-pronouns">{{ member.pronouns }}</span>
|
<span class="profile-pronouns">{{ member.pronouns }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-meta">
|
<div class="profile-meta">
|
||||||
<span v-if="member.circle" class="badge" :class="member.circle">
|
<CircleBadge v-if="member.circle" :circle="member.circle" :label="circleLabels[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>
|
||||||
|
|
@ -278,14 +276,10 @@ onUnmounted(() => {
|
||||||
pageBreadcrumbTitle.value = "";
|
pageBreadcrumbTitle.value = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Page head
|
useSiteMeta(() => ({
|
||||||
useHead({
|
title: member.value ? member.value.name : "Member Profile",
|
||||||
title: computed(() =>
|
noindex: true,
|
||||||
member.value
|
}));
|
||||||
? `${member.value.name} — Ghost Guild`
|
|
||||||
: "Member Profile — Ghost Guild",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -372,7 +366,7 @@ useHead({
|
||||||
}
|
}
|
||||||
.profile-name {
|
.profile-name {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 42px;
|
font-size: 36px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -277,16 +277,7 @@ onBeforeUnmount(() => {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- useHead ----
|
useSiteMeta({ title: 'Member Directory', noindex: true })
|
||||||
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.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Init ----
|
// ---- Init ----
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,9 @@ if (!policy) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
|
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({
|
useSiteMeta({
|
||||||
title: `${policy.title} · Ghost Guild`,
|
title: policy.title,
|
||||||
|
description: policy.description,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,8 +231,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useHead({
|
useSiteMeta({
|
||||||
title: 'Privacy Policy · Ghost Guild',
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
118
app/pages/policies/refunds.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<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>
|
||||||
|
|
@ -250,8 +250,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
useHead({
|
useSiteMeta({
|
||||||
title: 'Terms of Service · Ghost Guild',
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="page-fill">
|
||||||
<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>
|
<div v-else class="page-fill">
|
||||||
<!-- 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,31 +26,44 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DESCRIPTION -->
|
<!-- TWO-COLUMN BODY -->
|
||||||
<div v-if="series.description" class="section">
|
<div class="series-body" :class="{ 'has-aside': series.tickets?.enabled }">
|
||||||
|
<!-- LEFT: MAIN CONTENT -->
|
||||||
|
<div class="series-main">
|
||||||
|
<div v-if="series.description" class="section description">
|
||||||
<p>{{ series.description }}</p>
|
<p>{{ series.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EVENT LIST -->
|
<div class="section" :class="{ 'section-flush': series.events?.length }">
|
||||||
<div class="section">
|
|
||||||
<div class="section-label">Sessions</div>
|
<div class="section-label">Sessions</div>
|
||||||
<div v-if="series.events?.length">
|
<div v-if="series.events?.length" class="sessions-box">
|
||||||
<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>
|
||||||
|
|
||||||
<!-- PASS PURCHASE -->
|
<!-- Questions (inline when no sidebar) -->
|
||||||
<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 }"
|
||||||
|
|
@ -59,13 +72,13 @@
|
||||||
:user-name="memberData?.name"
|
:user-name="memberData?.name"
|
||||||
@purchase-success="handlePurchaseSuccess"
|
@purchase-success="handlePurchaseSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QUESTIONS -->
|
<div class="aside-panel">
|
||||||
<div class="section">
|
<div class="box-title">Questions?</div>
|
||||||
<div class="section-label">Questions?</div>
|
<p class="aside-detail">Drop us a line.</p>
|
||||||
<p>If you have questions about this series, reach out to us.</p>
|
<a class="aside-link" href="mailto:events@ghostguild.org">events@ghostguild.org</a>
|
||||||
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,9 +117,11 @@ const handlePurchaseSuccess = () => {
|
||||||
refreshNuxtData()
|
refreshNuxtData()
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead(() => ({
|
useSiteMeta(() => ({
|
||||||
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
|
title: series.value ? `${series.value.title} · Event Series` : 'Event Series',
|
||||||
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
|
description:
|
||||||
|
series.value?.description ||
|
||||||
|
(series.value?.title ? `${series.value.title} — a Ghost Guild event series.` : undefined),
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -137,28 +152,105 @@ useHead(() => ({
|
||||||
}
|
}
|
||||||
.meta-text { color: var(--text-faint); }
|
.meta-text { color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
|
||||||
|
.page-fill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TWO-COLUMN BODY ---- */
|
||||||
|
.series-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.series-body.has-aside {
|
||||||
|
grid-template-columns: 1fr 280px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.series-main { min-width: 0; }
|
||||||
|
.series-aside {
|
||||||
|
border-left: 1px dashed var(--border);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
.series-main .section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
|
.section p { font-size: 12px; color: var(--text-dim); line-height: 1.7; max-width: 560px; margin-bottom: 8px; }
|
||||||
.section a { font-size: 12px; color: var(--candle); }
|
.section a { font-size: 12px; color: var(--candle); }
|
||||||
|
.section.description p { font-size: 14px; color: var(--text); }
|
||||||
|
|
||||||
|
.section-flush { padding-bottom: 0; }
|
||||||
|
.sessions-box {
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
margin: 10px -32px 0;
|
||||||
|
}
|
||||||
.event-row {
|
.event-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 32px 80px 1fr;
|
grid-template-columns: 32px auto 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 10px 0;
|
padding: 10px 32px;
|
||||||
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); }
|
.event-date { color: var(--text-faint); white-space: nowrap; }
|
||||||
|
.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,214 +1,87 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Page Header -->
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Event Series"
|
title="Event Series"
|
||||||
subtitle="Multi-session events on cooperative topics"
|
subtitle="Multi-session events on cooperative topics"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Series Grid -->
|
<div v-if="pending" class="state-msg">Loading series...</div>
|
||||||
<section class="py-20 bg-[--ui-bg]">
|
|
||||||
<UContainer>
|
<div v-else-if="!filteredSeries.length" class="state-msg">
|
||||||
<div v-if="pending" class="text-center py-12">
|
<p>
|
||||||
<div
|
No series right now. Check back later or browse
|
||||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
<NuxtLink to="/events">upcoming events</NuxtLink>.
|
||||||
></div>
|
</p>
|
||||||
<p class="text-[--ui-text-muted]">Loading series...</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-else>
|
||||||
v-else-if="filteredSeries.length > 0"
|
<section
|
||||||
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="border border-[--ui-border] rounded overflow-hidden hover:border-primary transition-colors"
|
class="series-section"
|
||||||
>
|
>
|
||||||
<!-- Series Header -->
|
<div class="series-head">
|
||||||
<div class="p-6 border-b border-[--ui-border]">
|
<h2>{{ series.title }}</h2>
|
||||||
<div
|
<div class="series-meta-row">
|
||||||
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
|
<span v-if="series.type" class="badge all">{{ formatSeriesType(series.type) }}</span>
|
||||||
>
|
<span class="meta-text">
|
||||||
<div class="flex-1">
|
{{ series.eventCount }} sessions<template v-if="series.totalEvents"> of {{ series.totalEvents }} planned</template>
|
||||||
<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
|
<span v-if="series.startDate && series.endDate" class="meta-text">
|
||||||
:class="[
|
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||||
'inline-flex items-center px-2 py-1 rounded text-xs font-medium',
|
</span>
|
||||||
series.status === 'active'
|
<span v-if="series.totalRegistrations" class="meta-text">
|
||||||
? 'bg-candlelight-900/20 text-candlelight-500 dark:text-candlelight-400 border border-candlelight-700/30'
|
{{ series.totalRegistrations }} registered
|
||||||
: 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>
|
||||||
<h2 class="text-display-sm font-bold text-[--ui-text] mb-2">
|
<p v-if="series.description" class="series-desc">
|
||||||
{{ 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 class="text-3xl font-bold text-[--ui-text] mb-1">
|
|
||||||
{{ series.eventCount }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-[--ui-text-muted]">Events</div>
|
|
||||||
<div
|
|
||||||
v-if="series.totalEvents"
|
|
||||||
class="text-xs text-[--ui-text-muted] mt-1"
|
|
||||||
>
|
|
||||||
of {{ series.totalEvents }} planned
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Events List -->
|
<div v-if="series.events?.length" class="sessions">
|
||||||
<div class="divide-y divide-[--ui-border]">
|
|
||||||
<div
|
<div
|
||||||
v-for="event in series.events"
|
v-for="(event, index) in series.events"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
class="p-4 hover:bg-[--ui-bg-elevated] transition-colors"
|
class="event-row"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<span class="event-num">
|
||||||
<div class="flex items-center gap-4 flex-1 min-w-0">
|
{{ String(event.series?.position || index + 1).padStart(2, '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="inline-flex items-center px-3 py-1 bg-primary text-white text-sm rounded hover:bg-primary/90 transition-colors"
|
class="event-title-link"
|
||||||
>
|
>
|
||||||
View
|
{{ event.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
<span class="event-status">{{ getEventStatus(event) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Footer -->
|
<div class="series-foot">
|
||||||
<div
|
<NuxtLink :to="`/series/${series.id}`" class="view-link">
|
||||||
class="px-6 py-4 bg-[--ui-bg-elevated] border-t border-[--ui-border]"
|
View series →
|
||||||
>
|
|
||||||
<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>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="text-center py-16">
|
|
||||||
<Icon
|
|
||||||
name="heroicons:squares-2x2"
|
|
||||||
class="w-16 h-16 text-[--ui-text-muted] mx-auto mb-4 opacity-50"
|
|
||||||
/>
|
|
||||||
<h3 class="text-display-sm font-semibold text-[--ui-text] mb-2">
|
|
||||||
No series right now
|
|
||||||
</h3>
|
|
||||||
<p class="text-[--ui-text-muted] max-w-md mx-auto">
|
|
||||||
Check back later or browse
|
|
||||||
<NuxtLink to="/events" class="text-primary">upcoming events</NuxtLink>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</UContainer>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// SEO
|
useSiteMeta({
|
||||||
useHead({
|
title: "Event Series",
|
||||||
title: "Event Series - Ghost Guild",
|
description:
|
||||||
meta: [
|
"Multi-session event series on cooperative topics — from foundations courses to practitioner cohorts.",
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
content:
|
|
||||||
"Multi-session events on cooperative topics for game developers.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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(
|
||||||
|
|
@ -216,7 +89,6 @@ const filteredSeries = computed(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
const formatSeriesType = (type) => {
|
const formatSeriesType = (type) => {
|
||||||
const types = {
|
const types = {
|
||||||
workshop_series: "Workshop Series",
|
workshop_series: "Workshop Series",
|
||||||
|
|
@ -228,25 +100,6 @@ 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",
|
||||||
|
|
@ -255,50 +108,133 @@ 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,6 +17,7 @@
|
||||||
|
|
||||||
<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('')
|
||||||
|
|
|
||||||
77
app/utils/timezones.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// 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
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# 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,33 +1,30 @@
|
||||||
# Launch Readiness
|
# Launch Readiness
|
||||||
|
|
||||||
**Status as of 2026-04-20.** Target launch: before 2026-05-01.
|
**Status as of 2026-05-18. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
|
||||||
|
|
||||||
Single source of truth for work that must happen before cutover. P0 blocks launch. P1 is strongly preferred but survivable. Completed items have been archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`.
|
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
|
## Current state
|
||||||
|
|
||||||
- Vitest on `main`: **652/658 passing**. 6 pre-existing failures in `tests/server/api/helcim-payment.test.js` — unrelated to launch-blocking work, noted in the deploy checklist for visibility.
|
- 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.
|
||||||
- `main` is now caught up locally (2026-04-20): `feature/helcim-plan-consolidation` (40 commits) and `feature/contribution-amount-redesign` (17 commits) fast-forwarded in. Not pushed — site is not on Netlify yet.
|
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
|
||||||
- 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.**
|
- Contribution-amount migration has **NOT** yet been run against prod.
|
||||||
- Cadence/contribution UX unified across signup + edit surfaces 2026-04-20. Uncommitted in working tree — see "Cadence UX refinements" below.
|
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
|
||||||
- **Charitable receipts Phase 1 built on `feature/receipts-phase-1` (commits `bf5a333..91711aa`, 2026-04-20). Unmerged.** All four spec items shipped: `Payment` model + idempotent `upsertPaymentFromHelcim` helper, synchronous payment logging on both new paid subscriptions and free→paid upgrades, nightly reconciliation script, `/join` charity note, and `taxReceiptPreferences` schema field (no UI — Phase 2). Resend-owned confirmation email (`server/emails/paymentConfirmation.js`) is CRA-safe. Remaining work is deploy-time only (merge branch, disable Helcim default email on plans 50302 + 50303, backfill, real staging charge) — tracked in Deploy checklist.
|
- `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).
|
||||||
|
|
||||||
### Cadence UX refinements (2026-04-20, uncommitted)
|
|
||||||
|
|
||||||
Shipped across `accept-invite.vue`, `join.vue`, `member/account.vue`, `welcome.vue`, `member/dashboard.vue`, and a new shared `SignupFlowOverlay.vue`:
|
|
||||||
|
|
||||||
- **Shared SignupFlowOverlay component.** Extracted from `/join` progress overlay; now used by both `/join` and `/accept-invite`.
|
|
||||||
- **Static "Monthly Contribution" label** on all three contribution inputs (previously dynamic — flipped to "Annual Contribution" when annual cadence was selected, which was misleading because the stored value is always the monthly base).
|
|
||||||
- **"Per Year" / "Per Month"** toggle copy (was "Annual" / "Monthly"). On `/accept-invite`, Per Year is now the default; `/join` stays on Per Month by default.
|
|
||||||
- **Live billing-summary card** below the contribution input on both signup flows — reads e.g. "You'll be charged $180 today ($15/month × 12). Then $180 every year, until you cancel."
|
|
||||||
- **Welcome heading on dashboard** for new signups: `/member/dashboard?welcome=1` renders "Welcome to Ghost Guild, {name}" instead of "Welcome back, {name}". `/welcome` redirect now always carries the param; `/accept-invite` navigates to the dashboard with the param directly.
|
|
||||||
- **$0 member polish on `/member/account`**: Payment History section hidden for $0 members with no prior charges (condition now `contributionAmount > 0 || paymentHistory.length > 0` — fixes a regression where paid-then-$0 members lost visibility of their past payments). Solidarity-Fund sentence in the Danger Zone also hidden at $0.
|
|
||||||
- **Next charge row above payment history** on `/member/account`: When a member has an upcoming charge, a "Next charge: $X on DATE" row renders above the transaction list (dashed `--candle` border). Separate from the existing compact "Next payment" row in the Membership Card summary.
|
|
||||||
- **Fixed `subscription.get.js` Helcim field mapping.** Helcim's GET `/subscriptions/:id` returns `data` as a single object (not array) with the field `dateBilling` (not `nextBillingDate`). The lazy refresh endpoint now handles both shapes — previously it returned empty strings, so neither the Membership-card "Next payment" nor the new "Next charge" row rendered for any member whose cached `nextBillingDate` was missing. Note: `subscription.post.js` and `update-contribution.post.js` still read `subscription.nextBillingDate` from Helcim's CREATE response (same wrong field), which is why the cache was empty to begin with. Left unfixed in this pass — the lazy GET refresh now masks it. Worth cleaning up post-launch.
|
|
||||||
- **State-aware contribution-change hint** on `/member/account`: "You'll be charged $X today to start your subscription." ($0 → paid) / "Your paid subscription will be cancelled." (paid → $0) / "Changes apply on your next billing cycle." (paid → paid, different amount).
|
|
||||||
- **Server-side invite accept** now creates the Helcim customer and sets the auth cookie before returning, for both free and paid branches.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -39,137 +36,119 @@ None outstanding.
|
||||||
|
|
||||||
## P1 — Strongly preferred before launch
|
## P1 — Strongly preferred before launch
|
||||||
|
|
||||||
### Charitable receipts — Phase 1 ✅ COMPLETE (`docs/specs/receipts-launch-spec.md`)
|
None outstanding.
|
||||||
|
|
||||||
Built on `feature/receipts-phase-1`, commits `bf5a333..91711aa` (2026-04-20). **Unmerged.** All four spec items shipped; remaining work is deploy-time only (tracked in Deploy checklist).
|
|
||||||
|
|
||||||
Shipped:
|
|
||||||
- **Payment logging.** New `Payment` model (`server/models/payment.js`) + idempotent `upsertPaymentFromHelcim` helper keyed on unique `helcimTransactionId` (`server/utils/payments.js`). Synchronous write paths:
|
|
||||||
- New paid subscription → `server/api/helcim/subscription.post.js` fetches the newest paid Helcim tx and upserts a Payment with `paymentType` from cadence + `sendConfirmation: true`. Wrapped in try/catch so a logging failure cannot break subscription creation.
|
|
||||||
- Free → paid upgrade → `server/api/members/update-contribution.post.js` (Case 1 branch) does the same.
|
|
||||||
- Paid → paid amount change (Case 3) is intentionally **not** wired synchronously — no new tx at the moment of change; the next recurring charge is captured by the reconciliation script.
|
|
||||||
- **Confirmation email via Resend, not Helcim.** Spec alternative (b) chosen. `server/emails/paymentConfirmation.js` is CRA-safe: charity name "Baby Ghosts Studio Development Fund" + "not an official donation receipt / tax receipts available later in 2026" disclaimer. Triggered only on new Payment inserts; send failures are swallowed. Helcim's default confirmation must be disabled on plans 50302 + 50303 at cutover (Deploy checklist).
|
|
||||||
- **Join page copy.** Factual charity note below contribution tiers on `/join` only (`app/pages/join.vue:83`). `/accept-invite` and `/member/account` intentionally untouched per spec §3.
|
|
||||||
- **Member schema field.** `taxReceiptPreferences` nested object added to `server/models/member.js` (filesCanadianTaxes, middleInitial, confirmedAddress sub-object, setupCompletedAt). Defaults null/false — existing members read as "not set up." Schema-only; no Zod, no route, no UI. Phase 2 binds to it without migration.
|
|
||||||
- **Reconciliation script.** `scripts/reconcile-helcim-payments.mjs` iterates every Member with `helcimCustomerId`, pulls recent Helcim transactions, and upserts via the same helper. Idempotent. Dry-run by default; `--apply` to write. No confirmation emails sent during reconcile. Dual purpose: launch-day backfill for the ~34 pre-existing members, and nightly cron post-launch to catch recurring charges that bypass the synchronous write paths.
|
|
||||||
|
|
||||||
Remaining (deploy-time, not code):
|
|
||||||
- [ ] Merge `feature/receipts-phase-1` into `main`.
|
|
||||||
- Manual Helcim-dashboard step + prod reconcile + staging test charge — see Deploy checklist.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deploy checklist
|
## Deploy checklist
|
||||||
|
|
||||||
Applies when the site is connected to Netlify / production hosting. Nothing here is actionable until that connection exists; kept here so nothing gets forgotten at cutover.
|
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`.
|
- [ ] 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.
|
- [ ] **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 production env.
|
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in Dokploy env.
|
||||||
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in production env.
|
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy env.
|
||||||
- [ ] Decide on the 6 failing tests in `tests/server/api/helcim-payment.test.js` — either fix or consciously accept. Not launch-blocking, but pre-existing red tests tend to mask new regressions.
|
- [ ] **Set `NUXT_RECONCILE_TOKEN`** to any 32+ char random string. Shared secret between the Dokploy scheduled task and `/api/internal/reconcile-payments`.
|
||||||
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); safe to re-run as a nightly reconciliation job post-launch.
|
- [ ] 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.
|
- [ ] **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 on staging** via the cloudflared tunnel and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
|
- [ ] **Run one real test charge against the deployed app** and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
|
||||||
|
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
|
||||||
|
- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
|
||||||
|
|
||||||
**Env vars required in production (reference):**
|
### 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`
|
- `MONGODB_URI`
|
||||||
- `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins)
|
- `JWT_SECRET` (or `NUXT_JWT_SECRET` — the `NUXT_` variant wins)
|
||||||
- `RESEND_API_KEY`
|
- `RESEND_API_KEY`
|
||||||
- `HELCIM_API_TOKEN`
|
- `HELCIM_API_TOKEN`
|
||||||
- `NUXT_HELCIM_MONTHLY_PLAN_ID`
|
- `NUXT_HELCIM_MONTHLY_PLAN_ID=50302`
|
||||||
- `NUXT_HELCIM_ANNUAL_PLAN_ID`
|
- `NUXT_HELCIM_ANNUAL_PLAN_ID=50303`
|
||||||
- `SLACK_BOT_TOKEN`
|
|
||||||
- `BASE_URL`
|
|
||||||
- `OIDC_COOKIE_SECRET`
|
|
||||||
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
|
- `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
|
## Manual browser tests still needed
|
||||||
|
|
||||||
Cannot be verified by Vitest. Both require a real browser + real Helcim test card + real email, via cloudflared tunnel or ngrok HTTPS (Helcim requires HTTPS for the pay.js iframe).
|
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.
|
||||||
|
|
||||||
**Shared setup (do once):**
|
|
||||||
- `npx nuxi dev --https` in one terminal, `cloudflared tunnel --url https://localhost:3000` (or `ngrok http https://localhost:3000`) in another. Use the tunnel URL as `BASE_URL` in `.env`.
|
|
||||||
- Helcim sandbox test card: see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/reference_helcim_sandbox.md`.
|
|
||||||
- Apply the contribution-amount migration against local Mongo first so seeded members match the new schema:
|
|
||||||
```
|
|
||||||
node scripts/migrate-contribution-amount.cjs # dry-run
|
|
||||||
node scripts/migrate-contribution-amount.cjs --apply # apply
|
|
||||||
```
|
|
||||||
After applying, confirm in mongosh: `db.members.countDocuments({ contributionAmount: { $exists: true } })` should equal total member count; `db.members.countDocuments({ contributionAmount: { $type: 'string' } })` must be `0`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [x] **Pre-registrant invite → accept flow with a paid contribution amount.** ✅ Passed 2026-04-20 — both Monthly $7 and Annual $15 variants completed end-to-end. DB verified programmatically: `contributionAmount` stored as Number, `billingCadence` correct, `helcimCustomerId` + `helcimSubscriptionId` populated, `status: active`, no `contributionTier` field, preReg transitioned to `accepted` with `memberId` set.
|
## Post-launch & deferred work
|
||||||
|
|
||||||
- **Contribution-amount redesign end-to-end.** Covers the full surface of the `contributionTier` → `contributionAmount` rename.
|
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).**
|
||||||
|
|
||||||
- [x] **Signup flows — `/join`:** ✅ Passed 2026-04-20. All 5 variants ran functionally clean (welcome-heading regression was caught, fixed via `?welcome=1` propagation through `/welcome`, not retested — trusted):
|
|
||||||
1. `$0` Monthly — Member created with no Helcim subscription.
|
|
||||||
2. `$5` Monthly (preset) — Helcim `recurringAmount: 5`.
|
|
||||||
3. `$17` Monthly (non-preset) — Helcim `recurringAmount: 17`, `$15` chip label via `findLast`.
|
|
||||||
4. `$17` Annual — Helcim `recurringAmount: 204`, `billingCadence: 'annual'`, Mongo stores monthly-equivalent `17`.
|
|
||||||
5. `$50` Annual (top preset) — Helcim `recurringAmount: 600`.
|
|
||||||
|
|
||||||
- [x] **Edit flows — `/member/account` as an active paid member:** ✅ Passed 2026-04-20 against Cleo's Annual subscription (Helcim sub 138682).
|
|
||||||
- Raise $15 → $30 annual: `updateHelcimSubscription` hit with `recurringAmount: 360`, Mongo `contributionAmount: 30` (Number).
|
|
||||||
- Lower $30 → $5 annual: `recurringAmount: 60`, Mongo `contributionAmount: 5` (Number).
|
|
||||||
- ~~Switch cadence (Monthly $17 ↔ Annual $17).~~ **Deferred from launch.** Server (`update-contribution.post.js:184-189`) explicitly rejects cadence changes on existing subscriptions; no UI toggle exists on `/member/account`. Re-scope post-launch if/when we want to support cadence switch (would need Helcim subscription replacement flow, not a plain update).
|
|
||||||
|
|
||||||
- [x] **Admin flow — `/admin/members/[id]` edit:** ✅ Passed 2026-04-20.
|
|
||||||
- Changed Cleo $5 → $15 via admin PUT. Mongo wrote `contributionAmount: 15` (Number). `contributionTier` field absent across all 34 members (`countDocuments({ contributionTier: { $exists: true } }) === 0`).
|
|
||||||
- Known non-blocker: admin edit does not sync the change to Helcim's `recurringAmount`. Admin override is direct Mongo-only by design; had to PATCH Helcim manually to re-sync Cleo post-test. Worth noting in docs or surfacing in admin UI post-launch.
|
|
||||||
|
|
||||||
**Assert across all flows:**
|
|
||||||
- Mongo `contributionAmount` is always `Number`, never `String`.
|
|
||||||
- No `contributionTier` values written anywhere (greppable: `db.members.findOne({}, { contributionTier: 1 })` should return whatever the migration left; no *new* writes to that field).
|
|
||||||
- No "save $X", "2 months free", or discount copy appears in any UI surface. Annual is just `amount × 12` exactly.
|
|
||||||
- Guidance chip labels (`$0`/`$5`/`$15`/`$30`/`$50`) are matched via `findLast`, so $17 lands on the `$15` label, $49 lands on `$30`, $51 lands on `$50`.
|
|
||||||
|
|
||||||
**Key files if debugging:** `app/pages/join.vue`, `app/pages/member/account.vue`, `app/pages/admin/members/[id].vue`, `server/api/helcim/subscription.post.js`, `server/api/members/update-contribution.post.js`, `server/api/admin/members/[id].put.js`, `app/config/contributions.js` + `server/config/contributions.js`.
|
|
||||||
|
|
||||||
**Cosmetic follow-ups noted in Post-launch backlog below** — won't block this test (they're naming, not behavior).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bylaws decoupling — follow-ups (added 2026-04-18)
|
|
||||||
|
|
||||||
Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in `useMemberStatus.js` and `account.vue` (2026-04-18). Server-side status gating shipped as B2 (see archive). The behavioral changes below remain.
|
|
||||||
|
|
||||||
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
|
|
||||||
|
|
||||||
### B1. `cancel-subscription` flips status to `pending_payment`
|
|
||||||
- `server/api/members/cancel-subscription.post.js:31,48`
|
|
||||||
- When a member cancels their paid subscription, status is set to `pending_payment` and contribution amount to `0`. Under the new model, cancelling a payment plan moves the member to the $0 contribution — status should stay `active`.
|
|
||||||
- **Fix:** change `status: 'pending_payment'` → `status: 'active'` in both the `findByIdAndUpdate` payload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing).
|
|
||||||
- Add coverage in `tests/server/api/cancel-subscription.test.js` if it doesn't already exist.
|
|
||||||
|
|
||||||
### B3. Vestigial `pending_payment` status
|
|
||||||
- Once payment is fully decoupled, `pending_payment` no longer gates anything and is functionally equivalent to `active`. Consider removing it from the enum (`server/models/member.js:38`, `server/utils/schemas.js:299`) and treating new signups as `active` from the moment of account creation.
|
|
||||||
- Touches: signup flow (`helcim/customer.post.js:34`, `invite/accept.post.js:48`), admin filter UI (`app/pages/admin/members/index.vue:45,382,499,1145`, `[id].vue:69,286`), admin alerts (`server/utils/adminAlerts.js:22,100-116`, `server/models/adminAlertDismissal.js:6`), and a data migration to flip existing `pending_payment` rows to `active`.
|
|
||||||
- Larger refactor — break out into its own ticket once B1 lands.
|
|
||||||
|
|
||||||
### B4. Admin "Pending Payment" filter label (cosmetic)
|
|
||||||
- `app/pages/admin/members/index.vue:45,499`, `[id].vue:69` show `pending_payment` as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep `pending_payment` for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-launch backlog
|
|
||||||
|
|
||||||
See `docs/TODO.md` for:
|
|
||||||
- Button minimum target size (WCAG AAA 2.5.5).
|
|
||||||
- `/oidc/interaction/[uid]` routing quirk.
|
|
||||||
- Admin layout migration from `guild-*` tokens to zine spec.
|
|
||||||
- Admin dashboard quick-action button contrast.
|
|
||||||
- Members table NAME column clipping.
|
|
||||||
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
|
|
||||||
- `tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI.
|
|
||||||
|
|
||||||
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
|
|
||||||
- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
|
|
||||||
- Delete dead `app/components/TierPicker.vue`.
|
|
||||||
- Update stale tier comment in `app/composables/useMemberPayment.js:59`.
|
|
||||||
- Update error log message referencing "tier" in `server/api/members/update-contribution.post.js:221`.
|
|
||||||
- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
|
@ -7,16 +7,20 @@ const publicPages = [
|
||||||
{ name: "Join", path: "/join" },
|
{ name: "Join", path: "/join" },
|
||||||
{ name: "Events", path: "/events" },
|
{ name: "Events", path: "/events" },
|
||||||
{ name: "Coming Soon", path: "/coming-soon" },
|
{ name: "Coming Soon", path: "/coming-soon" },
|
||||||
|
{ name: "Accept Invite", path: "/accept-invite" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const memberPages = [
|
const memberPages = [
|
||||||
{ name: "Member Dashboard", path: "/member/dashboard" },
|
{ name: "Member Dashboard", path: "/member/dashboard" },
|
||||||
{ name: "Member Profile", path: "/member/profile" },
|
{ name: "Member Profile", path: "/member/profile" },
|
||||||
|
{ name: "Member Account", path: "/member/account" },
|
||||||
|
{ name: "Board", path: "/board" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminPages = [
|
const adminPages = [
|
||||||
{ name: "Admin Members", path: "/admin/members" },
|
{ name: "Admin Members", path: "/admin/members" },
|
||||||
{ name: "Admin Events Create", path: "/admin/events/create" },
|
{ name: "Admin Events Create", path: "/admin/events/create" },
|
||||||
|
{ name: "Admin Pre-Registrants", path: "/admin/pre-registrants" },
|
||||||
];
|
];
|
||||||
|
|
||||||
test.describe("accessibility — public pages", () => {
|
test.describe("accessibility — public pages", () => {
|
||||||
|
|
|
||||||
170
e2e/accept-invite.spec.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const FAKE_TOKEN = 'fake-invite-token-for-e2e'
|
||||||
|
const FAKE_PREREG_ID = '000000000000000000000001'
|
||||||
|
|
||||||
|
async function mockVerifyOk(page, overrides = {}) {
|
||||||
|
await page.route('**/api/invite/verify', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
preRegistrationId: FAKE_PREREG_ID,
|
||||||
|
name: overrides.name ?? 'Pre Registered User',
|
||||||
|
email: overrides.email ?? `prereg-${Date.now()}@example.com`,
|
||||||
|
city: overrides.city ?? 'Vancouver, BC',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockAcceptFree(page) {
|
||||||
|
await page.route('**/api/invite/accept', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
requiresPayment: false,
|
||||||
|
redirectUrl: '/member/dashboard',
|
||||||
|
member: {
|
||||||
|
id: 'mem-1',
|
||||||
|
email: 'prereg@example.com',
|
||||||
|
name: 'Pre Registered User',
|
||||||
|
circle: 'community',
|
||||||
|
contributionAmount: 0,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route('**/api/auth/status', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
authenticated: true,
|
||||||
|
member: { id: 'mem-1', name: 'Pre Registered User', status: 'active' },
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoAcceptInvite(page) {
|
||||||
|
await page.goto(`/accept-invite#${FAKE_TOKEN}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Accept Invite — pre-registrant signup', () => {
|
||||||
|
test('verifies invitation and shows form fields', async ({ page }) => {
|
||||||
|
await mockVerifyOk(page, { name: 'Ada Lovelace', email: 'ada@example.com' })
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
|
||||||
|
await expect(page.locator('#accept-name')).toBeVisible()
|
||||||
|
await expect(page.locator('#accept-name')).toHaveValue('Ada Lovelace')
|
||||||
|
await expect(page.locator('#accept-email')).toHaveValue('ada@example.com')
|
||||||
|
await expect(page.locator('#circle-community')).toBeAttached()
|
||||||
|
await expect(page.locator('#circle-founder')).toBeAttached()
|
||||||
|
await expect(page.locator('#circle-practitioner')).toBeAttached()
|
||||||
|
await expect(page.locator('#accept-cadence-monthly')).toBeAttached()
|
||||||
|
await expect(page.locator('#accept-cadence-annual')).toBeAttached()
|
||||||
|
await expect(page.locator('#accept-contribution')).toBeVisible()
|
||||||
|
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.form-submit')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows error when no token in URL hash', async ({ page }) => {
|
||||||
|
await page.goto('/accept-invite')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
|
||||||
|
await expect(page.locator('.error-box')).toContainText(/No invitation token/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows error when token verification fails', async ({ page }) => {
|
||||||
|
await page.route('**/api/invite/verify', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
|
||||||
|
await expect(page.locator('.error-box')).toContainText(/Invalid or expired/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit disabled until name + agreement filled', async ({ page }) => {
|
||||||
|
await mockVerifyOk(page, { name: '' })
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
|
||||||
|
await expect(page.locator('#accept-name')).toBeVisible()
|
||||||
|
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||||
|
|
||||||
|
await page.locator('#accept-name').fill('New Member')
|
||||||
|
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||||
|
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cadence toggle updates billing summary total', async ({ page }) => {
|
||||||
|
await mockVerifyOk(page)
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
|
||||||
|
await expect(page.locator('#accept-contribution')).toBeVisible()
|
||||||
|
await page.locator('#accept-contribution').fill('10')
|
||||||
|
|
||||||
|
await page.locator('label[for="accept-cadence-monthly"]').click()
|
||||||
|
await expect(page.locator('.billing-summary')).toContainText('$10 today')
|
||||||
|
|
||||||
|
await page.locator('label[for="accept-cadence-annual"]').click()
|
||||||
|
await expect(page.locator('.billing-summary')).toContainText('$120 today')
|
||||||
|
await expect(page.locator('.billing-summary')).toContainText('$10/month')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preset chip sets contribution amount', async ({ page }) => {
|
||||||
|
await mockVerifyOk(page)
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
|
||||||
|
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
|
||||||
|
const chip = page.locator('.contribution-preset-chip').nth(1)
|
||||||
|
const chipText = await chip.textContent()
|
||||||
|
const expected = chipText.replace(/[^0-9]/g, '')
|
||||||
|
|
||||||
|
await chip.click()
|
||||||
|
await expect(page.locator('#accept-contribution')).toHaveValue(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('free tier happy path shows welcome state', async ({ page }) => {
|
||||||
|
await mockVerifyOk(page, { name: 'Free Tester', email: `free-${Date.now()}@example.com` })
|
||||||
|
await mockAcceptFree(page)
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
|
||||||
|
await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
|
||||||
|
await page.locator('#circle-community').check({ force: true })
|
||||||
|
await page.locator('#accept-contribution').fill('0')
|
||||||
|
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||||
|
|
||||||
|
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||||
|
await expect(page.locator('.form-submit')).toContainText(/Accept Invitation/)
|
||||||
|
|
||||||
|
await page.locator('.form-submit').click()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
||||||
|
).toBeVisible({ timeout: 15000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('paid tier submit button copy switches to Continue to Payment', async ({ page }) => {
|
||||||
|
await mockVerifyOk(page)
|
||||||
|
await gotoAcceptInvite(page)
|
||||||
|
|
||||||
|
await page.locator('#accept-contribution').fill('10')
|
||||||
|
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||||
|
await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Skipped: full paid-tier submission requires intercepting HelcimPay.js modal
|
||||||
|
// (external script loads an iframe and posts a message back to verifyPayment).
|
||||||
|
// Feasible but out of scope for this initial coverage pass.
|
||||||
|
test.skip('paid tier full flow with mocked HelcimPay', async () => {})
|
||||||
|
})
|
||||||
|
|
@ -11,6 +11,7 @@ test.describe('Admin board channels page', () => {
|
||||||
|
|
||||||
test('create, edit, and delete a channel', async ({ adminPage }) => {
|
test('create, edit, and delete a channel', async ({ adminPage }) => {
|
||||||
await adminPage.goto('/admin/board-channels')
|
await adminPage.goto('/admin/board-channels')
|
||||||
|
await adminPage.waitForLoadState('networkidle')
|
||||||
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
|
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
|
|
@ -18,14 +19,14 @@ test.describe('Admin board channels page', () => {
|
||||||
const suffix = Date.now().toString().slice(-6)
|
const suffix = Date.now().toString().slice(-6)
|
||||||
const channelName = `e2e-channel-${suffix}`
|
const channelName = `e2e-channel-${suffix}`
|
||||||
const editedName = `e2e-channel-${suffix}-edited`
|
const editedName = `e2e-channel-${suffix}-edited`
|
||||||
const slackId = `C${suffix}XYZ`
|
|
||||||
|
|
||||||
// --- Create ---
|
// --- Create ---
|
||||||
|
// Create flow only takes a name; the Slack channel ID is auto-assigned on
|
||||||
|
// creation and only becomes editable in the Edit modal.
|
||||||
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
|
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
|
||||||
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
|
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
|
||||||
|
|
||||||
await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
|
await adminPage.locator('input[placeholder="e.g., coop-formation"]').fill(channelName)
|
||||||
await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
|
|
||||||
|
|
||||||
// Select the first available cooperative tag if any are present
|
// Select the first available cooperative tag if any are present
|
||||||
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
|
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
|
||||||
|
|
@ -44,7 +45,7 @@ test.describe('Admin board channels page', () => {
|
||||||
await row.getByRole('button', { name: 'Edit' }).click()
|
await row.getByRole('button', { name: 'Edit' }).click()
|
||||||
|
|
||||||
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
|
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
|
||||||
const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
|
const nameInput = adminPage.locator('input[placeholder="e.g., coop-formation"]')
|
||||||
await nameInput.fill(editedName)
|
await nameInput.fill(editedName)
|
||||||
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
|
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,116 @@ test.describe('Admin events access control', () => {
|
||||||
expect(page.url()).not.toContain('/admin/events')
|
expect(page.url()).not.toContain('/admin/events')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('Admin events CRUD', () => {
|
||||||
|
test('create, edit, and delete an event', async ({ adminPage }) => {
|
||||||
|
const suffix = Date.now().toString().slice(-6)
|
||||||
|
const title = `e2e-event-${suffix}`
|
||||||
|
const editedTitle = `e2e-event-${suffix}-edited`
|
||||||
|
|
||||||
|
// Re-prime the auth cookie immediately before this multi-step flow.
|
||||||
|
// The shared test-admin account's tokenVersion is bumped whenever
|
||||||
|
// auth.spec.js's logout test runs in parallel, which would otherwise
|
||||||
|
// surface mid-flow as "Session has been revoked" on the first POST.
|
||||||
|
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
|
||||||
|
if (loginRes.status() !== 302) {
|
||||||
|
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
await adminPage.goto('/admin/events/create')
|
||||||
|
await expect(adminPage.locator('h1')).toContainText('Create Event')
|
||||||
|
// Ensure Vue has hydrated (initial $fetch for series/tags has resolved)
|
||||||
|
// before interacting — under cross-file load, hydration can lag and a
|
||||||
|
// pre-hydration submit will native-POST against an empty form.
|
||||||
|
await adminPage.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
await adminPage
|
||||||
|
.getByPlaceholder('Enter a clear, descriptive event title')
|
||||||
|
.fill(title)
|
||||||
|
|
||||||
|
await adminPage
|
||||||
|
.getByPlaceholder(
|
||||||
|
'Provide a clear description of what attendees can expect from this event'
|
||||||
|
)
|
||||||
|
.fill('e2e test event description')
|
||||||
|
|
||||||
|
await adminPage
|
||||||
|
.getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name')
|
||||||
|
.fill('https://example.com/zoom')
|
||||||
|
|
||||||
|
const startInput = adminPage.getByPlaceholder(
|
||||||
|
"e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
||||||
|
)
|
||||||
|
await startInput.fill('next Tuesday at 3pm')
|
||||||
|
await startInput.blur()
|
||||||
|
|
||||||
|
const endInput = adminPage.getByPlaceholder(
|
||||||
|
"e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
||||||
|
)
|
||||||
|
await endInput.fill('next Tuesday at 5pm')
|
||||||
|
await endInput.blur()
|
||||||
|
|
||||||
|
await adminPage.getByRole('button', { name: 'Create Event' }).click()
|
||||||
|
|
||||||
|
// The form posts via $fetch and then auto-redirects after a 1.5s setTimeout.
|
||||||
|
// Under cross-file load that auto-redirect can race against waitForURL.
|
||||||
|
// Wait for the surfaced success/error state, fail fast on error, then
|
||||||
|
// navigate explicitly so subsequent assertions are deterministic.
|
||||||
|
await expect(
|
||||||
|
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
|
||||||
|
).toBeVisible({ timeout: 15000 })
|
||||||
|
await expect(adminPage.locator('.success-box')).toBeVisible()
|
||||||
|
await adminPage.goto('/admin/events')
|
||||||
|
await adminPage.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Filter to just our event — orphan rows from prior failed runs can push
|
||||||
|
// the new row off page 1 of the paginated list.
|
||||||
|
await adminPage.getByPlaceholder('Search events...').fill(title)
|
||||||
|
const row = adminPage.locator('tr', { hasText: title })
|
||||||
|
await expect(row).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
// --- Edit ---
|
||||||
|
// Find the event ID from the row's "View" link (href is /events/<slug-or-id>),
|
||||||
|
// and use the row's Edit button. Pair the click with waitForURL so we don't
|
||||||
|
// miss the navigation event under load.
|
||||||
|
await Promise.all([
|
||||||
|
adminPage.waitForURL(/\/admin\/events\/create\?edit=/, { timeout: 15000 }),
|
||||||
|
row.getByRole('button', { name: 'Edit' }).click(),
|
||||||
|
])
|
||||||
|
await expect(adminPage.locator('h1')).toContainText('Edit Event')
|
||||||
|
|
||||||
|
const titleInput = adminPage.getByPlaceholder(
|
||||||
|
'Enter a clear, descriptive event title'
|
||||||
|
)
|
||||||
|
await titleInput.fill(editedTitle)
|
||||||
|
|
||||||
|
await adminPage.getByRole('button', { name: 'Update Event' }).click()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
|
||||||
|
).toBeVisible({ timeout: 15000 })
|
||||||
|
await expect(adminPage.locator('.success-box')).toBeVisible()
|
||||||
|
await adminPage.goto('/admin/events')
|
||||||
|
await adminPage.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Filter to the edited event's unique title for the same pagination reason.
|
||||||
|
await adminPage.getByPlaceholder('Search events...').fill(editedTitle)
|
||||||
|
const editedRow = adminPage.locator('tr', { hasText: editedTitle })
|
||||||
|
await expect(editedRow).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
// --- Delete (custom modal, not browser dialog) ---
|
||||||
|
await editedRow.getByRole('button', { name: 'Del' }).click()
|
||||||
|
await expect(
|
||||||
|
adminPage.getByRole('heading', { name: 'Delete Event' })
|
||||||
|
).toBeVisible()
|
||||||
|
await adminPage
|
||||||
|
.locator('.modal')
|
||||||
|
.getByRole('button', { name: 'Delete' })
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
adminPage.locator('tr', { hasText: editedTitle })
|
||||||
|
).toHaveCount(0, { timeout: 10000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||