Compare commits
No commits in common. "main" and "testing-infrastructure" have entirely different histories.
main
...
testing-in
382 changed files with 13671 additions and 39402 deletions
|
|
@ -6,8 +6,6 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild
|
|||
# HELCIM_API_TOKEN=your-live-helcim-api-token
|
||||
HELCIM_API_TOKEN=your-test-helcim-api-token
|
||||
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
|
||||
NUXT_HELCIM_MONTHLY_PLAN_ID=<set_after_migration>
|
||||
NUXT_HELCIM_ANNUAL_PLAN_ID=<set_after_migration>
|
||||
|
||||
# Email Configuration (Resend)
|
||||
RESEND_API_KEY=your-resend-api-key
|
||||
|
|
@ -16,8 +14,6 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org
|
|||
# Slack Integration
|
||||
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
||||
SLACK_OAUTH_TOKEN=your-slack-oauth-token
|
||||
# AdminGhost bot token — used for admin-only channel creation. Falls back to SLACK_BOT_TOKEN if unset.
|
||||
SLACK_ADMIN_BOT_TOKEN=xoxb-adminghost-token
|
||||
|
||||
# JWT Secret for authentication
|
||||
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
||||
|
|
@ -32,6 +28,3 @@ BASE_URL=http://localhost:3000
|
|||
OIDC_CLIENT_ID=outline-wiki
|
||||
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
||||
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
||||
|
||||
# Outline Wiki Integration
|
||||
OUTLINE_API_KEY=
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:run
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
needs: vitest
|
||||
env:
|
||||
MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test
|
||||
JWT_SECRET: ci-test-jwt-secret
|
||||
RESEND_API_KEY: re_ci_dummy_not_used
|
||||
HELCIM_API_TOKEN: helcim_ci_dummy_not_used
|
||||
OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret
|
||||
NUXT_PUBLIC_COMING_SOON: 'false'
|
||||
NODE_ENV: development
|
||||
ALLOW_DEV_TEST_ENDPOINTS: 'true'
|
||||
BASE_URL: http://localhost:3000
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Start MongoDB
|
||||
run: |
|
||||
docker rm -f mongo-ci 2>/dev/null || true
|
||||
docker run -d --name mongo-ci mongo:7
|
||||
# Forgejo runs each job inside its own container; attach Mongo to
|
||||
# that container's network so MONGODB_URI=mongodb://mongo-ci:27017
|
||||
# resolves from inside the runner.
|
||||
RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
|
||||
docker network connect "$RUNNER_NET" mongo-ci
|
||||
docker ps
|
||||
- name: Wait for MongoDB
|
||||
run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done'
|
||||
- name: MongoDB log on failure
|
||||
if: failure()
|
||||
run: docker logs mongo-ci || true
|
||||
- name: Seed test data
|
||||
run: node scripts/seed-all.js && node scripts/seed-tags.js
|
||||
- run: npm run build
|
||||
- name: Start server
|
||||
run: node .output/server/index.mjs > /tmp/server.log 2>&1 &
|
||||
env:
|
||||
PORT: 3000
|
||||
- name: Wait for server
|
||||
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
|
||||
- name: Server log on failure
|
||||
if: failure()
|
||||
run: cat /tmp/server.log || true
|
||||
- run: npx playwright test
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
notify:
|
||||
name: Notify on failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: [vitest, playwright]
|
||||
if: failure()
|
||||
steps:
|
||||
- name: Post to Slack
|
||||
run: |
|
||||
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||
-H 'Content-type: application/json' \
|
||||
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
|
||||
|
||||
94
.github/workflows/test.yml
vendored
Normal file
94
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:run
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
needs: vitest
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
ports:
|
||||
- 27017:27017
|
||||
env:
|
||||
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
|
||||
JWT_SECRET: ci-test-jwt-secret
|
||||
NUXT_PUBLIC_COMING_SOON: 'false'
|
||||
NODE_ENV: development
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm run build
|
||||
- name: Start server
|
||||
run: node .output/server/index.mjs &
|
||||
env:
|
||||
PORT: 3000
|
||||
- name: Wait for server
|
||||
run: npx wait-on http://localhost:3000 --timeout 30000
|
||||
- run: npx playwright test --ignore-snapshots
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
visual:
|
||||
runs-on: ubuntu-latest
|
||||
needs: vitest
|
||||
continue-on-error: true
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
ports:
|
||||
- 27017:27017
|
||||
env:
|
||||
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
|
||||
JWT_SECRET: ci-test-jwt-secret
|
||||
NUXT_PUBLIC_COMING_SOON: 'false'
|
||||
NODE_ENV: development
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm run build
|
||||
- name: Start server
|
||||
run: node .output/server/index.mjs &
|
||||
env:
|
||||
PORT: 3000
|
||||
- name: Wait for server
|
||||
run: npx wait-on http://localhost:3000 --timeout 30000
|
||||
- run: npx playwright test e2e/visual/
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: visual-diffs
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -18,7 +18,7 @@ logs
|
|||
.fleet
|
||||
.idea
|
||||
/docs/
|
||||
/*.md
|
||||
*.md/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
|
|
@ -26,18 +26,6 @@ logs
|
|||
!.env.example
|
||||
scripts/*.js
|
||||
|
||||
# Migration backup files
|
||||
.migration-backup-*.json
|
||||
|
||||
# Playwright
|
||||
e2e/test-results/
|
||||
playwright-report/
|
||||
e2e/.auth/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
.superpowers/
|
||||
|
||||
.claude
|
||||
scripts/dump-babyghosts-preregistrations.mjs
|
||||
|
|
|
|||
0
.husky/pre-push
Executable file → Normal file
0
.husky/pre-push
Executable file → Normal file
|
|
@ -3,26 +3,21 @@ project_name: "ghostguild-org"
|
|||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# swift systemverilog terraform toml typescript
|
||||
# typescript_vts vue yaml zig
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
|
|
@ -70,17 +65,53 @@ read_only: false
|
|||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
#
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
|
|
@ -91,14 +122,11 @@ fixed_tools: []
|
|||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
|
|
@ -122,19 +150,3 @@ read_only_memory_patterns: []
|
|||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
|
|
|||
99
CLAUDE.md
Normal file
99
CLAUDE.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Ghost Guild is a membership community platform for game developers exploring cooperative business models. Built with Nuxt 4, Vue 3, MongoDB, and Nuxt UI 4.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server at http://localhost:3000
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run test:run # Vitest single run (pre-push hook)
|
||||
npm run test:e2e # Playwright E2E (needs dev server + MongoDB)
|
||||
npm run test:a11y # Accessibility scans
|
||||
npm run test:all # Vitest + Playwright
|
||||
```
|
||||
|
||||
**Dev helpers:** `GET /api/dev/test-login` — creates a test admin user and sets auth cookie (dev only, blocked in production). Navigate to this URL to access admin pages during development.
|
||||
|
||||
**Testing:** Vitest for unit/handler tests (`tests/`), Playwright for E2E (`e2e/`). Husky pre-push hook runs Vitest. See `TESTING.md` for details.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
|
||||
- **Framework:** Nuxt 4 (Vue 3 + Nitro server)
|
||||
- **UI:** Nuxt UI 4 (`@nuxt/ui@^4`) with Tailwind CSS
|
||||
- **Database:** MongoDB via Mongoose
|
||||
- **Auth:** JWT magic link (email-only, no passwords)
|
||||
- **Payments:** Helcim (recurring subscriptions + ticket sales)
|
||||
- **Email:** Resend
|
||||
- **Slack:** `@slack/web-api` for member invitations and notifications
|
||||
- **Images:** Cloudinary
|
||||
- **Analytics:** Plausible (`ghostguild.org`)
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `app/composables/` — State management via `useState()` (no Pinia/Vuex). Key composables: `useAuth`, `useHelcim`, `useMemberPayment`, `useMemberStatus`
|
||||
- `app/config/` — Circle definitions (`circles.js`) and contribution tiers (`contributions.js`) used across frontend and forms
|
||||
- `app/middleware/` — Route guards: `auth.js` (member pages), `admin.js` (admin pages), `coming-soon.global.js` (launch gate)
|
||||
- `app/layouts/` — `default` (sidebar, member/public), `admin` (sidebar, admin pages), `landing`, `coming-soon`
|
||||
- `server/api/` — Nitro API routes organized by feature: `auth/`, `events/`, `members/`, `helcim/`, `series/`, `updates/`, `admin/`, `slack/`, `dev/` (dev-only helpers)
|
||||
- `server/models/` — Mongoose schemas: `Member`, `Event`, `Series`, `Update`
|
||||
- `server/utils/` — Service integrations: `mongoose.js`, `helcim.js`, `resend.js`, `slack.ts`, `tickets.js`
|
||||
|
||||
### Domain Model
|
||||
|
||||
Three membership **circles**: Community, Founder, Practitioner — each with different access and context. Five **contribution tiers**: $0, $5, $15, $30, $50/month via Helcim subscriptions.
|
||||
|
||||
Member statuses: `pending_payment`, `active`, `suspended`, `cancelled`.
|
||||
|
||||
Events support ticketing with circle-specific pricing overrides and can be grouped into Series with bundled passes.
|
||||
|
||||
### Design System (Zine Direction)
|
||||
|
||||
- **Palette:** CSS custom properties in `:root` / `.dark` blocks in `app/assets/css/main.css` — `--bg` (cream/#f4efe4), `--surface`, `--border`, `--candle` (gold accent), `--ember` (rust accent), `--text`, `--text-bright`, `--text-dim`, `--text-faint`, `--parch` (inverted blocks), `--c-community`, `--c-founder`, `--c-practitioner`
|
||||
- **Typography:** Brygada 1918 (serif, display/headings) + Commit Mono (monospace, body/UI/everything structural) — loaded via Google Fonts in `nuxt.config.ts`
|
||||
- **Theme:** `primary: amber`, `neutral: stone` — configured in `app/app.config.ts`. Tailwind `@theme` maps `--font-sans` and `--font-mono` to Commit Mono, `--font-display` to Brygada 1918
|
||||
- **Key classes:** `.btn` / `.btn-primary` / `.btn-danger` (buttons), `.field` (form groups), `.badge` (circle badges), `.section-label` (10px uppercase headers), `.dashed-box` (bordered containers), `.section-divider`
|
||||
- **Visual language:** Dashed borders (1px dashed), cream backgrounds, no rounded corners, text-forward density, minimal decoration
|
||||
- **Color mode:** `@nuxtjs/color-mode` with preference `system`, fallback `light`. Dark mode via `.dark` class on `<html>`
|
||||
- **Layouts:** `default` (sidebar + main, member/public pages), `admin` (sidebar + main, admin pages), `landing` (horizontal nav, unused)
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. Required: `MONGODB_URI`, `JWT_SECRET`, `RESEND_API_KEY`, `HELCIM_API_TOKEN`, `SLACK_BOT_TOKEN`. Public vars are prefixed `NUXT_PUBLIC_`. The `NUXT_PUBLIC_COMING_SOON` flag gates access behind a launch page.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All frontend code is plain JavaScript (not TypeScript), using Vue 3 Composition API
|
||||
- Server utilities auto-imported by Nitro — no explicit imports needed in API routes
|
||||
- Use `USwitch` (not `UToggle`) — this is the correct Nuxt UI 3+ component name
|
||||
- No fallback/placeholder data — always use real data
|
||||
- Follow Nuxt 4 file-based routing conventions for route naming
|
||||
- Always check Nuxt UI 4 latest documentation on the web when implementing UI components
|
||||
- Auth API responses (`/api/auth/status`, `/api/auth/member`) must include `status` in the returned member object — `useMemberStatus` defaults to `PENDING_PAYMENT` if missing
|
||||
- Helcim payment testing requires ngrok: `npx nuxi dev --https` then `ngrok http https://localhost:3000` — Helcim blocks localhost origins
|
||||
- The `/api/helcim/initialize-payment` endpoint skips auth for `event_ticket` type payments (public users can buy tickets)
|
||||
|
||||
## Product Spec
|
||||
|
||||
The sections below describe planned and in-progress features for reference.
|
||||
|
||||
### Member Features
|
||||
- Profiles with privacy controls (public/members-only/private per field)
|
||||
- Member updates/mini blog with rich text and images
|
||||
- Peer support system with Cal.com integration for 1:1 scheduling
|
||||
|
||||
### Events System
|
||||
- RSVP with capacity limits and waitlist management
|
||||
- Calendar export (.ics), ticketing, series passes
|
||||
- Member-proposed events with interest threshold
|
||||
|
||||
### Resources (Planned)
|
||||
- Learning paths by circle, templates and tools, case studies
|
||||
- Tag by circle relevance, download tracking, version control
|
||||
15
Dockerfile
15
Dockerfile
|
|
@ -1,19 +1,12 @@
|
|||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
# Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts && npx nuxt prepare
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage — only the self-contained .output is needed.
|
||||
# bash + curl are added so Dokploy scheduled tasks (which wrap commands in
|
||||
# `bash -c "..."`) can run; alpine ships only ash and has no curl by default.
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache bash curl
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.output .output
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
/*
|
||||
* Self-hosted font declarations for Ghost Guild — Zine Direction
|
||||
* Font declarations for Ghost Guild — Zine Direction
|
||||
*
|
||||
* Brygada 1918: Display/heading serif
|
||||
* Commit Mono: Body/UI monospace
|
||||
* Brygada 1918: Display/heading serif (Google Fonts, variable 400-700, italic)
|
||||
* Commit Mono: Body/UI monospace (Google Fonts)
|
||||
*
|
||||
* Fonts are bundled locally via Fontsource.
|
||||
* Loaded via Google Fonts link in nuxt.config.ts head.
|
||||
*/
|
||||
|
||||
@import "@fontsource-variable/brygada-1918/wght.css";
|
||||
@import "@fontsource-variable/brygada-1918/wght-italic.css";
|
||||
|
||||
@import "@fontsource/commit-mono/400.css";
|
||||
@import "@fontsource/commit-mono/500.css";
|
||||
@import "@fontsource/commit-mono/600.css";
|
||||
@import "@fontsource/commit-mono/700.css";
|
||||
|
|
|
|||
|
|
@ -15,42 +15,31 @@
|
|||
|
||||
:root {
|
||||
--bg: #f4efe4;
|
||||
--input-bg: #faf8f2;
|
||||
--surface: #e8dfc8;
|
||||
--surface-hover: #e0d6bc;
|
||||
--border: #b8a880;
|
||||
--border-d: #a89470;
|
||||
--candle: #7a5a10;
|
||||
--candle-dim: #866518;
|
||||
--candle-dim: #9a7420;
|
||||
--candle-faint: #c4a448;
|
||||
--ember: #8a4420;
|
||||
--text: #2a2015;
|
||||
--text-bright: #1a1008;
|
||||
--text-dim: #5a5040;
|
||||
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
|
||||
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
|
||||
--text-dim (5.80:1) while meeting AA for small text. */
|
||||
--text-faint: #665c4b;
|
||||
--text-faint: #8a7e6a;
|
||||
--parch: #2a2015;
|
||||
--parch-hover: #3a3025;
|
||||
--parch-text: #ede4d0;
|
||||
--parch-text-dim: #b8ae98;
|
||||
--parch-accent: #c4a448;
|
||||
--parch-border: #b8a880;
|
||||
--c-community: #7a4838;
|
||||
--c-founder: #8a4420;
|
||||
--c-practitioner: #2a4650;
|
||||
--green: #4a6a38;
|
||||
--green-bg: rgba(74, 106, 56, 0.08);
|
||||
--ember-bg: rgba(138, 68, 32, 0.1);
|
||||
--page-pad-x: 28px;
|
||||
--page-pad-y: 24px;
|
||||
--page-collapse: 1024px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #131210;
|
||||
--input-bg: #1c1a17;
|
||||
--surface: #1a1815;
|
||||
--surface-hover: #252220;
|
||||
--border: #2a2520;
|
||||
|
|
@ -58,33 +47,28 @@
|
|||
--candle: #d4a03a;
|
||||
--candle-dim: #b8922e;
|
||||
--candle-faint: #8a7030;
|
||||
--ember: #ca6a3a;
|
||||
--ember: #c06030;
|
||||
--text: #a89880;
|
||||
--text-bright: #d0c8b0;
|
||||
--text-dim: #958774;
|
||||
--text-faint: #8b7b62;
|
||||
/* Parch family intentionally stays pinned to light-mode values —
|
||||
inverted blocks are a consistent zine/terminal inset in both themes.
|
||||
See: --parch-accent and --parch-border for on-parch accents/borders. */
|
||||
--text-dim: #8a7e6a;
|
||||
--text-faint: #5a5040;
|
||||
--parch: #ede4d0;
|
||||
--parch-hover: #d4c8a8;
|
||||
--parch-text: #2a2015;
|
||||
--parch-text-dim: #5a5040;
|
||||
--c-community: #a06850;
|
||||
--c-founder: #c06030;
|
||||
--c-practitioner: #4a7080;
|
||||
--green: #6e9c52;
|
||||
--green-bg: rgba(110, 156, 82, 0.12);
|
||||
--ember-bg: rgba(202, 106, 58, 0.14);
|
||||
--page-pad-x: 28px;
|
||||
--page-pad-y: 24px;
|
||||
--page-collapse: 1024px;
|
||||
}
|
||||
|
||||
/* ---- TAILWIND @THEME MAPPING ---- */
|
||||
|
||||
@theme {
|
||||
--font-sans: "Commit Mono", monospace;
|
||||
--font-body: "Commit Mono", monospace;
|
||||
--font-mono: "Commit Mono", monospace;
|
||||
--font-display: "Brygada 1918", serif;
|
||||
--font-serif: "Brygada 1918", serif;
|
||||
--font-sans: 'Commit Mono', monospace;
|
||||
--font-body: 'Commit Mono', monospace;
|
||||
--font-mono: 'Commit Mono', monospace;
|
||||
--font-display: 'Brygada 1918', serif;
|
||||
--font-serif: 'Brygada 1918', serif;
|
||||
|
||||
/* Map primary to candle for Nuxt UI components */
|
||||
--color-primary-500: var(--candle);
|
||||
|
|
@ -97,35 +81,14 @@
|
|||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- NOISE TEXTURE OVERLAY ---- */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
background: url("~/assets/images/noise.webp") repeat;
|
||||
opacity: 0.025;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
p a, blockquote a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
a { color: var(--candle); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- SECTION LABELS ---- */
|
||||
.section-label {
|
||||
|
|
@ -145,26 +108,14 @@ p a, blockquote a {
|
|||
padding: 2px 8px;
|
||||
border: 1px dashed;
|
||||
}
|
||||
.badge.community {
|
||||
color: var(--c-community);
|
||||
border-color: rgba(122, 72, 56, 0.35);
|
||||
}
|
||||
.badge.founder {
|
||||
color: var(--c-founder);
|
||||
border-color: rgba(138, 68, 32, 0.35);
|
||||
}
|
||||
.badge.practitioner {
|
||||
color: var(--c-practitioner);
|
||||
border-color: rgba(42, 70, 80, 0.35);
|
||||
}
|
||||
.badge.all {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.badge.community { color: var(--c-community); border-color: rgba(122, 72, 56, 0.35); }
|
||||
.badge.founder { color: var(--c-founder); border-color: rgba(138, 68, 32, 0.35); }
|
||||
.badge.practitioner { color: var(--c-practitioner); border-color: rgba(42, 70, 80, 0.35); }
|
||||
.badge.all { color: var(--text-dim); border-color: var(--border); }
|
||||
|
||||
/* ---- BUTTONS ---- */
|
||||
.btn {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 7px 18px;
|
||||
border: 1px dashed var(--border);
|
||||
|
|
@ -174,26 +125,14 @@ p a, blockquote a {
|
|||
letter-spacing: 0.04em;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
/* WCAG 2.4.7 — keyboard focus must be visibly indicated. Dashed outline
|
||||
echoes the design system's zine/dashed aesthetic. */
|
||||
.btn:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.btn:hover { background: var(--surface-hover); border-color: var(--border-d); }
|
||||
.btn-primary {
|
||||
background: var(--candle);
|
||||
color: var(--bg);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--candle-dim);
|
||||
border-color: var(--candle-dim);
|
||||
}
|
||||
.btn-primary:hover { background: var(--candle-dim); border-color: var(--candle-dim); }
|
||||
.btn-danger {
|
||||
color: var(--ember);
|
||||
border-color: var(--ember);
|
||||
|
|
@ -205,9 +144,7 @@ p a, blockquote a {
|
|||
}
|
||||
|
||||
/* ---- FORM FIELDS ---- */
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
|
|
@ -216,21 +153,17 @@ p a, blockquote a {
|
|||
margin-bottom: 3px;
|
||||
display: block;
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-bright);
|
||||
background: var(--input-bg);
|
||||
background: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
|
@ -241,25 +174,8 @@ p a, blockquote a {
|
|||
padding: 20px 24px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.dashed-box:hover {
|
||||
border-color: var(--candle-faint);
|
||||
}
|
||||
.dashed-box.no-hover:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */
|
||||
/* Negative-margin overlap: every item keeps all 4 borders,
|
||||
siblings overlap by 1px, active item paints on top via z-index. */
|
||||
.segmented {
|
||||
display: flex;
|
||||
}
|
||||
.segmented > * {
|
||||
position: relative;
|
||||
}
|
||||
.segmented > * + * {
|
||||
margin-left: -1px;
|
||||
}
|
||||
.dashed-box:hover { border-color: var(--candle-faint); }
|
||||
.dashed-box.no-hover:hover { border-color: var(--border); }
|
||||
|
||||
/* ---- SECTION DIVIDERS ---- */
|
||||
.section-divider {
|
||||
|
|
@ -276,98 +192,6 @@ p a, blockquote a {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Nuxt UI placeholder contrast ----
|
||||
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
|
||||
AA on cream and white backgrounds (≈2.4:1). Override globally to --text-dim
|
||||
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
|
||||
[data-slot="placeholder"] {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---- SHARED USelectMenu STYLES ----
|
||||
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
||||
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
||||
button.zine-select,
|
||||
button.timezone-select {
|
||||
display: flex !important;
|
||||
width: 100%;
|
||||
padding: 5px 8px !important;
|
||||
font-family: "Commit Mono", monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text-bright) !important;
|
||||
background: var(--input-bg) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
min-height: 0;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
button.zine-select:hover,
|
||||
button.timezone-select:hover {
|
||||
background: var(--input-bg) !important;
|
||||
}
|
||||
|
||||
button.zine-select:focus,
|
||||
button.zine-select:focus-visible,
|
||||
button.zine-select[aria-expanded="true"],
|
||||
button.timezone-select:focus,
|
||||
button.timezone-select:focus-visible,
|
||||
button.timezone-select[aria-expanded="true"] {
|
||||
border-color: var(--candle) !important;
|
||||
}
|
||||
|
||||
.tz-content {
|
||||
background: var(--input-bg) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
||||
--tw-ring-shadow: 0 0 #0000 !important;
|
||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||
font-family: "Commit Mono", monospace !important;
|
||||
}
|
||||
|
||||
.tz-input {
|
||||
border-bottom: 1px dashed var(--border) !important;
|
||||
}
|
||||
|
||||
.tz-input input {
|
||||
font-family: "Commit Mono", monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text-bright) !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 6px 8px !important;
|
||||
box-shadow: none !important;
|
||||
--tw-ring-shadow: 0 0 #0000 !important;
|
||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||
}
|
||||
|
||||
.tz-item {
|
||||
font-family: "Commit Mono", monospace !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text) !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
.tz-item::before {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.tz-item[data-highlighted]::before,
|
||||
.tz-item[data-highlighted]:not([data-disabled])::before {
|
||||
background: var(--surface-hover) !important;
|
||||
}
|
||||
|
||||
.tz-item[data-highlighted],
|
||||
.tz-item[data-highlighted]:not([data-disabled]) {
|
||||
color: var(--text-bright) !important;
|
||||
}
|
||||
|
||||
/* ---- MOBILE ---- */
|
||||
@media (max-width: 1023px) {
|
||||
body {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -17,36 +17,29 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span
|
||||
v-if="item.path === '/member/dashboard' && showOnboardingDot"
|
||||
class="onboarding-dot"
|
||||
/>
|
||||
</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-section">Explore</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li v-for="item in exploreItems" :key="item.path">
|
||||
<a
|
||||
v-if="item.external"
|
||||
:href="item.path"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="handleNavigate"
|
||||
>
|
||||
{{ item.label
|
||||
}}<span class="external-hint" aria-hidden="true">ext</span>
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-section">Community</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li v-for="item in communityItems" :key="item.path">
|
||||
<NuxtLink
|
||||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -56,23 +49,11 @@
|
|||
<div class="sidebar-section">Navigate</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li v-for="item in publicItems" :key="item.path">
|
||||
<a
|
||||
v-if="item.external"
|
||||
:href="item.path"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="handleNavigate"
|
||||
>
|
||||
{{ item.label
|
||||
}}<span class="external-hint" aria-hidden="true">ext</span>
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -83,8 +64,7 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -94,23 +74,11 @@
|
|||
<div class="sidebar-section">Navigate</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li v-for="item in publicItems" :key="item.path">
|
||||
<a
|
||||
v-if="item.external"
|
||||
:href="item.path"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="handleNavigate"
|
||||
>
|
||||
{{ item.label
|
||||
}}<span class="external-hint" aria-hidden="true">ext</span>
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -121,8 +89,7 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -132,17 +99,29 @@
|
|||
<!-- Meta at bottom -->
|
||||
<div class="sidebar-meta">
|
||||
<ClientOnly>
|
||||
Part of
|
||||
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
||||
A Canadian nonprofit
|
||||
<template v-if="isAuthenticated">
|
||||
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
|
||||
<span
|
||||
v-if="memberData?.circle"
|
||||
class="member-circle"
|
||||
:style="{ color: `var(--c-${memberData.circle})` }"
|
||||
>{{ memberData.circle }}</span>
|
||||
<br v-if="memberData?.circle">
|
||||
<a href="#" @click.prevent="handleLogout">Sign out</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
||||
A Canadian nonprofit<br>
|
||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
Part of
|
||||
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
||||
A Canadian nonprofit
|
||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
||||
A Canadian nonprofit<br>
|
||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<DevLoginPanel v-if="isDev" />
|
||||
<ColorModeToggle />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
|
@ -155,59 +134,68 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const emit = defineEmits(["navigate"]);
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
const route = useRoute();
|
||||
const { isAuthenticated, memberData, logout } = useAuth();
|
||||
const isDev = import.meta.dev;
|
||||
|
||||
const showOnboardingDot = computed(
|
||||
() => isAuthenticated.value && !memberData.value?.onboarding?.completedAt,
|
||||
);
|
||||
const route = useRoute()
|
||||
const { isAuthenticated, logout, memberData } = useAuth()
|
||||
const { openLoginModal } = useLoginModal()
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (props.isMobile) {
|
||||
emit("navigate");
|
||||
emit('navigate')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
handleNavigate();
|
||||
navigateTo("/");
|
||||
};
|
||||
await logout()
|
||||
handleNavigate()
|
||||
}
|
||||
|
||||
const openLogin = () => {
|
||||
openLoginModal()
|
||||
handleNavigate()
|
||||
}
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
};
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
// Public nav items
|
||||
const publicItems = [
|
||||
{ label: "Home", path: "/" },
|
||||
{ label: "About", path: "/about" },
|
||||
{ label: "Events", path: "/events" },
|
||||
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
|
||||
];
|
||||
{ label: 'Home', path: '/' },
|
||||
{ label: 'About', path: '/about' },
|
||||
{ label: 'Events', path: '/events' },
|
||||
{ label: 'Members', path: '/members' },
|
||||
{ label: 'Wiki', path: '/wiki' },
|
||||
]
|
||||
|
||||
const joinItems = [{ label: "Become a member", path: "/join" }];
|
||||
const joinItems = [
|
||||
{ label: 'Become a member', path: '/join' },
|
||||
{ label: 'Propose an event', path: '/events' },
|
||||
]
|
||||
|
||||
// Logged-in nav items
|
||||
const youItems = [
|
||||
{ label: "Dashboard", path: "/member/dashboard" },
|
||||
{ label: "Profile", path: "/member/profile" },
|
||||
{ label: "Account", path: "/member/account" },
|
||||
];
|
||||
{ label: 'Dashboard', path: '/member/dashboard' },
|
||||
{ label: 'Profile', path: '/member/profile' },
|
||||
{ label: 'Account', path: '/member/account' },
|
||||
{ label: 'My Updates', path: '/member/my-updates' },
|
||||
]
|
||||
|
||||
const exploreItems = [
|
||||
{ label: "Events", path: "/events" },
|
||||
{ label: "Members", path: "/members" },
|
||||
{ label: "Board", path: "/board" },
|
||||
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
|
||||
{ label: "About", path: "/about" },
|
||||
];
|
||||
{ label: 'Events', path: '/events' },
|
||||
{ label: 'Members', path: '/members' },
|
||||
{ label: 'Wiki', path: '/wiki' },
|
||||
{ label: 'About', path: '/about' },
|
||||
]
|
||||
|
||||
const communityItems = [
|
||||
{ label: 'Peer Support', path: '/members' },
|
||||
{ label: 'Propose an Event', path: '/events' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -233,14 +221,12 @@ const exploreItems = [
|
|||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: "Brygada 1918", serif;
|
||||
display: block;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--candle);
|
||||
padding: 0 24px;
|
||||
height: 53px;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
@ -251,7 +237,6 @@ const exploreItems = [
|
|||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
|
|
@ -282,11 +267,6 @@ const exploreItems = [
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-nav a.sign-out {
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
|
|
@ -311,28 +291,14 @@ const exploreItems = [
|
|||
color: var(--candle-dim);
|
||||
}
|
||||
|
||||
.external-hint {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
margin-left: 4px;
|
||||
position: relative;
|
||||
top: -0.5px;
|
||||
}
|
||||
.external-hint::before {
|
||||
content: "[";
|
||||
}
|
||||
.external-hint::after {
|
||||
content: "]";
|
||||
.member-name {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.onboarding-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
margin-left: 0px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
.member-circle {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,386 +0,0 @@
|
|||
<template>
|
||||
<article class="board-post">
|
||||
<header class="post-header">
|
||||
<span class="post-meta">{{ typeLabel }}</span>
|
||||
<div v-if="editable && !pendingDelete" class="post-actions">
|
||||
<button type="button" class="action-btn" @click="$emit('edit', post)">Edit</button>
|
||||
<button type="button" class="action-btn danger" @click="$emit('delete', post)">Delete</button>
|
||||
</div>
|
||||
<div v-else-if="editable && pendingDelete" class="post-actions confirm">
|
||||
<span class="confirm-label">Delete?</span>
|
||||
<button type="button" class="action-btn" @click="$emit('cancel-delete', post)">Cancel</button>
|
||||
<button type="button" class="action-btn danger" @click="$emit('confirm-delete', post)">Confirm</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h2 class="post-title">{{ post.title }}</h2>
|
||||
|
||||
<div v-if="post.seeking" class="post-block">
|
||||
<div class="block-label">Seeking</div>
|
||||
<p class="block-text">{{ post.seeking }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="post.offering" class="post-block">
|
||||
<div class="block-label">Offering</div>
|
||||
<p class="block-text">{{ post.offering }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="post.note" class="post-note">{{ post.note }}</p>
|
||||
|
||||
<div v-if="post.tags && post.tags.length" class="post-tags">
|
||||
<span v-for="slug in post.tags" :key="slug" class="tag-pill">{{ tagLabel(slug) }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="post-footer">
|
||||
<div class="author">
|
||||
<img
|
||||
v-if="authorAvatar"
|
||||
:src="authorAvatar"
|
||||
:alt="post.author.name"
|
||||
class="author-avatar"
|
||||
>
|
||||
<span v-else class="author-avatar avatar-placeholder" aria-hidden="true">{{ authorInitial }}</span>
|
||||
<span class="author-name">{{ post.author ? post.author.name : 'Unknown' }}</span>
|
||||
<span v-if="slackHandle" class="slack-handle-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="slack-handle"
|
||||
:title="copied ? 'Copied!' : 'Click to copy Slack handle'"
|
||||
@click="copySlackHandle"
|
||||
>@{{ slackHandle }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-link"
|
||||
:class="{ copied }"
|
||||
@click="copySlackHandle"
|
||||
>{{ copied ? 'Copied!' : 'Copy' }}</button>
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="slackLinks.length === 1"
|
||||
:href="slackLinks[0].url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="slack-link"
|
||||
>Discuss in #{{ slackLinks[0].name }} →</a>
|
||||
<details v-else-if="slackLinks.length > 1" class="slack-menu">
|
||||
<summary class="slack-link">Discuss on Slack ▾</summary>
|
||||
<ul class="slack-menu-list">
|
||||
<li v-for="link in slackLinks" :key="link.id">
|
||||
<a :href="link.url" target="_blank" rel="noopener" class="slack-link">#{{ link.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true },
|
||||
channels: { type: Array, default: () => [] },
|
||||
tags: { type: Array, default: () => [] },
|
||||
editable: { type: Boolean, default: false },
|
||||
pendingDelete: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['edit', 'delete', 'confirm-delete', 'cancel-delete'])
|
||||
|
||||
const { slackUrl } = useBoardChannels()
|
||||
|
||||
const capitalizeAvatar = (str) => {
|
||||
if (str.toLowerCase() === 'wtf') return 'WTF'
|
||||
return str
|
||||
.split('-')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join('-')
|
||||
}
|
||||
|
||||
const authorAvatar = computed(() => {
|
||||
const a = props.post.author?.avatar
|
||||
if (!a) return null
|
||||
return `/ghosties/Ghost-${capitalizeAvatar(a)}.png`
|
||||
})
|
||||
|
||||
const slackHandle = computed(() => props.post.author?.board?.slackHandle || '')
|
||||
|
||||
const authorInitial = computed(() => {
|
||||
const name = props.post.author?.name || ''
|
||||
return name.trim().charAt(0).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
const copySlackHandle = async () => {
|
||||
if (!slackHandle.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(`@${slackHandle.value}`)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 1500)
|
||||
} catch {
|
||||
// clipboard unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const tagLabelMap = computed(() => {
|
||||
const map = {}
|
||||
for (const t of props.tags) map[t.slug] = t.label || t.name || t.slug
|
||||
return map
|
||||
})
|
||||
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
|
||||
|
||||
const hasSeeking = computed(() => !!(props.post.seeking && props.post.seeking.trim()))
|
||||
const hasOffering = computed(() => !!(props.post.offering && props.post.offering.trim()))
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
if (hasSeeking.value && hasOffering.value) return 'SEEKING + OFFERING'
|
||||
if (hasSeeking.value) return 'SEEKING'
|
||||
if (hasOffering.value) return 'OFFERING'
|
||||
return ''
|
||||
})
|
||||
|
||||
const slackLinks = computed(() => {
|
||||
const postTags = props.post.tags || []
|
||||
if (!postTags.length) return []
|
||||
return props.channels
|
||||
.filter((c) => {
|
||||
if (!c.slackChannelId) return false
|
||||
const slugs = c.tagSlugs || []
|
||||
return slugs.some((s) => postTags.includes(s))
|
||||
})
|
||||
.map((c) => ({
|
||||
id: c.slackChannelId,
|
||||
name: c.slackChannelName || c.name || c.slackChannelId,
|
||||
url: slackUrl(c.slackChannelId),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-post {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 20px 24px;
|
||||
background: var(--surface);
|
||||
break-inside: avoid;
|
||||
-webkit-column-break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.post-actions.confirm .confirm-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember);
|
||||
margin-right: 2px;
|
||||
}
|
||||
.action-btn {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 9px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
color: var(--text-bright);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
.action-btn.danger:hover {
|
||||
color: var(--ember);
|
||||
border-color: var(--ember);
|
||||
}
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.block-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.block-text {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.post-note {
|
||||
font-size: 11px;
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
.author-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
.author-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
.slack-handle-wrap {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.slack-handle {
|
||||
font-size: 11px;
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
font-family: "Commit Mono", monospace;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slack-handle:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
.slack-handle:focus-visible,
|
||||
.copy-link:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.copy-link {
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--candle);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.copy-link:hover {
|
||||
color: var(--candle-dim);
|
||||
}
|
||||
.copy-link.copied {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.slack-menu {
|
||||
position: relative;
|
||||
}
|
||||
.slack-menu > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slack-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.slack-menu-list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
.slack-link {
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--candle-faint);
|
||||
}
|
||||
.slack-link:hover {
|
||||
color: var(--candle-dim);
|
||||
text-decoration: none;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
<template>
|
||||
<form class="post-form" @submit.prevent="handleSubmit">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">{{ isEdit ? 'Edit post' : 'New post' }}</h2>
|
||||
<p class="form-hint">Fill in <em>Seeking</em> or <em>Offering</em> (or both).</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="post-title">Title</label>
|
||||
<input
|
||||
id="post-title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="Short summary"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="post-seeking">Seeking <span class="opt">(optional)</span></label>
|
||||
<textarea
|
||||
id="post-seeking"
|
||||
v-model="form.seeking"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
placeholder="What are you looking for?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="post-offering">Offering <span class="opt">(optional)</span></label>
|
||||
<textarea
|
||||
id="post-offering"
|
||||
v-model="form.offering"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
placeholder="What can you offer?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="post-note">Note <span class="opt">(optional)</span></label>
|
||||
<textarea
|
||||
id="post-note"
|
||||
v-model="form.note"
|
||||
rows="2"
|
||||
maxlength="300"
|
||||
placeholder="Anything else to add?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="tags.length" class="field">
|
||||
<label>Tags</label>
|
||||
<div class="pill-grid">
|
||||
<button
|
||||
v-for="tag in tags"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{ selected: form.tags.includes(tag.slug) }"
|
||||
@click="toggleTag(tag.slug)"
|
||||
>{{ tag.label || tag.name || tag.slug }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="$emit('cancel')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ isEdit ? 'Save changes' : 'Post' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
post: { type: Object, default: null },
|
||||
tags: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
|
||||
const isEdit = computed(() => !!props.post)
|
||||
|
||||
const form = reactive({
|
||||
title: props.post?.title || '',
|
||||
seeking: props.post?.seeking || '',
|
||||
offering: props.post?.offering || '',
|
||||
note: props.post?.note || '',
|
||||
tags: Array.isArray(props.post?.tags) ? [...props.post.tags] : [],
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
|
||||
watch(() => props.post, (p) => {
|
||||
form.title = p?.title || ''
|
||||
form.seeking = p?.seeking || ''
|
||||
form.offering = p?.offering || ''
|
||||
form.note = p?.note || ''
|
||||
form.tags = Array.isArray(p?.tags) ? [...p.tags] : []
|
||||
}, { immediate: false })
|
||||
|
||||
function toggleTag(slug) {
|
||||
const idx = form.tags.indexOf(slug)
|
||||
if (idx === -1) form.tags.push(slug)
|
||||
else form.tags.splice(idx, 1)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
error.value = ''
|
||||
const title = form.title.trim()
|
||||
const seeking = form.seeking.trim()
|
||||
const offering = form.offering.trim()
|
||||
|
||||
if (!title) {
|
||||
error.value = 'Title is required.'
|
||||
return
|
||||
}
|
||||
if (!seeking && !offering) {
|
||||
error.value = 'Add at least one of Seeking or Offering.'
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
title,
|
||||
seeking,
|
||||
offering,
|
||||
note: form.note.trim(),
|
||||
tags: [...form.tags],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-form {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 16px 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.form-title {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-family: "Commit Mono", monospace;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.form-hint em {
|
||||
color: var(--text-dim);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.field label .opt {
|
||||
color: var(--text-faint);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-bright);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill:hover {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
.pill.selected {
|
||||
background: var(--surface);
|
||||
color: var(--text-bright);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
.pill:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: 11px;
|
||||
color: var(--ember);
|
||||
margin: 8px 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--ember);
|
||||
background: var(--ember-bg);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,16 +4,13 @@
|
|||
v-for="circle in circles"
|
||||
:key="circle.value"
|
||||
class="circle-option"
|
||||
:class="{
|
||||
selected: modelValue === circle.value,
|
||||
current: savedValue === circle.value,
|
||||
}"
|
||||
:class="{ current: modelValue === circle.value }"
|
||||
@click="$emit('update:modelValue', circle.value)"
|
||||
>
|
||||
<span class="circle-name">{{ circle.label }}</span>
|
||||
<span class="circle-desc">{{ circle.description }}</span>
|
||||
<span
|
||||
v-if="savedValue === circle.value"
|
||||
v-if="modelValue === circle.value"
|
||||
class="circle-tag"
|
||||
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
|
||||
>Current</span>
|
||||
|
|
@ -24,13 +21,12 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
savedValue: { type: String, default: '' },
|
||||
circles: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
|
||||
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
|
||||
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative practice' },
|
||||
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative business' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
@ -48,7 +44,7 @@ defineEmits(['update:modelValue'])
|
|||
|
||||
.circle-option {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 12px 12px;
|
||||
padding: 14px 12px;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
|
@ -58,7 +54,7 @@ defineEmits(['update:modelValue'])
|
|||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.circle-option.selected {
|
||||
.circle-option.current {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
background: var(--surface);
|
||||
|
|
@ -71,19 +67,19 @@ defineEmits(['update:modelValue'])
|
|||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.circle-option.selected .circle-name {
|
||||
.circle-option.current .circle-name {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.circle-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-faint);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.circle-tag {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 6px;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,22 @@
|
|||
<template>
|
||||
<div class="color-mode-toggle segmented">
|
||||
<div class="color-mode-toggle">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:class="{ active: colorMode.preference === option.value }"
|
||||
@click="colorMode.preference = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
>{{ option.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const colorMode = useColorMode();
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const options = [
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "System", value: "system" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
];
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -30,7 +28,7 @@ const options = [
|
|||
.color-mode-toggle button {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
background: transparent;
|
||||
|
|
@ -38,12 +36,10 @@ const options = [
|
|||
border: 1px dashed var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||
.color-mode-toggle button + button {
|
||||
margin-left: -1px;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.color-mode-toggle button:hover {
|
||||
|
|
@ -55,6 +51,13 @@ const options = [
|
|||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* When active button is adjacent to dashed, restore left border */
|
||||
.color-mode-toggle button.active + button {
|
||||
border-left: 1px dashed var(--border);
|
||||
}
|
||||
.color-mode-toggle button:has(+ button.active) {
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
class="columns-layout"
|
||||
:class="[`columns-${cols}`, `divider-${divider}`, `collapse-${collapse}`]"
|
||||
>
|
||||
<template v-if="cols === 'events-sidebar'">
|
||||
<div class="col col-main">
|
||||
<slot />
|
||||
</div>
|
||||
<EventsMiniSidebar :events="upcomingEvents" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- cols="2": named slots only. Use <template #left> and <template #right>. -->
|
||||
<div class="col col-left">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="col col-right">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
cols: { type: String, default: '2' }, // "2" | "events-sidebar"
|
||||
divider: { type: String, default: 'dashed' }, // "dashed" | "none"
|
||||
collapse: { type: String, default: '1024' }, // "1024" | "768"
|
||||
limit: { type: Number, default: 3 },
|
||||
})
|
||||
|
||||
let upcomingEvents = ref([])
|
||||
if (props.cols === 'events-sidebar') {
|
||||
const { data } = await useFetch('/api/events', {
|
||||
query: { upcoming: true, limit: props.limit },
|
||||
default: () => [],
|
||||
server: false,
|
||||
})
|
||||
upcomingEvents = computed(() => data.value || [])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.columns-layout {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* cols="2" */
|
||||
.columns-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
/* cols="events-sidebar" */
|
||||
.columns-events-sidebar {
|
||||
grid-template-columns: 1fr 200px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Ensure grid children don't overflow */
|
||||
.col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Dashed divider: right border on the first column child (except events-sidebar, which owns its own border-left) */
|
||||
.divider-dashed .col:first-child,
|
||||
.divider-dashed .col-main {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
.divider-dashed.columns-events-sidebar .col-main {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Responsive collapse at 1024px (default) */
|
||||
.collapse-1024 {
|
||||
--col-collapse: 1024px;
|
||||
}
|
||||
|
||||
/* Responsive collapse at 768px */
|
||||
.collapse-768 {
|
||||
--col-collapse: 768px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.collapse-1024 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.collapse-1024 .col:first-child,
|
||||
.collapse-1024 .col-main {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.collapse-768 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.collapse-768 .col:first-child,
|
||||
.collapse-768 .col-main {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<template>
|
||||
<div class="coop-tag-selector">
|
||||
<div class="pill-grid">
|
||||
<button
|
||||
v-for="tag in tags"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{ selected: modelValue.includes(tag.slug) }"
|
||||
@click="toggle(tag.slug)"
|
||||
>{{ tag.label || tag.name || tag.slug }}</button>
|
||||
</div>
|
||||
<div class="suggest-link">
|
||||
<button type="button" class="suggest-btn" @click="$emit('suggest')">Don't see what you're looking for?</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
tags: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "suggest"]);
|
||||
|
||||
function toggle(slug) {
|
||||
const current = [...props.modelValue];
|
||||
const idx = current.indexOf(slug);
|
||||
if (idx === -1) {
|
||||
emit("update:modelValue", [...current, slug]);
|
||||
} else {
|
||||
current.splice(idx, 1);
|
||||
emit("update:modelValue", current);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coop-tag-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
|
||||
.pill.selected {
|
||||
background: var(--surface);
|
||||
color: var(--text-bright);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.suggest-link {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.suggest-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.suggest-btn:hover {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
<template>
|
||||
<div class="craft-tag-selector">
|
||||
<div class="pill-grid">
|
||||
<button
|
||||
v-for="tag in tags"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{ selected: modelValue.includes(tag.slug) }"
|
||||
@click="toggle(tag.slug)"
|
||||
>{{ tag.label }}</button>
|
||||
</div>
|
||||
<div class="suggest-link">
|
||||
<span @click="$emit('suggest')">Don't see what you're looking for?</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
tags: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "suggest"]);
|
||||
|
||||
function toggle(slug) {
|
||||
const current = [...props.modelValue];
|
||||
const idx = current.indexOf(slug);
|
||||
if (idx === -1) {
|
||||
emit("update:modelValue", [...current, slug]);
|
||||
} else {
|
||||
current.splice(idx, 1);
|
||||
emit("update:modelValue", current);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.craft-tag-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
|
||||
.pill.selected {
|
||||
background: var(--surface);
|
||||
color: var(--text-bright);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.suggest-link {
|
||||
font-size: 10px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.suggest-link span {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.suggest-link span:hover {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<template>
|
||||
<div class="dev-login">
|
||||
<div class="dev-label">Dev Login</div>
|
||||
<div class="dev-actions">
|
||||
<div class="dev-buttons">
|
||||
<a href="/api/dev/test-login" class="dev-button">Admin</a>
|
||||
<button class="dev-button dev-logout" @click="handleLogout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
<USelectMenu
|
||||
v-model="selectedEmail"
|
||||
:items="members"
|
||||
value-key="value"
|
||||
:filter-fields="['label', 'value']"
|
||||
placeholder="Switch user..."
|
||||
:search-input="{ placeholder: 'Search members...' }"
|
||||
class="dev-select"
|
||||
size="xs"
|
||||
@update:model-value="loginAsEmail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const selectedEmail = ref(null);
|
||||
const { logout } = useAuth();
|
||||
|
||||
const { data: members } = await useFetch("/api/dev/members", {
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const loginAsEmail = (email) => {
|
||||
if (email) {
|
||||
navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, {
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-login {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--ember);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dev-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dev-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dev-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dev-button {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
background: var(--surface);
|
||||
color: var(--ember);
|
||||
border: 1px solid var(--ember);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dev-button:hover {
|
||||
background: var(--ember);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.dev-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep([data-slot="base"]) {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:deep([data-slot="placeholder"]) {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
70
app/components/EventSeriesBadge.vue
Normal file
70
app/components/EventSeriesBadge.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div
|
||||
class="series-badge p-4 bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span
|
||||
class="series-badge__label text-sm font-semibold text-guild-300 dark:text-guild-300"
|
||||
>
|
||||
Part of a Series
|
||||
</span>
|
||||
<span
|
||||
v-if="totalEvents"
|
||||
class="series-badge__count inline-flex items-center px-2 py-0.5 rounded-md bg-guild-700/50 dark:bg-guild-600/50 text-sm font-medium text-guild-200 dark:text-guild-200"
|
||||
>
|
||||
<template v-if="position">
|
||||
Event {{ position }} of {{ totalEvents }}
|
||||
</template>
|
||||
<template v-else> {{ totalEvents }} events in series </template>
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
class="series-badge__title text-lg font-semibold text-guild-100 dark:text-guild-100 mb-2"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="series-badge__description text-sm text-guild-300 dark:text-guild-300"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="seriesId" class="flex-shrink-0 self-start">
|
||||
<UButton
|
||||
:to="`/series/${seriesId}`"
|
||||
color="primary"
|
||||
size="md"
|
||||
label="View Series"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
position: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
totalEvents: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
seriesId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,27 +1,37 @@
|
|||
<template>
|
||||
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
|
||||
<div
|
||||
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
|
||||
<div
|
||||
class="bg-gradient-to-br from-candlelight-500 to-candlelight-700 dark:from-candlelight-600 dark:to-candlelight-800 p-6"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
|
||||
<span class="text-sm font-semibold" style="color: var(--parch-text)">
|
||||
<Icon
|
||||
name="heroicons:ticket"
|
||||
class="w-5 h-5 text-candlelight-900 dark:text-candlelight-200"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-candlelight-900 dark:text-candlelight-200">
|
||||
Series Pass
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="font-display text-xl font-bold mb-1" style="color: var(--parch-text)">
|
||||
<h3 class="text-xl font-bold text-white mb-1">
|
||||
{{ ticket.name }}
|
||||
</h3>
|
||||
<p v-if="ticket.description" class="text-sm" style="color: var(--parch-text); opacity: 0.85">
|
||||
<p v-if="ticket.description" class="text-sm text-candlelight-900 dark:text-candlelight-200">
|
||||
{{ ticket.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-3xl font-bold" style="color: var(--parch-text)">
|
||||
<div class="text-3xl font-bold text-white text-ui-mono">
|
||||
{{ formatPrice(ticket.price, ticket.currency) }}
|
||||
</div>
|
||||
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
|
||||
<div
|
||||
v-if="ticket.isEarlyBird"
|
||||
class="text-xs text-candlelight-900 dark:text-candlelight-200 mt-1"
|
||||
>
|
||||
Early Bird Price
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -29,23 +39,29 @@
|
|||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6" style="background: var(--surface)">
|
||||
<div class="p-6 bg-guild-800/50 dark:bg-guild-700/30">
|
||||
<!-- What's Included -->
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
||||
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
||||
What's Included
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2" style="color: var(--text)">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||
<div class="flex items-center gap-2 text-guild-300 dark:text-guild-300">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
||||
<span>Access to all {{ totalEvents }} events in the series</span>
|
||||
</div>
|
||||
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||
<div
|
||||
v-if="ticket.isFree && !isMember"
|
||||
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
|
||||
>
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
||||
<span>Automatic registration for all sessions</span>
|
||||
</div>
|
||||
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||
<div
|
||||
v-if="memberSavings > 0"
|
||||
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
|
||||
>
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
||||
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,31 +69,33 @@
|
|||
|
||||
<!-- Events List Preview -->
|
||||
<div v-if="events && events.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
||||
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
||||
Series Schedule
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(event, index) in events.slice(0, 3)"
|
||||
:key="event.id"
|
||||
class="flex items-start gap-3 p-3"
|
||||
class="flex items-start gap-3 p-3 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||
class="w-8 h-8 rounded-full bg-candlelight-600/20 border border-candlelight-500/30 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
|
||||
<span class="text-sm font-bold text-candlelight-300">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm" style="color: var(--text)">
|
||||
<div class="font-medium text-guild-100 dark:text-guild-100 text-sm">
|
||||
{{ event.title }}
|
||||
</div>
|
||||
<div class="text-xs mt-1" style="color: var(--text-faint)">
|
||||
<div class="text-xs text-guild-400 dark:text-guild-400 mt-1">
|
||||
{{ formatEventDate(event.startDate) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
|
||||
<div
|
||||
v-if="events.length > 3"
|
||||
class="text-center text-sm text-guild-400 dark:text-guild-400 pt-2"
|
||||
>
|
||||
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -86,14 +104,13 @@
|
|||
<!-- Member Benefit Callout -->
|
||||
<div
|
||||
v-if="ticket.isFree && isMember"
|
||||
class="p-4 mb-6"
|
||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="heroicons:sparkles" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
||||
<Icon name="heroicons:sparkles" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
|
||||
<div class="text-sm" style="color: var(--candle)">
|
||||
<div class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
|
||||
<div class="text-sm text-candlelight-400">
|
||||
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -103,14 +120,13 @@
|
|||
<!-- Public vs Member Pricing -->
|
||||
<div
|
||||
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
|
||||
class="p-4 mb-6"
|
||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="heroicons:tag" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
||||
<Icon name="heroicons:tag" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
|
||||
<div class="text-sm" style="color: var(--candle)">
|
||||
<div class="font-semibold text-candlelight-300 mb-1">Member Savings</div>
|
||||
<div class="text-sm text-candlelight-400">
|
||||
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
|
||||
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
|
||||
</div>
|
||||
|
|
@ -120,15 +136,22 @@
|
|||
|
||||
<!-- Availability -->
|
||||
<div v-if="availability" class="mb-6">
|
||||
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="!availability.unlimited && availability.remaining !== null"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
|
||||
class="w-5 h-5"
|
||||
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
||||
:class="[
|
||||
'w-5 h-5',
|
||||
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
|
||||
]"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
availability.remaining > 5 ? 'text-candlelight-300' : 'text-ember-300'
|
||||
]"
|
||||
>
|
||||
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
|
||||
</span>
|
||||
|
|
@ -137,12 +160,12 @@
|
|||
|
||||
<!-- Sold Out / Waitlist -->
|
||||
<div v-if="!available" class="space-y-3">
|
||||
<div class="p-4" style="background: var(--ember-bg); border: 1px solid var(--ember)">
|
||||
<div class="p-4 bg-ember-900/20 border border-ember-700/30 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
|
||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
|
||||
<div class="text-sm" style="color: var(--ember)">
|
||||
<div class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
|
||||
<div class="text-sm text-ember-400">
|
||||
All series passes have been claimed.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,7 +174,7 @@
|
|||
<UButton
|
||||
v-if="availability?.waitlistAvailable"
|
||||
block
|
||||
color="neutral"
|
||||
color="gray"
|
||||
size="lg"
|
||||
@click="$emit('join-waitlist')"
|
||||
>
|
||||
|
|
@ -160,16 +183,12 @@
|
|||
</div>
|
||||
|
||||
<!-- Already Registered -->
|
||||
<div
|
||||
v-else-if="alreadyRegistered"
|
||||
class="p-4"
|
||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||
>
|
||||
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
|
||||
<Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
|
||||
<div class="text-sm" style="color: var(--candle)">
|
||||
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
|
||||
<div class="text-sm text-candlelight-400">
|
||||
You have a series pass and are registered for all {{ totalEvents }} events.
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,42 +1,72 @@
|
|||
<template>
|
||||
<div
|
||||
class="ticket-card"
|
||||
:class="{
|
||||
'is-selected': isSelected,
|
||||
'is-unavailable': !isAvailable || alreadyRegistered,
|
||||
}"
|
||||
class="ticket-card rounded-xl border p-6 transition-all duration-200"
|
||||
:class="[
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-guild-600 bg-guild-800/50',
|
||||
isAvailable && !alreadyRegistered
|
||||
? 'hover:border-primary/50 cursor-pointer'
|
||||
: 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Ticket Header -->
|
||||
<div class="ticket-header">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
|
||||
<p v-if="ticketInfo.description" class="ticket-desc">
|
||||
<h3 class="text-lg font-semibold text-guild-100">
|
||||
{{ ticketInfo.name }}
|
||||
</h3>
|
||||
<p v-if="ticketInfo.description" class="text-sm text-guild-300 mt-1">
|
||||
{{ ticketInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="ticketInfo.isMember" class="badge">Members Only</span>
|
||||
|
||||
<!-- Badge -->
|
||||
<div v-if="ticketInfo.isMember" class="flex-shrink-0 ml-4">
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-500 dark:bg-candlelight-900/30 dark:text-candlelight-400"
|
||||
>
|
||||
Members Only
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price Display -->
|
||||
<div class="ticket-price-block">
|
||||
<div class="ticket-price-row">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span
|
||||
class="ticket-price"
|
||||
:class="{ 'is-free': ticketInfo.isFree }"
|
||||
class="text-3xl font-bold text-ui-mono"
|
||||
:class="ticketInfo.isFree ? 'text-candlelight-400' : 'text-guild-100'"
|
||||
>
|
||||
{{ ticketInfo.formattedPrice }}
|
||||
</span>
|
||||
<span v-if="ticketInfo.isEarlyBird" class="badge early-bird">
|
||||
|
||||
<!-- Early Bird Badge -->
|
||||
<span
|
||||
v-if="ticketInfo.isEarlyBird"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-600 dark:bg-candlelight-900/35 dark:text-candlelight-400"
|
||||
>
|
||||
Early Bird
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice" class="ticket-regular-price">
|
||||
Regular: {{ ticketInfo.formattedRegularPrice }}
|
||||
<!-- Regular Price (if early bird) -->
|
||||
<div
|
||||
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
|
||||
class="mt-1"
|
||||
>
|
||||
<span class="text-sm text-guild-400 line-through">
|
||||
Regular: {{ ticketInfo.formattedRegularPrice }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" class="ticket-deadline">
|
||||
<!-- Early Bird Countdown -->
|
||||
<div
|
||||
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
|
||||
class="mt-2 text-xs text-candlelight-500 dark:text-candlelight-400"
|
||||
>
|
||||
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
|
||||
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -44,38 +74,59 @@
|
|||
<!-- Member Savings -->
|
||||
<div
|
||||
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
|
||||
class="ticket-savings"
|
||||
class="mb-4 p-3 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
||||
>
|
||||
<p>You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!</p>
|
||||
<p class="ticket-savings-detail">
|
||||
<p class="text-sm text-candlelight-400">
|
||||
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
|
||||
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
|
||||
</p>
|
||||
<p class="text-xs text-guild-400 mt-1">
|
||||
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Availability -->
|
||||
<div class="ticket-availability">
|
||||
<span v-if="alreadyRegistered" class="status-registered">
|
||||
You're registered
|
||||
</span>
|
||||
<span v-else-if="!isAvailable" class="status-sold-out">
|
||||
Sold Out
|
||||
</span>
|
||||
<span v-else-if="ticketInfo.remaining !== null" class="status-remaining">
|
||||
{{ ticketInfo.remaining }} remaining
|
||||
</span>
|
||||
<span v-else class="status-remaining">
|
||||
Unlimited availability
|
||||
</span>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span
|
||||
v-if="alreadyRegistered"
|
||||
class="text-candlelight-400 flex items-center gap-1"
|
||||
>
|
||||
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
|
||||
You're registered
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!isAvailable"
|
||||
class="text-ember-400 flex items-center gap-1"
|
||||
>
|
||||
<Icon name="heroicons:x-circle-solid" class="w-4 h-4" />
|
||||
Sold Out
|
||||
</span>
|
||||
<span v-else-if="ticketInfo.remaining !== null" class="text-guild-300">
|
||||
{{ ticketInfo.remaining }} remaining
|
||||
</span>
|
||||
<span v-else class="text-guild-300"> Unlimited availability </span>
|
||||
</div>
|
||||
|
||||
<!-- Selection Indicator -->
|
||||
<div v-if="isSelected && isAvailable && !alreadyRegistered">
|
||||
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waitlist Option -->
|
||||
<div
|
||||
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
|
||||
class="ticket-waitlist"
|
||||
class="mt-4 pt-4 border-t border-guild-600"
|
||||
>
|
||||
<button class="btn" @click.stop="$emit('join-waitlist')">
|
||||
<UButton
|
||||
color="gray"
|
||||
size="sm"
|
||||
block
|
||||
@click.stop="$emit('join-waitlist')"
|
||||
>
|
||||
Join Waitlist
|
||||
</button>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -113,11 +164,13 @@ const formatDeadline = (deadline) => {
|
|||
const now = new Date();
|
||||
const diff = date - now;
|
||||
|
||||
// If less than 24 hours, show hours
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
// Otherwise show date
|
||||
return `on ${date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
|
@ -134,103 +187,6 @@ const formatPrice = (amount) => {
|
|||
|
||||
<style scoped>
|
||||
.ticket-card {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding: 20px 24px;
|
||||
transition: border-color 0.15s;
|
||||
cursor: default;
|
||||
}
|
||||
.ticket-card.is-selected {
|
||||
border-color: var(--candle-faint);
|
||||
}
|
||||
.ticket-card.is-unavailable {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ticket-card:not(.is-unavailable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ticket-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ticket-name {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.ticket-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ticket-price-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ticket-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.ticket-price {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.ticket-price.is-free {
|
||||
color: var(--candle);
|
||||
}
|
||||
.ticket-regular-price {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: line-through;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ticket-deadline {
|
||||
font-size: 10px;
|
||||
color: var(--candle-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.early-bird {
|
||||
color: var(--candle-dim);
|
||||
border-color: var(--candle-faint);
|
||||
}
|
||||
|
||||
.ticket-savings {
|
||||
border: 1px dashed var(--candle-faint);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--candle);
|
||||
}
|
||||
.ticket-savings-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ticket-availability {
|
||||
font-size: 11px;
|
||||
}
|
||||
.status-registered {
|
||||
color: var(--green);
|
||||
}
|
||||
.status-sold-out {
|
||||
color: var(--ember);
|
||||
}
|
||||
.status-remaining {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.ticket-waitlist {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,65 @@
|
|||
<template>
|
||||
<div class="event-ticket-purchase">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="ticket-panel">
|
||||
<div class="box-title">Tickets</div>
|
||||
<p class="ticket-status">Loading ticket information...</p>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-guild-300">Loading ticket information...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="ticket-panel">
|
||||
<div class="box-title">Tickets</div>
|
||||
<p class="ticket-status" style="color: var(--ember)">
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-ember-300 mb-2">
|
||||
Unable to Load Tickets
|
||||
</p>
|
||||
<p class="ticket-detail">{{ error }}</p>
|
||||
</h3>
|
||||
<p class="text-ember-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Series Pass Required -->
|
||||
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
|
||||
<div class="box-title">Tickets</div>
|
||||
<p class="ticket-status" style="color: var(--candle)">
|
||||
<div
|
||||
v-else-if="ticketInfo?.requiresSeriesPass"
|
||||
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
|
||||
>
|
||||
<Icon name="heroicons:ticket" class="w-6 h-6" />
|
||||
Series Pass Required
|
||||
</p>
|
||||
<p class="ticket-detail">
|
||||
</h3>
|
||||
<p class="text-candlelight-400 mb-4">
|
||||
This event is part of
|
||||
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
|
||||
pass to attend.
|
||||
</p>
|
||||
<p class="ticket-hint">
|
||||
<p class="text-sm text-guild-300 mb-6">
|
||||
Purchase a series pass to get access to all events in this series.
|
||||
</p>
|
||||
<NuxtLink
|
||||
<UButton
|
||||
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
>
|
||||
<button class="btn btn-primary">View Series & Purchase Pass</button>
|
||||
</NuxtLink>
|
||||
View Series & Purchase Pass
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Already Registered -->
|
||||
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
||||
<p class="ticket-status" style="color: var(--green)">
|
||||
<div
|
||||
v-else-if="ticketInfo?.alreadyRegistered"
|
||||
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
|
||||
>
|
||||
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
|
||||
You're Registered!
|
||||
</p>
|
||||
<p class="ticket-detail">
|
||||
</h3>
|
||||
<p class="text-candlelight-400 mb-4">
|
||||
<template v-if="ticketInfo.viaSeriesPass">
|
||||
You have access to this event via your series pass for
|
||||
<strong>{{ ticketInfo.series?.title }}</strong
|
||||
|
|
@ -52,7 +70,7 @@
|
|||
details.
|
||||
</template>
|
||||
</p>
|
||||
<p class="ticket-hint">
|
||||
<p class="text-sm text-guild-300">
|
||||
See you on {{ formatEventDate(eventStartDate) }}!
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -65,145 +83,128 @@
|
|||
:is-selected="true"
|
||||
:is-available="ticketInfo.available"
|
||||
:already-registered="ticketInfo.alreadyRegistered"
|
||||
class="mb-6"
|
||||
@join-waitlist="handleJoinWaitlist"
|
||||
/>
|
||||
|
||||
<!-- Registration (logged-in member) -->
|
||||
<div
|
||||
v-if="
|
||||
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
|
||||
"
|
||||
class="ticket-panel"
|
||||
>
|
||||
<p
|
||||
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
||||
class="ticket-notice"
|
||||
style="color: var(--candle)"
|
||||
>
|
||||
This event is free for Ghost Guild members
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!ticketInfo.isFree"
|
||||
class="ticket-notice"
|
||||
style="color: var(--candle)"
|
||||
>
|
||||
Payment of {{ ticketInfo.formattedPrice }} will be processed securely
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="processing"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{
|
||||
processing
|
||||
? "Processing..."
|
||||
: ticketInfo.isFree
|
||||
? "Register for this event"
|
||||
: `Pay ${ticketInfo.formattedPrice}`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Registration Form (guest) -->
|
||||
<div
|
||||
v-else-if="ticketInfo.available && !ticketInfo.alreadyRegistered"
|
||||
class="ticket-panel"
|
||||
>
|
||||
<div class="box-title">
|
||||
<!-- Registration Form -->
|
||||
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
|
||||
<h3 class="text-xl font-bold text-guild-100 mb-4">
|
||||
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="field">
|
||||
<label for="ticket-name">Full Name</label>
|
||||
<input
|
||||
id="ticket-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
required
|
||||
:disabled="processing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="ticket-email">Email Address</label>
|
||||
<input
|
||||
id="ticket-email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
:disabled="processing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!ticketInfo.isFree"
|
||||
class="ticket-notice"
|
||||
style="color: var(--candle)"
|
||||
>
|
||||
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
||||
securely
|
||||
</p>
|
||||
|
||||
<div class="consent-block">
|
||||
<label class="consent-field">
|
||||
<input
|
||||
v-model="form.createAccount"
|
||||
type="checkbox"
|
||||
:disabled="processing"
|
||||
/>
|
||||
<span
|
||||
>Create a free guest account so I can manage my
|
||||
registration</span
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Name Field -->
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-guild-200 mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<p class="field-hint consent-hint">
|
||||
Guest accounts let you view your tickets and register faster next
|
||||
time. We won't add you to member communications.
|
||||
<UInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter your full name"
|
||||
:disabled="processing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-guild-200 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
:disabled="processing || isLoggedIn"
|
||||
/>
|
||||
<p v-if="isLoggedIn" class="text-xs text-guild-400 mt-1">
|
||||
Using your member email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="processing || !form.name || !form.email"
|
||||
<!-- Member Benefits Notice -->
|
||||
<div
|
||||
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
||||
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
||||
>
|
||||
{{
|
||||
processing
|
||||
? "Processing..."
|
||||
: ticketInfo.isFree
|
||||
? "Complete Registration"
|
||||
: `Pay ${ticketInfo.formattedPrice}`
|
||||
}}
|
||||
</button>
|
||||
<p class="text-sm text-candlelight-300 flex items-center gap-2">
|
||||
<Icon name="heroicons:sparkles" class="w-4 h-4" />
|
||||
This event is free for Ghost Guild members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Required Notice -->
|
||||
<div
|
||||
v-if="!ticketInfo.isFree"
|
||||
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
||||
>
|
||||
<p class="text-sm text-candlelight-300 flex items-center gap-2">
|
||||
<Icon name="heroicons:credit-card" class="w-4 h-4" />
|
||||
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
||||
securely
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
:loading="processing"
|
||||
:disabled="!form.name || !form.email"
|
||||
>
|
||||
{{
|
||||
processing
|
||||
? "Processing..."
|
||||
: ticketInfo.isFree
|
||||
? "Complete Registration"
|
||||
: `Pay ${ticketInfo.formattedPrice}`
|
||||
}}
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sold Out with Waitlist -->
|
||||
<div
|
||||
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
|
||||
class="ticket-panel"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<div class="box-title">Waitlist</div>
|
||||
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||
<p class="ticket-detail">
|
||||
<Icon
|
||||
name="heroicons:ticket"
|
||||
class="w-16 h-16 text-guild-400 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
|
||||
<p class="text-guild-300 mb-6">
|
||||
This event is currently at capacity. Join the waitlist to be notified
|
||||
if spots become available.
|
||||
</p>
|
||||
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
|
||||
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
|
||||
Join Waitlist
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Sold Out (No Waitlist) -->
|
||||
<div v-else-if="!ticketInfo.available" class="ticket-panel">
|
||||
<div class="box-title">Tickets</div>
|
||||
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||
<p class="ticket-detail">
|
||||
<div v-else-if="!ticketInfo.available" class="text-center py-8">
|
||||
<Icon
|
||||
name="heroicons:x-circle"
|
||||
class="w-16 h-16 text-ember-400 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
|
||||
<p class="text-guild-300">
|
||||
Unfortunately, this event is at capacity and no longer accepting
|
||||
registrations.
|
||||
</p>
|
||||
|
|
@ -219,25 +220,17 @@ const props = defineProps({
|
|||
required: true,
|
||||
},
|
||||
eventStartDate: {
|
||||
type: [String, Date],
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
eventTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
eventTimezone: {
|
||||
type: String,
|
||||
default: "America/Toronto",
|
||||
},
|
||||
userEmail: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
userName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["success", "error"]);
|
||||
|
|
@ -252,9 +245,8 @@ const error = ref(null);
|
|||
const ticketInfo = ref(null);
|
||||
|
||||
const form = ref({
|
||||
name: props.userName || "",
|
||||
name: "",
|
||||
email: props.userEmail || "",
|
||||
createAccount: true,
|
||||
});
|
||||
|
||||
const isLoggedIn = computed(() => !!props.userEmail);
|
||||
|
|
@ -264,13 +256,11 @@ onMounted(async () => {
|
|||
await fetchTicketInfo();
|
||||
});
|
||||
|
||||
const fetchTicketInfo = async (emailOverride = null) => {
|
||||
const fetchTicketInfo = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const effectiveEmail = emailOverride || props.userEmail;
|
||||
|
||||
// First check if this event requires a series pass
|
||||
if (props.userEmail) {
|
||||
try {
|
||||
|
|
@ -280,6 +270,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
|||
|
||||
if (seriesAccess.requiresSeriesPass) {
|
||||
if (seriesAccess.hasSeriesPass) {
|
||||
// User has series pass - show as already registered
|
||||
ticketInfo.value = {
|
||||
available: true,
|
||||
alreadyRegistered: true,
|
||||
|
|
@ -290,6 +281,7 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
|||
loading.value = false;
|
||||
return;
|
||||
} else {
|
||||
// User needs to buy series pass
|
||||
ticketInfo.value = {
|
||||
available: false,
|
||||
requiresSeriesPass: true,
|
||||
|
|
@ -301,14 +293,13 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
|||
}
|
||||
}
|
||||
} catch (seriesErr) {
|
||||
// If series check fails, continue with regular ticket check
|
||||
console.warn("Series access check failed:", seriesErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular ticket availability check
|
||||
const params = effectiveEmail
|
||||
? `?email=${encodeURIComponent(effectiveEmail)}`
|
||||
: "";
|
||||
const params = props.userEmail ? `?email=${props.userEmail}` : "";
|
||||
const response = await $fetch(
|
||||
`/api/events/${props.eventId}/tickets/available${params}`,
|
||||
);
|
||||
|
|
@ -329,19 +320,24 @@ const handleSubmit = async () => {
|
|||
try {
|
||||
let transactionId = null;
|
||||
|
||||
// If payment is required, initialize Helcim and process payment
|
||||
if (!ticketInfo.value.isFree) {
|
||||
// Initialize Helcim payment
|
||||
await initializeTicketPayment(
|
||||
props.eventId,
|
||||
form.value.email,
|
||||
ticketInfo.value.price,
|
||||
props.eventTitle,
|
||||
);
|
||||
|
||||
// Show Helcim modal and complete payment
|
||||
const paymentResult = await verifyPayment();
|
||||
|
||||
if (!paymentResult.success) {
|
||||
throw new Error("Payment was not completed");
|
||||
}
|
||||
|
||||
// For purchase transactions, we get a transactionId
|
||||
transactionId = paymentResult.transactionId;
|
||||
|
||||
if (!transactionId) {
|
||||
|
|
@ -349,38 +345,32 @@ const handleSubmit = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
createAccount: form.value.createAccount,
|
||||
};
|
||||
if (transactionId) body.transactionId = transactionId;
|
||||
|
||||
// Purchase ticket
|
||||
const response = await $fetch(
|
||||
`/api/events/${props.eventId}/tickets/purchase`,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
body: {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
transactionId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Success!
|
||||
toast.add({
|
||||
title: "Success!",
|
||||
description: ticketInfo.value.isFree
|
||||
? "You're registered for this event"
|
||||
: "Ticket purchased successfully!",
|
||||
color: "success",
|
||||
color: "green",
|
||||
});
|
||||
|
||||
emit("success", response);
|
||||
|
||||
if (response?.signedIn) {
|
||||
// New guest account or returning guest — refresh client auth state so the
|
||||
// rest of the app sees them as logged in.
|
||||
await useAuth().checkMemberStatus();
|
||||
}
|
||||
|
||||
await fetchTicketInfo(form.value.email);
|
||||
// Refresh ticket info to show registered state
|
||||
await fetchTicketInfo();
|
||||
} catch (err) {
|
||||
console.error("Error purchasing ticket:", err);
|
||||
|
||||
|
|
@ -392,7 +382,7 @@ const handleSubmit = async () => {
|
|||
toast.add({
|
||||
title: "Registration Failed",
|
||||
description: errorMessage,
|
||||
color: "error",
|
||||
color: "red",
|
||||
});
|
||||
|
||||
emit("error", err);
|
||||
|
|
@ -403,10 +393,11 @@ const handleSubmit = async () => {
|
|||
};
|
||||
|
||||
const handleJoinWaitlist = () => {
|
||||
// TODO: Implement waitlist functionality
|
||||
toast.add({
|
||||
title: "Waitlist",
|
||||
description: "Waitlist functionality coming soon!",
|
||||
color: "info",
|
||||
color: "blue",
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -416,64 +407,6 @@ const formatEventDate = (date) => {
|
|||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: props.eventTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-panel {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.ticket-status {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ticket-detail {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ticket-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ticket-notice {
|
||||
font-size: 11px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.consent-block {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: flex-start;
|
||||
column-gap: 8px;
|
||||
row-gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.consent-field {
|
||||
display: contents;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.consent-field input[type="checkbox"] {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--candle);
|
||||
}
|
||||
.consent-hint {
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,18 +6,13 @@
|
|||
<div v-if="events?.length" class="em-rows">
|
||||
<div v-for="event in events" :key="event._id" class="em-item">
|
||||
<div class="em-inset em-item-body">
|
||||
<span class="em-date">{{ formatDate(event) }}</span>
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || event._id}`"
|
||||
class="em-title"
|
||||
>{{ event.title }}</NuxtLink
|
||||
>
|
||||
<span class="em-date">{{ formatDate(event.date) }}</span>
|
||||
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
||||
<span
|
||||
v-if="event.circle"
|
||||
class="em-circle"
|
||||
:style="{ color: `var(--c-${event.circle})` }"
|
||||
>{{ event.circle }}</span
|
||||
>
|
||||
>{{ event.circle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,16 +30,13 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
events: { type: Array, default: () => [] },
|
||||
});
|
||||
})
|
||||
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -107,7 +99,7 @@ const formatDate = (event) => {
|
|||
}
|
||||
|
||||
.em-circle {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
|
|||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
padding: 16px 28px;
|
||||
padding: 14px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -5,16 +5,14 @@
|
|||
<img
|
||||
:src="transformedImageUrl"
|
||||
:alt="modelValue.alt || 'Event image'"
|
||||
class="w-full h-48 object-cover"
|
||||
style="border: 1px solid var(--border)"
|
||||
class="w-full h-48 object-cover rounded-lg border border-guild-700"
|
||||
@error="console.log('Image failed to load:', transformedImageUrl)"
|
||||
@load="console.log('Image loaded successfully:', transformedImageUrl)"
|
||||
>
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 p-1 rounded-full transition-colors"
|
||||
style="background: var(--ember); color: var(--parch-text)"
|
||||
@click="removeImage"
|
||||
type="button"
|
||||
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -23,84 +21,67 @@
|
|||
<!-- Upload Area -->
|
||||
<div
|
||||
v-if="!modelValue?.url"
|
||||
class="border-2 border-dashed p-6 text-center transition-colors"
|
||||
:style="
|
||||
isDragging
|
||||
? 'border-color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent)'
|
||||
: 'border-color: var(--border)'
|
||||
"
|
||||
class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
:class="{ 'border-candlelight-400 bg-candlelight-900/20': isDragging }"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Icon
|
||||
name="heroicons:photo"
|
||||
class="w-12 h-12 mx-auto"
|
||||
style="color: var(--text-dim)"
|
||||
/>
|
||||
<Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" />
|
||||
<div>
|
||||
<p style="color: var(--text-dim)">
|
||||
<p class="text-guild-400">
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium"
|
||||
style="color: var(--candle)"
|
||||
@click="$refs.fileInput.click()"
|
||||
class="text-candlelight-400 hover:text-candlelight-300 font-medium"
|
||||
>
|
||||
Click to upload
|
||||
</button>
|
||||
or drag and drop
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-faint)">
|
||||
PNG, JPG, GIF up to 10MB
|
||||
</p>
|
||||
<p class="text-sm text-guild-500">PNG, JPG, GIF up to 10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alt Text Input -->
|
||||
<div v-if="modelValue?.url">
|
||||
<label
|
||||
class="block text-sm font-medium mb-1"
|
||||
style="color: var(--text-bright)"
|
||||
>
|
||||
<label class="block text-sm font-medium text-guild-100 mb-1">
|
||||
Alt Text (for accessibility)
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.alt || ''"
|
||||
placeholder="Describe this image..."
|
||||
class="w-full px-3 py-2 alt-text-input"
|
||||
@input="updateAltText($event.target.value)"
|
||||
>
|
||||
placeholder="Describe this image..."
|
||||
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-candlelight-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div v-if="isUploading" class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span style="color: var(--text-dim)">Uploading...</span>
|
||||
<span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
|
||||
<span class="text-guild-400">Uploading...</span>
|
||||
<span class="text-guild-400">{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="w-full rounded-full h-2"
|
||||
style="background: var(--surface)"
|
||||
>
|
||||
<div class="w-full bg-guild-800 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${uploadProgress}%; background: var(--candle)`"
|
||||
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${uploadProgress}%`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
|
||||
<div v-if="errorMessage" class="text-sm text-ember-400">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -220,16 +201,3 @@ const updateAltText = (altText) => {
|
|||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alt-text-input {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.alt-text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
required
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
|
|
@ -144,15 +144,6 @@ watch(isOpen, (newValue) => {
|
|||
loginError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Escape' && isOpen.value) {
|
||||
resetAndClose()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -182,7 +173,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
|||
|
||||
.modal-overline {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--candle);
|
||||
margin-bottom: 12px;
|
||||
|
|
@ -218,7 +209,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
|||
.info-box {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
padding: 12px 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed var(--border);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
|
|
|
|||
|
|
@ -1,31 +1,60 @@
|
|||
<template>
|
||||
<ClientOnly>
|
||||
<div v-if="shouldShowBanner" class="status-banner">
|
||||
<div class="status-banner-inner">
|
||||
<div class="status-banner-text">
|
||||
<strong class="status-banner-label">{{ statusConfig.label }}</strong>
|
||||
<span class="status-banner-msg">{{ bannerMessage }}</span>
|
||||
<div v-if="shouldShowBanner" class="w-full">
|
||||
<div
|
||||
:class="[
|
||||
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
|
||||
statusConfig.bgColor,
|
||||
statusConfig.borderColor,
|
||||
]"
|
||||
>
|
||||
<Icon
|
||||
:name="statusConfig.icon"
|
||||
:class="['w-5 h-5 flex-shrink-0 mt-0.5', statusConfig.textColor]"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 :class="['font-semibold mb-1', statusConfig.textColor]">
|
||||
{{ statusConfig.label }}
|
||||
</h3>
|
||||
<p :class="['text-sm', statusConfig.textColor, 'opacity-90']">
|
||||
{{ bannerMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="nextAction" class="status-banner-actions">
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<!-- Payment button for pending payment status -->
|
||||
<button
|
||||
v-if="isPendingPayment"
|
||||
:disabled="isProcessingPayment"
|
||||
class="btn btn-primary"
|
||||
<UButton
|
||||
v-if="isPendingPayment && nextAction"
|
||||
:color="getButtonColor(nextAction.color)"
|
||||
size="sm"
|
||||
:loading="isProcessingPayment"
|
||||
@click="handleActionClick"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
|
||||
</button>
|
||||
</UButton>
|
||||
|
||||
<!-- Link button for other actions -->
|
||||
<NuxtLink
|
||||
v-else-if="nextAction.link"
|
||||
v-else-if="nextAction && nextAction.link"
|
||||
:to="nextAction.link"
|
||||
class="btn"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
|
||||
getActionButtonClass(nextAction.color),
|
||||
]"
|
||||
>
|
||||
{{ nextAction.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
v-if="dismissible"
|
||||
@click="isDismissed = true"
|
||||
class="text-guild-400 hover:text-guild-200 transition-colors"
|
||||
:aria-label="`Dismiss ${statusConfig.label} banner`"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -33,6 +62,17 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
dismissible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isPendingPayment,
|
||||
isSuspended,
|
||||
|
|
@ -41,9 +81,11 @@ const {
|
|||
getNextAction,
|
||||
getBannerMessage,
|
||||
} = useMemberStatus();
|
||||
|
||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||
|
||||
const isDismissed = ref(false);
|
||||
|
||||
// Handle action button click
|
||||
const handleActionClick = async () => {
|
||||
if (isPendingPayment.value) {
|
||||
try {
|
||||
|
|
@ -54,57 +96,33 @@ const handleActionClick = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const shouldShowBanner = computed(
|
||||
() => isPendingPayment.value || isSuspended.value || isCancelled.value,
|
||||
);
|
||||
// Map color names to UButton color props
|
||||
const getButtonColor = (color) => {
|
||||
const colorMap = {
|
||||
orange: "warning",
|
||||
blue: "primary",
|
||||
gray: "neutral",
|
||||
};
|
||||
return colorMap[color] || "primary";
|
||||
};
|
||||
|
||||
// Only show banner if status is not active
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (isDismissed.value) return false;
|
||||
return isPendingPayment.value || isSuspended.value || isCancelled.value;
|
||||
});
|
||||
|
||||
const bannerMessage = computed(() => getBannerMessage());
|
||||
const nextAction = computed(() => getNextAction());
|
||||
|
||||
// Button styling based on color
|
||||
const getActionButtonClass = (color) => {
|
||||
const baseClass = "hover:scale-105 active:scale-95";
|
||||
const colorClasses = {
|
||||
orange: "bg-candlelight-600 text-white hover:bg-candlelight-700",
|
||||
blue: "bg-guild-600 text-white hover:bg-guild-500",
|
||||
gray: "bg-guild-700 text-guild-100 hover:bg-guild-600",
|
||||
};
|
||||
return `${baseClass} ${colorClasses[color] || colorClasses.blue}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-banner {
|
||||
width: 100%;
|
||||
background: var(--parch);
|
||||
}
|
||||
|
||||
.status-banner-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-banner-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-banner-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--parch-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-banner-msg {
|
||||
font-size: 12px;
|
||||
color: var(--parch-text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-banner-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Ensure no border-radius leaks in from global resets or UButton */
|
||||
.status-banner .btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,67 @@
|
|||
<template>
|
||||
<div class="natural-date-input">
|
||||
<UInput
|
||||
:model-value="rawInput"
|
||||
:placeholder="placeholder"
|
||||
:color="trailingState"
|
||||
@update:model-value="onInputChange"
|
||||
<div class="space-y-2">
|
||||
<div class="relative">
|
||||
<UInput
|
||||
v-model="naturalInput"
|
||||
:placeholder="placeholder"
|
||||
:color="
|
||||
hasError && naturalInput.trim()
|
||||
? 'error'
|
||||
: isValidParse && naturalInput.trim()
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@input="parseNaturalInput"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<template #trailing>
|
||||
<Icon
|
||||
v-if="isValidParse && naturalInput.trim()"
|
||||
name="heroicons:check-circle"
|
||||
class="w-5 h-5 text-candlelight-500"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="hasError && naturalInput.trim()"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="w-5 h-5 text-ember-500"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="parsedDate && isValidParse"
|
||||
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800"
|
||||
>
|
||||
<template #trailing>
|
||||
<Icon
|
||||
v-if="isValid && rawInput.trim()"
|
||||
name="heroicons:check-circle"
|
||||
class="w-5 h-5"
|
||||
style="color: var(--candle)"
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
||||
<span>{{ formatParsedDate(parsedDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasError && naturalInput.trim()"
|
||||
class="text-sm text-ember-400 bg-ember-900/20 px-3 py-2 rounded-lg border border-ember-800"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback datetime-local input -->
|
||||
<details class="text-sm">
|
||||
<summary class="cursor-pointer text-guild-400 hover:text-guild-100">
|
||||
Use traditional date picker
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<UInput
|
||||
v-model="datetimeValue"
|
||||
type="datetime-local"
|
||||
@change="onDatetimeChange"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="hasError && rawInput.trim()"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="w-5 h-5"
|
||||
style="color: var(--ember)"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
<p
|
||||
v-if="rawInput.trim() && isValid"
|
||||
class="preview-line"
|
||||
style="color: var(--candle)"
|
||||
>
|
||||
→ {{ previewText }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="rawInput.trim() && hasError"
|
||||
class="preview-line"
|
||||
style="color: var(--ember)"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -42,197 +69,176 @@
|
|||
import * as chrono from "chrono-node";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
|
||||
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
|
||||
},
|
||||
inputClass: {
|
||||
type: [String, Object],
|
||||
default: "",
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayTimezone: { type: String, default: "" },
|
||||
required: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const rawInput = ref("");
|
||||
const isValid = ref(false);
|
||||
const naturalInput = ref("");
|
||||
const parsedDate = ref(null);
|
||||
const isValidParse = ref(false);
|
||||
const hasError = ref(false);
|
||||
const errorMessage = ref("");
|
||||
// previewDate holds the parsed value as a UTC Date so we can format it in
|
||||
// arbitrary timezones without re-parsing. Source of truth for the preview.
|
||||
const previewDate = ref(null);
|
||||
const datetimeValue = ref("");
|
||||
|
||||
const trailingState = computed(() => {
|
||||
if (!rawInput.value.trim()) return undefined;
|
||||
if (hasError.value) return "error";
|
||||
if (isValid.value) return "success";
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const previewText = computed(() => {
|
||||
if (!previewDate.value) return "";
|
||||
const tz = activeTZ();
|
||||
const date = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}).format(previewDate.value);
|
||||
const abbr = shortTimezoneName(previewDate.value, tz);
|
||||
return abbr ? `${date} ${abbr}` : date;
|
||||
});
|
||||
|
||||
const activeTZ = () =>
|
||||
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Seed the input from modelValue without triggering chrono. The parent's
|
||||
// value is canonical — we just render it as a chrono-friendly readable
|
||||
// string so the user can backspace and tweak in place.
|
||||
const seedFromModelValue = () => {
|
||||
if (!props.modelValue) {
|
||||
rawInput.value = "";
|
||||
isValid.value = false;
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
previewDate.value = null;
|
||||
return;
|
||||
// Initialize with current value
|
||||
onMounted(() => {
|
||||
if (props.modelValue) {
|
||||
const date = new Date(props.modelValue);
|
||||
if (!isNaN(date.getTime())) {
|
||||
parsedDate.value = date;
|
||||
datetimeValue.value = formatForDatetimeLocal(date);
|
||||
isValidParse.value = true;
|
||||
}
|
||||
}
|
||||
const tz = activeTZ();
|
||||
const utc = zonedLocalToUTC(props.modelValue, tz);
|
||||
if (!utc) return;
|
||||
previewDate.value = utc;
|
||||
isValid.value = true;
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
rawInput.value = readableSeed(utc, tz);
|
||||
};
|
||||
|
||||
onMounted(seedFromModelValue);
|
||||
});
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(next) => {
|
||||
const tz = activeTZ();
|
||||
const expected = previewDate.value
|
||||
? utcToZonedLocal(previewDate.value, tz)
|
||||
: "";
|
||||
if (next === expected) return;
|
||||
seedFromModelValue();
|
||||
(newValue) => {
|
||||
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
|
||||
const date = new Date(newValue);
|
||||
if (!isNaN(date.getTime())) {
|
||||
parsedDate.value = date;
|
||||
datetimeValue.value = formatForDatetimeLocal(date);
|
||||
isValidParse.value = true;
|
||||
naturalInput.value = ""; // Clear natural input when set externally
|
||||
}
|
||||
} else if (!newValue) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.displayTimezone,
|
||||
() => {
|
||||
// Re-interpret the current input under the new TZ so the preview and
|
||||
// emitted value reflect the new timezone semantics.
|
||||
if (rawInput.value.trim()) parse(rawInput.value);
|
||||
},
|
||||
);
|
||||
const parseNaturalInput = () => {
|
||||
const input = naturalInput.value.trim();
|
||||
|
||||
const onInputChange = (value) => {
|
||||
rawInput.value = value;
|
||||
parse(value);
|
||||
if (!input) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse with chrono-node
|
||||
const results = chrono.parse(input);
|
||||
|
||||
if (results.length > 0) {
|
||||
const result = results[0];
|
||||
const date = result.date();
|
||||
|
||||
// Validate the parsed date
|
||||
if (date && !isNaN(date.getTime())) {
|
||||
parsedDate.value = date;
|
||||
isValidParse.value = true;
|
||||
hasError.value = false;
|
||||
datetimeValue.value = formatForDatetimeLocal(date);
|
||||
emit("update:modelValue", formatForDatetimeLocal(date));
|
||||
} else {
|
||||
setError("Could not parse this date format");
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError("Error parsing date");
|
||||
}
|
||||
};
|
||||
|
||||
const parse = (input) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
isValid.value = false;
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
previewDate.value = null;
|
||||
emit("update:modelValue", "");
|
||||
return;
|
||||
const onBlur = () => {
|
||||
// If we have a valid parse but the input changed, try to parse again
|
||||
if (naturalInput.value.trim() && !isValidParse.value) {
|
||||
parseNaturalInput();
|
||||
}
|
||||
const tz = activeTZ();
|
||||
let results;
|
||||
try {
|
||||
results = chrono.parse(trimmed, referenceNowInTZ(tz));
|
||||
} catch {
|
||||
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
||||
return;
|
||||
};
|
||||
|
||||
const onDatetimeChange = () => {
|
||||
if (datetimeValue.value) {
|
||||
const date = new Date(datetimeValue.value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
parsedDate.value = date;
|
||||
isValidParse.value = true;
|
||||
hasError.value = false;
|
||||
naturalInput.value = ""; // Clear natural input when using traditional picker
|
||||
emit("update:modelValue", datetimeValue.value);
|
||||
}
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
if (!results.length) {
|
||||
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
||||
return;
|
||||
}
|
||||
const date = results[0].date();
|
||||
if (!date || Number.isNaN(date.getTime())) {
|
||||
setError("Couldn't read that date");
|
||||
return;
|
||||
}
|
||||
// chrono returned a Date whose browser-local components match what the
|
||||
// user typed in the event timezone (because we shifted the reference).
|
||||
// Read those components as wall-clock in displayTimezone.
|
||||
const localStr = browserComponentsToString(date);
|
||||
const utc = zonedLocalToUTC(localStr, tz);
|
||||
if (!utc) {
|
||||
setError("Couldn't parse this date");
|
||||
return;
|
||||
}
|
||||
isValid.value = true;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
parsedDate.value = null;
|
||||
isValidParse.value = false;
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
previewDate.value = utc;
|
||||
emit("update:modelValue", localStr);
|
||||
};
|
||||
|
||||
const setError = (msg) => {
|
||||
isValid.value = false;
|
||||
hasError.value = true;
|
||||
errorMessage.value = msg;
|
||||
previewDate.value = null;
|
||||
emit("update:modelValue", "");
|
||||
};
|
||||
|
||||
// Build a Date object whose browser-local components equal the current
|
||||
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
|
||||
// Friday" anchor to the event TZ rather than the editor's browser TZ.
|
||||
const referenceNowInTZ = (tz) => {
|
||||
const nowStr = utcToZonedLocal(new Date(), tz);
|
||||
if (!nowStr) return new Date();
|
||||
const [d, t] = nowStr.split("T");
|
||||
const [y, mo, day] = d.split("-").map(Number);
|
||||
const [h, mi] = t.split(":").map(Number);
|
||||
return new Date(y, mo - 1, day, h, mi);
|
||||
const setError = (message) => {
|
||||
isValidParse.value = false;
|
||||
hasError.value = true;
|
||||
errorMessage.value = message;
|
||||
parsedDate.value = null;
|
||||
};
|
||||
|
||||
const browserComponentsToString = (date) => {
|
||||
const y = date.getFullYear();
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
const h = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}T${h}:${mi}`;
|
||||
const formatForDatetimeLocal = (date) => {
|
||||
if (!date) return "";
|
||||
// Format as YYYY-MM-DDTHH:MM for datetime-local input
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const readableSeed = (utc, tz) => {
|
||||
// Format chosen to round-trip cleanly through chrono.parse.
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
const formatParsedDate = (date) => {
|
||||
if (!date) return "";
|
||||
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = date.toDateString() === tomorrow.toDateString();
|
||||
|
||||
const timeStr = date.toLocaleString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}).format(utc);
|
||||
});
|
||||
|
||||
if (isToday) {
|
||||
return `Today at ${timeStr}`;
|
||||
} else if (isTomorrow) {
|
||||
return `Tomorrow at ${timeStr}`;
|
||||
} else {
|
||||
return date.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.natural-date-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-line {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
<template>
|
||||
<ClientOnly>
|
||||
<div v-if="!loading" class="onboarding-widget">
|
||||
<!-- Welcome mode: onboarding in progress -->
|
||||
<template v-if="!isComplete">
|
||||
<div class="ow-prompt">> welcome</div>
|
||||
<div class="ow-message">You are in the <strong>Ghost Guild</strong>. A few passages remain unexplored.</div>
|
||||
<div class="ow-hint">Next: {{ currentSuggestion.text }}</div>
|
||||
<NuxtLink
|
||||
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
|
||||
:to="currentSuggestion.action"
|
||||
class="ow-action"
|
||||
>
|
||||
{{ currentSuggestion.actionText }} →
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else-if="currentSuggestion.isExternal"
|
||||
:href="currentSuggestion.action"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="ow-action"
|
||||
@click="trackGoal('wikiClicked')"
|
||||
>
|
||||
{{ currentSuggestion.actionText }} →
|
||||
</a>
|
||||
<div class="ow-progress">
|
||||
<span class="ow-bar"><span class="ow-bar-fill">{{ barFill }}</span><span class="ow-bar-empty">{{ barEmpty }}</span></span>
|
||||
{{ completedCount }} of 4 explored
|
||||
<button
|
||||
v-if="currentSuggestion.key"
|
||||
type="button"
|
||||
class="ow-skip"
|
||||
@click="handleSkip"
|
||||
>Skip this</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Suggestion mode: onboarding complete -->
|
||||
<template v-else>
|
||||
<!-- Empty state -->
|
||||
<div v-if="currentSuggestion.key === 'empty'" class="ow-prompt">> look</div>
|
||||
<div v-if="currentSuggestion.key === 'empty'" class="ow-message ow-message--dim">{{ currentSuggestion.text }}</div>
|
||||
|
||||
<!-- Recommendation (event, board, or wiki) -->
|
||||
<template v-if="currentSuggestion.key !== 'empty'">
|
||||
<div class="ow-prompt">> look</div>
|
||||
<div class="ow-message">{{ currentSuggestion.text }}</div>
|
||||
<a
|
||||
v-if="currentSuggestion.isExternal && currentSuggestion.action"
|
||||
:href="currentSuggestion.action"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="ow-action"
|
||||
>
|
||||
{{ currentSuggestion.actionText }} →
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else-if="currentSuggestion.action"
|
||||
:to="currentSuggestion.action"
|
||||
class="ow-action"
|
||||
>
|
||||
{{ currentSuggestion.actionText }} →
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { goals, isComplete, currentSuggestion, trackGoal, skipSuggestion, loading } = useOnboarding()
|
||||
|
||||
const handleSkip = () => {
|
||||
const key = currentSuggestion.value?.key
|
||||
if (key) skipSuggestion(key)
|
||||
}
|
||||
|
||||
const completedCount = computed(() => {
|
||||
const g = goals.value
|
||||
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedBoard, g.hasClickedWiki]
|
||||
.filter(Boolean).length
|
||||
})
|
||||
|
||||
const barFill = computed(() => '[' + '#'.repeat(completedCount.value * 2))
|
||||
const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onboarding-widget {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px dashed var(--parch-border);
|
||||
background: var(--parch);
|
||||
color: var(--parch-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ow-prompt {
|
||||
color: var(--parch-accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ow-message {
|
||||
color: var(--parch-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ow-message--dim {
|
||||
color: var(--parch-text-dim);
|
||||
}
|
||||
|
||||
.ow-hint {
|
||||
color: var(--parch-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ow-action {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 4px 12px;
|
||||
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
|
||||
color: var(--parch-accent);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ow-action:hover {
|
||||
border-color: var(--parch-accent);
|
||||
border-style: solid;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ow-progress {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--parch-text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ow-bar {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ow-bar-fill {
|
||||
color: var(--parch-accent);
|
||||
}
|
||||
|
||||
.ow-bar-empty {
|
||||
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
|
||||
}
|
||||
|
||||
.ow-skip {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--parch-text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dashed;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.ow-skip:hover {
|
||||
color: var(--parch-accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,7 +15,7 @@ defineProps({
|
|||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 16px;
|
||||
padding: 24px 28px 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.page-header h1 {
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
<template>
|
||||
<div class="page-section" :class="`divider-${divider}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
divider: { type: String, default: 'none' }, // "top" | "bottom" | "none"
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-section {
|
||||
padding: var(--page-pad-x) var(--page-pad-x);
|
||||
}
|
||||
.page-section.divider-top {
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.page-section.divider-bottom {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<template>
|
||||
<component :is="as" class="page-shell">
|
||||
<PageHeader v-if="title" :title="title" :subtitle="subtitle" />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
as: { type: String, default: 'div' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
color: var(--parch-text);
|
||||
padding: 32px;
|
||||
margin: 0;
|
||||
border-bottom: 1px dashed var(--parch-border);
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.parchment-inset :deep(h2) {
|
||||
|
|
@ -30,6 +30,6 @@
|
|||
}
|
||||
|
||||
.parchment-inset :deep(a) {
|
||||
color: var(--parch-accent);
|
||||
color: var(--candle-faint);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
101
app/components/PeerSupportBadge.vue
Normal file
101
app/components/PeerSupportBadge.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<!-- Corner Sticker Badge -->
|
||||
<div
|
||||
v-if="type === 'sticker'"
|
||||
class="absolute top-2 right-2 z-10"
|
||||
:title="title"
|
||||
>
|
||||
<div
|
||||
class="relative transform rotate-3 hover:rotate-0 transition-transform"
|
||||
style="width: 60px; height: 66px"
|
||||
>
|
||||
<!-- Shield background -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
class="absolute inset-0 w-full h-full drop-shadow-lg"
|
||||
>
|
||||
<path
|
||||
d="M500 70 150 175.3v217.1C150 785 500 930 500 930s350-145 350-537.6V175.2L500 70Z"
|
||||
class="fill-candlelight-500"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Content on top of shield -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<Icon
|
||||
name="heroicons:chat-bubble-left-right-solid"
|
||||
class="w-6 h-6 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sparkle effect -->
|
||||
<div
|
||||
class="absolute top-0 right-1 w-2 h-2 bg-candlelight-300 rounded-full animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline Badge -->
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-all',
|
||||
variant === 'default' &&
|
||||
'bg-candlelight-900/20 text-candlelight-400 border-candlelight-500/40 hover:bg-candlelight-900/30',
|
||||
variant === 'subtle' &&
|
||||
'bg-candlelight-900/10 text-candlelight-500 border-candlelight-500/20',
|
||||
variant === 'solid' &&
|
||||
'bg-candlelight-500 text-white border-candlelight-600 hover:bg-candlelight-600',
|
||||
]"
|
||||
:title="title"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:chat-bubble-left-right"
|
||||
:class="[
|
||||
'w-3.5 h-3.5',
|
||||
variant === 'default' && 'text-candlelight-400',
|
||||
variant === 'subtle' && 'text-candlelight-500',
|
||||
variant === 'solid' && 'text-white',
|
||||
]"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Badge type - inline or corner sticker
|
||||
* @values inline, sticker
|
||||
*/
|
||||
type: {
|
||||
type: String,
|
||||
default: "inline",
|
||||
validator: (value) => ["inline", "sticker"].includes(value),
|
||||
},
|
||||
/**
|
||||
* Display variant of the badge (for inline type)
|
||||
* @values default, subtle, solid
|
||||
*/
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["default", "subtle", "solid"].includes(value),
|
||||
},
|
||||
/**
|
||||
* Custom label text (defaults to "Offering Peer Support")
|
||||
*/
|
||||
label: {
|
||||
type: String,
|
||||
default: "Offering Peer Support",
|
||||
},
|
||||
/**
|
||||
* Tooltip/title text
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
default: "This member offers 1:1 peer support sessions",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
67
app/components/PrivacyToggle.vue
Normal file
67
app/components/PrivacyToggle.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div class="priv">
|
||||
<span
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:class="{ on: modelValue === opt.value }"
|
||||
@click="$emit('update:modelValue', opt.value)"
|
||||
>{{ opt.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: String, default: 'public' },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const options = [
|
||||
{ label: 'Public', value: 'public' },
|
||||
{ label: 'Members', value: 'members' },
|
||||
{ label: 'Private', value: 'private' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.priv {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
font-size: 9px;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.priv span {
|
||||
padding: 2px 7px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.priv span + span {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.priv span:hover {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.priv span.on {
|
||||
background: var(--surface);
|
||||
color: var(--text-bright);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.priv span.on + span {
|
||||
border-left-color: var(--candle);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,16 +4,19 @@
|
|||
<div v-if="loading" class="text-center py-8">
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||
/>
|
||||
></div>
|
||||
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-state p-6">
|
||||
<h3 class="error-state__heading text-lg font-semibold mb-2">
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-ember-300 mb-2">
|
||||
Unable to Load Series Pass
|
||||
</h3>
|
||||
<p class="error-state__body">{{ error }}</p>
|
||||
<p class="text-ember-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
|
|
@ -45,7 +48,7 @@
|
|||
<!-- Registration Form -->
|
||||
<div
|
||||
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
||||
class="registration-form p-6"
|
||||
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
|
||||
>
|
||||
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
||||
{{
|
||||
|
|
@ -55,7 +58,7 @@
|
|||
}}
|
||||
</h3>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Name Field -->
|
||||
<div>
|
||||
<label
|
||||
|
|
@ -100,20 +103,18 @@
|
|||
<!-- Member Benefits Notice -->
|
||||
<div
|
||||
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
||||
class="p-4"
|
||||
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
name="heroicons:sparkles"
|
||||
class="w-5 h-5 flex-shrink-0 mt-0.5"
|
||||
style="color: var(--candle)"
|
||||
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-semibold mb-1" style="color: var(--candle)">
|
||||
<div class="font-semibold text-candlelight-300 mb-1">
|
||||
Member Benefit
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--candle)">
|
||||
<div class="text-sm text-candlelight-400">
|
||||
This series pass is free for Ghost Guild members!
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,7 +144,6 @@
|
|||
<p class="text-xs text-[--ui-text-muted] text-center">
|
||||
By registering, you'll be automatically registered for all
|
||||
{{ seriesInfo.totalEvents }} events in this series.
|
||||
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -182,7 +182,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
||||
|
||||
const toast = useToast();
|
||||
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
|
||||
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
|
||||
|
||||
// State
|
||||
const loading = ref(true);
|
||||
|
|
@ -264,9 +264,10 @@ const handleSubmit = async () => {
|
|||
paymentProcessing.value = true;
|
||||
|
||||
// Initialize Helcim payment for series pass
|
||||
await initializeSeriesTicketPayment(
|
||||
await initializeTicketPayment(
|
||||
props.seriesId,
|
||||
form.value.email,
|
||||
passInfo.value.ticket.price,
|
||||
props.seriesInfo.title,
|
||||
);
|
||||
|
||||
|
|
@ -285,7 +286,6 @@ const handleSubmit = async () => {
|
|||
const purchaseBody = {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
ticketType: passInfo.value.ticket.type,
|
||||
};
|
||||
if (transactionId) purchaseBody.paymentId = transactionId;
|
||||
|
||||
|
|
@ -297,17 +297,12 @@ const handleSubmit = async () => {
|
|||
}
|
||||
);
|
||||
|
||||
// Refresh client auth state if server signed us in (guest upgrade)
|
||||
if (purchaseResponse?.signedIn) {
|
||||
await useAuth().checkMemberStatus();
|
||||
}
|
||||
|
||||
// Show success message
|
||||
toast.add({
|
||||
title: "Series Pass Purchased!",
|
||||
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
||||
color: "green",
|
||||
duration: 5000,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Emit success event
|
||||
|
|
@ -327,7 +322,7 @@ const handleSubmit = async () => {
|
|||
title: "Purchase Failed",
|
||||
description: errorMessage,
|
||||
color: "red",
|
||||
duration: 5000,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
emit("purchase-error", errorMessage);
|
||||
|
|
@ -354,18 +349,3 @@ const formatPrice = (price, currency = "CAD") => {
|
|||
}).format(price);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-state {
|
||||
background: color-mix(in srgb, var(--ember) 8%, transparent);
|
||||
border: 1px dashed var(--ember);
|
||||
}
|
||||
.error-state__heading,
|
||||
.error-state__body {
|
||||
color: var(--ember);
|
||||
}
|
||||
.registration-form {
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="state !== 'idle'" class="signup-flow-overlay">
|
||||
<div class="signup-flow-card">
|
||||
<div class="signup-flow-step">{{ stepLabel }}</div>
|
||||
|
||||
<template v-if="isProgress">
|
||||
<h2 class="signup-flow-heading">{{ progressHeading }}</h2>
|
||||
<p class="signup-flow-body">
|
||||
Please don't close this window. This usually takes a few seconds.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-if="state === 'success'">
|
||||
<h2 class="signup-flow-heading">Welcome to Ghost Guild!</h2>
|
||||
<DashedBox :hoverable="false">
|
||||
<div class="section-label" style="margin-bottom: 12px">
|
||||
Membership Details
|
||||
</div>
|
||||
<dl class="details-list">
|
||||
<div class="details-row">
|
||||
<dt>Name</dt><dd>{{ summary?.name }}</dd>
|
||||
</div>
|
||||
<div class="details-row">
|
||||
<dt>Email</dt><dd>{{ summary?.email }}</dd>
|
||||
</div>
|
||||
<div class="details-row">
|
||||
<dt>Circle</dt><dd class="capitalize">{{ summary?.circle }}</dd>
|
||||
</div>
|
||||
<div class="details-row">
|
||||
<dt>Contribution</dt><dd>{{ summary?.contribution }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</DashedBox>
|
||||
<p class="signup-flow-body" style="margin-top: 16px">
|
||||
Check {{ summary?.email }} for a sign-in link to finish setting up
|
||||
your account. The link expires in 15 minutes.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-if="state === 'error'">
|
||||
<h2 class="signup-flow-heading">We couldn't complete your signup</h2>
|
||||
<div v-if="errorMessage" class="error-box">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div class="button-row" style="margin-top: 20px">
|
||||
<button class="btn" @click="$emit('close')">
|
||||
Back to form
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
state: { type: String, required: true },
|
||||
summary: { type: Object, default: null },
|
||||
errorMessage: { type: String, default: "" },
|
||||
dashboardHref: { type: String, default: "/welcome" },
|
||||
});
|
||||
|
||||
defineEmits(["close"]);
|
||||
|
||||
const PROGRESS_STATES = [
|
||||
"creating-customer",
|
||||
"opening-payment",
|
||||
"processing-payment",
|
||||
"creating-subscription",
|
||||
];
|
||||
|
||||
const isProgress = computed(() => PROGRESS_STATES.includes(props.state));
|
||||
|
||||
const progressHeading = computed(() => {
|
||||
switch (props.state) {
|
||||
case "creating-customer": return "Creating your account...";
|
||||
case "opening-payment": return "Opening secure payment...";
|
||||
case "processing-payment": return "Confirming your card...";
|
||||
case "creating-subscription": return "Activating your membership...";
|
||||
default: return "";
|
||||
}
|
||||
});
|
||||
|
||||
const stepLabel = computed(() => {
|
||||
switch (props.state) {
|
||||
case "creating-customer":
|
||||
case "opening-payment":
|
||||
return "Step 2 of 3 — Payment";
|
||||
case "processing-payment":
|
||||
case "creating-subscription":
|
||||
return "Step 2 of 3 — Finalizing";
|
||||
case "success":
|
||||
return "Step 3 of 3 — Welcome";
|
||||
case "error":
|
||||
return "Something went wrong";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signup-flow-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--parch) 72%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.signup-flow-card {
|
||||
background: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 32px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.signup-flow-step {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.signup-flow-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-bright);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.signup-flow-body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.details-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.details-row dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.details-row dd {
|
||||
color: var(--text-bright);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
border: 1px dashed var(--ember);
|
||||
color: var(--ember);
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
104
app/components/TagInput.vue
Normal file
104
app/components/TagInput.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<div class="tags" @click="focusInput">
|
||||
<span v-for="(tag, i) in modelValue" :key="tag" class="tag">
|
||||
{{ tag }}
|
||||
<span class="rm" @click.stop="removeTag(i)">×</span>
|
||||
</span>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="newTag"
|
||||
@keydown.enter.prevent="addTag"
|
||||
@keydown.backspace="handleBackspace"
|
||||
:placeholder="modelValue?.length ? '' : placeholder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Add tag...' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const input = ref(null)
|
||||
const newTag = ref('')
|
||||
|
||||
const focusInput = () => {
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = newTag.value.trim()
|
||||
if (tag && !props.modelValue.includes(tag)) {
|
||||
emit('update:modelValue', [...props.modelValue, tag])
|
||||
}
|
||||
newTag.value = ''
|
||||
}
|
||||
|
||||
const removeTag = (index) => {
|
||||
const tags = [...props.modelValue]
|
||||
tags.splice(index, 1)
|
||||
emit('update:modelValue', tags)
|
||||
}
|
||||
|
||||
const handleBackspace = () => {
|
||||
if (!newTag.value && props.modelValue.length) {
|
||||
removeTag(props.modelValue.length - 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 3px 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
background: var(--bg);
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tags:focus-within {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
border: 1px dashed var(--border-d);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.rm {
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rm:hover {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.tags input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 1px 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
<template>
|
||||
<UModal v-model:open="open" :title="`Suggest a ${pool} tag`" :dismissible="true">
|
||||
<template #body>
|
||||
<div class="suggest-modal-body">
|
||||
<div v-if="success" class="success-msg">
|
||||
Thanks! We'll review your suggestion.
|
||||
</div>
|
||||
<form v-else @submit.prevent="submit" class="suggest-form">
|
||||
<div class="field">
|
||||
<label>Tag name</label>
|
||||
<input
|
||||
v-model="tagName"
|
||||
type="text"
|
||||
placeholder="e.g., Game Narrative Design"
|
||||
required
|
||||
:disabled="submitting"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting || !tagName.trim()">
|
||||
{{ submitting ? "Sending..." : "Submit suggestion" }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="open = false">Cancel</button>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
pool: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const open = defineModel("open", { default: false });
|
||||
|
||||
const tagName = ref("");
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
watch(open, (val) => {
|
||||
if (!val) {
|
||||
// reset state when closed
|
||||
tagName.value = "";
|
||||
submitting.value = false;
|
||||
success.value = false;
|
||||
error.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!tagName.value.trim()) return;
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await $fetch("/api/tags/suggest", {
|
||||
method: "POST",
|
||||
body: { label: tagName.value.trim(), pool: props.pool },
|
||||
});
|
||||
success.value = true;
|
||||
} catch (e) {
|
||||
error.value = e?.data?.message || "Something went wrong. Please try again.";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.suggest-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.suggest-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
font-size: 12px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--green);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--ember);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
98
app/components/TierPicker.vue
Normal file
98
app/components/TierPicker.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="tier-picker">
|
||||
<div
|
||||
v-for="tier in tiers"
|
||||
:key="tier.amount"
|
||||
class="tier-option"
|
||||
:class="{ current: modelValue === tier.amount }"
|
||||
@click="$emit('update:modelValue', tier.amount)"
|
||||
>
|
||||
<span class="tier-amount">{{ tier.display }}</span>
|
||||
<span class="tier-label">{{ tier.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Number, default: 0 },
|
||||
tiers: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ amount: 0, display: '$0', label: 'Free' },
|
||||
{ amount: 5, display: '$5', label: '/month' },
|
||||
{ amount: 15, display: '$15', label: '/month' },
|
||||
{ amount: 30, display: '$30', label: '/month' },
|
||||
{ amount: 50, display: '$50', label: '/month' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tier-picker {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tier-option {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tier-option + .tier-option {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tier-option:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tier-option.current {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tier-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-family: 'Brygada 1918', serif;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tier-option.current .tier-amount {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.tier-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tier-option.current .tier-label {
|
||||
color: var(--candle-dim);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tier-picker {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tier-option {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,73 +1,23 @@
|
|||
<template>
|
||||
<div class="top-strip">
|
||||
<span>
|
||||
<slot name="left">
|
||||
<span class="breadcrumb-nav">
|
||||
<NuxtLink to="/" class="breadcrumb-link">ghostguild.org</NuxtLink>
|
||||
<template v-for="(crumb, i) in breadcrumbs" :key="i">
|
||||
<span class="breadcrumb-sep"> / </span>
|
||||
<NuxtLink
|
||||
v-if="i < breadcrumbs.length - 1"
|
||||
:to="crumb.path"
|
||||
class="breadcrumb-link"
|
||||
>{{ crumb.label }}</NuxtLink
|
||||
>
|
||||
<ClientOnly v-else>
|
||||
<span class="breadcrumb-current">{{ crumb.label }}</span>
|
||||
<template #fallback>
|
||||
<span class="breadcrumb-current"> </span>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="left">ghostguild.org{{ pagePath ? ` / ${pagePath}` : '' }}</slot>
|
||||
</span>
|
||||
<span class="right">
|
||||
<span>
|
||||
<slot name="right">
|
||||
<ClientOnly>
|
||||
<template v-if="memberData">
|
||||
<NuxtLink to="/member/profile" class="member-link">
|
||||
<img
|
||||
v-if="memberData.avatar"
|
||||
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
||||
:alt="memberData.name"
|
||||
class="member-avatar"
|
||||
>
|
||||
<svg
|
||||
v-else
|
||||
class="member-avatar default-ghost"
|
||||
viewBox="0 0 136 129"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="59.75 0 59.75 1.794 50.792 1.794 50.792 3.585 43.627 3.585 43.627 7.169 34.669 7.169 34.669 10.752 27.5 10.752 27.5 16.127 22.125 16.127 22.125 21.502 16.752 21.502 16.752 28.668 13.167 28.668 13.167 37.626 9.583 37.626 9.583 44.791 7.794 44.791 7.794 53.749 6 53.749 6 75.251 7.794 75.251 7.794 84.209 9.583 84.209 9.583 91.376 13.167 91.376 13.167 100.334 16.752 100.334 16.752 107.498 22.125 107.498 22.125 112.873 27.5 112.873 27.5 118.25 34.669 118.25 34.669 121.831 43.627 121.831 43.627 125.415 50.792 125.415 50.792 127.208 59.75 127.208 59.75 129 81.25 129 81.25 127.208 90.208 127.208 90.208 125.415 97.377 125.415 97.377 121.831 106.335 121.831 106.335 118.25 113.5 118.25 113.5 112.873 118.875 112.873 118.875 107.498 124.252 107.498 124.252 100.334 127.833 100.334 127.833 91.376 131.417 91.376 131.417 84.209 133.21 84.209 133.21 75.251 135 75.251 135 53.749 133.21 53.749 133.21 44.791 131.417 44.791 131.417 37.626 127.833 37.626 127.833 28.668 124.252 28.668 124.252 21.502 118.875 21.502 118.875 16.127 113.5 16.127 113.5 10.752 106.335 10.752 106.335 7.169 97.377 7.169 97.377 3.585 90.208 3.585 90.208 1.794 81.25 1.794 81.25 0"
|
||||
/>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="1.356 82 1.356 83.308 0 83.308 0 98.999 1.356 98.999 1.356 100.309 9.501 100.309 9.501 104.231 8.143 104.231 8.143 106.847 1.356 106.847 1.356 108.154 0 108.154 0 114.694 1.356 114.694 1.356 116 10.855 116 10.855 114.694 13.57 114.694 13.57 112.08 16.285 112.08 16.285 109.464 17.644 109.464 17.644 104.231 19 104.231 19 83.308 17.644 83.308 17.644 82"
|
||||
/>
|
||||
<g transform="translate(50, 38)" fill="#000">
|
||||
<polygon
|
||||
points="7.072 0.642 7.072 2.569 7.714 2.569 7.714 4.499 8.358 4.499 8.358 6.427 9 6.427 9 8.356 8.358 8.356 8.358 9 4.501 9 4.501 8.356 3.859 8.356 3.859 6.427 2.571 6.427 2.571 4.499 1.286 4.499 1.286 2.569 0 2.569 0 0.642 0.642 0.642 0.642 0 6.431 0 6.431 0.642"
|
||||
/>
|
||||
<polygon
|
||||
points="40.395 25 40.395 25.599 41 25.599 41 30.399 40.395 30.399 40.395 31 21.605 31 21.605 30.399 21 30.399 21 25.599 21.605 25.599 21.605 25"
|
||||
/>
|
||||
<polygon
|
||||
points="52.072 0.642 52.072 2.569 52.714 2.569 52.714 4.499 53.358 4.499 53.358 6.427 54 6.427 54 8.356 53.358 8.356 53.358 9 49.501 9 49.501 8.356 48.859 8.356 48.859 6.427 47.571 6.427 47.571 4.499 46.286 4.499 46.286 2.569 45 2.569 45 0.642 45.642 0.642 45.642 0 51.431 0 51.431 0.642"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{{ memberData.name }}
|
||||
</NuxtLink>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<a href="#" class="sign-out" @click.prevent="handleLogout"
|
||||
>sign out</a
|
||||
>
|
||||
Signed in as {{ memberData.name }}
|
||||
<template v-if="memberData.circle">
|
||||
· {{ memberData.circle }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
A cooperative for game developers
|
||||
</template>
|
||||
<template #fallback>
|
||||
A cooperative for game developers
|
||||
</template>
|
||||
<template v-else> The Baby Ghosts member program </template>
|
||||
<template #fallback> The Baby Ghosts member program </template>
|
||||
</ClientOnly>
|
||||
</slot>
|
||||
</span>
|
||||
|
|
@ -75,38 +25,16 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: "" },
|
||||
});
|
||||
defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const { memberData, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigateTo("/");
|
||||
};
|
||||
|
||||
const capitalize = (str) => {
|
||||
if (!str) return "";
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
if (!props.pagePath) return [];
|
||||
const segments = props.pagePath.split(" / ");
|
||||
let path = "";
|
||||
return segments.map((segment) => {
|
||||
path += "/" + segment.replace(/\s+/g, "-");
|
||||
const label = segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
return { label, path };
|
||||
});
|
||||
});
|
||||
const { memberData } = useAuth()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-strip {
|
||||
padding: 0 32px;
|
||||
min-height: 53px;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
|
|
@ -114,62 +42,6 @@ const breadcrumbs = computed(() => {
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.top-strip a {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.top-strip a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
.member-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.member-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.member-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.default-ghost {
|
||||
color: var(--border);
|
||||
}
|
||||
.right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sep {
|
||||
color: var(--text-faint);
|
||||
margin: 0 8px;
|
||||
}
|
||||
.top-strip a.sign-out {
|
||||
font-size: 12px;
|
||||
color: var(--ember);
|
||||
text-decoration: none;
|
||||
}
|
||||
.top-strip a.sign-out:hover {
|
||||
color: var(--ember);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-nav {
|
||||
display: inline;
|
||||
}
|
||||
.breadcrumb-link {
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.breadcrumb-current {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.top-strip a { color: var(--text-faint); }
|
||||
.top-strip a:hover { color: var(--candle); }
|
||||
</style>
|
||||
|
|
|
|||
191
app/components/UpdateCard.vue
Normal file
191
app/components/UpdateCard.vue
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<template>
|
||||
<UCard variant="outline" class="update-card">
|
||||
<div class="flex gap-4">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="update.author?.avatar"
|
||||
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
|
||||
:alt="update.author.name"
|
||||
class="w-12 h-12 rounded-full"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-12 rounded-full bg-guild-700 flex items-center justify-center text-guild-300 font-bold"
|
||||
>
|
||||
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<h3 class="font-semibold text-guild-100">
|
||||
<NuxtLink
|
||||
v-if="update.author?._id"
|
||||
:to="`/updates/user/${update.author._id}`"
|
||||
class="hover:text-guild-300 transition-colors"
|
||||
>
|
||||
{{ update.author.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>Unknown Member</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-guild-400">
|
||||
<time :datetime="update.createdAt">
|
||||
{{ formatDate(update.createdAt) }}
|
||||
</time>
|
||||
<span v-if="isEdited" class="text-guild-500">(edited)</span>
|
||||
<span
|
||||
v-if="update.privacy === 'private'"
|
||||
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
|
||||
>
|
||||
Private
|
||||
</span>
|
||||
<span
|
||||
v-if="update.privacy === 'public'"
|
||||
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
|
||||
>
|
||||
Public
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions (for author only) -->
|
||||
<div v-if="isAuthor" class="flex gap-2">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
icon="i-lucide-edit"
|
||||
aria-label="Edit update"
|
||||
@click="$emit('edit', update)"
|
||||
/>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
icon="i-lucide-trash-2"
|
||||
aria-label="Delete update"
|
||||
@click="$emit('delete', update)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="text-guild-200 whitespace-pre-wrap break-words mb-3">
|
||||
<template v-if="showPreview && update.content.length > 300">
|
||||
{{ update.content.substring(0, 300) }}...
|
||||
<NuxtLink
|
||||
:to="`/updates/${update._id}`"
|
||||
class="text-guild-400 hover:text-guild-300 ml-1"
|
||||
>
|
||||
Read more
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ update.content }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Images (if any) -->
|
||||
<div v-if="update.images?.length" class="mb-3 space-y-2">
|
||||
<img
|
||||
v-for="(image, index) in update.images"
|
||||
:key="index"
|
||||
:src="image.url"
|
||||
:alt="image.alt || 'Update image'"
|
||||
class="rounded-lg max-w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="flex items-center gap-4 text-sm text-guild-400">
|
||||
<NuxtLink
|
||||
:to="`/updates/${update._id}`"
|
||||
class="hover:text-guild-300 transition-colors"
|
||||
>
|
||||
View full update
|
||||
</NuxtLink>
|
||||
<span v-if="update.commentsEnabled" class="text-guild-500">
|
||||
Comments (coming soon)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
update: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showPreview: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["edit", "delete"]);
|
||||
|
||||
const { memberData } = useAuth();
|
||||
|
||||
const isAuthor = computed(() => {
|
||||
return memberData.value && props.update.author?._id === memberData.value.id;
|
||||
});
|
||||
|
||||
const isEdited = computed(() => {
|
||||
const created = new Date(props.update.createdAt).getTime();
|
||||
const updated = new Date(props.update.updatedAt).getTime();
|
||||
return updated - created > 1000; // More than 1 second difference
|
||||
});
|
||||
|
||||
const capitalize = (str) => {
|
||||
if (!str) return "";
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
const handleImageError = (e) => {
|
||||
e.target.src = "/ghosties/Ghost-Mild.png"; // Fallback ghost
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
const now = new Date();
|
||||
const updateDate = new Date(date);
|
||||
const diffInSeconds = Math.floor((now - updateDate) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "just now";
|
||||
if (diffInSeconds < 3600)
|
||||
return `${Math.floor(diffInSeconds / 60)} minutes ago`;
|
||||
if (diffInSeconds < 86400)
|
||||
return `${Math.floor(diffInSeconds / 3600)} hours ago`;
|
||||
if (diffInSeconds < 604800)
|
||||
return `${Math.floor(diffInSeconds / 86400)} days ago`;
|
||||
|
||||
return updateDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year:
|
||||
updateDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-card {
|
||||
background-color: var(--color-guild-800);
|
||||
border-color: var(--color-guild-600);
|
||||
}
|
||||
|
||||
.update-card:hover {
|
||||
border-color: var(--color-guild-500);
|
||||
}
|
||||
|
||||
:deep(.card) {
|
||||
background-color: var(--color-guild-800);
|
||||
}
|
||||
</style>
|
||||
184
app/components/UpdateForm.vue
Normal file
184
app/components/UpdateForm.vue
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<UFormField label="What's on your mind?" name="content" required>
|
||||
<UTextarea
|
||||
v-model="formData.content"
|
||||
placeholder="Share your thoughts, updates, questions, or learnings with the community..."
|
||||
:rows="8"
|
||||
autoresize
|
||||
:maxrows="20"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Privacy Settings -->
|
||||
<div class="border border-guild-700 rounded-lg p-4 bg-guild-800/30">
|
||||
<h3 class="text-sm font-medium text-guild-200 mb-4">Privacy Settings</h3>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="formData.privacy"
|
||||
type="radio"
|
||||
value="public"
|
||||
class="w-4 h-4 text-guild-400"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-guild-200 font-medium">Public</div>
|
||||
<div class="text-sm text-guild-400">
|
||||
Visible to everyone, including non-members
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="formData.privacy"
|
||||
type="radio"
|
||||
value="members"
|
||||
class="w-4 h-4 text-guild-400"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-guild-200 font-medium">Members Only</div>
|
||||
<div class="text-sm text-guild-400">
|
||||
Only visible to Ghost Guild members
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="formData.privacy"
|
||||
type="radio"
|
||||
value="private"
|
||||
class="w-4 h-4 text-guild-400"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-guild-200 font-medium">Private</div>
|
||||
<div class="text-sm text-guild-400">Only visible to you</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Upload (Future) -->
|
||||
<!-- TODO: Add image upload integration with Cloudinary -->
|
||||
|
||||
<!-- Comments Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<USwitch v-model="formData.commentsEnabled" />
|
||||
<div>
|
||||
<div class="text-guild-200 font-medium">Enable Comments</div>
|
||||
<div class="text-sm text-guild-400">
|
||||
Allow members to comment on this update
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div
|
||||
class="flex justify-between items-center pt-4 border-t border-guild-700"
|
||||
>
|
||||
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
:loading="submitting"
|
||||
:disabled="!formData.content.trim()"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="bg-ember-900/20 border border-ember-400/30 rounded-lg p-4"
|
||||
>
|
||||
<p class="text-ember-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
content: "",
|
||||
privacy: "members",
|
||||
commentsEnabled: true,
|
||||
images: [],
|
||||
}),
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: "Post Update",
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["submit", "cancel"]);
|
||||
|
||||
const formData = reactive({
|
||||
content: props.initialData.content || "",
|
||||
privacy: props.initialData.privacy || "members",
|
||||
commentsEnabled: props.initialData.commentsEnabled ?? true,
|
||||
images: props.initialData.images || [],
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.content.trim()) return;
|
||||
emit("submit", { ...formData });
|
||||
};
|
||||
|
||||
// Watch for initialData changes (for edit mode)
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(newData) => {
|
||||
if (newData) {
|
||||
formData.content = newData.content || "";
|
||||
formData.privacy = newData.privacy || "members";
|
||||
formData.commentsEnabled = newData.commentsEnabled ?? true;
|
||||
formData.images = newData.images || [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Field labels */
|
||||
:deep(label) {
|
||||
color: var(--color-guild-200) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Textarea styling */
|
||||
:deep(textarea) {
|
||||
background-color: var(--color-guild-800) !important;
|
||||
color: var(--color-guild-200) !important;
|
||||
border-color: var(--color-guild-600) !important;
|
||||
}
|
||||
|
||||
:deep(textarea::placeholder) {
|
||||
color: var(--color-guild-500) !important;
|
||||
}
|
||||
|
||||
:deep(textarea:focus) {
|
||||
border-color: var(--color-guild-400) !important;
|
||||
background-color: var(--color-guild-700) !important;
|
||||
}
|
||||
|
||||
/* Radio buttons */
|
||||
input[type="radio"] {
|
||||
accent-color: var(--color-candlelight-600);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
<!-- app/components/admin/AdminAlertsPanel.vue -->
|
||||
<template>
|
||||
<div v-if="hasContent" class="alerts-panel">
|
||||
<div class="section-label">Needs Attention</div>
|
||||
|
||||
<div
|
||||
v-for="alert in visibleAlerts"
|
||||
:key="alert.type"
|
||||
class="alert-row"
|
||||
:class="`severity-${alert.severity}`"
|
||||
>
|
||||
<div class="alert-head">
|
||||
<div>
|
||||
<span class="alert-title">{{ alert.title }}</span>
|
||||
<span class="alert-count">{{ alert.count }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss-btn"
|
||||
:disabled="dismissing[alert.type]"
|
||||
@click="dismissAlert(alert)"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="alert.items.length" class="alert-items">
|
||||
<li v-for="(item, idx) in displayItems(alert)" :key="item.id || idx">
|
||||
<NuxtLink v-if="item.href" :to="item.href">{{ item.label }}</NuxtLink>
|
||||
<span v-else>{{ item.label }}</span>
|
||||
<span v-if="item.sublabel" class="alert-item-sub">— {{ item.sublabel }}</span>
|
||||
</li>
|
||||
<li v-if="alert.items.length > maxItems" class="alert-more">
|
||||
and {{ alert.items.length - maxItems }} more
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="!visibleAlerts.length" class="empty-active">
|
||||
No active alerts.
|
||||
</div>
|
||||
|
||||
<div v-if="dismissedAlerts.length" class="restore-section">
|
||||
<button
|
||||
v-if="!restoreOpen"
|
||||
type="button"
|
||||
class="restore-toggle"
|
||||
@click="restoreOpen = true"
|
||||
>
|
||||
Restore dismissed ({{ dismissedAlerts.length }})
|
||||
</button>
|
||||
<div v-else class="restore-panel">
|
||||
<div class="section-label restore-label">Restore dismissed alerts</div>
|
||||
<ul class="restore-list">
|
||||
<li v-for="d in dismissedAlerts" :key="d.alertType">
|
||||
<label class="restore-option">
|
||||
<input
|
||||
v-model="selectedRestore"
|
||||
type="checkbox"
|
||||
:value="d.alertType"
|
||||
/>
|
||||
<span>{{ d.title }}</span>
|
||||
<span class="restore-when">dismissed {{ formatDismissedAt(d.dismissedAt) }}</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="restore-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss-btn"
|
||||
:disabled="!selectedRestore.length || restoring"
|
||||
@click="restoreSelected"
|
||||
>
|
||||
Restore selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss-btn"
|
||||
@click="cancelRestore"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const maxItems = 5
|
||||
const dismissing = reactive({})
|
||||
const restoreOpen = ref(false)
|
||||
const selectedRestore = ref([])
|
||||
const restoring = ref(false)
|
||||
|
||||
const { data, refresh } = await useFetch('/api/admin/alerts', {
|
||||
default: () => ({ alerts: [] })
|
||||
})
|
||||
const { data: dismissedData, refresh: refreshDismissed } = await useFetch(
|
||||
'/api/admin/alerts/dismissed',
|
||||
{ default: () => ({ dismissed: [] }) }
|
||||
)
|
||||
|
||||
const visibleAlerts = computed(() => data.value?.alerts || [])
|
||||
const dismissedAlerts = computed(() => dismissedData.value?.dismissed || [])
|
||||
const hasContent = computed(
|
||||
() => visibleAlerts.value.length > 0 || dismissedAlerts.value.length > 0
|
||||
)
|
||||
|
||||
function displayItems(alert) {
|
||||
return alert.items.slice(0, maxItems)
|
||||
}
|
||||
|
||||
async function dismissAlert(alert) {
|
||||
dismissing[alert.type] = true
|
||||
try {
|
||||
await $fetch('/api/admin/alerts/dismiss', {
|
||||
method: 'POST',
|
||||
body: { alertType: alert.type, signature: alert.signature }
|
||||
})
|
||||
// Refetch both lists so the dismissed alert appears in the restore list
|
||||
await Promise.all([refresh(), refreshDismissed()])
|
||||
} catch (err) {
|
||||
console.error('Failed to dismiss alert', err)
|
||||
await refresh()
|
||||
} finally {
|
||||
dismissing[alert.type] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSelected() {
|
||||
if (!selectedRestore.value.length) return
|
||||
restoring.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/alerts/restore', {
|
||||
method: 'POST',
|
||||
body: { alertTypes: selectedRestore.value }
|
||||
})
|
||||
selectedRestore.value = []
|
||||
restoreOpen.value = false
|
||||
await Promise.all([refresh(), refreshDismissed()])
|
||||
} catch (err) {
|
||||
console.error('Failed to restore alerts', err)
|
||||
} finally {
|
||||
restoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRestore() {
|
||||
selectedRestore.value = []
|
||||
restoreOpen.value = false
|
||||
}
|
||||
|
||||
function formatDismissedAt(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
const now = Date.now()
|
||||
const diffMin = Math.round((now - d.getTime()) / 60000)
|
||||
if (diffMin < 1) return 'just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffH = Math.round(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH}h ago`
|
||||
const diffD = Math.round(diffH / 24)
|
||||
return `${diffD}d ago`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alerts-panel {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert-row {
|
||||
border: 1px dashed var(--border);
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-row.severity-critical {
|
||||
border-left-color: var(--ember);
|
||||
}
|
||||
|
||||
.alert-row.severity-attention {
|
||||
border-left-color: var(--candle);
|
||||
}
|
||||
|
||||
.alert-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-count {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover:not(:disabled) {
|
||||
border-color: var(--candle);
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.dismiss-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.alert-items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.alert-items li {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.alert-items a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.alert-items a:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert-item-sub {
|
||||
color: var(--text-faint);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.alert-more {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-active {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.restore-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.restore-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-decoration: underline dashed;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.restore-toggle:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.restore-panel {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.restore-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.restore-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.restore-list li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.restore-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.restore-option input[type='checkbox'] {
|
||||
accent-color: var(--candle);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.restore-when {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.restore-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export function useBoardChannels() {
|
||||
const channels = useState('board.channels', () => [])
|
||||
|
||||
async function fetchChannels() {
|
||||
const result = await $fetch('/api/board/channels')
|
||||
channels.value = result?.channels || []
|
||||
return channels.value
|
||||
}
|
||||
|
||||
function slackUrl(channelId) {
|
||||
return `https://gammaspace.slack.com/archives/${channelId}`
|
||||
}
|
||||
|
||||
return {
|
||||
channels: readonly(channels),
|
||||
fetchChannels,
|
||||
slackUrl,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
export function useBoardPosts() {
|
||||
const posts = useState('board.posts', () => [])
|
||||
const loading = useState('board.loading', () => false)
|
||||
|
||||
async function fetchPosts(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await $fetch('/api/board/posts', { params })
|
||||
posts.value = result?.posts || []
|
||||
return posts.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createPost(body) {
|
||||
const created = await $fetch('/api/board/posts', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
await fetchPosts()
|
||||
return created
|
||||
}
|
||||
|
||||
async function updatePost(id, body) {
|
||||
const updated = await $fetch(`/api/board/posts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
await fetchPosts()
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deletePost(id) {
|
||||
const result = await $fetch(`/api/board/posts/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await fetchPosts()
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
posts: readonly(posts),
|
||||
loading: readonly(loading),
|
||||
fetchPosts,
|
||||
createPost,
|
||||
updatePost,
|
||||
deletePost,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +1,85 @@
|
|||
// Utility composable for event date handling with timezone support.
|
||||
// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ.
|
||||
// Utility composable for event date handling with timezone support
|
||||
export const useEventDateUtils = () => {
|
||||
const DEFAULT_TIMEZONE = "America/Toronto";
|
||||
const TIMEZONE = "America/Toronto";
|
||||
|
||||
// Format a date to a specific format
|
||||
const formatDate = (date, options = {}) => {
|
||||
if (!date) return "";
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
const {
|
||||
month = "short",
|
||||
day = "numeric",
|
||||
year = "numeric",
|
||||
weekday,
|
||||
timeZone,
|
||||
} = options;
|
||||
const { month = "short", day = "numeric", year = "numeric" } = options;
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
...(weekday && { weekday }),
|
||||
month,
|
||||
day,
|
||||
year,
|
||||
...(timeZone && { timeZone }),
|
||||
}).format(dateObj);
|
||||
};
|
||||
|
||||
const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
|
||||
// Format event date range
|
||||
const formatDateRange = (startDate, endDate, compact = false) => {
|
||||
if (!startDate || !endDate) return "No dates";
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const tzOpts = timeZone ? { timeZone } : {};
|
||||
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||
const startDay = Number(
|
||||
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||
);
|
||||
const endDay = Number(
|
||||
end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||
);
|
||||
const year = Number(
|
||||
end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||
);
|
||||
const startMonthIdx = startMonth; // compared as label string
|
||||
const endMonthIdx = endMonth;
|
||||
const startYear = Number(
|
||||
start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||
);
|
||||
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
|
||||
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
|
||||
const startDay = start.getDate();
|
||||
const endDay = end.getDate();
|
||||
const year = end.getFullYear();
|
||||
|
||||
if (compact) {
|
||||
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||
if (
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getFullYear() === end.getFullYear()
|
||||
) {
|
||||
return `${startMonth} ${startDay}-${endDay}`;
|
||||
}
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
|
||||
}
|
||||
|
||||
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||
if (
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getFullYear() === end.getFullYear()
|
||||
) {
|
||||
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||
} else if (startYear === year) {
|
||||
} else if (start.getFullYear() === end.getFullYear()) {
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||
} else {
|
||||
return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`;
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if a date is in the past
|
||||
const isPastDate = (date) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return dateObj < new Date();
|
||||
const now = new Date();
|
||||
return dateObj < now;
|
||||
};
|
||||
|
||||
const isToday = (date, timeZone) => {
|
||||
// Check if a date is today
|
||||
const isToday = (date) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const today = new Date();
|
||||
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
|
||||
return (
|
||||
dateObj.toLocaleDateString("en-US", opts) ===
|
||||
today.toLocaleDateString("en-US", opts)
|
||||
dateObj.getDate() === today.getDate() &&
|
||||
dateObj.getMonth() === today.getMonth() &&
|
||||
dateObj.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (date, includeSeconds = false, timeZone) => {
|
||||
// Get a readable time string
|
||||
const formatTime = (date, includeSeconds = false) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
const options = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(includeSeconds && { second: "2-digit" }),
|
||||
...(timeZone && { timeZone }),
|
||||
}).format(dateObj);
|
||||
};
|
||||
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
|
||||
};
|
||||
|
||||
return {
|
||||
DEFAULT_TIMEZONE,
|
||||
// Legacy alias for callers that hard-coded the constant.
|
||||
TIMEZONE: DEFAULT_TIMEZONE,
|
||||
TIMEZONE,
|
||||
formatDate,
|
||||
formatDateRange,
|
||||
isPastDate,
|
||||
|
|
|
|||
90
app/composables/useHelcim.js
Normal file
90
app/composables/useHelcim.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Helcim API integration composable
|
||||
export const useHelcim = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const helcimToken = config.public.helcimToken
|
||||
|
||||
// Base URL for Helcim API
|
||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||
|
||||
// Helper function to make API requests
|
||||
const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
|
||||
try {
|
||||
const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'api-token': helcimToken
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Helcim API error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Create a customer
|
||||
const createCustomer = async (customerData) => {
|
||||
return await makeHelcimRequest('/customers', 'POST', {
|
||||
customerType: 'PERSON',
|
||||
contactName: customerData.name,
|
||||
email: customerData.email,
|
||||
billingAddress: customerData.billingAddress || {}
|
||||
})
|
||||
}
|
||||
|
||||
// Create a subscription
|
||||
const createSubscription = async (customerId, planId, cardToken) => {
|
||||
return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
|
||||
customerId,
|
||||
planId,
|
||||
cardToken,
|
||||
startDate: new Date().toISOString().split('T')[0] // Today's date
|
||||
})
|
||||
}
|
||||
|
||||
// Get customer details
|
||||
const getCustomer = async (customerId) => {
|
||||
return await makeHelcimRequest(`/customers/${customerId}`)
|
||||
}
|
||||
|
||||
// Get subscription details
|
||||
const getSubscription = async (subscriptionId) => {
|
||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
|
||||
}
|
||||
|
||||
// Update subscription
|
||||
const updateSubscription = async (subscriptionId, updates) => {
|
||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
|
||||
}
|
||||
|
||||
// Cancel subscription
|
||||
const cancelSubscription = async (subscriptionId) => {
|
||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
|
||||
}
|
||||
|
||||
// Get payment plans
|
||||
const getPaymentPlans = async () => {
|
||||
return await makeHelcimRequest('/recurring/plans')
|
||||
}
|
||||
|
||||
// Verify card token (for testing)
|
||||
const verifyCardToken = async (cardToken) => {
|
||||
return await makeHelcimRequest('/cards/verify', 'POST', {
|
||||
cardToken
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
createCustomer,
|
||||
createSubscription,
|
||||
getCustomer,
|
||||
getSubscription,
|
||||
updateSubscription,
|
||||
cancelSubscription,
|
||||
getPaymentPlans,
|
||||
verifyCardToken
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ export const useHelcimPay = () => {
|
|||
let checkoutToken = null;
|
||||
let secretToken = null;
|
||||
|
||||
// Initialize HelcimPay.js session (membership signup flow)
|
||||
// Initialize HelcimPay.js session
|
||||
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
||||
try {
|
||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||
|
|
@ -12,7 +12,6 @@ export const useHelcimPay = () => {
|
|||
customerId,
|
||||
customerCode,
|
||||
amount,
|
||||
metadata: { type: "membership_signup" },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -29,14 +28,26 @@ export const useHelcimPay = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _initializeTicket = async (metadata, errorPrefix) => {
|
||||
// Initialize payment for event ticket purchase
|
||||
const initializeTicketPayment = async (
|
||||
eventId,
|
||||
email,
|
||||
amount,
|
||||
eventTitle = null,
|
||||
) => {
|
||||
try {
|
||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||
method: "POST",
|
||||
body: {
|
||||
customerId: null,
|
||||
customerCode: metadata.email,
|
||||
metadata,
|
||||
customerCode: email, // Use email as customer code for event tickets
|
||||
amount,
|
||||
metadata: {
|
||||
type: "event_ticket",
|
||||
eventId,
|
||||
email,
|
||||
eventTitle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -46,29 +57,16 @@ export const useHelcimPay = () => {
|
|||
return {
|
||||
success: true,
|
||||
checkoutToken: response.checkoutToken,
|
||||
amount: response.amount,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Failed to initialize ${errorPrefix} session`);
|
||||
throw new Error("Failed to initialize ticket payment session");
|
||||
} catch (error) {
|
||||
console.error(`${errorPrefix} initialization error:`, error);
|
||||
console.error("Ticket payment initialization error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeTicketPayment = (eventId, email, eventTitle = null) =>
|
||||
_initializeTicket(
|
||||
{ type: "event_ticket", eventId, email, eventTitle },
|
||||
"ticket payment",
|
||||
);
|
||||
|
||||
const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
|
||||
_initializeTicket(
|
||||
{ type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
|
||||
"series payment",
|
||||
);
|
||||
|
||||
// Show payment modal
|
||||
const showPaymentModal = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -141,7 +139,6 @@ export const useHelcimPay = () => {
|
|||
if (typeof window.appendHelcimPayIframe === "function") {
|
||||
// Set up event listener for HelcimPay.js responses
|
||||
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
||||
let observerTimer, paymentTimer;
|
||||
|
||||
const handleHelcimPayEvent = (event) => {
|
||||
console.log("Received window message:", event.data);
|
||||
|
|
@ -151,8 +148,6 @@ export const useHelcimPay = () => {
|
|||
|
||||
// Remove event listener to prevent multiple responses
|
||||
window.removeEventListener("message", handleHelcimPayEvent);
|
||||
clearTimeout(observerTimer);
|
||||
clearTimeout(paymentTimer);
|
||||
|
||||
// Close the Helcim modal
|
||||
if (typeof window.removeHelcimPayIframe === "function") {
|
||||
|
|
@ -242,10 +237,10 @@ export const useHelcimPay = () => {
|
|||
);
|
||||
|
||||
// Clean up observer after a timeout
|
||||
observerTimer = setTimeout(() => observer.disconnect(), 5000);
|
||||
setTimeout(() => observer.disconnect(), 5000);
|
||||
|
||||
// Add timeout to clean up if no response (10 minutes for manual card entry)
|
||||
paymentTimer = setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
console.log("Payment timeout reached, cleaning up event listener...");
|
||||
window.removeEventListener("message", handleHelcimPayEvent);
|
||||
reject(new Error("Payment timeout - no response received"));
|
||||
|
|
@ -277,7 +272,6 @@ export const useHelcimPay = () => {
|
|||
return {
|
||||
initializeHelcimPay,
|
||||
initializeTicketPayment,
|
||||
initializeSeriesTicketPayment,
|
||||
verifyPayment,
|
||||
cleanup,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,81 +25,45 @@ export const useMemberPayment = () => {
|
|||
paymentSuccess.value = false
|
||||
|
||||
try {
|
||||
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||
// AND a card's on file, we can skip the paid getOrCreateCustomer round
|
||||
// trip entirely and go straight to subscription creation.
|
||||
const hasCachedHelcimIds = Boolean(
|
||||
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
||||
// Step 1: Get or create Helcim customer
|
||||
await getOrCreateCustomer()
|
||||
|
||||
// Step 2: Initialize Helcim payment with $0 for card verification
|
||||
await initializeHelcimPay(
|
||||
customerId.value,
|
||||
customerCode.value,
|
||||
0,
|
||||
)
|
||||
|
||||
let existing = null
|
||||
let probedExistingCard = false
|
||||
let cardToken = null
|
||||
// Step 3: Show payment modal and get payment result
|
||||
const paymentResult = await verifyPayment()
|
||||
console.log('Payment result:', paymentResult)
|
||||
|
||||
if (hasCachedHelcimIds) {
|
||||
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||
return null
|
||||
})
|
||||
probedExistingCard = true
|
||||
if (existing?.cardToken) {
|
||||
customerId.value = memberData.value.helcimCustomerId
|
||||
customerCode.value = memberData.value.helcimCustomerCode
|
||||
cardToken = existing.cardToken
|
||||
}
|
||||
if (!paymentResult.success) {
|
||||
throw new Error('Payment verification failed')
|
||||
}
|
||||
|
||||
if (!cardToken) {
|
||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||
// to re-save it, breaking retries after a partial-failed signup.
|
||||
const [, existingFromFull] = await Promise.all([
|
||||
getOrCreateCustomer(),
|
||||
probedExistingCard
|
||||
? Promise.resolve(existing)
|
||||
: $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||
return null
|
||||
}),
|
||||
])
|
||||
// Step 4: Verify payment on backend
|
||||
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cardToken: paymentResult.cardToken,
|
||||
customerId: customerId.value,
|
||||
},
|
||||
})
|
||||
|
||||
cardToken = existingFromFull?.cardToken || null
|
||||
}
|
||||
|
||||
if (!cardToken) {
|
||||
await initializeHelcimPay(
|
||||
customerId.value,
|
||||
customerCode.value,
|
||||
0,
|
||||
)
|
||||
|
||||
const paymentResult = await verifyPayment()
|
||||
|
||||
if (!paymentResult.success) {
|
||||
throw new Error('Payment verification failed')
|
||||
}
|
||||
|
||||
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cardToken: paymentResult.cardToken,
|
||||
customerId: customerId.value,
|
||||
},
|
||||
})
|
||||
|
||||
if (!verifyResult.success) {
|
||||
throw new Error('Payment verification failed on backend')
|
||||
}
|
||||
|
||||
cardToken = paymentResult.cardToken
|
||||
if (!verifyResult.success) {
|
||||
throw new Error('Payment verification failed on backend')
|
||||
}
|
||||
|
||||
// Step 5: Create subscription with proper contribution tier
|
||||
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customerId: customerId.value,
|
||||
customerCode: customerCode.value,
|
||||
contributionAmount: memberData.value?.contributionAmount ?? 5,
|
||||
cardToken,
|
||||
contributionTier: memberData.value?.contributionTier || '5',
|
||||
cardToken: paymentResult.cardToken,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -107,6 +71,7 @@ export const useMemberPayment = () => {
|
|||
throw new Error('Subscription creation failed')
|
||||
}
|
||||
|
||||
// Step 6: Payment successful - refresh member data
|
||||
paymentSuccess.value = true
|
||||
await checkMemberStatus()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,146 +4,137 @@
|
|||
*/
|
||||
|
||||
export const MEMBER_STATUSES = {
|
||||
PENDING_PAYMENT: "pending_payment",
|
||||
ACTIVE: "active",
|
||||
SUSPENDED: "suspended",
|
||||
CANCELLED: "cancelled",
|
||||
};
|
||||
PENDING_PAYMENT: 'pending_payment',
|
||||
ACTIVE: 'active',
|
||||
SUSPENDED: 'suspended',
|
||||
CANCELLED: 'cancelled',
|
||||
}
|
||||
|
||||
export const MEMBER_STATUS_CONFIG = {
|
||||
pending_payment: {
|
||||
label: "Setting up payment",
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-500/10",
|
||||
borderColor: "border-orange-500/30",
|
||||
textColor: "text-orange-300",
|
||||
icon: "heroicons:exclamation-triangle",
|
||||
severity: "warning",
|
||||
canRSVP: true,
|
||||
label: 'Payment Pending',
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
borderColor: 'border-orange-500/30',
|
||||
textColor: 'text-orange-300',
|
||||
icon: 'heroicons:exclamation-triangle',
|
||||
severity: 'warning',
|
||||
canRSVP: false,
|
||||
canAccessMembers: true,
|
||||
canPeerSupport: true,
|
||||
canPeerSupport: false,
|
||||
},
|
||||
active: {
|
||||
label: "Active Member",
|
||||
color: "green",
|
||||
bgColor: "bg-green-500/10",
|
||||
borderColor: "border-green-500/30",
|
||||
textColor: "text-green-300",
|
||||
icon: "heroicons:check-circle",
|
||||
severity: "success",
|
||||
label: 'Active Member',
|
||||
color: 'green',
|
||||
bgColor: 'bg-green-500/10',
|
||||
borderColor: 'border-green-500/30',
|
||||
textColor: 'text-green-300',
|
||||
icon: 'heroicons:check-circle',
|
||||
severity: 'success',
|
||||
canRSVP: true,
|
||||
canAccessMembers: true,
|
||||
canPeerSupport: true,
|
||||
},
|
||||
suspended: {
|
||||
label: "Membership Suspended",
|
||||
color: "red",
|
||||
bgColor: "bg-red-500/10",
|
||||
borderColor: "border-red-500/30",
|
||||
textColor: "text-red-300",
|
||||
icon: "heroicons:no-symbol",
|
||||
severity: "error",
|
||||
label: 'Membership Suspended',
|
||||
color: 'red',
|
||||
bgColor: 'bg-red-500/10',
|
||||
borderColor: 'border-red-500/30',
|
||||
textColor: 'text-red-300',
|
||||
icon: 'heroicons:no-symbol',
|
||||
severity: 'error',
|
||||
canRSVP: false,
|
||||
canAccessMembers: false,
|
||||
canPeerSupport: false,
|
||||
},
|
||||
cancelled: {
|
||||
label: "Membership Cancelled",
|
||||
color: "gray",
|
||||
bgColor: "bg-gray-500/10",
|
||||
borderColor: "border-gray-500/30",
|
||||
textColor: "text-gray-300",
|
||||
icon: "heroicons:x-circle",
|
||||
severity: "error",
|
||||
label: 'Membership Cancelled',
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-500/10',
|
||||
borderColor: 'border-gray-500/30',
|
||||
textColor: 'text-gray-300',
|
||||
icon: 'heroicons:x-circle',
|
||||
severity: 'error',
|
||||
canRSVP: false,
|
||||
canAccessMembers: false,
|
||||
canPeerSupport: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useMemberStatus = () => {
|
||||
const { memberData } = useAuth();
|
||||
const { memberData } = useAuth()
|
||||
|
||||
// Get current member status
|
||||
const status = computed(
|
||||
() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT,
|
||||
);
|
||||
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT)
|
||||
|
||||
// Get status configuration
|
||||
const statusConfig = computed(
|
||||
() =>
|
||||
MEMBER_STATUS_CONFIG[status.value] ||
|
||||
MEMBER_STATUS_CONFIG.pending_payment,
|
||||
);
|
||||
const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment)
|
||||
|
||||
// Helper methods
|
||||
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
|
||||
const isPendingPayment = computed(
|
||||
() => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
|
||||
);
|
||||
const isSuspended = computed(
|
||||
() => status.value === MEMBER_STATUSES.SUSPENDED,
|
||||
);
|
||||
const isCancelled = computed(
|
||||
() => status.value === MEMBER_STATUSES.CANCELLED,
|
||||
);
|
||||
const isInactive = computed(() => !isActive.value);
|
||||
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
|
||||
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
|
||||
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
|
||||
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
|
||||
const isInactive = computed(() => !isActive.value)
|
||||
|
||||
// Check if member can perform action
|
||||
const canRSVP = computed(() => statusConfig.value.canRSVP);
|
||||
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
|
||||
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
|
||||
const canRSVP = computed(() => statusConfig.value.canRSVP)
|
||||
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
|
||||
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
|
||||
|
||||
// Get action button text and link based on status
|
||||
const getNextAction = () => {
|
||||
if (isPendingPayment.value) {
|
||||
return {
|
||||
label: "Complete Payment",
|
||||
link: "/member/account",
|
||||
icon: "heroicons:credit-card",
|
||||
color: "orange",
|
||||
};
|
||||
label: 'Complete Payment',
|
||||
link: '/member/profile#account',
|
||||
icon: 'heroicons:credit-card',
|
||||
color: 'orange',
|
||||
}
|
||||
}
|
||||
if (isCancelled.value) {
|
||||
return {
|
||||
label: "Reactivate Membership",
|
||||
link: "/member/account",
|
||||
icon: "heroicons:arrow-path",
|
||||
color: "blue",
|
||||
};
|
||||
label: 'Reactivate Membership',
|
||||
link: '/member/profile#account',
|
||||
icon: 'heroicons:arrow-path',
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
if (isSuspended.value) {
|
||||
return {
|
||||
label: "Contact Support",
|
||||
link: "mailto:support@ghostguild.org",
|
||||
icon: "heroicons:envelope",
|
||||
color: "gray",
|
||||
};
|
||||
label: 'Contact Support',
|
||||
link: 'mailto:support@ghostguild.org',
|
||||
icon: 'heroicons:envelope',
|
||||
color: 'gray',
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
// Get banner message based on status
|
||||
const getBannerMessage = () => {
|
||||
if (isPendingPayment.value) {
|
||||
return "Your payment setup isn't finished yet. Your membership and access aren't affected — finish whenever you're ready, or reach out if there's a snag.";
|
||||
return 'Your membership is pending payment. Please complete your payment to unlock full features.'
|
||||
}
|
||||
if (isSuspended.value) {
|
||||
return "Your account is paused while we work through a community issue. We'll be in touch.";
|
||||
return 'Your membership has been suspended. Please contact support to reactivate your account.'
|
||||
}
|
||||
if (isCancelled.value) {
|
||||
return "Your account is closed. Reach out if you'd like to come back.";
|
||||
return 'Your membership has been cancelled. Would you like to reactivate?'
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
// Get RSVP restriction message
|
||||
const getRSVPMessage = () => {
|
||||
if (isSuspended.value || isCancelled.value) {
|
||||
return "Your account isn't active right now. Reach out if you have questions.";
|
||||
if (isPendingPayment.value) {
|
||||
return 'Complete your payment to register for events'
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (isSuspended.value || isCancelled.value) {
|
||||
return 'Your membership status prevents RSVP. Please reactivate your account.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
|
|
@ -160,5 +151,5 @@ export const useMemberStatus = () => {
|
|||
getBannerMessage,
|
||||
getRSVPMessage,
|
||||
MEMBER_STATUSES,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
/**
|
||||
* Onboarding Composable
|
||||
* Tracks new member onboarding goals and provides post-graduation suggestions.
|
||||
*/
|
||||
export function useOnboarding(options = {}) {
|
||||
const goals = useState('onboarding.goals', () => ({
|
||||
hasProfileTags: false,
|
||||
hasVisitedEvent: false,
|
||||
hasEngagedBoard: false,
|
||||
hasClickedWiki: false,
|
||||
}))
|
||||
|
||||
const skipped = useState('onboarding.skipped', () => ({
|
||||
profileTags: false,
|
||||
visitEvent: false,
|
||||
board: false,
|
||||
wiki: false,
|
||||
}))
|
||||
|
||||
const completedAt = useState('onboarding.completedAt', () => null)
|
||||
const loading = useState('onboarding.loading', () => false)
|
||||
const recommendations = useState('onboarding.recommendations', () => ({
|
||||
events: [],
|
||||
wiki: [],
|
||||
}))
|
||||
|
||||
// Track whether we've already fetched status this session
|
||||
const _fetched = useState('onboarding._fetched', () => false)
|
||||
|
||||
// For the purpose of advancing the suggestion widget, a skipped goal is
|
||||
// treated as "done" — the underlying goal/graduation check is unchanged.
|
||||
const effectiveGoals = computed(() => ({
|
||||
hasProfileTags: goals.value.hasProfileTags || skipped.value.profileTags,
|
||||
hasVisitedEvent: goals.value.hasVisitedEvent || skipped.value.visitEvent,
|
||||
hasEngagedBoard: goals.value.hasEngagedBoard || skipped.value.board,
|
||||
hasClickedWiki: goals.value.hasClickedWiki || skipped.value.wiki,
|
||||
}))
|
||||
|
||||
const isComplete = computed(() =>
|
||||
!!completedAt.value ||
|
||||
(effectiveGoals.value.hasProfileTags &&
|
||||
effectiveGoals.value.hasVisitedEvent &&
|
||||
effectiveGoals.value.hasEngagedBoard &&
|
||||
effectiveGoals.value.hasClickedWiki)
|
||||
)
|
||||
|
||||
const pickCategory = options.pickCategory || ((categories) => {
|
||||
return categories[Math.floor(Math.random() * categories.length)]
|
||||
})
|
||||
|
||||
const currentSuggestion = computed(() => {
|
||||
// Not graduated — return highest-priority incomplete, non-skipped goal
|
||||
if (!isComplete.value) {
|
||||
if (!effectiveGoals.value.hasProfileTags) {
|
||||
return {
|
||||
key: 'profileTags',
|
||||
text: 'Complete your profile by adding your craft and community tags',
|
||||
action: '/member/profile',
|
||||
actionText: 'Set up tags',
|
||||
}
|
||||
}
|
||||
if (!effectiveGoals.value.hasVisitedEvent) {
|
||||
return {
|
||||
key: 'visitEvent',
|
||||
text: 'Check out upcoming events',
|
||||
action: '/events',
|
||||
actionText: 'Browse events',
|
||||
}
|
||||
}
|
||||
if (!effectiveGoals.value.hasEngagedBoard) {
|
||||
return {
|
||||
key: 'board',
|
||||
text: 'Explore the board to find collaborators',
|
||||
action: '/board',
|
||||
actionText: 'Explore board',
|
||||
}
|
||||
}
|
||||
if (!effectiveGoals.value.hasClickedWiki) {
|
||||
return {
|
||||
key: 'wiki',
|
||||
text: 'Browse the wiki for resources and guides',
|
||||
action: 'https://wiki.ghostguild.org',
|
||||
actionText: 'Browse wiki',
|
||||
isExternal: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Graduated — suggestion mode
|
||||
const cats = ['events', 'wiki'].filter(
|
||||
(c) => recommendations.value[c]?.length > 0
|
||||
)
|
||||
|
||||
if (cats.length === 0) {
|
||||
return { key: 'empty', text: 'No suggestions right now' }
|
||||
}
|
||||
|
||||
const selected = pickCategory(cats)
|
||||
const items = recommendations.value[selected]
|
||||
|
||||
if (items?.length > 0) {
|
||||
return buildRecommendation(selected, items[0])
|
||||
}
|
||||
|
||||
return { key: 'empty', text: 'No suggestions right now' }
|
||||
})
|
||||
|
||||
function buildRecommendation(category, item) {
|
||||
if (category === 'events') {
|
||||
return {
|
||||
key: 'event',
|
||||
text: `Upcoming event: ${item.title}`,
|
||||
action: `/events/${item.slug}`,
|
||||
actionText: 'View event',
|
||||
}
|
||||
}
|
||||
if (category === 'wiki') {
|
||||
return {
|
||||
key: 'wiki',
|
||||
text: `Recommended: ${item.title}`,
|
||||
action: item.url || null,
|
||||
actionText: 'Read article',
|
||||
isExternal: true,
|
||||
}
|
||||
}
|
||||
return { key: 'empty', text: 'No suggestions right now' }
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
if (_fetched.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/onboarding/status')
|
||||
if (data?.goals) {
|
||||
goals.value = { ...goals.value, ...data.goals }
|
||||
}
|
||||
if (data?.skipped) {
|
||||
skipped.value = { ...skipped.value, ...data.skipped }
|
||||
}
|
||||
if (data?.completedAt) {
|
||||
completedAt.value = data.completedAt
|
||||
}
|
||||
_fetched.value = true
|
||||
|
||||
// If graduated, fetch recommendations
|
||||
if (completedAt.value) {
|
||||
await fetchRecommendations()
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — goals stay at defaults
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecommendations() {
|
||||
const [events, wiki] = await Promise.allSettled([
|
||||
$fetch('/api/events/recommended'),
|
||||
$fetch('/api/wiki/recommended'),
|
||||
])
|
||||
recommendations.value = {
|
||||
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
||||
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
|
||||
}
|
||||
}
|
||||
|
||||
async function trackGoal(goalName) {
|
||||
if (isComplete.value) return
|
||||
try {
|
||||
await $fetch('/api/onboarding/track', {
|
||||
method: 'POST',
|
||||
body: { goal: goalName },
|
||||
})
|
||||
} catch {
|
||||
// Fire-and-forget
|
||||
}
|
||||
}
|
||||
|
||||
async function skipSuggestion(key) {
|
||||
// Optimistically advance locally; server call is fire-and-forget.
|
||||
if (skipped.value[key] !== undefined) {
|
||||
skipped.value = { ...skipped.value, [key]: true }
|
||||
}
|
||||
try {
|
||||
await $fetch('/api/onboarding/track', {
|
||||
method: 'POST',
|
||||
body: { skip: key },
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal — will re-fetch on next session
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on first use
|
||||
fetchStatus()
|
||||
|
||||
return {
|
||||
goals: readonly(goals),
|
||||
isComplete: readonly(isComplete),
|
||||
completedAt: readonly(completedAt),
|
||||
currentSuggestion,
|
||||
trackGoal,
|
||||
skipSuggestion,
|
||||
skipped: readonly(skipped),
|
||||
recommendations: readonly(recommendations),
|
||||
loading: readonly(loading),
|
||||
}
|
||||
}
|
||||
16
app/composables/usePeerSupport.js
Normal file
16
app/composables/usePeerSupport.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export const usePeerSupport = () => {
|
||||
const updateSettings = async (settings) => {
|
||||
return await $fetch('/api/members/me/peer-support', {
|
||||
method: 'PATCH',
|
||||
body: settings
|
||||
});
|
||||
};
|
||||
|
||||
const getSupporters = async (topic) => {
|
||||
return await $fetch('/api/peer-support', {
|
||||
query: topic ? { topic } : {}
|
||||
});
|
||||
};
|
||||
|
||||
return { updateSettings, getSupporters };
|
||||
};
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/**
|
||||
* useSiteMeta — set page-level SEO + social meta with site defaults baked in.
|
||||
*
|
||||
* Builds absolute URLs from runtimeConfig.public.appUrl so og:image and og:url
|
||||
* resolve for crawlers. Defaults og:type=website, twitter:card=summary_large_image,
|
||||
* og:site_name=Ghost Guild. Set noindex:true to emit robots="noindex, nofollow".
|
||||
*
|
||||
* Pass a function (or refs in fields) to keep tags reactive when content loads
|
||||
* asynchronously via useFetch.
|
||||
*/
|
||||
export function useSiteMeta(input) {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const appUrl = (runtimeConfig.public.appUrl || '').replace(/\/$/, '')
|
||||
|
||||
const resolve = () => (typeof input === 'function' ? input() : input) || {}
|
||||
|
||||
const buildAbsolute = (path) => {
|
||||
if (!path) return undefined
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
return `${appUrl}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
}
|
||||
|
||||
const titleGetter = () => resolve().title || 'Ghost Guild'
|
||||
const descGetter = () => resolve().description || undefined
|
||||
const isBareTitle = () => Boolean(resolve().bareTitle)
|
||||
const imageGetter = () => buildAbsolute(resolve().image || '/og/default.png')
|
||||
const typeGetter = () => resolve().type || 'website'
|
||||
const robotsGetter = () =>
|
||||
resolve().noindex ? 'noindex, nofollow' : undefined
|
||||
const canonicalGetter = () => buildAbsolute(route.path)
|
||||
|
||||
useSeoMeta({
|
||||
title: titleGetter,
|
||||
description: descGetter,
|
||||
ogSiteName: 'Ghost Guild',
|
||||
ogTitle: titleGetter,
|
||||
ogDescription: descGetter,
|
||||
ogType: typeGetter,
|
||||
ogUrl: canonicalGetter,
|
||||
ogImage: imageGetter,
|
||||
ogImageWidth: 1200,
|
||||
ogImageHeight: 630,
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: titleGetter,
|
||||
twitterDescription: descGetter,
|
||||
twitterImage: imageGetter,
|
||||
robots: robotsGetter,
|
||||
})
|
||||
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: canonicalGetter }],
|
||||
})
|
||||
|
||||
if (isBareTitle()) {
|
||||
useHead({ titleTemplate: null })
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export const CIRCLES = {
|
|||
shortDescription: "Building your studio",
|
||||
description: "For those actively establishing or growing their coop",
|
||||
features: [
|
||||
"Teams working toward applying for Cooperative Foundations",
|
||||
"Teams working toward applying for the Peer Accelerator",
|
||||
"Early-stage coop studios",
|
||||
"Studios transitioning to coop model",
|
||||
],
|
||||
|
|
@ -33,7 +33,7 @@ export const CIRCLES = {
|
|||
value: "practitioner",
|
||||
label: "Practitioners",
|
||||
shortDescription: "Leading and mentoring",
|
||||
description: "For alumni and experienced studio founders",
|
||||
description: "For Peer Accelerator alumni and experienced studio founders",
|
||||
features: [
|
||||
"Those implementing cooperative models",
|
||||
"Industry mentors and advisors",
|
||||
|
|
|
|||
|
|
@ -1,22 +1,82 @@
|
|||
// Guidance presets for the contribution amount input.
|
||||
// These are NOT tiers — just suggested amounts with matching guidance copy.
|
||||
export const CONTRIBUTION_PRESETS = [
|
||||
{ amount: 0, label: "I need support right now" },
|
||||
{ amount: 5, label: "I can contribute" },
|
||||
{ amount: 15, label: "I can sustain the community" },
|
||||
{ amount: 30, label: "I can support others too" },
|
||||
{ amount: 50, label: "I want to sponsor multiple members" },
|
||||
]
|
||||
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
|
||||
export const CONTRIBUTION_TIERS = {
|
||||
FREE: {
|
||||
value: "0",
|
||||
amount: 0,
|
||||
label: "$0 - I need support right now",
|
||||
tier: "free",
|
||||
helcimPlanId: null, // No Helcim plan needed for free tier
|
||||
},
|
||||
SUPPORTER: {
|
||||
value: "5",
|
||||
amount: 5,
|
||||
label: "$5 - I can contribute",
|
||||
tier: "supporter",
|
||||
helcimPlanId: "supporter-monthly-5",
|
||||
},
|
||||
MEMBER: {
|
||||
value: "15",
|
||||
amount: 15,
|
||||
label: "$15 - I can sustain the community",
|
||||
tier: "member",
|
||||
helcimPlanId: "member-monthly-15",
|
||||
},
|
||||
ADVOCATE: {
|
||||
value: "30",
|
||||
amount: 30,
|
||||
label: "$30 - I can support others too",
|
||||
tier: "advocate",
|
||||
helcimPlanId: "advocate-monthly-30",
|
||||
},
|
||||
CHAMPION: {
|
||||
value: "50",
|
||||
amount: 50,
|
||||
label: "$50 - I want to sponsor multiple members",
|
||||
tier: "champion",
|
||||
helcimPlanId: "champion-monthly-50",
|
||||
},
|
||||
};
|
||||
|
||||
export const requiresPayment = (amount) => amount > 0
|
||||
// Get all contribution options as an array (useful for forms)
|
||||
export const getContributionOptions = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS);
|
||||
};
|
||||
|
||||
export const isValidContributionAmount = (amount) =>
|
||||
Number.isInteger(amount) && amount >= 0
|
||||
// Get valid contribution values for validation
|
||||
export const getValidContributionValues = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
|
||||
};
|
||||
|
||||
export const getGuidanceLabel = (amount) => {
|
||||
if (amount === null || amount === undefined) return null
|
||||
const n = Number(amount)
|
||||
if (!Number.isFinite(n) || n < 0) return null
|
||||
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
|
||||
return match?.label ?? null
|
||||
}
|
||||
// Get contribution tier by value
|
||||
export const getContributionTierByValue = (value) => {
|
||||
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
|
||||
};
|
||||
|
||||
// Get Helcim plan ID for a contribution tier
|
||||
export const getHelcimPlanId = (contributionValue) => {
|
||||
const tier = getContributionTierByValue(contributionValue);
|
||||
return tier?.helcimPlanId || null;
|
||||
};
|
||||
|
||||
// Check if a contribution tier requires payment
|
||||
export const requiresPayment = (contributionValue) => {
|
||||
const tier = getContributionTierByValue(contributionValue);
|
||||
return tier?.amount > 0;
|
||||
};
|
||||
|
||||
// Check if a contribution value is valid
|
||||
export const isValidContributionValue = (value) => {
|
||||
return getValidContributionValues().includes(value);
|
||||
};
|
||||
|
||||
// Get contribution tier by Helcim plan ID
|
||||
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
|
||||
return Object.values(CONTRIBUTION_TIERS).find(
|
||||
(tier) => tier.helcimPlanId === helcimPlanId,
|
||||
);
|
||||
};
|
||||
|
||||
// Get paid tiers only (excluding free tier)
|
||||
export const getPaidContributionTiers = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
// Central configuration for Ghost Guild event types.
|
||||
// Keep values in sync with the `eventType` enum in server/models/event.js.
|
||||
export const EVENT_TYPES = [
|
||||
{ value: "talk", label: "Talk / Presentation" },
|
||||
{ value: "workshop", label: "Workshop" },
|
||||
{ value: "community-meetup", label: "Community Meetup" },
|
||||
{ value: "coworking", label: "Co-working Session" },
|
||||
{ value: "peer-session", label: "Peer Session" },
|
||||
{ value: "skills-share", label: "Skills Share" },
|
||||
{ value: "info-session", label: "Info Session" },
|
||||
];
|
||||
|
||||
export const EVENT_TYPE_VALUES = EVENT_TYPES.map((t) => t.value);
|
||||
|
||||
const labelLookup = Object.fromEntries(
|
||||
EVENT_TYPES.map((t) => [t.value, t.label]),
|
||||
);
|
||||
|
||||
export function eventTypeLabel(value) {
|
||||
return labelLookup[value] || value || "";
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export const STATUS_LABELS = {
|
||||
active: "Active",
|
||||
pending_payment: "Payment setup incomplete",
|
||||
suspended: "Paused",
|
||||
cancelled: "Closed",
|
||||
};
|
||||
|
||||
export const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
// Curated IANA timezone options for the profile editor.
|
||||
// Grouped roughly by region; values are standard IANA identifiers.
|
||||
export const TIMEZONE_OPTIONS = [
|
||||
// Americas
|
||||
{ label: 'Pacific — Los Angeles', value: 'America/Los_Angeles' },
|
||||
{ label: 'Pacific — Vancouver', value: 'America/Vancouver' },
|
||||
{ label: 'Mountain — Denver', value: 'America/Denver' },
|
||||
{ label: 'Mountain — Edmonton', value: 'America/Edmonton' },
|
||||
{ label: 'Central — Chicago', value: 'America/Chicago' },
|
||||
{ label: 'Central — Mexico City', value: 'America/Mexico_City' },
|
||||
{ label: 'Eastern — Toronto', value: 'America/Toronto' },
|
||||
{ label: 'Eastern — New York', value: 'America/New_York' },
|
||||
{ label: 'Atlantic — Halifax', value: 'America/Halifax' },
|
||||
{ label: 'Newfoundland — St. John’s', value: 'America/St_Johns' },
|
||||
{ label: 'Brazil — São Paulo', value: 'America/Sao_Paulo' },
|
||||
{ label: 'Argentina — Buenos Aires', value: 'America/Argentina/Buenos_Aires' },
|
||||
|
||||
// Europe / Africa
|
||||
{ label: 'UTC', value: 'UTC' },
|
||||
{ label: 'UK — London', value: 'Europe/London' },
|
||||
{ label: 'Ireland — Dublin', value: 'Europe/Dublin' },
|
||||
{ label: 'Central Europe — Berlin', value: 'Europe/Berlin' },
|
||||
{ label: 'Central Europe — Paris', value: 'Europe/Paris' },
|
||||
{ label: 'Central Europe — Madrid', value: 'Europe/Madrid' },
|
||||
{ label: 'Eastern Europe — Helsinki', value: 'Europe/Helsinki' },
|
||||
{ label: 'Africa — Lagos', value: 'Africa/Lagos' },
|
||||
{ label: 'Africa — Johannesburg', value: 'Africa/Johannesburg' },
|
||||
|
||||
// Asia / Oceania
|
||||
{ label: 'Middle East — Dubai', value: 'Asia/Dubai' },
|
||||
{ label: 'India — Kolkata', value: 'Asia/Kolkata' },
|
||||
{ label: 'Southeast Asia — Bangkok', value: 'Asia/Bangkok' },
|
||||
{ label: 'China — Shanghai', value: 'Asia/Shanghai' },
|
||||
{ label: 'Japan — Tokyo', value: 'Asia/Tokyo' },
|
||||
{ label: 'Korea — Seoul', value: 'Asia/Seoul' },
|
||||
{ label: 'Australia — Sydney', value: 'Australia/Sydney' },
|
||||
{ label: 'Australia — Perth', value: 'Australia/Perth' },
|
||||
{ label: 'New Zealand — Auckland', value: 'Pacific/Auckland' },
|
||||
];
|
||||
|
|
@ -1,11 +1,6 @@
|
|||
<template>
|
||||
<div class="site">
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:z-100 focus:p-3 focus:bg-(--bg) focus:text-(--text)"
|
||||
>Skip to content</a
|
||||
>
|
||||
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]">Skip to content</a>
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside class="sidebar sidebar-desktop">
|
||||
<NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>
|
||||
|
|
@ -19,61 +14,20 @@
|
|||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/pre-registrants"
|
||||
:class="{ active: route.path.startsWith('/admin/pre-registrants') }"
|
||||
>
|
||||
Pre-Registrants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/members"
|
||||
:class="{ active: route.path.startsWith('/admin/members') }"
|
||||
>
|
||||
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }">
|
||||
Members
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/events"
|
||||
:class="{ active: route.path.startsWith('/admin/events') }"
|
||||
>
|
||||
<NuxtLink to="/admin/events" :class="{ active: route.path.startsWith('/admin/events') }">
|
||||
Events
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/series-management"
|
||||
:class="{ active: route.path.includes('/admin/series') }"
|
||||
>
|
||||
<NuxtLink to="/admin/series-management" :class="{ active: route.path.includes('/admin/series') }">
|
||||
Series
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/wiki"
|
||||
:class="{ active: route.path.startsWith('/admin/wiki') }"
|
||||
>
|
||||
Wiki
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/board-channels"
|
||||
:class="{ active: route.path.startsWith('/admin/board-channels') }"
|
||||
>
|
||||
Board Channels
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/site-content"
|
||||
:class="{ active: route.path.startsWith('/admin/site-content') }"
|
||||
>
|
||||
Site Content
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-section">Site</div>
|
||||
|
|
@ -84,7 +38,7 @@
|
|||
</div>
|
||||
|
||||
<div class="sidebar-meta">
|
||||
<span class="admin-tag">admin</span><br >
|
||||
<span class="admin-tag">admin</span><br>
|
||||
<a href="#" @click.prevent="logout">Sign out</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -105,109 +59,42 @@
|
|||
<USlideover v-model:open="isMobileMenuOpen" side="left">
|
||||
<template #body>
|
||||
<aside class="sidebar sidebar-mobile">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="sidebar-brand"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>Ghost Guild</NuxtLink
|
||||
>
|
||||
<NuxtLink to="/" class="sidebar-brand" @click="isMobileMenuOpen = false">Ghost Guild</NuxtLink>
|
||||
|
||||
<div class="sidebar-body">
|
||||
<div class="sidebar-section">Admin</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin"
|
||||
:class="{ active: route.path === '/admin' }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
<NuxtLink to="/admin" :class="{ active: route.path === '/admin' }" @click="isMobileMenuOpen = false">
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/pre-registrants"
|
||||
:class="{ active: route.path.startsWith('/admin/pre-registrants') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Pre-Registrants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/members"
|
||||
:class="{ active: route.path.startsWith('/admin/members') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }" @click="isMobileMenuOpen = false">
|
||||
Members
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/events"
|
||||
:class="{ active: route.path.startsWith('/admin/events') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
<NuxtLink to="/admin/events" :class="{ active: route.path.startsWith('/admin/events') }" @click="isMobileMenuOpen = false">
|
||||
Events
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/series-management"
|
||||
:class="{ active: route.path.includes('/admin/series') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
<NuxtLink to="/admin/series-management" :class="{ active: route.path.includes('/admin/series') }" @click="isMobileMenuOpen = false">
|
||||
Series
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/wiki"
|
||||
:class="{ active: route.path.startsWith('/admin/wiki') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Wiki
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/board-channels"
|
||||
:class="{ active: route.path.startsWith('/admin/board-channels') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Board Channels
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/site-content"
|
||||
:class="{ active: route.path.startsWith('/admin/site-content') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Site Content
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-section">Site</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/member/dashboard"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>Your Dashboard</NuxtLink
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/" @click="isMobileMenuOpen = false"
|
||||
>Public Site</NuxtLink
|
||||
>
|
||||
</li>
|
||||
<li><NuxtLink to="/member/dashboard" @click="isMobileMenuOpen = false">Your Dashboard</NuxtLink></li>
|
||||
<li><NuxtLink to="/" @click="isMobileMenuOpen = false">Public Site</NuxtLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-meta">
|
||||
<span class="admin-tag">admin</span><br >
|
||||
<span class="admin-tag">admin</span><br>
|
||||
<a href="#" @click.prevent="logout">Sign out</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -217,23 +104,23 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useSiteMeta({ title: "Admin", noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const { logout } = useAuth();
|
||||
|
||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||
const route = useRoute()
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
const currentPageName = computed(() => {
|
||||
const path = route.path;
|
||||
if (path === "/admin") return "admin";
|
||||
const segments = path.slice(1).split("/");
|
||||
if (pageBreadcrumbTitle.value && segments.length > 1) {
|
||||
return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / ");
|
||||
const path = route.path
|
||||
if (path === '/admin') return 'admin'
|
||||
return path.slice(1).replace(/\//g, ' / ')
|
||||
})
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
return segments.join(" / ");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -267,20 +154,16 @@ const currentPageName = computed(() => {
|
|||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: "Brygada 1918", serif;
|
||||
display: block;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--candle);
|
||||
padding: 0 24px;
|
||||
height: 53px;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-brand:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-brand:hover { text-decoration: none; }
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
|
|
@ -356,7 +239,7 @@ const currentPageName = computed(() => {
|
|||
}
|
||||
|
||||
.brand {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--candle);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
<template>
|
||||
<div class="coming-soon-layout">
|
||||
<div class="min-h-screen bg-guild-900">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coming-soon-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
<template>
|
||||
<div class="site">
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]"
|
||||
>Skip to content</a
|
||||
>
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]">Skip to content</a>
|
||||
<!-- Desktop Sidebar -->
|
||||
<AppNavigation class="sidebar-desktop" />
|
||||
|
||||
|
|
@ -28,24 +24,20 @@
|
|||
<AppNavigation :is-mobile="true" @navigate="isMobileMenuOpen = false" />
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const route = useRoute();
|
||||
|
||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||
const isMobileMenuOpen = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const currentPageName = computed(() => {
|
||||
const path = route.path;
|
||||
if (path === "/") return "";
|
||||
const segments = path.slice(1).split("/");
|
||||
if (pageBreadcrumbTitle.value && segments.length > 1) {
|
||||
return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / ");
|
||||
}
|
||||
return segments.join(" / ");
|
||||
});
|
||||
const path = route.path
|
||||
if (path === '/') return ''
|
||||
// Convert /member/dashboard → member / dashboard
|
||||
return path.slice(1).replace(/\//g, ' / ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -79,7 +71,7 @@ const currentPageName = computed(() => {
|
|||
}
|
||||
|
||||
.brand {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--candle);
|
||||
|
|
|
|||
|
|
@ -1,38 +1,36 @@
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
// Skip on server-side rendering
|
||||
if (process.server) {
|
||||
console.log("🛡️ Auth middleware - skipping on server");
|
||||
return;
|
||||
console.log('🛡️ Auth middleware - skipping on server')
|
||||
return
|
||||
}
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { openLoginModal } = useLoginModal();
|
||||
const { memberData, checkMemberStatus } = useAuth()
|
||||
const { openLoginModal } = useLoginModal()
|
||||
|
||||
console.log("🛡️ Auth middleware (CLIENT) - route:", to.path);
|
||||
console.log(" - memberData exists:", !!memberData.value);
|
||||
console.log(" - Running on:", process.server ? "SERVER" : "CLIENT");
|
||||
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
|
||||
console.log(' - memberData exists:', !!memberData.value)
|
||||
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT')
|
||||
|
||||
// If no member data, try to check authentication
|
||||
if (!memberData.value) {
|
||||
console.log(" - No member data, checking authentication...");
|
||||
const isAuthenticated = await checkMemberStatus();
|
||||
console.log(" - Authentication result:", isAuthenticated);
|
||||
console.log(' - No member data, checking authentication...')
|
||||
const isAuthenticated = await checkMemberStatus()
|
||||
console.log(' - Authentication result:', isAuthenticated)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log(" - ❌ Authentication failed, showing login modal");
|
||||
console.log(' - ❌ Authentication failed, showing login modal')
|
||||
// Open login modal instead of redirecting
|
||||
openLoginModal({
|
||||
title: "Sign in to continue",
|
||||
description: "You need to be signed in to access this page",
|
||||
title: 'Sign in to continue',
|
||||
description: 'You need to be signed in to access this page',
|
||||
dismissible: true,
|
||||
redirectTo: to.fullPath,
|
||||
});
|
||||
// Let navigation proceed — the page renders its own unauthenticated
|
||||
// fallback, and the modal opens on top. abortNavigation() on an initial
|
||||
// page load resets client state, which closes the modal before it shows.
|
||||
return;
|
||||
})
|
||||
// Abort navigation - stay on current page with modal open
|
||||
return abortNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" - ✅ Authentication successful for:", memberData.value?.email);
|
||||
});
|
||||
console.log(' - ✅ Authentication successful for:', memberData.value?.email)
|
||||
})
|
||||
|
|
@ -12,24 +12,11 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||
if (
|
||||
to.path === "/coming-soon" ||
|
||||
to.path === "/auth/wiki-login" ||
|
||||
to.path === "/auth/oidc-error" ||
|
||||
to.path === "/auth/logout-confirm" ||
|
||||
to.path === "/auth/logout-success" ||
|
||||
to.path === "/verify" ||
|
||||
to.path.startsWith("/admin")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
|
||||
try {
|
||||
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
|
||||
const member = await $fetch("/api/auth/member", { headers });
|
||||
if (member?.role === "admin") return;
|
||||
} catch {
|
||||
// Not authenticated — fall through to redirect
|
||||
}
|
||||
|
||||
// Redirect all other routes to coming-soon
|
||||
return navigateTo("/coming-soon");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
if (process.server) return;
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
|
||||
if (!memberData.value) {
|
||||
const isAuthenticated = await checkMemberStatus();
|
||||
if (!isAuthenticated) {
|
||||
return navigateTo("/join");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1,118 +1,95 @@
|
|||
<template>
|
||||
<PageShell>
|
||||
<div class="about-page">
|
||||
<!-- ABOUT HERO (side by side) -->
|
||||
<div class="about-hero">
|
||||
<div class="about-hero-left">
|
||||
<h1>About Ghost Guild</h1>
|
||||
<p>
|
||||
A membership community for game developers exploring cooperative
|
||||
models.
|
||||
</p>
|
||||
<p>A membership community for game developers exploring cooperative business models.</p>
|
||||
</div>
|
||||
<div class="about-hero-right">
|
||||
<div class="section-label">Our Story</div>
|
||||
<p>
|
||||
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
|
||||
advancing cooperative and worker-centric models in the game industry
|
||||
since 2023.
|
||||
</p>
|
||||
<p>
|
||||
Developers interested in co-op practice had few places to learn,
|
||||
connect, and figure things out alongside others doing the same work.
|
||||
Ghost Guild is that place: a membership community for developers at
|
||||
every stage of cooperative practice, with resources, events, and peers
|
||||
to learn from.
|
||||
</p>
|
||||
<p>
|
||||
We don't prescribe a single model. We're here to explore the options,
|
||||
learn from people who've tried them, and build something that works
|
||||
for your team.
|
||||
</p>
|
||||
<p>Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been supporting indie game developers since 2018. We noticed a gap: game developers interested in cooperative models had nowhere to learn, practice, and connect with others doing the same work.</p>
|
||||
<p>Ghost Guild is the response — a membership program where developers at every stage of cooperative practice can find resources, events, mentorship, and community.</p>
|
||||
<p>We don't prescribe a single model. We're a place to explore the options, learn from people who've tried them, and build something that works for your team.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||
<ColumnsLayout cols="events-sidebar" :limit="3">
|
||||
<!-- THE CIRCLES -->
|
||||
<div class="about-section" id="circles">
|
||||
<div class="section-label">The Circles</div>
|
||||
<div class="circles-grid">
|
||||
<div id="community" class="circle-cell">
|
||||
<h2 style="color: var(--c-community)">Community</h2>
|
||||
<div class="content-area">
|
||||
<div class="content-main">
|
||||
|
||||
<p>For anyone exploring cooperative models.</p>
|
||||
</div>
|
||||
<div id="founder" class="circle-cell">
|
||||
<h2 style="color: var(--c-founder)">Founder</h2>
|
||||
<p>For people actively building cooperatives.</p>
|
||||
</div>
|
||||
<div id="practitioner" class="circle-cell">
|
||||
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
|
||||
<p>For experienced practitioners sharing what they know.</p>
|
||||
<!-- THE CIRCLES -->
|
||||
<div class="about-section" id="circles">
|
||||
<div class="section-label">The Circles</div>
|
||||
<div class="circles-grid">
|
||||
<div id="community" class="circle-cell">
|
||||
<h3 style="color: var(--c-community);">Community</h3>
|
||||
<div class="circle-subtitle">"The open hall"</div>
|
||||
<p>For anyone exploring cooperative models. Wiki access, public events, Slack community, monthly meetings.</p>
|
||||
</div>
|
||||
<div id="founder" class="circle-cell">
|
||||
<h3 style="color: var(--c-founder);">Founder</h3>
|
||||
<div class="circle-subtitle">"The workshop"</div>
|
||||
<p>For people actively building cooperatives. Peer accelerator, mentorship, governance templates.</p>
|
||||
</div>
|
||||
<div id="practitioner" class="circle-cell">
|
||||
<h3 style="color: var(--c-practitioner);">Practitioner</h3>
|
||||
<div class="circle-subtitle">"The alcove"</div>
|
||||
<p>For experienced practitioners. Mentoring, teaching, shaping the program direction.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
|
||||
<div class="two-col-row">
|
||||
<!-- HOW CONTRIBUTION WORKS -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">How Contribution Works</div>
|
||||
<p>
|
||||
Membership is $0–50/month, pay what you can. Nobody is
|
||||
excluded for lack of funds. Your contribution supports
|
||||
infrastructure, events, and community resources.
|
||||
</p>
|
||||
<p>Membership is $0–50/month, pay what you can. Nobody is excluded for lack of funds. Your contribution supports infrastructure, events, and community resources.</p>
|
||||
<ul class="tier-list">
|
||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
||||
<li><span class="tier-amt">$5</span> I can contribute</li>
|
||||
<li>
|
||||
<span class="tier-amt">$15</span> I can sustain the community
|
||||
</li>
|
||||
<li><span class="tier-amt">$15</span> I can sustain the community</li>
|
||||
<li><span class="tier-amt">$30</span> I can support others too</li>
|
||||
<li>
|
||||
<span class="tier-amt">$50</span> I want to sponsor multiple
|
||||
members
|
||||
</li>
|
||||
<li><span class="tier-amt">$50</span> I want to sponsor multiple members</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- COMMUNITY -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">Community</div>
|
||||
<p>
|
||||
We gather in Slack, at monthly meetings, and through peer support
|
||||
sessions. The wiki is our shared knowledge base — growing as
|
||||
members contribute. Events range from workshops to social hangs to
|
||||
deep-dive series.
|
||||
</p>
|
||||
<p>We gather in Slack, at monthly meetings, and through peer support sessions. The wiki is our shared knowledge base — growing as members contribute. Events range from workshops to social hangs to deep-dive series.</p>
|
||||
<NuxtLink to="/join" class="cta">Join the Guild →</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- ABOUT BABY GHOSTS -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">About Baby Ghosts</div>
|
||||
<p>Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit advancing cooperative models in game development. No tracking. No ads. No venture capital.</p>
|
||||
<p><a href="https://babyghosts.fund" target="_blank">babyghosts.fund →</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABOUT BABY GHOSTS -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">About Baby Ghosts</div>
|
||||
<p>
|
||||
Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing
|
||||
cooperative models in game development.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://babyghosts.org" target="_blank"
|
||||
>babyghosts.org →</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</ColumnsLayout>
|
||||
</PageShell>
|
||||
<!-- EVENTS MINI SIDEBAR -->
|
||||
<EventsMiniSidebar :events="upcomingEvents" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useSiteMeta({
|
||||
title: 'About',
|
||||
description:
|
||||
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
|
||||
const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||
query: { limit: 3, upcoming: true },
|
||||
default: () => [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
|
||||
.about-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- ABOUT HERO ---- */
|
||||
.about-hero {
|
||||
display: grid;
|
||||
|
|
@ -127,7 +104,7 @@ useSiteMeta({
|
|||
align-self: stretch;
|
||||
}
|
||||
.about-hero-left h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -151,6 +128,24 @@ useSiteMeta({
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ---- CONTENT AREA ---- */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
.content-main {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- SECTIONS ---- */
|
||||
.about-section {
|
||||
padding: 28px 32px;
|
||||
|
|
@ -181,11 +176,9 @@ useSiteMeta({
|
|||
padding: 20px;
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
.circle-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.circle-cell:last-child { border-right: none; }
|
||||
.circle-cell h3 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
|
|
@ -203,19 +196,6 @@ useSiteMeta({
|
|||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* ---- TWO-COL ROW ---- */
|
||||
.two-col-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.two-col-row .about-section {
|
||||
border-bottom: none;
|
||||
}
|
||||
.two-col-row .about-section:first-child {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* ---- TIER LIST ---- */
|
||||
.tier-list {
|
||||
list-style: none;
|
||||
|
|
@ -229,9 +209,7 @@ useSiteMeta({
|
|||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.tier-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tier-list li:last-child { border-bottom: none; }
|
||||
.tier-amt {
|
||||
color: var(--text-bright);
|
||||
font-weight: 600;
|
||||
|
|
@ -240,23 +218,13 @@ useSiteMeta({
|
|||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.circles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.content-area { grid-template-columns: 1fr; }
|
||||
.circles-grid { grid-template-columns: 1fr; }
|
||||
.circle-cell {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.circle-cell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.two-col-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.two-col-row .about-section:first-child {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.circle-cell:last-child { border-bottom: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.about-hero {
|
||||
|
|
|
|||
|
|
@ -1,728 +0,0 @@
|
|||
<template>
|
||||
<div class="accept-invite">
|
||||
<!-- Verifying -->
|
||||
<div v-if="step === 'verifying'" class="center-box">
|
||||
<div class="spinner" />
|
||||
<p>Verifying your invitation...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="step === 'error'" class="center-box">
|
||||
<h1>Invitation Error</h1>
|
||||
<div class="error-box">{{ errorMessage }}</div>
|
||||
<NuxtLink to="/" class="btn" style="margin-top: 16px">Go to Ghost Guild</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Accept Form -->
|
||||
<div v-else-if="step === 'form'" class="form-container">
|
||||
<h1>Accept Your Invitation</h1>
|
||||
<p class="form-intro">
|
||||
Welcome to Ghost Guild. Review your info below, choose your circle and contribution, and you're in.
|
||||
</p>
|
||||
|
||||
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
|
||||
|
||||
<form @submit.prevent="handleAccept">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="accept-name">Name</label>
|
||||
<input
|
||||
id="accept-name"
|
||||
v-model="form.name"
|
||||
class="form-input"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="accept-email">Email</label>
|
||||
<input
|
||||
id="accept-email"
|
||||
:value="preRegEmail"
|
||||
class="form-input"
|
||||
type="email"
|
||||
disabled
|
||||
>
|
||||
<p class="field-note">Email cannot be changed. Contact us if you need to use a different email.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="accept-pronouns">Pronouns</label>
|
||||
<input
|
||||
id="accept-pronouns"
|
||||
v-model="form.pronouns"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="e.g. they/them, she/her"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="accept-location">City / Region</label>
|
||||
<input
|
||||
id="accept-location"
|
||||
v-model="form.location"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="e.g. Vancouver, BC"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Circle</label>
|
||||
<p class="field-note" style="margin-bottom: 8px">Which circle fits where you are right now?</p>
|
||||
<div class="circle-radios">
|
||||
<div class="circle-radio community">
|
||||
<input
|
||||
id="circle-community"
|
||||
v-model="form.circle"
|
||||
type="radio"
|
||||
name="circle"
|
||||
value="community"
|
||||
>
|
||||
<label for="circle-community">
|
||||
<span class="circle-label-name" style="color: var(--c-community);">Community</span>
|
||||
<span class="circle-label-desc">Learning about co-ops</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="circle-radio founder">
|
||||
<input
|
||||
id="circle-founder"
|
||||
v-model="form.circle"
|
||||
type="radio"
|
||||
name="circle"
|
||||
value="founder"
|
||||
>
|
||||
<label for="circle-founder">
|
||||
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span>
|
||||
<span class="circle-label-desc">Building your studio</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="circle-radio practitioner">
|
||||
<input
|
||||
id="circle-practitioner"
|
||||
v-model="form.circle"
|
||||
type="radio"
|
||||
name="circle"
|
||||
value="practitioner"
|
||||
>
|
||||
<label for="circle-practitioner">
|
||||
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span>
|
||||
<span class="circle-label-desc">Leading and mentoring</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label" for="accept-motivation">What brings you to Ghost Guild?</label>
|
||||
<textarea
|
||||
id="accept-motivation"
|
||||
v-model="form.motivation"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
placeholder="2-3 sentences about what you're looking for"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Billing Cadence</label>
|
||||
<div class="cadence-radios">
|
||||
<div class="circle-radio">
|
||||
<input
|
||||
id="accept-cadence-annual"
|
||||
v-model="cadence"
|
||||
type="radio"
|
||||
name="cadence"
|
||||
value="annual"
|
||||
>
|
||||
<label for="accept-cadence-annual">
|
||||
<span class="circle-label-name">Per Year</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="circle-radio">
|
||||
<input
|
||||
id="accept-cadence-monthly"
|
||||
v-model="cadence"
|
||||
type="radio"
|
||||
name="cadence"
|
||||
value="monthly"
|
||||
>
|
||||
<label for="accept-cadence-monthly">
|
||||
<span class="circle-label-name">Per Month</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label" for="accept-contribution">
|
||||
Monthly Contribution
|
||||
</label>
|
||||
<div class="contribution-input-row">
|
||||
<span class="contribution-currency">$</span>
|
||||
<input
|
||||
id="accept-contribution"
|
||||
v-model.number="form.contributionAmount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="contribution-input"
|
||||
>
|
||||
</div>
|
||||
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
|
||||
<button
|
||||
v-for="preset in CONTRIBUTION_PRESETS"
|
||||
:key="preset.amount"
|
||||
type="button"
|
||||
class="contribution-preset-chip"
|
||||
@click="form.contributionAmount = preset.amount"
|
||||
>
|
||||
${{ preset.amount }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
||||
<p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.contributionAmount > 0" class="form-group full-width">
|
||||
<div class="billing-summary">
|
||||
<p class="billing-summary-line">
|
||||
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month × 12)</span>.
|
||||
</p>
|
||||
<p class="billing-summary-line">
|
||||
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
v-model="form.agreedToGuidelines"
|
||||
type="checkbox"
|
||||
>
|
||||
<span>
|
||||
I agree to the Ghost Guild
|
||||
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button
|
||||
class="form-submit"
|
||||
type="submit"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
>
|
||||
<span v-if="isSubmitting">Processing...</span>
|
||||
<span v-else-if="needsPayment">Continue to Payment</span>
|
||||
<span v-else>Accept Invitation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Flow overlay: covers the page through payment + redirect. -->
|
||||
<SignupFlowOverlay
|
||||
:state="flowState"
|
||||
:summary="flowSummary"
|
||||
:error-message="errorMessage"
|
||||
dashboard-href="/member/dashboard?welcome=1"
|
||||
@close="closeFlowOverlay"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
requiresPayment,
|
||||
CONTRIBUTION_PRESETS,
|
||||
getGuidanceLabel,
|
||||
} from "~/config/contributions";
|
||||
|
||||
definePageMeta({ layout: false });
|
||||
useSiteMeta({ title: "Accept Invitation", noindex: true });
|
||||
|
||||
const { checkMemberStatus } = useAuth();
|
||||
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
|
||||
|
||||
const step = ref("verifying");
|
||||
const errorMessage = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const preRegId = ref(null);
|
||||
const preRegEmail = ref("");
|
||||
const token = ref("");
|
||||
const cadence = ref("annual"); // 'monthly' | 'annual'
|
||||
|
||||
// Flow overlay state — drives the post-submit full-viewport UI.
|
||||
const flowState = ref("idle");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
pronouns: "",
|
||||
location: "",
|
||||
circle: "community",
|
||||
motivation: "",
|
||||
contributionAmount: 15,
|
||||
agreedToGuidelines: false,
|
||||
});
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
form.name &&
|
||||
form.circle &&
|
||||
Number.isInteger(form.contributionAmount) &&
|
||||
form.contributionAmount >= 0 &&
|
||||
form.agreedToGuidelines
|
||||
);
|
||||
});
|
||||
|
||||
const needsPayment = computed(() => {
|
||||
return requiresPayment(form.contributionAmount);
|
||||
});
|
||||
|
||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
||||
|
||||
const firstCharge = computed(() => {
|
||||
const amount = form.contributionAmount || 0;
|
||||
return cadence.value === "annual" ? amount * 12 : amount;
|
||||
});
|
||||
|
||||
const formatContributionAmount = (amount) => {
|
||||
if (!amount || amount === 0) return "$0";
|
||||
const display = cadence.value === "annual" ? amount * 12 : amount;
|
||||
const suffix = cadence.value === "annual" ? "/yr" : "/mo";
|
||||
return `$${display}${suffix}`;
|
||||
};
|
||||
|
||||
const flowSummary = computed(() => ({
|
||||
name: form.name,
|
||||
email: preRegEmail.value,
|
||||
circle: form.circle,
|
||||
contribution: formatContributionAmount(form.contributionAmount),
|
||||
}));
|
||||
|
||||
const closeFlowOverlay = () => {
|
||||
flowState.value = "idle";
|
||||
errorMessage.value = "";
|
||||
};
|
||||
|
||||
// On mount: extract token from fragment, verify
|
||||
onMounted(async () => {
|
||||
const hash = window.location.hash?.slice(1);
|
||||
if (!hash) {
|
||||
step.value = "error";
|
||||
errorMessage.value = "No invitation token found. Please check your email link.";
|
||||
return;
|
||||
}
|
||||
|
||||
token.value = hash;
|
||||
|
||||
try {
|
||||
const result = await $fetch("/api/invite/verify", {
|
||||
method: "POST",
|
||||
body: { token: hash },
|
||||
});
|
||||
|
||||
preRegId.value = result.preRegistrationId;
|
||||
preRegEmail.value = result.email;
|
||||
form.name = result.name || "";
|
||||
form.location = result.city || "";
|
||||
step.value = "form";
|
||||
} catch (err) {
|
||||
step.value = "error";
|
||||
errorMessage.value =
|
||||
err.data?.statusMessage || "This invitation link is invalid or has expired.";
|
||||
}
|
||||
});
|
||||
|
||||
const handleAccept = async () => {
|
||||
if (isSubmitting.value || !isFormValid.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = "";
|
||||
flowState.value = "creating-customer";
|
||||
|
||||
try {
|
||||
const accepted = await $fetch("/api/invite/accept", {
|
||||
method: "POST",
|
||||
body: {
|
||||
preRegistrationId: preRegId.value,
|
||||
name: form.name,
|
||||
pronouns: form.pronouns || undefined,
|
||||
location: form.location || undefined,
|
||||
circle: form.circle,
|
||||
motivation: form.motivation || undefined,
|
||||
contributionAmount: form.contributionAmount,
|
||||
agreedToGuidelines: form.agreedToGuidelines,
|
||||
token: token.value,
|
||||
},
|
||||
});
|
||||
|
||||
if (!accepted.requiresPayment) {
|
||||
// Free tier — session cookie already set by accept endpoint
|
||||
await checkMemberStatus();
|
||||
flowState.value = "success";
|
||||
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Paid tier: initialize HelcimPay session, auto-open modal
|
||||
flowState.value = "opening-payment";
|
||||
await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0);
|
||||
|
||||
const paymentResult = await verifyPayment();
|
||||
if (!paymentResult?.success) {
|
||||
throw new Error("Payment was not completed.");
|
||||
}
|
||||
|
||||
flowState.value = "processing-payment";
|
||||
await $fetch("/api/helcim/verify-payment", {
|
||||
method: "POST",
|
||||
body: {
|
||||
cardToken: paymentResult.cardToken,
|
||||
customerId: accepted.customerId,
|
||||
},
|
||||
});
|
||||
|
||||
flowState.value = "creating-subscription";
|
||||
await $fetch("/api/helcim/subscription", {
|
||||
method: "POST",
|
||||
body: {
|
||||
customerId: accepted.customerId,
|
||||
customerCode: accepted.customerCode,
|
||||
contributionAmount: form.contributionAmount,
|
||||
cadence: cadence.value,
|
||||
cardToken: paymentResult.cardToken,
|
||||
},
|
||||
});
|
||||
|
||||
await checkMemberStatus();
|
||||
flowState.value = "success";
|
||||
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
|
||||
} catch (err) {
|
||||
errorMessage.value =
|
||||
err.data?.statusMessage ||
|
||||
err.message ||
|
||||
"Failed to accept invitation. Please try again.";
|
||||
flowState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.accept-invite {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
|
||||
.center-box {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.center-box h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.center-box p {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
|
||||
.form-container h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-intro {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed var(--ember);
|
||||
color: var(--ember);
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ---- FORM ---- */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--candle);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field-note {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
|
||||
.contribution-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.contribution-currency {
|
||||
font-weight: 600;
|
||||
}
|
||||
.contribution-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--parch);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.contribution-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
.contribution-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.contribution-preset-chip {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--parch);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contribution-preset-chip:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
.contribution-guidance {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
color: var(--ink-soft, currentColor);
|
||||
}
|
||||
|
||||
/* ---- BILLING SUMMARY ---- */
|
||||
.billing-summary {
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.billing-summary-line {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.billing-summary-line + .billing-summary-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.billing-summary-line strong {
|
||||
color: var(--text-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- CIRCLE RADIOS ---- */
|
||||
.circle-radios {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cadence-radios {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.circle-radio {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.circle-radio input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.circle-radio label {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.circle-radio input:checked + label {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.circle-label-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.circle-label-desc {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---- CHECKBOX ---- */
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label a {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
/* ---- SUBMIT BUTTON ---- */
|
||||
.form-submit {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: var(--candle);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payment-instruction {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- SPINNER ---- */
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px dashed var(--candle);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 600px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.circle-radios {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cadence-radios {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,596 +0,0 @@
|
|||
<template>
|
||||
<div class="admin-board-channels">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1>Board Channels</h1>
|
||||
<p>Create Slack channels for cooperative tags. New channels are created in Slack when you click Create Channel.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="openCreateModal">+ New Channel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unmapped Tags Indicator -->
|
||||
<div v-if="unmappedTags.length > 0" class="unmapped-block">
|
||||
<div class="section-label">Unmapped Cooperative Tags</div>
|
||||
<p class="unmapped-hint">These cooperative tags are not yet mapped to any board channel:</p>
|
||||
<div class="tag-pills">
|
||||
<span v-for="tag in unmappedTags" :key="tag.slug" class="tag-pill tag-pill-warning">
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channels List -->
|
||||
<div class="channels-list">
|
||||
<div v-if="!channels.length" class="empty-state">
|
||||
<p>No board channels configured yet.</p>
|
||||
<p class="empty-hint">Click "+ New Channel" to create your first board channel in Slack.</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Mapped Tags</th>
|
||||
<th class="actions-col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="channel in channels" :key="channel._id">
|
||||
<td class="name-cell">
|
||||
<div class="channel-name">{{ channel.name }}</div>
|
||||
<div class="channel-id">{{ channel.slackChannelId }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tag-pills">
|
||||
<span
|
||||
v-for="slug in channel.tagSlugs || []"
|
||||
:key="slug"
|
||||
class="tag-pill"
|
||||
>
|
||||
{{ tagLabel(slug) }}
|
||||
</span>
|
||||
<span v-if="!(channel.tagSlugs || []).length" class="tag-empty">—</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="link-btn" @click="openEditModal(channel)">Edit</button>
|
||||
<button class="link-btn link-btn-danger" @click="deleteChannel(channel)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create / Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editingId ? 'Edit Channel' : 'New Channel' }}</h2>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="formData.name" type="text" placeholder="e.g., coop-formation" />
|
||||
<p v-if="!editingId" class="help-text">A new Slack channel will be created with this name. Lowercase, letters/numbers/dashes only.</p>
|
||||
</div>
|
||||
<div v-if="editingId" class="field">
|
||||
<label>Slack Channel ID</label>
|
||||
<input v-model="formData.slackChannelId" type="text" placeholder="C0123456789" />
|
||||
<p class="help-text">The Slack channel ID (starts with C).</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Mapped Tags</label>
|
||||
<p class="help-text">Cooperative tags that route posts to this channel.</p>
|
||||
<div class="pill-grid">
|
||||
<button
|
||||
v-for="tag in cooperativeTags"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{
|
||||
selected: formData.tagSlugs.includes(tag.slug),
|
||||
disabled: tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug),
|
||||
}"
|
||||
:disabled="!!(tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug))"
|
||||
:title="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)
|
||||
? `Already mapped to ${tagOwner(tag.slug)}`
|
||||
: ''"
|
||||
@click="toggleTag(tag.slug)"
|
||||
>{{ tag.label }}<span
|
||||
v-if="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)"
|
||||
class="pill-owner"
|
||||
> · {{ tagOwner(tag.slug) }}</span></button>
|
||||
<p v-if="!cooperativeTags.length" class="help-text">No cooperative tags available.</p>
|
||||
</div>
|
||||
<p class="help-text">Each tag can only be mapped to one channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="closeModal">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="saveChannel">
|
||||
{{ saving ? 'Saving...' : (editingId ? 'Save Changes' : 'Create Channel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const { channels, fetchChannels } = useBoardChannels()
|
||||
|
||||
const { data: tagsData } = await useFetch('/api/tags')
|
||||
|
||||
const cooperativeTags = computed(() =>
|
||||
(tagsData.value?.tags || []).filter((t) => t.pool === 'cooperative'),
|
||||
)
|
||||
|
||||
const tagLabelMap = computed(() => {
|
||||
const map = {}
|
||||
for (const tag of tagsData.value?.tags || []) {
|
||||
map[tag.slug] = tag.label
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
|
||||
|
||||
const mappedSlugs = computed(() => {
|
||||
const set = new Set()
|
||||
for (const ch of channels.value) {
|
||||
for (const slug of ch.tagSlugs || []) set.add(slug)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
// Map of slug -> channel name, EXCLUDING the channel currently being edited.
|
||||
const otherChannelTagMap = computed(() => {
|
||||
const map = {}
|
||||
for (const ch of channels.value) {
|
||||
if (editingId.value && String(ch._id) === String(editingId.value)) continue
|
||||
for (const slug of ch.tagSlugs || []) map[slug] = ch.name
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const tagOwner = (slug) => otherChannelTagMap.value[slug] || ''
|
||||
|
||||
const unmappedTags = computed(() =>
|
||||
cooperativeTags.value.filter((t) => !mappedSlugs.value.has(t.slug)),
|
||||
)
|
||||
|
||||
// ---- Modal State ----
|
||||
const showModal = ref(false)
|
||||
const editingId = ref(null)
|
||||
const saving = ref(false)
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
slackChannelId: '',
|
||||
tagSlugs: [],
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
formData.name = ''
|
||||
formData.slackChannelId = ''
|
||||
formData.tagSlugs = []
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const openEditModal = (channel) => {
|
||||
editingId.value = channel._id
|
||||
formData.name = channel.name || ''
|
||||
formData.slackChannelId = channel.slackChannelId || ''
|
||||
formData.tagSlugs = [...(channel.tagSlugs || [])]
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const toggleTag = (slug) => {
|
||||
const idx = formData.tagSlugs.indexOf(slug)
|
||||
if (idx === -1) formData.tagSlugs.push(slug)
|
||||
else formData.tagSlugs.splice(idx, 1)
|
||||
}
|
||||
|
||||
const saveChannel = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.add({
|
||||
title: 'Missing fields',
|
||||
description: 'Name is required.',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (editingId.value && !formData.slackChannelId.trim()) {
|
||||
toast.add({
|
||||
title: 'Missing fields',
|
||||
description: 'Slack channel ID is required.',
|
||||
color: 'red',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const body = {
|
||||
name: formData.name.trim(),
|
||||
tagSlugs: formData.tagSlugs,
|
||||
}
|
||||
if (formData.slackChannelId.trim()) {
|
||||
body.slackChannelId = formData.slackChannelId.trim()
|
||||
}
|
||||
if (editingId.value) {
|
||||
await $fetch(`/api/admin/board-channels/${editingId.value}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
toast.add({ title: 'Channel updated', color: 'green' })
|
||||
} else {
|
||||
await $fetch('/api/admin/board-channels', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
toast.add({ title: 'Channel created', color: 'green' })
|
||||
}
|
||||
await fetchChannels()
|
||||
closeModal()
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: 'Save failed',
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChannel = async (channel) => {
|
||||
if (!window.confirm(`Delete channel "${channel.name}"? This cannot be undone.`)) return
|
||||
try {
|
||||
await $fetch(`/api/admin/board-channels/${channel._id}`, { method: 'DELETE' })
|
||||
toast.add({ title: 'Channel deleted', color: 'green' })
|
||||
await fetchChannels()
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: 'Delete failed',
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchChannels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-board-channels {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---- Unmapped Indicator ---- */
|
||||
.unmapped-block {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.unmapped-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ---- Tag Pills ---- */
|
||||
.tag-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tag-pill-warning {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.tag-empty {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Table ---- */
|
||||
.channels-list {
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.channels-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channels-table th,
|
||||
.channels-table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.channels-table thead th {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
font-weight: normal;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.channels-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.channel-id {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--candle-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.link-btn-danger {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.link-btn-danger:hover {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill:hover {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
.pill.selected {
|
||||
background: var(--surface);
|
||||
color: var(--text-bright);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
.pill.disabled,
|
||||
.pill:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pill.disabled:hover {
|
||||
color: var(--text-faint);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.pill-owner {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -16,12 +16,23 @@
|
|||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
<div class="field" style="margin-bottom: 0; flex: 1;">
|
||||
<input v-model="searchQuery" placeholder="Search events..." >
|
||||
<input v-model="searchQuery" placeholder="Search events..." />
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
<select v-model="typeFilter">
|
||||
<option value="all">All Types</option>
|
||||
<option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="workshop">Workshop</option>
|
||||
<option value="social">Social</option>
|
||||
<option value="showcase">Showcase</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
<select v-model="statusFilter">
|
||||
<option value="all">All Status</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="past">Past</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
|
|
@ -33,214 +44,124 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading / Error -->
|
||||
<div v-if="pending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<span>Loading events...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
Error loading events: {{ error }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- ── Upcoming Events ── -->
|
||||
<div class="section-divider">
|
||||
<span class="section-label">Upcoming Events</span>
|
||||
<span class="event-count">{{ upcomingFiltered.length }}</span>
|
||||
<!-- Events Table -->
|
||||
<div class="table-wrap">
|
||||
<div v-if="pending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<span>Loading events...</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table v-if="upcomingPaged.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Registration</th>
|
||||
<th>Tickets</th>
|
||||
<th class="col-actions-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in upcomingPaged" :key="event._id">
|
||||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
|
||||
<div v-else-if="error" class="error-state">
|
||||
Error loading events: {{ error }}
|
||||
</div>
|
||||
|
||||
<table v-else-if="filteredEvents.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Registration</th>
|
||||
<th>Tickets</th>
|
||||
<th class="col-actions-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in filteredEvents" :key="event._id">
|
||||
<!-- Title -->
|
||||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img
|
||||
:src="event.featureImage.url"
|
||||
:alt="event.title"
|
||||
@error="handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||
<span class="series-pos">{{ event.series.position }}</span>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||
<span class="series-pos">{{ event.series.position }}</span>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
<div class="event-flags">
|
||||
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||
</div>
|
||||
<div class="event-flags">
|
||||
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event) }}</span>
|
||||
<span class="date-time">{{ formatTime(event) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||
<span v-if="event.maxAttendees" class="reg-count">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-tickets">
|
||||
<template v-if="event.tickets?.enabled">
|
||||
<span class="ticket-on">Ticketing On</span>
|
||||
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||
<template v-else>
|
||||
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||
</span>
|
||||
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<template v-if="event.tickets.public.quantity">
|
||||
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Type -->
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||
</td>
|
||||
|
||||
<!-- Registration -->
|
||||
<td>
|
||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||
<span v-if="event.maxAttendees" class="reg-count">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Tickets -->
|
||||
<td class="col-tickets">
|
||||
<template v-if="event.tickets?.enabled">
|
||||
<span class="ticket-on">Ticketing On</span>
|
||||
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||
<template v-else>
|
||||
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||
</span>
|
||||
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<template v-if="event.tickets.public.quantity">
|
||||
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||
</td>
|
||||
|
||||
<div v-else class="empty-state">No upcoming events matching your filters</div>
|
||||
<!-- Actions -->
|
||||
<td class="col-actions">
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || String(event._id)}`"
|
||||
class="link-btn"
|
||||
title="View"
|
||||
>View</NuxtLink>
|
||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="upcomingPageCount > 1" class="pagination">
|
||||
<button class="page-btn" :disabled="upcomingPage === 1" @click="upcomingPage--">←</button>
|
||||
<span class="page-info">{{ upcomingPage }} / {{ upcomingPageCount }}</span>
|
||||
<button class="page-btn" :disabled="upcomingPage === upcomingPageCount" @click="upcomingPage++">→</button>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
No events found matching your criteria
|
||||
</div>
|
||||
|
||||
<!-- ── Past Events ── -->
|
||||
<div class="section-divider">
|
||||
<span class="section-label">Past Events</span>
|
||||
<span class="event-count">{{ pastFiltered.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table v-if="pastPaged.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Registration</th>
|
||||
<th>Tickets</th>
|
||||
<th class="col-actions-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in pastPaged" :key="event._id">
|
||||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||
<span class="series-pos">{{ event.series.position }}</span>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
<div class="event-flags">
|
||||
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event) }}</span>
|
||||
<span class="date-time">{{ formatTime(event) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||
<span v-if="event.maxAttendees" class="reg-count">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-tickets">
|
||||
<template v-if="event.tickets?.enabled">
|
||||
<span class="ticket-on">Ticketing On</span>
|
||||
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||
<template v-else>
|
||||
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||
</span>
|
||||
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<template v-if="event.tickets.public.quantity">
|
||||
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="empty-state">No past events matching your filters</div>
|
||||
|
||||
<div v-if="pastPageCount > 1" class="pagination">
|
||||
<button class="page-btn" :disabled="pastPage === 1" @click="pastPage--">←</button>
|
||||
<span class="page-info">{{ pastPage }} / {{ pastPageCount }}</span>
|
||||
<button class="page-btn" :disabled="pastPage === pastPageCount" @click="pastPage++">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
<!-- Confirm Delete Modal -->
|
||||
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
|
||||
<div class="modal">
|
||||
|
|
@ -264,8 +185,6 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
|
|
@ -280,12 +199,33 @@ const {
|
|||
|
||||
const searchQuery = ref('')
|
||||
const typeFilter = ref('all')
|
||||
const statusFilter = ref('all')
|
||||
const seriesFilter = ref('all')
|
||||
|
||||
const upcomingPage = ref(1)
|
||||
const pastPage = ref(1)
|
||||
const UPCOMING_PAGE_SIZE = 10
|
||||
const PAST_PAGE_SIZE = 5
|
||||
const filteredEvents = computed(() => {
|
||||
if (!events.value) return []
|
||||
|
||||
return events.value.filter((event) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
|
||||
const matchesType =
|
||||
typeFilter.value === 'all' || event.eventType === typeFilter.value
|
||||
|
||||
const eventStatus = getEventStatus(event)
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' || eventStatus.toLowerCase() === statusFilter.value
|
||||
|
||||
const matchesSeries =
|
||||
seriesFilter.value === 'all' ||
|
||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus && matchesSeries
|
||||
})
|
||||
})
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
|
|
@ -297,74 +237,19 @@ const getEventStatus = (event) => {
|
|||
return 'Past'
|
||||
}
|
||||
|
||||
const applyBaseFilters = (list) => {
|
||||
if (!list) return []
|
||||
return list.filter((event) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
|
||||
const matchesType =
|
||||
typeFilter.value === 'all' || event.eventType === typeFilter.value
|
||||
|
||||
const matchesSeries =
|
||||
seriesFilter.value === 'all' ||
|
||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||
|
||||
return matchesSearch && matchesType && matchesSeries
|
||||
})
|
||||
}
|
||||
|
||||
const upcomingFiltered = computed(() => {
|
||||
return applyBaseFilters(events.value)
|
||||
.filter((e) => getEventStatus(e) !== 'Past')
|
||||
.sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
|
||||
})
|
||||
|
||||
const pastFiltered = computed(() => {
|
||||
return applyBaseFilters(events.value)
|
||||
.filter((e) => getEventStatus(e) === 'Past')
|
||||
.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
|
||||
})
|
||||
|
||||
const upcomingPageCount = computed(() => Math.max(1, Math.ceil(upcomingFiltered.value.length / UPCOMING_PAGE_SIZE)))
|
||||
const pastPageCount = computed(() => Math.max(1, Math.ceil(pastFiltered.value.length / PAST_PAGE_SIZE)))
|
||||
|
||||
const upcomingPaged = computed(() => {
|
||||
const start = (upcomingPage.value - 1) * UPCOMING_PAGE_SIZE
|
||||
return upcomingFiltered.value.slice(start, start + UPCOMING_PAGE_SIZE)
|
||||
})
|
||||
|
||||
const pastPaged = computed(() => {
|
||||
const start = (pastPage.value - 1) * PAST_PAGE_SIZE
|
||||
return pastFiltered.value.slice(start, start + PAST_PAGE_SIZE)
|
||||
})
|
||||
|
||||
// Reset pagination when filters change
|
||||
watch([searchQuery, typeFilter, seriesFilter], () => {
|
||||
upcomingPage.value = 1
|
||||
pastPage.value = 1
|
||||
})
|
||||
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return ''
|
||||
return new Date(event.startDate).toLocaleDateString('en-US', {
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: event.displayTimezone || 'America/Toronto',
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (event) => {
|
||||
if (!event?.startDate) return ''
|
||||
return new Date(event.startDate).toLocaleTimeString('en-US', {
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: event.displayTimezone || 'America/Toronto',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +309,10 @@ const editEvent = (event) => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-events {}
|
||||
.admin-events {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
|
|
@ -462,34 +350,9 @@ const editEvent = (event) => {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---- SECTION DIVIDER ---- */
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 28px 0;
|
||||
}
|
||||
|
||||
.section-divider .section-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.event-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 1px 7px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- TABLE ---- */
|
||||
.table-wrap {
|
||||
padding: 12px 28px 24px;
|
||||
padding: 0 28px 28px;
|
||||
}
|
||||
|
||||
table {
|
||||
|
|
@ -573,7 +436,7 @@ tbody td {
|
|||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--c-founder);
|
||||
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
|
||||
border: 1px dashed rgba(138, 68, 32, 0.3);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
|
|
@ -586,7 +449,7 @@ tbody td {
|
|||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--c-founder);
|
||||
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
||||
border: 1px dashed rgba(138, 68, 32, 0.4);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
|
|
@ -635,12 +498,12 @@ tbody td {
|
|||
|
||||
.status-upcoming {
|
||||
color: var(--candle);
|
||||
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
||||
border-color: rgba(122, 90, 16, 0.3);
|
||||
}
|
||||
|
||||
.status-ongoing {
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||
border-color: rgba(74, 106, 56, 0.3);
|
||||
}
|
||||
|
||||
.status-past {
|
||||
|
|
@ -650,7 +513,7 @@ tbody td {
|
|||
|
||||
.status-cancelled {
|
||||
color: var(--ember);
|
||||
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
|
||||
border-color: rgba(138, 68, 32, 0.3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
|
@ -717,41 +580,6 @@ tbody td {
|
|||
color: var(--ember);
|
||||
}
|
||||
|
||||
/* ---- PAGINATION ---- */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--candle);
|
||||
cursor: pointer;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
color: var(--text-faint);
|
||||
border-color: var(--border);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.page-btn:not(:disabled):hover {
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- STATES ---- */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
|
|
@ -769,7 +597,7 @@ tbody td {
|
|||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
@ -871,15 +699,11 @@ tbody td {
|
|||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
padding: 16px 20px 0;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
padding: 12px 20px 20px;
|
||||
padding: 0 12px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,113 +1,104 @@
|
|||
<template>
|
||||
<PageShell
|
||||
title="Admin Dashboard"
|
||||
subtitle="Members, events, and community operations"
|
||||
>
|
||||
<AdminAlertsPanel />
|
||||
<div class="admin-dash">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<p>Members, events, and community operations</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats + Quick Actions row -->
|
||||
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||
<template #left>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Overview</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Total Members</span>
|
||||
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Active Events</span>
|
||||
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Monthly Revenue</span>
|
||||
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Pending Slack Invites</span>
|
||||
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
|
||||
</div>
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Overview</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Total Members</span>
|
||||
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Active Events</span>
|
||||
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Monthly Revenue</span>
|
||||
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Pending Slack Invites</span>
|
||||
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #right>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Quick Actions</div>
|
||||
<NuxtLink to="/admin/members" class="action-link">
|
||||
Manage Members<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events" class="action-link">
|
||||
Manage Events<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events/create" class="action-link">
|
||||
Create Event<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/series/create" class="action-link">
|
||||
Create Series<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</ColumnsLayout>
|
||||
<div class="content-block">
|
||||
<div class="section-label">Quick Actions</div>
|
||||
<NuxtLink to="/admin/members" class="action-link">
|
||||
Manage Members<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events" class="action-link">
|
||||
Manage Events<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events/create" class="action-link">
|
||||
Create Event<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/series/create" class="action-link">
|
||||
Create Series<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity row -->
|
||||
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||
<template #left>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Recent Members</div>
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Recent Members</div>
|
||||
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentMembers.length" class="item-list">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||
<div>
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
|
||||
<span class="item-sub">{{ member.email }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<CircleBadge :circle="member.circle" />
|
||||
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-else-if="recentMembers.length" class="item-list">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||
<div>
|
||||
<span class="item-name">{{ member.name }}</span>
|
||||
<span class="item-sub">{{ member.email }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">No recent members</div>
|
||||
|
||||
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-state">No recent members</div>
|
||||
|
||||
<template #right>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Upcoming Events</div>
|
||||
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
<div class="content-block">
|
||||
<div class="section-label">Upcoming Events</div>
|
||||
|
||||
<div v-else-if="upcomingEvents.length" class="item-list">
|
||||
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
|
||||
<div>
|
||||
<span class="item-name">{{ event.title }}</span>
|
||||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||
</div>
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="upcomingEvents.length" class="item-list">
|
||||
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
|
||||
<div>
|
||||
<span class="item-name">{{ event.title }}</span>
|
||||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">No upcoming events</div>
|
||||
|
||||
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</ColumnsLayout>
|
||||
</PageShell>
|
||||
<div v-else class="empty-state">No upcoming events</div>
|
||||
|
||||
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { eventTypeLabel } from '~/config/eventTypes'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
|
|
@ -134,16 +125,48 @@ const formatDateTime = (dateString) => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ---- ROWS ---- */
|
||||
.admin-row {
|
||||
.admin-dash {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
padding: 28px 28px 20px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.admin-block {
|
||||
.page-header h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---- CONTENT GRID ---- */
|
||||
.content-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.content-block {
|
||||
padding: 24px 28px;
|
||||
border-right: 1px dashed var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-block:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ---- STATS ---- */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
|
|
@ -218,12 +241,6 @@ const formatDateTime = (dateString) => {
|
|||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.item-name:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.item-sub {
|
||||
|
|
@ -291,7 +308,24 @@ a.item-name:hover {
|
|||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 768px) {
|
||||
.admin-block {
|
||||
.content-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.content-block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1104
app/pages/admin/members.vue
Normal file
1104
app/pages/admin/members.vue
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,858 +0,0 @@
|
|||
<template>
|
||||
<div class="admin-member-detail">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-nav">
|
||||
<NuxtLink to="/admin/members" class="back-link">← Members</NuxtLink>
|
||||
<NuxtLink v-if="member && member.status === 'active' && member.showInDirectory" :to="`/members/${member._id}`" class="profile-link" target="_blank">
|
||||
View public profile ↗
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1 v-if="member">{{ member.name }}</h1>
|
||||
<h1 v-else-if="pending">Loading…</h1>
|
||||
<h1 v-else>Member not found</h1>
|
||||
<p v-if="member" class="member-email">{{ member.email }}</p>
|
||||
</div>
|
||||
<div v-if="member" class="header-badges">
|
||||
<CircleBadge :circle="member.circle" />
|
||||
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
Loading member…
|
||||
</div>
|
||||
|
||||
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
|
||||
|
||||
<template v-else-if="member">
|
||||
<div class="detail-body">
|
||||
<!-- LEFT COLUMN: form + metadata -->
|
||||
<div class="detail-left">
|
||||
<!-- Edit form -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Member details</div>
|
||||
<form class="edit-form" @submit.prevent="submitEdit">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="form.name" type="text" required >
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input v-model="form.email" type="email" required >
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Circle</label>
|
||||
<select v-model="form.circle">
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Contribution ($/mo)</label>
|
||||
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
|
||||
<p class="field-hint field-hint--warn">
|
||||
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard — this form does not sync.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="form.status">
|
||||
<option
|
||||
v-for="(label, value) in STATUS_LABELS"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Role</label>
|
||||
<select v-model="form.role">
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : "Save changes" }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="resetForm">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Metadata -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Account info</div>
|
||||
<dl class="meta-list">
|
||||
<div v-if="member.memberNumber" class="meta-row">
|
||||
<dt>Member number</dt>
|
||||
<dd class="mono">#{{ member.memberNumber }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Member ID</dt>
|
||||
<dd class="mono">{{ member._id }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Joined</dt>
|
||||
<dd>{{ formatDate(member.createdAt) }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Invite email</dt>
|
||||
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
|
||||
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Slack invite</dt>
|
||||
<dd v-if="member.slackInvited" class="status-ok">
|
||||
Invited {{ formatDate(member.slackInvitedAt) }}
|
||||
</dd>
|
||||
<dd v-else class="meta-action">
|
||||
<span class="status-dim">Not yet invited</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-btn"
|
||||
:disabled="markingSlackInvited"
|
||||
@click="markSlackInvited"
|
||||
>
|
||||
{{ markingSlackInvited ? "Marking…" : "Mark as Slack invited" }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="member.helcimCustomerId" class="meta-row">
|
||||
<dt>Helcim customer</dt>
|
||||
<dd class="mono">{{ member.helcimCustomerId }}</dd>
|
||||
</div>
|
||||
<div v-if="member.helcimSubscriptionId" class="meta-row">
|
||||
<dt>Helcim subscription</dt>
|
||||
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Onboarding -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Onboarding</div>
|
||||
<dl class="meta-list">
|
||||
<div class="meta-row">
|
||||
<dt>Profile Tags</dt>
|
||||
<dd :class="hasProfileTags ? 'status-ok' : 'status-dim'">
|
||||
{{ hasProfileTags ? '✓ Complete' : '— Incomplete' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Event Page Visited</dt>
|
||||
<dd :class="member.onboarding?.eventPageVisited ? 'status-ok' : 'status-dim'">
|
||||
{{ member.onboarding?.eventPageVisited ? '✓ Complete' : '— Incomplete' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Board Engaged</dt>
|
||||
<dd :class="hasBoardEngaged ? 'status-ok' : 'status-dim'">
|
||||
{{ hasBoardEngaged ? '✓ Complete' : '— Incomplete' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Wiki Clicked</dt>
|
||||
<dd :class="member.onboarding?.wikiClicked ? 'status-ok' : 'status-dim'">
|
||||
{{ member.onboarding?.wikiClicked ? '✓ Complete' : '— Incomplete' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Completed</dt>
|
||||
<dd :class="member.onboarding?.completedAt ? 'status-ok' : 'status-dim'">
|
||||
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Notification preferences -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Notification preferences</div>
|
||||
<dl class="meta-list">
|
||||
<div class="meta-row">
|
||||
<dt>Event reminders</dt>
|
||||
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
|
||||
{{ member.notifications?.events !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Community updates</dt>
|
||||
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
|
||||
{{ member.notifications?.updates !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: activity log -->
|
||||
<div class="detail-right">
|
||||
<div class="activity-panel">
|
||||
<div class="activity-panel-header">
|
||||
<div class="section-label">Activity log</div>
|
||||
<span class="activity-legend">
|
||||
<span class="al-vis-badge">admin-only</span> = not visible to member
|
||||
</span>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
|
||||
<div class="spinner" />
|
||||
Loading activity...
|
||||
</div>
|
||||
<div v-else-if="activityEntries.length" class="activity-timeline">
|
||||
<div
|
||||
v-for="entry in activityEntries"
|
||||
:key="entry._id"
|
||||
class="al-item"
|
||||
:class="{ 'al-admin': entry.visibility === 'admin' }"
|
||||
>
|
||||
<div class="al-dot" />
|
||||
<div class="al-body">
|
||||
<div class="al-row">
|
||||
<UIcon :name="getActivity(entry).icon" class="al-icon" />
|
||||
<span class="al-text">{{ getActivity(entry).text }}</span>
|
||||
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
|
||||
</div>
|
||||
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activityHasMore" class="al-load-more">
|
||||
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
|
||||
{{ activityLoadingMore ? 'Loading...' : 'Load more' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="activity-empty">
|
||||
No activity recorded.
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatActivity } from '~/utils/activityText'
|
||||
import { STATUS_LABELS } from '~/config/memberStatus'
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
middleware: "admin",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
data: member,
|
||||
pending,
|
||||
error: fetchError,
|
||||
} = await useFetch(`/api/admin/members/${route.params.id}`);
|
||||
|
||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||
pageBreadcrumbTitle.value = member.value?.name || "";
|
||||
|
||||
watch(member, (val) => {
|
||||
pageBreadcrumbTitle.value = val?.name || "";
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
pageBreadcrumbTitle.value = "";
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
circle: "",
|
||||
contributionAmount: 0,
|
||||
status: "",
|
||||
role: "",
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
function populateForm(m) {
|
||||
if (!m) return;
|
||||
form.name = m.name;
|
||||
form.email = m.email;
|
||||
form.circle = m.circle;
|
||||
form.contributionAmount = m.contributionAmount ?? 0;
|
||||
form.status = m.status || "pending_payment";
|
||||
form.role = m.role || "member";
|
||||
}
|
||||
|
||||
// Populate once data is ready
|
||||
if (member.value) populateForm(member.value);
|
||||
watch(member, populateForm, { immediate: false });
|
||||
|
||||
function resetForm() {
|
||||
populateForm(member.value);
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await $fetch(`/api/admin/members/${route.params.id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
circle: form.circle,
|
||||
contributionAmount: form.contributionAmount,
|
||||
status: form.status,
|
||||
},
|
||||
});
|
||||
// Update role separately if it changed
|
||||
if (form.role !== member.value?.role) {
|
||||
await $fetch(`/api/admin/members/${route.params.id}/role`, {
|
||||
method: "PATCH",
|
||||
body: { role: form.role },
|
||||
});
|
||||
}
|
||||
// Reflect changes locally
|
||||
if (member.value) {
|
||||
member.value = { ...member.value, ...updated, role: form.role };
|
||||
pageBreadcrumbTitle.value = form.name;
|
||||
}
|
||||
toast.add({ title: "Member updated", color: "success" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to update member",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(val) {
|
||||
if (!val) return "—";
|
||||
return new Date(val).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "active") return "status-ok";
|
||||
if (status === "cancelled" || status === "suspended") return "status-error";
|
||||
return "status-dim";
|
||||
}
|
||||
|
||||
// Onboarding computed states
|
||||
const hasProfileTags = computed(() => {
|
||||
const m = member.value
|
||||
if (!m) return false
|
||||
return m.craftTags?.length > 0 && m.board?.topics?.length > 0
|
||||
})
|
||||
|
||||
const hasBoardEngaged = computed(() => {
|
||||
const m = member.value
|
||||
if (!m) return false
|
||||
return m.onboarding?.boardPageVisited && m.board?.topics?.some(
|
||||
t => ['help', 'interested', 'seeking'].includes(t.state)
|
||||
)
|
||||
})
|
||||
|
||||
const markingSlackInvited = ref(false)
|
||||
|
||||
async function markSlackInvited() {
|
||||
if (!member.value || markingSlackInvited.value) return
|
||||
markingSlackInvited.value = true
|
||||
try {
|
||||
const res = await $fetch(
|
||||
`/api/admin/members/${route.params.id}/slack-status`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: { slackInvited: true },
|
||||
},
|
||||
)
|
||||
member.value = { ...member.value, ...res.member }
|
||||
toast.add({ title: "Marked as Slack invited", color: "success" })
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to mark Slack invited",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
markingSlackInvited.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Activity log
|
||||
const activityEntries = ref([])
|
||||
const activityLoading = ref(false)
|
||||
const activityLoadingMore = ref(false)
|
||||
const activityHasMore = ref(false)
|
||||
const activityNextCursor = ref(null)
|
||||
|
||||
const getActivity = (entry) => formatActivity(entry)
|
||||
|
||||
async function loadActivity() {
|
||||
activityLoading.value = true
|
||||
try {
|
||||
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
|
||||
params: { limit: 20 }
|
||||
})
|
||||
activityEntries.value = data.entries
|
||||
activityHasMore.value = data.hasMore
|
||||
activityNextCursor.value = data.nextCursor
|
||||
} catch (err) {
|
||||
console.error('Failed to load activity:', err)
|
||||
} finally {
|
||||
activityLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreActivity() {
|
||||
if (!activityNextCursor.value) return
|
||||
activityLoadingMore.value = true
|
||||
try {
|
||||
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
|
||||
params: { limit: 20, before: activityNextCursor.value }
|
||||
})
|
||||
activityEntries.value.push(...data.entries)
|
||||
activityHasMore.value = data.hasMore
|
||||
activityNextCursor.value = data.nextCursor
|
||||
} catch (err) {
|
||||
console.error('Failed to load more activity:', err)
|
||||
} finally {
|
||||
activityLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadActivity)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-member-detail {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
padding: 28px 28px 20px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.profile-link {
|
||||
font-size: 11px;
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.profile-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.member-email {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 10px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN BODY ---- */
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field-hint--warn {
|
||||
color: var(--ember);
|
||||
border-left: 2px solid var(--ember);
|
||||
padding: 4px 0 4px 8px;
|
||||
}
|
||||
|
||||
.field-hint code {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px dashed var(--border);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.meta-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meta-row dt {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.02em;
|
||||
min-width: 140px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.meta-row dd {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.meta-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--candle);
|
||||
cursor: pointer;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- STATUS ---- */
|
||||
.status-ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status-dim {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
/* ---- STATES ---- */
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
padding: 40px 28px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px dashed var(--candle);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- ACTIVITY PANEL ---- */
|
||||
.detail-right {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.activity-panel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.activity-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-legend {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.activity-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px 28px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.activity-empty {
|
||||
padding: 32px 28px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.activity-timeline {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.al-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 0 10px;
|
||||
padding: 0 28px 0 20px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.al-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 27px;
|
||||
top: 18px;
|
||||
bottom: -16px;
|
||||
width: 1px;
|
||||
border-left: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.al-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.al-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.al-admin .al-dot {
|
||||
border-color: var(--candle-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.al-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.al-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.al-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.al-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.al-time {
|
||||
display: block;
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.al-vis-badge {
|
||||
font-size: 9px;
|
||||
color: var(--candle);
|
||||
border: 1px dashed var(--candle-faint);
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.al-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 28px 0;
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.activity-panel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.activity-panel-header {
|
||||
padding: 16px 20px 12px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.al-item {
|
||||
padding: 0 20px 0 14px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-row dt {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,857 +0,0 @@
|
|||
<template>
|
||||
<div class="admin-prereg">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1>Pre-Registrants</h1>
|
||||
<p v-if="stats">
|
||||
{{ stats.total }} total · {{ stats.pending }} pending ·
|
||||
{{ stats.selected }} selected · {{ stats.invited }} invited ·
|
||||
{{ stats.accepted }} accepted
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="!selectedIds.length"
|
||||
@click="markAsSelected"
|
||||
>
|
||||
Mark as Selected ({{ selectedIds.length }})
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!invitableIds.length"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Send Invites ({{ invitableIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search / Filter -->
|
||||
<div class="filter-bar">
|
||||
<div class="field" style="margin-bottom: 0; flex: 1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by name, email, city, role..."
|
||||
/>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0">
|
||||
<select v-model="statusFilter" aria-label="Filter by status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="selected">Selected</option>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrap">
|
||||
<div v-if="pending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<span>Loading pre-registrants...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
Error loading pre-registrants: {{ error }}
|
||||
</div>
|
||||
|
||||
<table v-else-if="filtered.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<label class="custom-check" aria-label="Select all">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allVisibleSelected"
|
||||
:indeterminate="!allVisibleSelected && someVisibleSelected"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
<span class="check-mark" />
|
||||
</label>
|
||||
</th>
|
||||
<th class="sortable" @click="toggleSort('name')">Name <span v-if="sortKey === 'name'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||
<th class="sortable" @click="toggleSort('email')">Email <span v-if="sortKey === 'email'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||
<th class="sortable" @click="toggleSort('city')">City <span v-if="sortKey === 'city'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||
<th class="sortable" @click="toggleSort('role')">Role <span v-if="sortKey === 'role'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||
<th class="sortable" @click="toggleSort('status')">Status <span v-if="sortKey === 'status'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||
<th class="sortable col-date" @click="toggleSort('createdAt')">Registered <span v-if="sortKey === 'createdAt'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="pr in filtered"
|
||||
:key="pr._id"
|
||||
class="selectable-row"
|
||||
:class="{ 'row-selected': selectedIds.includes(pr._id) }"
|
||||
@click="toggleSelect(pr._id)"
|
||||
>
|
||||
<td class="col-check" @click.stop>
|
||||
<label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.includes(pr._id)"
|
||||
@change="toggleSelect(pr._id)"
|
||||
/>
|
||||
<span class="check-mark" />
|
||||
</label>
|
||||
</td>
|
||||
<td class="col-name">{{ pr.name || "—" }}</td>
|
||||
<td class="col-email">{{ pr.email }}</td>
|
||||
<td>{{ pr.city || "—" }}</td>
|
||||
<td>{{ pr.role || "—" }}</td>
|
||||
<td @click.stop>
|
||||
<select
|
||||
class="inline-status"
|
||||
:class="`status-${pr.status}`"
|
||||
:value="pr.status"
|
||||
:disabled="savingId === pr._id"
|
||||
aria-label="Change status"
|
||||
@change="updateStatus(pr._id, $event.target.value)"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="selected">Selected</option>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-mono col-date">
|
||||
{{ formatDate(pr.createdAt) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
No pre-registrants found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Invites Modal -->
|
||||
<div
|
||||
v-if="showInviteModal"
|
||||
class="modal-overlay"
|
||||
@click.self="showInviteModal = false"
|
||||
>
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h2>Send Invitation Emails</h2>
|
||||
<button class="modal-close" @click="showInviteModal = false">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="help-text">
|
||||
Sending to <strong>{{ invitableIds.length }}</strong> pre-registrant{{
|
||||
invitableIds.length !== 1 ? "s" : ""
|
||||
}}. Each receives a unique invitation link valid for 48 hours.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label>Email Template</label>
|
||||
<textarea v-model="inviteTemplate" rows="12"></textarea>
|
||||
<p class="help-text" style="margin-top: 4px">
|
||||
Tokens: <code>{name}</code>, <code>{acceptLink}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invitePreview" class="field">
|
||||
<label>Preview ({{ invitePreview.name || invitePreview.email }})</label>
|
||||
<pre class="preview-box">{{ invitePreviewText }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="inviteResults" class="results-box">
|
||||
<strong>Invitations sent</strong>
|
||||
<p class="status-ok">{{ inviteResults.sent }} sent</p>
|
||||
<p v-if="inviteResults.failed" class="status-error">
|
||||
{{ inviteResults.failed }} failed
|
||||
</p>
|
||||
<div v-if="inviteResults.results?.some((r) => !r.success)">
|
||||
<p
|
||||
v-for="fail in inviteResults.results.filter((r) => !r.success)"
|
||||
:key="fail.email"
|
||||
class="status-error"
|
||||
style="font-size: 11px"
|
||||
>
|
||||
{{ fail.email }}: {{ fail.error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button @click="showInviteModal = false" class="btn">
|
||||
{{ inviteResults ? "Done" : "Cancel" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!inviteResults"
|
||||
:disabled="sendingInvites"
|
||||
@click="submitInvites"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{
|
||||
sendingInvites
|
||||
? "Sending..."
|
||||
: `Send ${invitableIds.length} invitation${invitableIds.length !== 1 ? "s" : ""}`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
middleware: "admin",
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
data: preRegistrants,
|
||||
pending,
|
||||
error,
|
||||
refresh,
|
||||
} = await useFetch("/api/admin/pre-registrants");
|
||||
|
||||
const { data: stats, refresh: refreshStats } = await useFetch(
|
||||
"/api/admin/pre-registrants/stats",
|
||||
);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const statusFilter = ref("");
|
||||
const selectedIds = ref([]);
|
||||
const savingId = ref(null);
|
||||
const sortKey = ref("");
|
||||
const sortDir = ref("asc");
|
||||
|
||||
// Invite
|
||||
const showInviteModal = ref(false);
|
||||
const sendingInvites = ref(false);
|
||||
const inviteResults = ref(null);
|
||||
|
||||
const DEFAULT_INVITE_TEMPLATE = `Hi {name},
|
||||
|
||||
You pre-registered for Ghost Guild, and we're ready for you.
|
||||
|
||||
Click below to accept your invitation, choose your circle, and set your contribution level:
|
||||
|
||||
{acceptLink}
|
||||
|
||||
This link expires in 48 hours. If it expires, we can send you a new one. Just reply to this email.
|
||||
|
||||
See you soon!
|
||||
|
||||
– Ghost Guild`;
|
||||
|
||||
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
||||
|
||||
const toggleSort = (key) => {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortKey.value = key;
|
||||
sortDir.value = "asc";
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!preRegistrants.value) return [];
|
||||
|
||||
const result = preRegistrants.value.filter((pr) => {
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
(pr.name || "").toLowerCase().includes(q) ||
|
||||
pr.email.toLowerCase().includes(q) ||
|
||||
(pr.city || "").toLowerCase().includes(q) ||
|
||||
(pr.role || "").toLowerCase().includes(q);
|
||||
|
||||
const matchesStatus = !statusFilter.value || pr.status === statusFilter.value;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
if (sortKey.value) {
|
||||
const dir = sortDir.value === "asc" ? 1 : -1;
|
||||
const key = sortKey.value;
|
||||
result.sort((a, b) => {
|
||||
const aVal = (a[key] || "").toString().toLowerCase();
|
||||
const bVal = (b[key] || "").toString().toLowerCase();
|
||||
return aVal < bVal ? -dir : aVal > bVal ? dir : 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Selection helpers
|
||||
const allVisibleSelected = computed(() => {
|
||||
if (!filtered.value.length) return false;
|
||||
return filtered.value.every((pr) => selectedIds.value.includes(pr._id));
|
||||
});
|
||||
|
||||
const someVisibleSelected = computed(() => {
|
||||
return filtered.value.some((pr) => selectedIds.value.includes(pr._id));
|
||||
});
|
||||
|
||||
// IDs of selected pre-registrants that can actually be invited (pending, selected, or invited for resend)
|
||||
const invitableIds = computed(() => {
|
||||
if (!preRegistrants.value) return [];
|
||||
return selectedIds.value.filter((id) => {
|
||||
const pr = preRegistrants.value.find((p) => p._id === id);
|
||||
return pr && (pr.status === "pending" || pr.status === "selected" || pr.status === "invited");
|
||||
});
|
||||
});
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allVisibleSelected.value) {
|
||||
const visibleIds = new Set(filtered.value.map((pr) => pr._id));
|
||||
selectedIds.value = selectedIds.value.filter((id) => !visibleIds.has(id));
|
||||
} else {
|
||||
const currentSet = new Set(selectedIds.value);
|
||||
for (const pr of filtered.value) {
|
||||
currentSet.add(pr._id);
|
||||
}
|
||||
selectedIds.value = [...currentSet];
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
const idx = selectedIds.value.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
selectedIds.value.splice(idx, 1);
|
||||
} else {
|
||||
selectedIds.value.push(id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (id, newStatus) => {
|
||||
savingId.value = id;
|
||||
try {
|
||||
await $fetch(`/api/admin/pre-registrants/${id}`, {
|
||||
method: "PUT",
|
||||
body: { status: newStatus },
|
||||
});
|
||||
await refresh();
|
||||
await refreshStats();
|
||||
toast.add({ title: "Status updated", color: "green" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to update",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
savingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Mark selected as "selected" status
|
||||
const markAsSelected = async () => {
|
||||
try {
|
||||
await $fetch("/api/admin/pre-registrants/bulk-status", {
|
||||
method: "PATCH",
|
||||
body: { ids: selectedIds.value, status: "selected" },
|
||||
});
|
||||
await refresh();
|
||||
await refreshStats();
|
||||
selectedIds.value = [];
|
||||
toast.add({ title: "Marked as selected", color: "green" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to update status",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Invite modal
|
||||
const invitePreview = computed(() => {
|
||||
if (!invitableIds.value.length || !preRegistrants.value) return null;
|
||||
return preRegistrants.value.find((pr) => pr._id === invitableIds.value[0]);
|
||||
});
|
||||
|
||||
const invitePreviewText = computed(() => {
|
||||
if (!invitePreview.value) return "";
|
||||
return inviteTemplate.value
|
||||
.replace(/\{name\}/g, invitePreview.value.name || "there")
|
||||
.replace(/\{acceptLink\}/g, "https://ghostguild.org/accept-invite#...");
|
||||
});
|
||||
|
||||
const openInviteModal = () => {
|
||||
inviteResults.value = null;
|
||||
showInviteModal.value = true;
|
||||
};
|
||||
|
||||
const submitInvites = async () => {
|
||||
sendingInvites.value = true;
|
||||
try {
|
||||
const result = await $fetch("/api/admin/pre-registrants/invite", {
|
||||
method: "POST",
|
||||
body: {
|
||||
preRegistrantIds: invitableIds.value,
|
||||
emailTemplate: inviteTemplate.value,
|
||||
},
|
||||
});
|
||||
|
||||
inviteResults.value = result;
|
||||
await refresh();
|
||||
await refreshStats();
|
||||
selectedIds.value = [];
|
||||
|
||||
toast.add({
|
||||
title: `Sent ${result.sent} invitation${result.sent !== 1 ? "s" : ""}`,
|
||||
description: result.failed ? `${result.failed} failed` : undefined,
|
||||
color: result.failed ? "orange" : "green",
|
||||
});
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to send invitations",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
sendingInvites.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-prereg {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
padding: 28px 28px 20px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- FILTER BAR ---- */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 28px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* ---- TABLE ---- */
|
||||
.table-wrap {
|
||||
padding: 0 28px 24px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 12px 10px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
thead th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
thead th.sortable:hover {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sort-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px;
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-check {
|
||||
width: 40px;
|
||||
padding-left: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.selectable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-selected {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ---- CUSTOM CHECKBOX ---- */
|
||||
.custom-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.custom-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.custom-check:hover .check-mark {
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.custom-check input:checked + .check-mark {
|
||||
background: var(--candle);
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.custom-check input:checked + .check-mark::after {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid var(--bg);
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(45deg) translateY(-1px);
|
||||
}
|
||||
|
||||
.custom-check input:indeterminate + .check-mark {
|
||||
background: var(--candle);
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.custom-check input:indeterminate + .check-mark::after {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 0;
|
||||
border-bottom: 1.5px solid var(--bg);
|
||||
}
|
||||
|
||||
.col-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.col-email {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.col-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---- STATUS BADGES ---- */
|
||||
.status-badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.status-selected {
|
||||
color: var(--candle);
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.status-invited {
|
||||
color: var(--text-bright);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
color: var(--ember);
|
||||
border-color: var(--ember);
|
||||
}
|
||||
|
||||
/* ---- INLINE STATUS SELECT ---- */
|
||||
.inline-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
|
||||
.inline-status:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* ---- STATUS INDICATORS ---- */
|
||||
.status-ok {
|
||||
color: var(--green);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--ember);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- MODALS ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
margin: 16px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-wide {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* ---- HELP TEXT ---- */
|
||||
.help-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
color: var(--text-bright);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- PREVIEW BOX ---- */
|
||||
.preview-box {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 16px;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- RESULTS BOX ---- */
|
||||
.results-box {
|
||||
padding: 16px 20px;
|
||||
border: 1px dashed var(--border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.results-box strong {
|
||||
color: var(--text-bright);
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ---- STATES ---- */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--ember);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px dashed var(--candle);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
padding: 0 12px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
<div class="series-title-row">
|
||||
<div>
|
||||
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
||||
<h2>{{ series.title }}</h2>
|
||||
<h3>{{ series.title }}</h3>
|
||||
<p class="series-desc">{{ series.description }}</p>
|
||||
</div>
|
||||
<div class="series-meta">
|
||||
|
|
@ -112,6 +112,7 @@
|
|||
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
|
||||
<button @click="editSeries(series)" class="link-btn">Edit</button>
|
||||
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
|
||||
<button @click="duplicateSeries(series)" class="link-btn">Duplicate</button>
|
||||
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,7 +171,15 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="section-label">Series Management Tools</div>
|
||||
<button @click="exportSeriesData" class="btn bulk-action">
|
||||
<button @click="reorderAllSeries" class="bulk-action">
|
||||
<strong>Auto-Reorder Series</strong>
|
||||
<span>Fix position numbers based on event dates</span>
|
||||
</button>
|
||||
<button @click="validateAllSeries" class="bulk-action">
|
||||
<strong>Validate Series Data</strong>
|
||||
<span>Check for consistency issues</span>
|
||||
</button>
|
||||
<button @click="exportSeriesData" class="bulk-action">
|
||||
<strong>Export Series Data</strong>
|
||||
<span>Download series information as JSON</span>
|
||||
</button>
|
||||
|
|
@ -566,6 +575,10 @@ const addEventToSeries = (series) => {
|
|||
navigateTo('/admin/events/create?series=true')
|
||||
}
|
||||
|
||||
const duplicateSeries = () => {
|
||||
// TODO: Implement
|
||||
}
|
||||
|
||||
const editSeries = (series) => {
|
||||
editingSeriesId.value = series.id
|
||||
editingSeriesData.value = {
|
||||
|
|
@ -683,6 +696,9 @@ const saveTicketsEdit = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const reorderAllSeries = () => { /* TODO */ }
|
||||
const validateAllSeries = () => { /* TODO */ }
|
||||
|
||||
const exportSeriesData = () => {
|
||||
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
|
|
@ -698,7 +714,10 @@ const exportSeriesData = () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-series {}
|
||||
.admin-series {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
|
|
@ -794,7 +813,7 @@ const exportSeriesData = () => {
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.series-header h2 {
|
||||
.series-header h3 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
|
@ -850,7 +869,7 @@ const exportSeriesData = () => {
|
|||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--c-founder);
|
||||
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
||||
border: 1px dashed rgba(138, 68, 32, 0.4);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -931,12 +950,12 @@ const exportSeriesData = () => {
|
|||
|
||||
.status-active {
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||
border-color: rgba(74, 106, 56, 0.3);
|
||||
}
|
||||
|
||||
.status-upcoming {
|
||||
color: var(--candle);
|
||||
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
||||
border-color: rgba(122, 90, 16, 0.3);
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
|
|
@ -946,7 +965,7 @@ const exportSeriesData = () => {
|
|||
|
||||
.status-ongoing {
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||
border-color: rgba(74, 106, 56, 0.3);
|
||||
}
|
||||
|
||||
/* ---- LINK BUTTONS ---- */
|
||||
|
|
@ -1198,7 +1217,7 @@ const exportSeriesData = () => {
|
|||
}
|
||||
|
||||
.series-list {
|
||||
padding: 20px 20px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.series-header,
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ const createAndAddEvent = async () => {
|
|||
<style scoped>
|
||||
.create-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
|
@ -270,14 +271,13 @@ const createAndAddEvent = async () => {
|
|||
.page-header p { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
.back-link {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
display: inline-block;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.back-link:hover { color: var(--candle); text-decoration: none; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
|
||||
.form-body { padding: 24px 28px; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,225 +0,0 @@
|
|||
<template>
|
||||
<div class="admin-site-content">
|
||||
<div class="page-header">
|
||||
<h1>Site Content</h1>
|
||||
<p>Editable copy rendered on the public site. Leave fields blank to use defaults.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="loading-state">Loading…</div>
|
||||
<div v-else class="content-blocks">
|
||||
<section v-for="entry in entries" :key="entry.key" class="content-block">
|
||||
<div class="block-header">
|
||||
<div>
|
||||
<div class="block-key">{{ entry.key }}</div>
|
||||
<div class="block-label">{{ KEY_LABELS[entry.key] || entry.key }}</div>
|
||||
</div>
|
||||
<div v-if="entry.updatedAt" class="block-meta">
|
||||
Updated {{ formatTime(entry.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input v-model="entry.title" type="text" maxlength="300" >
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Body</label>
|
||||
<textarea v-model="entry.body" rows="8" maxlength="5000" />
|
||||
<p class="help-text">Paragraphs separated by blank lines. Plain text only.</p>
|
||||
</div>
|
||||
|
||||
<div class="block-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="entry.saving"
|
||||
@click="save(entry)"
|
||||
>
|
||||
{{ entry.saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const KEY_LABELS = {
|
||||
'homepage.wiki_feature': 'Homepage: From the Wiki',
|
||||
}
|
||||
|
||||
const { data: keysData } = await useFetch('/api/site-content/keys')
|
||||
const knownKeys = computed(() => keysData.value?.keys || [])
|
||||
|
||||
const entries = ref([])
|
||||
const pending = ref(true)
|
||||
|
||||
const load = async () => {
|
||||
pending.value = true
|
||||
const results = await Promise.all(
|
||||
knownKeys.value.map((key) => $fetch(`/api/site-content/${key}`))
|
||||
)
|
||||
entries.value = results.map((r) => ({
|
||||
key: r.key,
|
||||
title: r.title || '',
|
||||
body: r.body || '',
|
||||
updatedAt: r.updatedAt || null,
|
||||
saving: false,
|
||||
}))
|
||||
pending.value = false
|
||||
}
|
||||
|
||||
await load()
|
||||
|
||||
const save = async (entry) => {
|
||||
entry.saving = true
|
||||
try {
|
||||
const updated = await $fetch(`/api/admin/site-content/${entry.key}`, {
|
||||
method: 'PUT',
|
||||
body: { title: entry.title, body: entry.body },
|
||||
})
|
||||
entry.updatedAt = updated.updatedAt
|
||||
toast.add({ title: 'Saved', color: 'green' })
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: 'Save failed',
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
entry.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-site-content {
|
||||
padding: 24px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.content-blocks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.block-key {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.block-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.block-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,121 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false });
|
||||
useSiteMeta({ title: "Sign Out", noindex: true });
|
||||
|
||||
// The xsrf token comes from a short-lived httpOnly cookie set by
|
||||
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).
|
||||
// We consume it during SSR, persist it into useState so the form input
|
||||
// hydrates correctly on the client, and clear the cookie immediately so the
|
||||
// token is strictly one-time use.
|
||||
const xsrf = useState<string>("oidc-logout-xsrf", () => "");
|
||||
|
||||
if (import.meta.server && !xsrf.value) {
|
||||
const cookie = useCookie("oidc_logout_xsrf");
|
||||
if (cookie.value) {
|
||||
xsrf.value = cookie.value;
|
||||
cookie.value = null;
|
||||
} else {
|
||||
// No active logout flow — somebody hit this page directly. Send them
|
||||
// back to the wiki rather than render a dead form.
|
||||
await navigateTo("https://wiki.ghostguild.org", {
|
||||
external: true,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-shell">
|
||||
<div class="dashed-box auth-box">
|
||||
<header class="auth-header">
|
||||
<p class="section-label">Ghost Guild</p>
|
||||
<h1 class="auth-title">Sign Out</h1>
|
||||
</header>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<p class="auth-body">
|
||||
Do you want to sign out of your Ghost Guild session?
|
||||
</p>
|
||||
<p class="auth-sub">
|
||||
This will sign you out of the wiki and any other connected services.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/oidc/session/end/confirm"
|
||||
class="auth-form"
|
||||
>
|
||||
<input type="hidden" name="xsrf" :value="xsrf" />
|
||||
<input type="hidden" name="logout" value="yes" />
|
||||
<button type="submit" class="btn btn-primary auth-btn">
|
||||
Yes, sign me out
|
||||
</button>
|
||||
<a href="https://wiki.ghostguild.org" class="btn auth-btn auth-btn-secondary">
|
||||
Stay signed in
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: var(--page-pad-y) var(--page-pad-x);
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--candle);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false });
|
||||
useSiteMeta({ title: "Signed Out", noindex: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-shell">
|
||||
<div class="dashed-box auth-box">
|
||||
<header class="auth-header">
|
||||
<p class="section-label">Ghost Guild</p>
|
||||
<h1 class="auth-title">Signed Out</h1>
|
||||
</header>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<p class="auth-body" role="status">
|
||||
You've been signed out.
|
||||
</p>
|
||||
|
||||
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">
|
||||
Return to Wiki
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: var(--page-pad-y) var(--page-pad-x);
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--candle);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false });
|
||||
useSiteMeta({ title: "Sign-In Error", noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Vue's default {{ }} interpolation escapes HTML on render, so these
|
||||
// values from the query string can never execute as markup — fixing the
|
||||
// XSS that existed in the old guildPageShell renderError implementation.
|
||||
const errorCode = computed(() =>
|
||||
typeof route.query.error === "string" ? route.query.error : "",
|
||||
);
|
||||
const errorDescription = computed(() =>
|
||||
typeof route.query.error_description === "string"
|
||||
? route.query.error_description
|
||||
: "",
|
||||
);
|
||||
const hasDetail = computed(
|
||||
() => Boolean(errorCode.value) || Boolean(errorDescription.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-shell">
|
||||
<div class="dashed-box auth-box">
|
||||
<header class="auth-header">
|
||||
<p class="section-label">Ghost Guild</p>
|
||||
<h1 class="auth-title">Something went wrong</h1>
|
||||
</header>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<p class="auth-body">
|
||||
An error occurred during authentication. Please try again.
|
||||
</p>
|
||||
|
||||
<div v-if="hasDetail" class="auth-detail" role="status">
|
||||
<p v-if="errorCode" class="auth-detail-code">{{ errorCode }}</p>
|
||||
<p v-if="errorDescription" class="auth-detail-desc">
|
||||
{{ errorDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">
|
||||
Return to Wiki
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: var(--page-pad-y) var(--page-pad-x);
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--candle);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-detail {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 12px 14px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.auth-detail-code {
|
||||
color: var(--ember);
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.auth-detail-desc {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,14 +2,12 @@
|
|||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
useSiteMeta({ title: "Wiki Sign In", noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
const uid = route.query.uid as string;
|
||||
|
||||
const email = ref("");
|
||||
const sent = ref(false);
|
||||
const notRegistered = ref(false);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
|
|
@ -17,85 +15,53 @@ async function sendMagicLink() {
|
|||
if (!email.value || !uid) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
notRegistered.value = false;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; registered: boolean }>(
|
||||
"/oidc/interaction/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: { email: email.value, uid },
|
||||
}
|
||||
);
|
||||
if (response.registered === false) {
|
||||
notRegistered.value = true;
|
||||
} else {
|
||||
sent.value = true;
|
||||
}
|
||||
await $fetch("/oidc/interaction/login", {
|
||||
method: "POST",
|
||||
body: { email: email.value, uid },
|
||||
});
|
||||
sent.value = true;
|
||||
} catch (e: any) {
|
||||
error.value =
|
||||
e?.data?.statusMessage || "Something went wrong. Please try again.";
|
||||
error.value = e?.data?.statusMessage || "Something went wrong. Please try again.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
sent.value = false;
|
||||
notRegistered.value = false;
|
||||
email.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="wiki-login">
|
||||
<div class="dashed-box wiki-login-box">
|
||||
<header class="wiki-login-header">
|
||||
<p class="section-label">Ghost Guild</p>
|
||||
<div class="wiki-login">
|
||||
<div class="wiki-login-card">
|
||||
<div class="wiki-login-header">
|
||||
<span class="wiki-login-overline">Ghost Guild</span>
|
||||
<h1 class="wiki-login-title">Wiki</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" >
|
||||
<div class="wiki-login-divider" />
|
||||
|
||||
<Transition name="wiki-fade" mode="out-in">
|
||||
<form
|
||||
v-if="!sent && !notRegistered"
|
||||
key="form"
|
||||
class="wiki-login-form"
|
||||
@submit.prevent="sendMagicLink"
|
||||
>
|
||||
<div class="field">
|
||||
<label for="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
<form v-if="!sent" key="form" @submit.prevent="sendMagicLink" class="wiki-login-form">
|
||||
<label for="email" class="wiki-login-label">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
class="wiki-login-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="wiki-login-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-if="error" class="wiki-login-error">{{ error }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary wiki-login-submit"
|
||||
:disabled="loading || !email"
|
||||
class="wiki-login-button"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="wiki-login-spinner"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-if="loading" class="wiki-login-spinner" />
|
||||
{{ loading ? "Sending" : "Continue" }}
|
||||
</button>
|
||||
|
||||
|
|
@ -104,130 +70,187 @@ function resetForm() {
|
|||
</p>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else-if="sent"
|
||||
key="sent"
|
||||
class="wiki-login-sent"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<h2 class="wiki-login-sent-heading">Check your inbox</h2>
|
||||
<div v-else key="sent" class="wiki-login-sent">
|
||||
<p class="wiki-login-sent-heading">Check your inbox</p>
|
||||
<p class="wiki-login-sent-detail">
|
||||
A sign-in link was sent to <strong>{{ email }}</strong>
|
||||
</p>
|
||||
<button class="wiki-login-reset" @click="resetForm">
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
key="not-registered"
|
||||
class="wiki-login-sent"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<h2 class="wiki-login-sent-heading">Not a member yet</h2>
|
||||
<p class="wiki-login-sent-detail">
|
||||
<strong>{{ email }}</strong> isn't registered as a Ghost Guild
|
||||
member. If you've pre-registered, an admin needs to invite you
|
||||
before you can sign in.
|
||||
</p>
|
||||
<p class="wiki-login-sent-detail">
|
||||
<a href="https://babyghosts.org/ghost-guild/" class="wiki-login-link"
|
||||
>Pre-register at Baby Ghosts</a
|
||||
>
|
||||
or email
|
||||
<a href="mailto:hello@babyghosts.org" class="wiki-login-link"
|
||||
>hello@babyghosts.org</a
|
||||
>
|
||||
if you think this is a mistake.
|
||||
</p>
|
||||
<button class="wiki-login-reset" @click="resetForm">
|
||||
<button
|
||||
@click="sent = false; email = '';"
|
||||
class="wiki-login-link"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wiki-login {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: var(--page-pad-y) var(--page-pad-x);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 70%, rgba(184, 135, 58, 0.06) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 70% 30%, rgba(178, 104, 64, 0.04) 0%, transparent 60%),
|
||||
var(--color-guild-900);
|
||||
}
|
||||
|
||||
.wiki-login-box {
|
||||
.dark .wiki-login {
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 70%, rgba(224, 184, 110, 0.05) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 70% 30%, rgba(218, 154, 114, 0.03) 0%, transparent 60%),
|
||||
var(--color-guild-900);
|
||||
}
|
||||
|
||||
.wiki-login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 24px 28px;
|
||||
padding: 2.5rem 2rem 2rem;
|
||||
background: var(--color-guild-800);
|
||||
border: 1px solid var(--color-guild-700);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.06),
|
||||
0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .wiki-login-card {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.2),
|
||||
0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.wiki-login-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wiki-login-overline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-guild-400);
|
||||
}
|
||||
|
||||
.wiki-login-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--candle);
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
color: var(--color-candlelight-400);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.wiki-login-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--color-guild-600),
|
||||
transparent
|
||||
);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.wiki-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wiki-login-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-guild-300);
|
||||
}
|
||||
|
||||
.wiki-login-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-guild-100);
|
||||
background: var(--color-guild-900);
|
||||
border: 1px solid var(--color-guild-600);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.wiki-login-input::placeholder {
|
||||
color: var(--color-guild-500);
|
||||
}
|
||||
|
||||
.wiki-login-input:focus {
|
||||
border-color: var(--color-candlelight-500);
|
||||
box-shadow: 0 0 0 3px rgba(184, 135, 58, 0.15);
|
||||
}
|
||||
|
||||
.wiki-login-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wiki-login-error {
|
||||
font-size: 13px;
|
||||
color: var(--ember);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-ember-400);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-login-submit {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
.wiki-login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-guild-50);
|
||||
background: var(--color-candlelight-500);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.wiki-login-submit:disabled {
|
||||
.wiki-login-button:hover:not(:disabled) {
|
||||
background: var(--color-candlelight-400);
|
||||
}
|
||||
|
||||
.wiki-login-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.wiki-login-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wiki-login-spinner {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid color-mix(in srgb, var(--bg) 35%, transparent);
|
||||
border-top-color: var(--bg);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: wiki-spin 0.7s linear infinite;
|
||||
animation: wiki-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes wiki-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.wiki-login-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-guild-500);
|
||||
text-align: center;
|
||||
margin: 4px 0 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-login-sent {
|
||||
|
|
@ -235,58 +258,45 @@ function resetForm() {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wiki-login-sent-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
color: var(--color-guild-100);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-login-sent-detail {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-guild-400);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wiki-login-sent-detail strong {
|
||||
color: var(--text-bright);
|
||||
color: var(--color-guild-200);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wiki-login-link {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.wiki-login-link:hover {
|
||||
color: var(--candle-dim);
|
||||
}
|
||||
|
||||
.wiki-login-reset {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--candle);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-candlelight-500);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
padding: 0;
|
||||
margin-top: 0.5rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wiki-login-reset:hover {
|
||||
color: var(--candle-dim);
|
||||
.wiki-login-link:hover {
|
||||
color: var(--color-candlelight-400);
|
||||
}
|
||||
|
||||
/* State transition */
|
||||
/* Transition */
|
||||
.wiki-fade-enter-active,
|
||||
.wiki-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -1,395 +0,0 @@
|
|||
<template>
|
||||
<PageShell title="Bulletin Board" :subtitle="pageSubtitle">
|
||||
<p class="page-intro">
|
||||
Make offers and requests related to shared interests and cooperative
|
||||
topics.
|
||||
</p>
|
||||
<div class="action-bar">
|
||||
<button
|
||||
v-if="cooperativeTags.length > 0"
|
||||
type="button"
|
||||
class="drawer-btn"
|
||||
@click="showTagsDrawer = !showTagsDrawer"
|
||||
>
|
||||
Tags...
|
||||
<span v-if="activeTagFilter" class="tag-count-badge">1</span>
|
||||
</button>
|
||||
<button type="button" class="new-post-btn" @click="openNewForm">
|
||||
+ New Post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showTagsDrawer && cooperativeTags.length > 0" class="tags-drawer">
|
||||
<div class="skills-bar">
|
||||
<span class="tag-label">Filter:</span>
|
||||
<button
|
||||
v-for="tag in visibleTagOptions"
|
||||
:key="tag.slug"
|
||||
type="button"
|
||||
class="skill-tag"
|
||||
:class="{ active: activeTagFilter === tag.slug }"
|
||||
@click="toggleTagFilter(tag.slug)"
|
||||
>
|
||||
{{ tag.label || tag.name }}
|
||||
</button>
|
||||
<button
|
||||
v-if="cooperativeTags.length > 10"
|
||||
type="button"
|
||||
class="more-btn"
|
||||
@click="showAllTags = !showAllTags"
|
||||
>
|
||||
{{ showAllTags ? 'Show less' : `+${cooperativeTags.length - 10} more` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showForm" class="form-wrapper">
|
||||
<BoardPostForm
|
||||
:post="editingPost"
|
||||
:tags="cooperativeTags"
|
||||
@submit="handleSubmit"
|
||||
@cancel="closeForm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<p>Loading board...</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="posts.length === 0" class="empty-state">
|
||||
<p class="empty-title">No posts yet.</p>
|
||||
<p class="empty-sub">Be the first to post.</p>
|
||||
<button type="button" class="new-post-btn" @click="openNewForm">
|
||||
+ New Post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="post-grid">
|
||||
<BoardPostCard
|
||||
v-for="post in posts"
|
||||
:key="post._id"
|
||||
:post="post"
|
||||
:channels="channels"
|
||||
:tags="cooperativeTags"
|
||||
:editable="isAuthor(post)"
|
||||
:pending-delete="pendingDeleteId === post._id"
|
||||
@edit="handleEdit"
|
||||
@delete="requestDelete"
|
||||
@confirm-delete="confirmDelete"
|
||||
@cancel-delete="cancelDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-state">
|
||||
<p>Loading board...</p>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({ middleware: ['members-auth'] })
|
||||
|
||||
const { memberData } = useAuth()
|
||||
const { posts, loading, fetchPosts, createPost, updatePost, deletePost } = useBoardPosts()
|
||||
const { channels, fetchChannels } = useBoardChannels()
|
||||
const toast = useToast()
|
||||
|
||||
const cooperativeTags = ref([])
|
||||
const showTagsDrawer = ref(false)
|
||||
const showAllTags = ref(false)
|
||||
const activeTagFilter = ref(null)
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingPost = ref(null)
|
||||
const pendingDeleteId = ref(null)
|
||||
|
||||
const currentMemberId = computed(() => memberData.value?._id || null)
|
||||
|
||||
const pageSubtitle = computed(() => {
|
||||
const count = posts.value.length
|
||||
return `${count} post${count === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
const visibleTagOptions = computed(() =>
|
||||
showAllTags.value ? cooperativeTags.value : cooperativeTags.value.slice(0, 10)
|
||||
)
|
||||
|
||||
const isAuthor = (post) => {
|
||||
if (!currentMemberId.value || !post.author) return false
|
||||
const authorId = typeof post.author === 'object' ? post.author._id : post.author
|
||||
return String(authorId) === String(currentMemberId.value)
|
||||
}
|
||||
|
||||
const toggleTagFilter = async (slug) => {
|
||||
activeTagFilter.value = activeTagFilter.value === slug ? null : slug
|
||||
await fetchPosts(activeTagFilter.value ? { tag: activeTagFilter.value } : {})
|
||||
}
|
||||
|
||||
const openNewForm = () => {
|
||||
editingPost.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
showForm.value = false
|
||||
editingPost.value = null
|
||||
}
|
||||
|
||||
const handleEdit = (post) => {
|
||||
editingPost.value = post
|
||||
showForm.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const requestDelete = (post) => {
|
||||
pendingDeleteId.value = post._id
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
pendingDeleteId.value = null
|
||||
}
|
||||
|
||||
const confirmDelete = async (post) => {
|
||||
try {
|
||||
await deletePost(post._id)
|
||||
pendingDeleteId.value = null
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: 'Failed to delete post',
|
||||
description: err?.data?.message || err?.message || 'Please try again.',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (body) => {
|
||||
try {
|
||||
if (editingPost.value) {
|
||||
await updatePost(editingPost.value._id, body)
|
||||
} else {
|
||||
await createPost(body)
|
||||
}
|
||||
closeForm()
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: editingPost.value ? 'Failed to update post' : 'Failed to create post',
|
||||
description: err?.data?.message || err?.message || 'Please try again.',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadTags = async () => {
|
||||
const data = await $fetch('/api/tags')
|
||||
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
|
||||
}
|
||||
|
||||
useSiteMeta({
|
||||
title: 'Bulletin Board',
|
||||
description:
|
||||
'The Ghost Guild bulletin board. Members post offers and requests around shared interests and cooperative topics.',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadTags(), fetchPosts(), fetchChannels()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-intro {
|
||||
padding: 12px 24px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.new-post-btn {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--candle);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--candle-faint);
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.new-post-btn:hover {
|
||||
border-style: solid;
|
||||
background: rgba(154, 116, 32, 0.08);
|
||||
}
|
||||
.new-post-btn:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- TAGS DRAWER ---- */
|
||||
.drawer-btn {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.drawer-btn:hover {
|
||||
border-color: var(--candle-faint);
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-btn:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tag-count-badge {
|
||||
font-size: 9px;
|
||||
background: var(--candle-faint);
|
||||
color: var(--candle);
|
||||
padding: 0 4px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.tags-drawer {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.skills-bar {
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.skills-bar .tag-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
margin-right: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.skills-bar .skill-tag {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.skills-bar .skill-tag:hover {
|
||||
border-color: var(--candle-faint);
|
||||
color: var(--text);
|
||||
}
|
||||
.skills-bar .skill-tag.active {
|
||||
border-color: var(--candle-dim);
|
||||
border-style: solid;
|
||||
color: var(--candle);
|
||||
background: rgba(154, 116, 32, 0.08);
|
||||
}
|
||||
.skills-bar .skill-tag:focus-visible,
|
||||
.more-btn:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.more-btn {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--candle);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.more-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- FORM WRAPPER ---- */
|
||||
.form-wrapper {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* ---- POST GRID (masonry via CSS columns) ---- */
|
||||
.post-grid {
|
||||
column-count: 2;
|
||||
column-gap: 16px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.post-grid > * {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
@media (min-width: 1400px) {
|
||||
.post-grid {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- LOADING / EMPTY ---- */
|
||||
.loading-state {
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-title {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.post-grid {
|
||||
column-count: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.action-bar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.skills-bar {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.post-grid,
|
||||
.form-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,30 +1,44 @@
|
|||
<template>
|
||||
<div class="coming-soon">
|
||||
<h1 class="coming-soon-title">Ghost Guild</h1>
|
||||
<p v-if="!isAuthenticated" class="coming-soon-subtitle">Coming Soon</p>
|
||||
<div class="min-h-screen w-full flex flex-col items-center justify-center px-4">
|
||||
<h1 class="text-display-xl font-bold mb-2 uppercase font-sans!">Ghost Guild</h1>
|
||||
<p
|
||||
v-if="!isAuthenticated"
|
||||
class="text-display-sm text-guild-400 mb-10 uppercase py-4 text-center font-sans!">
|
||||
Coming Soon
|
||||
</p>
|
||||
|
||||
<!-- Logged-in state -->
|
||||
<div v-if="isAuthenticated" class="coming-soon-auth">
|
||||
<p>
|
||||
Welcome, <strong>{{ memberData.name || memberData.email }}</strong>
|
||||
<div v-if="isAuthenticated" class="w-full max-w-sm flex flex-col items-center space-y-4 text-center mt-8">
|
||||
<p class="text-guild-200 font-sans py-4 text-center">
|
||||
Welcome, <strong class="text-guild-100">{{ memberData.name || memberData.email }}</strong>
|
||||
</p>
|
||||
<a href="https://wiki.ghostguild.org" class="coming-soon-btn">
|
||||
<a
|
||||
href="https://wiki.ghostguild.org"
|
||||
class="block w-full py-3 px-6 bg-candlelight-500 hover:bg-candlelight-600 text-guild-900 font-semibold rounded-full uppercase tracking-wide transition-colors font-sans text-center">
|
||||
Go to Wiki
|
||||
</a>
|
||||
<button class="coming-soon-signout" @click="handleLogout">
|
||||
<button
|
||||
class="block w-full text-sm text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide transition-colors"
|
||||
@click="handleLogout">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<div v-else class="coming-soon-form">
|
||||
<div v-else class="w-full max-w-sm">
|
||||
<!-- Success state -->
|
||||
<div v-if="loginSuccess" class="coming-soon-success">
|
||||
<h3>Check your email</h3>
|
||||
<p>
|
||||
<div v-if="loginSuccess" class="text-center py-4">
|
||||
<div
|
||||
class="w-16 h-16 bg-candlelight-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-10 h-10 text-candlelight-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-guild-100 mb-2">
|
||||
Check your email
|
||||
</h3>
|
||||
<p class="text-guild-300">
|
||||
We've sent a magic link to
|
||||
<strong>{{ email }}</strong
|
||||
>. Click the link to sign in.
|
||||
<strong class="text-guild-100">{{ email }}</strong>.
|
||||
Click the link to sign in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -36,28 +50,32 @@
|
|||
type="email"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
placeholder="your.email@example.com" />
|
||||
</UFormField>
|
||||
|
||||
<div v-if="loginError" class="coming-soon-error">
|
||||
<p>{{ loginError }}</p>
|
||||
<div v-if="loginError" class="mb-4 p-3 bg-ember-500/10 border border-ember-500/30 rounded-lg">
|
||||
<p class="text-ember-400 text-sm">{{ loginError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="coming-soon-actions">
|
||||
<div class="flex justify-center">
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="isLoggingIn"
|
||||
:disabled="!isFormValid"
|
||||
size="lg"
|
||||
class="uppercase tracking-wide font-semibold whitespace-nowrap"
|
||||
>
|
||||
class="rounded-full uppercase tracking-wide font-semibold whitespace-nowrap">
|
||||
Send Magic Link
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="coming-soon-preregister">
|
||||
<a href="https://babyghosts.org/ghost-guild/">Pre-Register</a>
|
||||
<div class="text-center pt-6 border-t border-guild-700 mt-6">
|
||||
<p class="text-guild-400 text-sm">
|
||||
<a
|
||||
href="https://babyghosts.fund/ghost-guild/"
|
||||
class="text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide">
|
||||
Pre-Register
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</UForm>
|
||||
</div>
|
||||
|
|
@ -109,138 +127,3 @@ const handleLogout = async () => {
|
|||
await logout();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coming-soon {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.coming-soon-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.coming-soon-subtitle {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 40px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.coming-soon-auth {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.coming-soon-auth strong {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.coming-soon-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
background: var(--parch);
|
||||
color: var(--parch-text);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.coming-soon-btn:hover {
|
||||
background: var(--parch-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.coming-soon-signout {
|
||||
font-size: 12px;
|
||||
color: var(--candle);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coming-soon-signout:hover {
|
||||
color: var(--candle-dim);
|
||||
}
|
||||
|
||||
.coming-soon-form {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.coming-soon-success {
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.coming-soon-success h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.coming-soon-success p {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.coming-soon-success strong {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.coming-soon-error {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--ember-bg);
|
||||
border: 1px dashed var(--ember);
|
||||
}
|
||||
|
||||
.coming-soon-error p {
|
||||
color: var(--ember);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.coming-soon-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.coming-soon-preregister {
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed var(--border);
|
||||
margin-top: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.coming-soon-preregister a {
|
||||
color: var(--candle);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
`
|
||||
<template>
|
||||
<PageShell
|
||||
title="Community Guidelines"
|
||||
subtitle="What you're agreeing to when you join Ghost Guild"
|
||||
>
|
||||
<div class="guidelines-prose">
|
||||
<section class="guidelines-section">
|
||||
<h2>Welcome</h2>
|
||||
<p>
|
||||
Ghost Guild is a community for game workers exploring cooperative and
|
||||
worker-centric models. By joining, you're becoming part of a growing
|
||||
community of practice built on mutual support, shared learning, and
|
||||
solidarity.
|
||||
</p>
|
||||
<p>
|
||||
This page covers everything you're agreeing to as a member. Related
|
||||
policies are linked throughout and are part of this agreement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>What Membership Means</h2>
|
||||
<p>
|
||||
Ghost Guild membership is about community and participation, not
|
||||
access to hidden content. Every member gets the same access to
|
||||
resources, events, and community spaces regardless of what they
|
||||
contribute financially.
|
||||
</p>
|
||||
<p>
|
||||
When you join Ghost Guild, you become a Class B member of Baby Ghosts,
|
||||
our parent charity. Class A membership is held by a small group
|
||||
involved in governance, mainly our directors. Class A and Class B have
|
||||
equal access to resources, community, events, and the Solidarity Fund.
|
||||
Voting at the Annual General Meeting is limited to Class A members, as
|
||||
set out in our
|
||||
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
|
||||
</p>
|
||||
|
||||
<h3>The three circles</h3>
|
||||
<p>
|
||||
Our three membership circles describe where you are in your journey
|
||||
with cooperative models. They're not a hierarchy.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Community Circle:</strong> for folks learning about
|
||||
cooperative principles
|
||||
</li>
|
||||
<li>
|
||||
<strong>Founder Circle:</strong> for those actively building a
|
||||
cooperative studio
|
||||
</li>
|
||||
<li>
|
||||
<strong>Practitioner Circle:</strong> for experienced cooperative
|
||||
studio leaders
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
You can move between circles as your work and interests evolve. Just
|
||||
reach out to the Membership Committee when you're ready.
|
||||
</p>
|
||||
|
||||
<h3>Solidarity economics</h3>
|
||||
<p>
|
||||
We operate on a pay-what-you-can model. Your contribution is fully
|
||||
decoupled from your circle. Members with more financial capacity help
|
||||
make space for members with less.
|
||||
</p>
|
||||
<p>
|
||||
If money is tight, choose the $0 option. If you have more capacity,
|
||||
contributing at a higher tier supports others. You can adjust your
|
||||
contribution anytime as your situation changes.
|
||||
</p>
|
||||
<p>
|
||||
The Solidarity Fund is administered by the Membership Committee, and
|
||||
its status is reported to the community each year.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Your Rights as a Member</h2>
|
||||
<p>As a Ghost Guild member, you have:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Equal access to resources, events, community spaces, and the
|
||||
Solidarity Fund, regardless of circle or contribution level
|
||||
</li>
|
||||
<li>
|
||||
Support from the Solidarity Fund if you face financial barriers
|
||||
</li>
|
||||
<li>The ability to move between circles as your journey evolves</li>
|
||||
<li>
|
||||
Privacy protection in line with our
|
||||
<NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Your Responsibilities as a Member</h2>
|
||||
<p>As a Ghost Guild member, you commit to:</p>
|
||||
<ol>
|
||||
<li>
|
||||
Upholding Baby Ghosts' and Gamma Space's shared values, including
|
||||
cooperation, mutual support, and equity
|
||||
</li>
|
||||
<li>
|
||||
Treating fellow members with care and following our
|
||||
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
|
||||
at all times
|
||||
</li>
|
||||
<li>
|
||||
Participating within your capacity. This is a community of practice.
|
||||
Show up in whatever way works for you.
|
||||
</li>
|
||||
<li>
|
||||
Contributing dues in line with your ability, or working with the
|
||||
Membership Committee to access the Solidarity Fund
|
||||
</li>
|
||||
<li>
|
||||
Approaching disagreements with openness and using our
|
||||
<NuxtLink to="/policies/conflict-resolution"
|
||||
>Conflict Resolution Policy</NuxtLink
|
||||
>
|
||||
when conflicts arise
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3>Community privacy</h3>
|
||||
<p>
|
||||
Our community spaces, including our shared Slack workspace, operate
|
||||
with an assumption of privacy. This means:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Don't share screenshots, message content, or other community content
|
||||
externally without the explicit consent of everyone involved
|
||||
</li>
|
||||
<li>
|
||||
Don't contribute community conversations, messages, or member
|
||||
content to generative AI tools like ChatGPT or Claude. This protects
|
||||
everyone's privacy and contributions.
|
||||
</li>
|
||||
<li>
|
||||
Violations of these privacy norms can result in removal from the
|
||||
community
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Contributing to the Commons</h2>
|
||||
<p>
|
||||
The Ghost Guild wiki at
|
||||
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
|
||||
knowledge commons. Anything you contribute to it is automatically and
|
||||
irrevocably licensed under the
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
>Creative Commons Attribution-ShareAlike 4.0 International
|
||||
License</a
|
||||
>
|
||||
(CC-BY-SA 4.0) at the moment you post it.
|
||||
</p>
|
||||
<p>In plain terms:</p>
|
||||
<ul>
|
||||
<li>You still hold the copyright to what you wrote</li>
|
||||
<li>
|
||||
Anyone (members, the public, other cooperatives, organizations
|
||||
adapting the material) can use, share, adapt, and build on your
|
||||
contribution, including for commercial purposes, as long as they
|
||||
credit you and release their derivatives under the same license
|
||||
</li>
|
||||
<li>
|
||||
You can't withdraw your contribution from the commons later, even if
|
||||
you leave Ghost Guild
|
||||
</li>
|
||||
<li>
|
||||
If wiki material gets republished elsewhere (like on
|
||||
<a href="https://coop.love">coop.love</a>), it stays under CC-BY-SA
|
||||
4.0 and you stay credited
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
This is how a knowledge commons works, and it's central to what Ghost
|
||||
Guild is doing. If you have something you'd rather keep private or
|
||||
under a more restrictive license, don't put it in the wiki.
|
||||
</p>
|
||||
<p>
|
||||
Profile information, bulletin board posts, comments in member-only
|
||||
spaces, and direct messages aren't part of the commons and stay under
|
||||
your control. See our
|
||||
<NuxtLink to="/policies/terms">Terms of Service</NuxtLink> for the
|
||||
details.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Our Privacy Commitments</h2>
|
||||
<p>
|
||||
Your personal information is used to administer your membership and to
|
||||
communicate with you about Ghost Guild.
|
||||
</p>
|
||||
<p>
|
||||
We use a small number of third-party services to run the platform
|
||||
(payment processing, email, hosting, analytics). Our
|
||||
<NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink> lists who
|
||||
they are and what they see.
|
||||
</p>
|
||||
<p>
|
||||
We don't sell your data, share it for marketing, or feed any community
|
||||
content into generative AI tools.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Membership Terms</h2>
|
||||
<p>
|
||||
Membership is valid for one year from joining or renewal. Dues can be
|
||||
paid monthly or annually, and renewal happens by continuing dues
|
||||
payments or arranging support through the Solidarity Fund.
|
||||
</p>
|
||||
<p>
|
||||
You can adjust your contribution to any amount, including $0, at any
|
||||
time. There's no minimum contribution to maintain membership in good
|
||||
standing. A failed monthly payment doesn't end your membership. If a
|
||||
payment doesn't go through, we'll reach out to work it out.
|
||||
</p>
|
||||
<p>
|
||||
You can end your membership at any time by contacting the Membership
|
||||
Committee. In rare cases, membership may be ended for serious
|
||||
violations of these guidelines, following the process in our
|
||||
<NuxtLink to="/policies/conflict-resolution"
|
||||
>Conflict Resolution Policy</NuxtLink
|
||||
>. Dues are not refunded.
|
||||
</p>
|
||||
<p>
|
||||
If you leave, your wiki contributions remain in the commons under
|
||||
their CC-BY-SA 4.0 license. Your other personal information is handled
|
||||
according to the retention rules in our
|
||||
<NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Related Policies</h2>
|
||||
<p>These policies are part of what you agree to by joining:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/policies/conflict-resolution"
|
||||
>Conflict Resolution Policy</NuxtLink
|
||||
>
|
||||
</li>
|
||||
<li><NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink></li>
|
||||
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="guidelines-section">
|
||||
<h2>Agreement</h2>
|
||||
<p>
|
||||
By joining Ghost Guild, you're confirming that you've read,
|
||||
understood, and agree to these community guidelines and the policies
|
||||
linked above.
|
||||
</p>
|
||||
<p class="welcome-line">Welcome to the community, Ghostie!</p>
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useSiteMeta({
|
||||
title: "Community Guidelines",
|
||||
description:
|
||||
"What you're agreeing to when you join Ghost Guild — community values, member commitments, and the policies that govern participation.",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guidelines-prose {
|
||||
max-width: 720px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.guidelines-section {
|
||||
padding: 28px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.guidelines-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.guidelines-section h2 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.guidelines-section h3 {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-bright);
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
|
||||
.guidelines-section p {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guidelines-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
.guidelines-section ul li {
|
||||
position: relative;
|
||||
padding: 2px 0 2px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.guidelines-section ul li::before {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
color: var(--candle-faint);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.guidelines-section ol {
|
||||
list-style: none;
|
||||
counter-reset: guideline-item;
|
||||
padding: 0;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
.guidelines-section ol li {
|
||||
counter-increment: guideline-item;
|
||||
position: relative;
|
||||
padding: 2px 0 2px 28px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.guidelines-section ol li::before {
|
||||
content: counter(guideline-item) ".";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
width: 22px;
|
||||
color: var(--candle-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.guidelines-section a {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.guidelines-section strong {
|
||||
color: var(--text-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.welcome-line {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-style: italic;
|
||||
color: var(--text-bright);
|
||||
font-size: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.guidelines-prose {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<script setup>
|
||||
await navigateTo("/board", { replace: true });
|
||||
</script>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<script setup>
|
||||
await navigateTo("/board", { replace: true });
|
||||
</script>
|
||||
511
app/pages/events/[id].vue
Normal file
511
app/pages/events/[id].vue
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
<template>
|
||||
<div v-if="pending" class="loading">Loading event details...</div>
|
||||
|
||||
<div v-else-if="error" class="loading">
|
||||
<h2>Event Not Found</h2>
|
||||
<p>The event you're looking for doesn't exist.</p>
|
||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- BACK LINK -->
|
||||
<div class="back-link">
|
||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- EVENT HEADER -->
|
||||
<div class="event-header">
|
||||
<h1>{{ event.title }}</h1>
|
||||
<div class="event-meta-row">
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Date</span>
|
||||
{{ formatDate(event.startDate) }}
|
||||
</div>
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Time</span>
|
||||
{{ formatTime(event.startDate, event.endDate) }}
|
||||
</div>
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Location</span>
|
||||
{{ event.location }}
|
||||
</div>
|
||||
<div v-if="event.circle" class="event-meta-item">
|
||||
<CircleBadge :circle="event.circle" />
|
||||
</div>
|
||||
<div v-if="event.maxAttendees" class="event-meta-item">
|
||||
<span class="meta-label">Capacity</span>
|
||||
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANCELLED NOTICE -->
|
||||
<div v-if="event.isCancelled" class="cancelled-notice">
|
||||
<strong>Event Cancelled</strong>
|
||||
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
|
||||
<p v-else>This event has been cancelled. We apologize for any inconvenience.</p>
|
||||
</div>
|
||||
|
||||
<!-- TWO-COLUMN BODY -->
|
||||
<div class="event-body">
|
||||
<!-- LEFT: MAIN CONTENT -->
|
||||
<div class="event-main">
|
||||
<!-- Series Badge -->
|
||||
<div v-if="event.series?.isSeriesEvent" class="section">
|
||||
<div class="series-note">
|
||||
<span class="section-label">Part of Series</span>
|
||||
<NuxtLink :to="`/series/${event.series.id}`">{{ event.series.title }}</NuxtLink>
|
||||
— Event {{ event.series.position }} of {{ event.series.totalEvents }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Circles -->
|
||||
<div v-if="event.targetCircles?.length" class="section">
|
||||
<span class="section-label">Recommended for</span>
|
||||
<div class="circle-badges">
|
||||
<CircleBadge v-for="circle in event.targetCircles" :key="circle" :circle="circle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="section">
|
||||
<h2>About This Event</h2>
|
||||
<p>{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Series Description -->
|
||||
<div v-if="event.series?.isSeriesEvent && event.series.description" class="section">
|
||||
<h2>About the {{ event.series.title }} Series</h2>
|
||||
<p>{{ event.series.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Agenda -->
|
||||
<div v-if="event.agenda?.length" class="section">
|
||||
<h2>Agenda</h2>
|
||||
<ol class="agenda-list">
|
||||
<li v-for="(item, index) in event.agenda" :key="index">{{ item }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Speakers -->
|
||||
<div v-if="event.speakers?.length" class="section">
|
||||
<h2>Speakers</h2>
|
||||
<div v-for="speaker in event.speakers" :key="speaker.name" class="speaker">
|
||||
<div class="speaker-name">{{ speaker.name }}</div>
|
||||
<div v-if="speaker.role" class="speaker-role">{{ speaker.role }}</div>
|
||||
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: SIDEBAR PANELS -->
|
||||
<div v-if="!event.isCancelled" class="event-aside">
|
||||
<!-- Ticket System -->
|
||||
<EventTicketPurchase
|
||||
v-if="event.tickets?.enabled"
|
||||
:event-id="event._id || event.id"
|
||||
:event-start-date="event.startDate"
|
||||
:event-title="event.title"
|
||||
:user-email="memberData?.email"
|
||||
@success="handleTicketSuccess"
|
||||
@error="handleTicketError"
|
||||
/>
|
||||
|
||||
<!-- Legacy Registration -->
|
||||
<template v-else>
|
||||
<!-- Already Registered -->
|
||||
<div v-if="registrationStatus === 'registered'" class="dashed-box">
|
||||
<div class="box-title">Registration</div>
|
||||
<p class="reg-status" style="color: var(--green);">You're registered!</p>
|
||||
<p class="reg-price">Confirmation sent to your email</p>
|
||||
<button class="btn btn-danger" @click="handleCancelRegistration" :disabled="isCancelling">
|
||||
{{ isCancelling ? 'Cancelling...' : 'Cancel Registration' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member Status Issues -->
|
||||
<div v-else-if="memberData && !canRSVP" class="dashed-box">
|
||||
<div class="box-title">Registration</div>
|
||||
<p class="reg-status" style="color: var(--ember);">{{ statusConfig.label }}</p>
|
||||
<p class="reg-price">{{ getRSVPMessage() }}</p>
|
||||
<NuxtLink v-if="isPendingPayment" to="#" @click.prevent="completePayment">
|
||||
<button class="btn btn-primary" :disabled="isProcessingPayment">
|
||||
{{ isProcessingPayment ? 'Processing...' : 'Complete Payment' }}
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Members-Only Gate -->
|
||||
<div v-else-if="event.membersOnly && memberData && !isMember" class="dashed-box">
|
||||
<div class="box-title">Registration</div>
|
||||
<p class="reg-status" style="color: var(--ember);">Membership Required</p>
|
||||
<p class="reg-price">This event is exclusive to members.</p>
|
||||
<NuxtLink to="/join"><button class="btn btn-primary">Become a Member</button></NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Can Register (logged in) -->
|
||||
<div v-else-if="memberData && (!event.membersOnly || isMember)" class="dashed-box">
|
||||
<div class="box-title">Registration</div>
|
||||
<div v-if="event.maxAttendees" class="reg-status">
|
||||
{{ event.maxAttendees - (event.registeredCount || 0) }} spots remaining
|
||||
</div>
|
||||
<div class="reg-price">Free for members</div>
|
||||
<button class="btn btn-primary" @click="handleRegistration" :disabled="isRegistering">
|
||||
{{ isRegistering ? 'Registering...' : 'Register for this event' }}
|
||||
</button>
|
||||
<a :href="`/api/events/${route.params.id}/calendar`" download class="cal-link">Add to calendar</a>
|
||||
</div>
|
||||
|
||||
<!-- Not Logged In -->
|
||||
<div v-else class="dashed-box">
|
||||
<div class="box-title">Registration</div>
|
||||
<form @submit.prevent="handleRegistration">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="registrationForm.name" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input v-model="registrationForm.email" type="email" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isRegistering">
|
||||
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Waitlist -->
|
||||
<div v-if="event.tickets?.waitlist?.enabled && isEventFull" class="dashed-box">
|
||||
<div class="box-title">Waitlist</div>
|
||||
<div v-if="isOnWaitlist">
|
||||
<p class="reg-status">You're on the waitlist (#{{ waitlistPosition }})</p>
|
||||
<button class="btn" @click="handleLeaveWaitlist" :disabled="isJoiningWaitlist">Leave Waitlist</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="reg-status" style="color: var(--ember);">This event is full</p>
|
||||
<form @submit.prevent="handleJoinWaitlist">
|
||||
<div v-if="!memberData" class="field">
|
||||
<label>Email</label>
|
||||
<input v-model="waitlistForm.email" type="email" required />
|
||||
</div>
|
||||
<button type="submit" class="btn" :disabled="isJoiningWaitlist">
|
||||
{{ isJoiningWaitlist ? 'Joining...' : 'Join Waitlist' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Event Details Box -->
|
||||
<div class="dashed-box">
|
||||
<div class="box-title">Event Details</div>
|
||||
<div v-if="event.eventType" class="detail-row">
|
||||
<span class="detail-key">Type</span>
|
||||
<span class="detail-val">{{ event.eventType }}</span>
|
||||
</div>
|
||||
<div v-if="event.membersOnly" class="detail-row">
|
||||
<span class="detail-key">Members only</span>
|
||||
<span class="detail-val">Yes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<div class="dashed-box">
|
||||
<div class="box-title">Questions?</div>
|
||||
<p style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Drop us a line.</p>
|
||||
<a href="mailto:events@ghostguild.org" style="font-size: 12px;">events@ghostguild.org</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`)
|
||||
|
||||
if (error.value?.statusCode === 404) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Event not found' })
|
||||
}
|
||||
|
||||
const { isMember, memberData, checkMemberStatus } = useAuth()
|
||||
const { isPendingPayment, isSuspended, isCancelled, canRSVP, statusConfig, getRSVPMessage } = useMemberStatus()
|
||||
const { completePayment, isProcessingPayment } = useMemberPayment()
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus()
|
||||
if (memberData.value) {
|
||||
registrationForm.value.name = memberData.value.name
|
||||
registrationForm.value.email = memberData.value.email
|
||||
registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member'
|
||||
await checkRegistrationStatus()
|
||||
checkWaitlistStatus()
|
||||
}
|
||||
})
|
||||
|
||||
const checkRegistrationStatus = async () => {
|
||||
if (!memberData.value?.email) return
|
||||
try {
|
||||
const response = await $fetch(`/api/events/${route.params.id}/check-registration`, {
|
||||
method: 'POST',
|
||||
body: { email: memberData.value.email },
|
||||
})
|
||||
if (response.isRegistered) registrationStatus.value = 'registered'
|
||||
} catch (err) {
|
||||
console.error('Failed to check registration status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const registrationForm = ref({ name: '', email: '', membershipLevel: 'non-member' })
|
||||
const isRegistering = ref(false)
|
||||
const isCancelling = ref(false)
|
||||
const registrationStatus = ref('not-registered')
|
||||
const isJoiningWaitlist = ref(false)
|
||||
const isOnWaitlist = ref(false)
|
||||
const waitlistPosition = ref(0)
|
||||
const waitlistForm = ref({ email: '' })
|
||||
|
||||
const isEventFull = computed(() => {
|
||||
if (!event.value?.maxAttendees) return false
|
||||
return (event.value.registeredCount || 0) >= event.value.maxAttendees
|
||||
})
|
||||
|
||||
const checkWaitlistStatus = () => {
|
||||
const email = memberData.value?.email || waitlistForm.value.email
|
||||
if (!email || !event.value?.tickets?.waitlist?.enabled) return
|
||||
const entries = event.value.tickets.waitlist.entries || []
|
||||
const idx = entries.findIndex((e) => e.email.toLowerCase() === email.toLowerCase())
|
||||
if (idx !== -1) { isOnWaitlist.value = true; waitlistPosition.value = idx + 1 }
|
||||
}
|
||||
|
||||
const handleJoinWaitlist = async () => {
|
||||
isJoiningWaitlist.value = true
|
||||
try {
|
||||
const email = memberData.value?.email || waitlistForm.value.email
|
||||
const name = memberData.value?.name || 'Guest'
|
||||
const response = await $fetch(`/api/events/${route.params.id}/waitlist`, { method: 'POST', body: { email, name } })
|
||||
isOnWaitlist.value = true
|
||||
waitlistPosition.value = response.position
|
||||
toast.add({ title: 'Added to Waitlist', description: `You're #${response.position} on the waitlist.`, color: 'orange' })
|
||||
} catch (err) {
|
||||
toast.add({ title: "Couldn't Join Waitlist", description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||
} finally { isJoiningWaitlist.value = false }
|
||||
}
|
||||
|
||||
const handleLeaveWaitlist = async () => {
|
||||
isJoiningWaitlist.value = true
|
||||
try {
|
||||
const email = memberData.value?.email || waitlistForm.value.email
|
||||
await $fetch(`/api/events/${route.params.id}/waitlist`, { method: 'DELETE', body: { email } })
|
||||
isOnWaitlist.value = false
|
||||
waitlistPosition.value = 0
|
||||
toast.add({ title: 'Removed from Waitlist', color: 'blue' })
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Error', description: 'Failed to leave waitlist.', color: 'red' })
|
||||
} finally { isJoiningWaitlist.value = false }
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(d)
|
||||
}
|
||||
|
||||
const formatTime = (start, end) => {
|
||||
const fmt = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
|
||||
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`
|
||||
}
|
||||
|
||||
const handleRegistration = async () => {
|
||||
isRegistering.value = true
|
||||
try {
|
||||
await $fetch(`/api/events/${route.params.id}/register`, { method: 'POST', body: registrationForm.value })
|
||||
registrationStatus.value = 'registered'
|
||||
toast.add({ title: 'Registered!', description: `You're registered for ${event.value.title}.`, color: 'green' })
|
||||
if (event.value.registeredCount !== undefined) event.value.registeredCount++
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Registration Failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||
} finally { isRegistering.value = false }
|
||||
}
|
||||
|
||||
const handleCancelRegistration = async () => {
|
||||
isCancelling.value = true
|
||||
try {
|
||||
await $fetch(`/api/events/${route.params.id}/cancel-registration`, {
|
||||
method: 'POST',
|
||||
body: { email: registrationForm.value.email || memberData.value?.email },
|
||||
})
|
||||
registrationStatus.value = 'not-registered'
|
||||
toast.add({ title: 'Registration Cancelled', color: 'blue' })
|
||||
if (event.value.registeredCount !== undefined) event.value.registeredCount--
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Cancellation Failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||
} finally { isCancelling.value = false }
|
||||
}
|
||||
|
||||
const handleTicketSuccess = () => { if (event.value.registeredCount !== undefined) event.value.registeredCount++ }
|
||||
const handleTicketError = (err) => { console.error('Ticket purchase failed:', err) }
|
||||
|
||||
useHead(() => ({
|
||||
title: event.value ? `${event.value.title} - Ghost Guild Events` : 'Event - Ghost Guild',
|
||||
meta: [{ name: 'description', content: event.value?.description || 'View event details and register' }],
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
padding: 48px 32px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.loading h2 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 22px;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
padding: 12px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.back-link a { color: var(--candle); text-decoration: none; }
|
||||
|
||||
.event-header {
|
||||
padding: 28px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.event-header h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.event-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cancelled-notice {
|
||||
padding: 20px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
color: var(--ember);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cancelled-notice strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN BODY ---- */
|
||||
.event-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
}
|
||||
.event-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.event-aside {
|
||||
border-left: 1px dashed var(--border);
|
||||
padding: 0;
|
||||
}
|
||||
.event-aside .dashed-box {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.event-aside .dashed-box:hover { border-color: var(--border); }
|
||||
|
||||
.section {
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.section h2 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.circle-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.series-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.speaker:last-child { border-bottom: none; }
|
||||
.speaker-name { font-size: 13px; color: var(--text-bright); font-weight: 500; }
|
||||
.speaker-role { font-size: 11px; color: var(--text-dim); }
|
||||
.speaker-bio { font-size: 11px; color: var(--text-faint); margin-top: 2px; }
|
||||
|
||||
.box-title {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.reg-status { font-size: 13px; color: var(--text); margin-bottom: 4px; }
|
||||
.reg-price { font-size: 11px; color: var(--text-faint); margin-bottom: 10px; }
|
||||
.cal-link {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-key { color: var(--text-faint); }
|
||||
.detail-val { color: var(--text); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.event-body { grid-template-columns: 1fr; }
|
||||
.event-aside { border-left: none; border-top: 1px dashed var(--border); }
|
||||
.event-meta-row { flex-direction: column; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,520 +0,0 @@
|
|||
<template>
|
||||
<div v-if="pending" class="loading">Loading event details...</div>
|
||||
|
||||
<div v-else-if="error" class="loading">
|
||||
<h2>Event Not Found</h2>
|
||||
<p>The event you're looking for doesn't exist.</p>
|
||||
<NuxtLink to="/events">← Back to Events</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="page-fill">
|
||||
<!-- EVENT HEADER -->
|
||||
<div class="event-header">
|
||||
<h1>{{ event.title }}</h1>
|
||||
<div class="event-meta-row">
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Date</span>
|
||||
{{ formatDate(event.startDate) }}
|
||||
</div>
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Time</span>
|
||||
{{ formatTime(event.startDate, event.endDate) }}
|
||||
</div>
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Location</span>
|
||||
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
|
||||
Platform TBD
|
||||
</span>
|
||||
<template v-else>{{ event.location }}</template>
|
||||
</div>
|
||||
<div v-if="event.circle" class="event-meta-item">
|
||||
<CircleBadge :circle="event.circle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANCELLED NOTICE -->
|
||||
<div v-if="event.isCancelled" class="cancelled-notice">
|
||||
<strong>Event Cancelled</strong>
|
||||
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
|
||||
<p v-else>
|
||||
This event has been cancelled. We apologize for any inconvenience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- FEATURE IMAGE -->
|
||||
<div v-if="event.featureImage?.url" class="event-feature-image">
|
||||
<img
|
||||
:src="event.featureImage.url"
|
||||
:alt="event.featureImage.alt || event.title"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- TWO-COLUMN BODY -->
|
||||
<div class="event-body">
|
||||
<!-- LEFT: MAIN CONTENT -->
|
||||
<div class="event-main">
|
||||
<!-- Series Badge -->
|
||||
<div v-if="event.series?.isSeriesEvent" class="section">
|
||||
<div class="series-note">
|
||||
<span class="section-label">Part of Series</span>
|
||||
<NuxtLink :to="`/series/${event.series.id}`">{{
|
||||
event.series.title
|
||||
}}</NuxtLink>
|
||||
— Event {{ event.series.position }} of
|
||||
{{ event.series.totalEvents }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Circles -->
|
||||
<div v-if="event.targetCircles?.length" class="section">
|
||||
<span class="section-label">Recommended for</span>
|
||||
<div class="circle-badges">
|
||||
<CircleBadge
|
||||
v-for="circle in event.targetCircles"
|
||||
:key="circle"
|
||||
:circle="circle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="section">
|
||||
<h2>About This Event</h2>
|
||||
<div class="prose" v-html="renderMarkdown(event.description)" />
|
||||
</div>
|
||||
|
||||
<!-- Series Description -->
|
||||
<div
|
||||
v-if="event.series?.isSeriesEvent && event.series.description"
|
||||
class="section"
|
||||
>
|
||||
<h2>About the {{ event.series.title }} Series</h2>
|
||||
<div class="prose" v-html="renderMarkdown(event.series.description)" />
|
||||
</div>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div v-if="event.content" class="section">
|
||||
<h2>Additional Information</h2>
|
||||
<div class="prose" v-html="renderMarkdown(event.content)" />
|
||||
</div>
|
||||
|
||||
<!-- Agenda -->
|
||||
<div v-if="event.agenda?.length" class="section">
|
||||
<h2>Agenda</h2>
|
||||
<ul class="agenda-list">
|
||||
<li v-for="(item, index) in event.agenda" :key="index">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Speakers -->
|
||||
<div v-if="event.speakers?.length" class="section">
|
||||
<h2>Speakers</h2>
|
||||
<div
|
||||
v-for="speaker in event.speakers"
|
||||
:key="speaker.name"
|
||||
class="speaker"
|
||||
>
|
||||
<div class="speaker-name">{{ speaker.name }}</div>
|
||||
<div v-if="speaker.role" class="speaker-role">
|
||||
{{ speaker.role }}
|
||||
</div>
|
||||
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: SIDEBAR PANELS -->
|
||||
<div v-if="!event.isCancelled" class="event-aside">
|
||||
<!-- Ticket System -->
|
||||
<EventTicketPurchase
|
||||
:event-id="event._id || event.id"
|
||||
:event-start-date="event.startDate"
|
||||
:event-title="event.title"
|
||||
:event-timezone="eventTimeZone"
|
||||
:user-email="memberData?.email"
|
||||
:user-name="memberData?.name"
|
||||
@success="handleTicketSuccess"
|
||||
@error="handleTicketError"
|
||||
/>
|
||||
|
||||
<!-- Event Details Box -->
|
||||
<div class="dashed-box">
|
||||
<div class="box-title">Event Details</div>
|
||||
<div v-if="event.eventType" class="detail-row">
|
||||
<span class="detail-key">Type</span>
|
||||
<span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
</div>
|
||||
<div v-if="event.membersOnly" class="detail-row">
|
||||
<span class="detail-key">Members only</span>
|
||||
<span class="detail-val">Yes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<div class="dashed-box">
|
||||
<div class="box-title">Questions?</div>
|
||||
<p
|
||||
style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px"
|
||||
>
|
||||
Drop us a line.
|
||||
</p>
|
||||
<a href="mailto:events@ghostguild.org" style="font-size: 12px"
|
||||
>events@ghostguild.org</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { eventTypeLabel } from "~/config/eventTypes";
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
data: event,
|
||||
pending,
|
||||
error,
|
||||
} = await useFetch(`/api/events/${route.params.slug}`);
|
||||
|
||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||
pageBreadcrumbTitle.value = event.value?.title || "";
|
||||
onUnmounted(() => {
|
||||
pageBreadcrumbTitle.value = "";
|
||||
});
|
||||
|
||||
if (error.value?.statusCode === 404) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Event not found" });
|
||||
}
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { trackGoal, isComplete } = useOnboarding();
|
||||
const { render: renderMarkdown } = useMarkdown();
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus();
|
||||
if (memberData.value && !isComplete.value) {
|
||||
trackGoal('eventPageVisited');
|
||||
}
|
||||
});
|
||||
|
||||
const eventTimeZone = computed(
|
||||
() => event.value?.displayTimezone || "America/Toronto",
|
||||
);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: eventTimeZone.value,
|
||||
}).format(d);
|
||||
};
|
||||
|
||||
const formatTime = (start, end) => {
|
||||
if (!start || !end) return "";
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: eventTimeZone.value,
|
||||
});
|
||||
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`;
|
||||
};
|
||||
|
||||
const handleTicketSuccess = () => {
|
||||
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
|
||||
};
|
||||
const handleTicketError = (err) => {
|
||||
console.error("Ticket purchase failed:", err);
|
||||
};
|
||||
|
||||
useSiteMeta(() => ({
|
||||
title: event.value ? `${event.value.title} · Events` : "Event",
|
||||
description:
|
||||
event.value?.description || "View event details and register.",
|
||||
type: "article",
|
||||
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
padding: 48px 32px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.loading h2 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 22px;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-feature-image {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.event-feature-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
padding: 28px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.event-header h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.event-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cancelled-notice {
|
||||
padding: 20px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
color: var(--ember);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cancelled-notice strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
|
||||
.page-fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN BODY ---- */
|
||||
.event-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
flex: 1;
|
||||
}
|
||||
.event-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.event-aside {
|
||||
border-left: 1px dashed var(--border);
|
||||
padding: 0;
|
||||
}
|
||||
.event-aside .dashed-box {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.event-aside .dashed-box:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.section h2 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section p {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.prose {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
.prose :deep(p) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.prose :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose :deep(a) {
|
||||
color: var(--ember);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.prose :deep(strong) {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.prose :deep(ul),
|
||||
.prose :deep(ol) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
.prose :deep(ul li),
|
||||
.prose :deep(ol li) {
|
||||
position: relative;
|
||||
padding: 2px 0 2px 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.prose :deep(ul li::before) {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
color: var(--candle-faint);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.prose :deep(ol) {
|
||||
counter-reset: prose-item;
|
||||
}
|
||||
.prose :deep(ol li) {
|
||||
counter-increment: prose-item;
|
||||
padding-left: 28px;
|
||||
}
|
||||
.prose :deep(ol li::before) {
|
||||
content: counter(prose-item) ".";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
color: var(--candle-faint);
|
||||
}
|
||||
.prose :deep(blockquote) {
|
||||
border-left: 2px solid var(--candle-faint);
|
||||
padding-left: 12px;
|
||||
margin: 12px 0;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.prose :deep(code) {
|
||||
font-family: "Commit Mono", monospace;
|
||||
background: var(--input-bg);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.circle-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.series-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
.agenda-list li {
|
||||
position: relative;
|
||||
padding: 2px 0 2px 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.agenda-list li::before {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
color: var(--candle-faint);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.speaker:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.speaker-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-bright);
|
||||
font-weight: 500;
|
||||
}
|
||||
.speaker-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.speaker-bio {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.box-title {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-key {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.detail-val {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.event-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.event-aside {
|
||||
border-left: none;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.event-meta-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,26 +3,14 @@
|
|||
<!-- HERO (compact) -->
|
||||
<div class="hero">
|
||||
<h1>Events</h1>
|
||||
<p>
|
||||
Workshops, meetups, and gatherings for game developers practicing
|
||||
cooperative models. Some events are open to the public.
|
||||
</p>
|
||||
<p>Workshops, meetups, and gatherings for game developers practicing cooperative models.</p>
|
||||
</div>
|
||||
|
||||
<!-- FILTER BAR -->
|
||||
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
||||
<button
|
||||
type="button"
|
||||
class="past-toggle"
|
||||
:class="{ active: includePastEvents }"
|
||||
:aria-pressed="includePastEvents"
|
||||
@click="includePastEvents = !includePastEvents"
|
||||
>
|
||||
<span class="past-toggle-box" aria-hidden="true">
|
||||
<span v-if="includePastEvents" class="past-toggle-check">×</span>
|
||||
</span>
|
||||
Show past events
|
||||
</button>
|
||||
<label class="filter-toggle">
|
||||
<input type="checkbox" v-model="includePastEvents"> Show past events
|
||||
</label>
|
||||
</FilterBar>
|
||||
|
||||
<!-- EVENT LIST -->
|
||||
|
|
@ -31,40 +19,24 @@
|
|||
v-for="event in filteredEvents"
|
||||
:key="event._id"
|
||||
class="event-row"
|
||||
:class="{ 'is-cancelled': event.isCancelled }"
|
||||
>
|
||||
<div class="event-date-col">
|
||||
<span class="event-date">{{ formatDate(event) }}</span>
|
||||
<span class="event-time">{{ formatTime(event) }}</span>
|
||||
</div>
|
||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||
<div class="event-info">
|
||||
<div class="event-title">
|
||||
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
||||
event.title
|
||||
}}</NuxtLink>
|
||||
<span v-if="event.isCancelled" class="cancelled-tag"
|
||||
>cancelled</span
|
||||
>
|
||||
<span v-if="event.isRegistered" class="registered-tag"
|
||||
>Registered</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="event.tagline" class="event-tagline">
|
||||
{{ event.tagline }}
|
||||
</div>
|
||||
<div class="event-sub">
|
||||
<span v-if="event.eventType" class="event-type-tag">{{
|
||||
eventTypeLabel(event.eventType)
|
||||
}}</span>
|
||||
<span v-if="event.eventType" class="sep">·</span>
|
||||
<span class="event-location">{{ formatLocation(event) }}</span>
|
||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
||||
</div>
|
||||
<div v-if="event.eventType" class="event-type">{{ event.eventType }}</div>
|
||||
</div>
|
||||
<div class="event-badges">
|
||||
<span v-if="event.membersOnly" class="members-badge">Members</span>
|
||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||
<span v-else class="badge all">Public</span>
|
||||
</div>
|
||||
<span class="event-capacity">
|
||||
<template v-if="event.maxAttendees">
|
||||
<span :class="{ 'seats-warn': isAlmostFull(event) }">
|
||||
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>Open</template>
|
||||
</span>
|
||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||
<span v-else class="badge all">All</span>
|
||||
</div>
|
||||
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
|
||||
</div>
|
||||
|
|
@ -75,114 +47,84 @@
|
|||
<div class="series-grid">
|
||||
<NuxtLink
|
||||
v-for="series in activeSeries"
|
||||
:key="series.id"
|
||||
:to="`/series/${series.id}`"
|
||||
:key="series._id"
|
||||
:to="`/series/${series._id}`"
|
||||
class="series-box"
|
||||
>
|
||||
<h2>{{ series.title }}</h2>
|
||||
<h3>{{ series.title }}</h3>
|
||||
<p class="series-desc">{{ series.description }}</p>
|
||||
<div class="series-meta">
|
||||
<span
|
||||
>{{
|
||||
series.eventCount || series.events?.length || 0
|
||||
}}
|
||||
sessions</span
|
||||
>
|
||||
<span v-if="series.startDate"
|
||||
>{{ formatDate(series.startDate) }} –
|
||||
{{ formatDate(series.endDate) }}</span
|
||||
>
|
||||
<span>{{ series.eventCount || series.events?.length || 0 }} sessions</span>
|
||||
<span v-if="series.startDate">{{ formatDate(series.startDate) }} – {{ formatDate(series.endDate) }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="activeSeries.length % 2"
|
||||
class="series-box series-box-filler"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROPOSE AN EVENT -->
|
||||
<div class="full-section">
|
||||
<div class="section-label">Have an idea?</div>
|
||||
<DashedBox>
|
||||
<h3>Propose an Event</h3>
|
||||
<p>Members can propose events for any circle. Workshops, social hangs, talks, or anything else that serves the community.</p>
|
||||
<NuxtLink to="/events" class="cta">Propose an event →</NuxtLink>
|
||||
</DashedBox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
|
||||
|
||||
useSiteMeta({
|
||||
title: "Events",
|
||||
description:
|
||||
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
|
||||
});
|
||||
|
||||
const activeFilter = ref("all");
|
||||
const includePastEvents = ref(false);
|
||||
const activeFilter = ref('all')
|
||||
const includePastEvents = ref(false)
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "All", value: "all" },
|
||||
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
|
||||
];
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Workshops', value: 'workshop' },
|
||||
{ label: 'Community', value: 'community' },
|
||||
{ label: 'Social', value: 'social' },
|
||||
{ label: 'Showcase', value: 'showcase' },
|
||||
]
|
||||
|
||||
const { data: eventsData } = await useFetch("/api/events");
|
||||
const { data: seriesData } = await useFetch("/api/series");
|
||||
const { data: eventsData } = await useFetch('/api/events')
|
||||
const { data: seriesData } = await useFetch('/api/series')
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
const now = new Date();
|
||||
if (!eventsData.value) return [];
|
||||
if (!eventsData.value) return []
|
||||
return eventsData.value.filter((event) => {
|
||||
if (!includePastEvents.value && new Date(event.startDate) < now)
|
||||
return false;
|
||||
if (activeFilter.value !== "all" && event.eventType !== activeFilter.value)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
if (!includePastEvents.value && new Date(event.startDate) < now) return false
|
||||
if (activeFilter.value !== 'all' && event.eventType !== activeFilter.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const activeSeries = computed(() => {
|
||||
if (!seriesData.value) return [];
|
||||
if (!seriesData.value) return []
|
||||
return seriesData.value.filter(
|
||||
(s) => s.status === "active" || s.isOngoing || s.isUpcoming,
|
||||
);
|
||||
});
|
||||
(s) => s.status === 'active' || s.isOngoing || s.isUpcoming,
|
||||
)
|
||||
})
|
||||
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
const tz = event.displayTimezone || "America/Toronto";
|
||||
const d = new Date(event.startDate);
|
||||
const opts = { month: "short", day: "numeric", timeZone: tz };
|
||||
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||
if (dYear !== nowYear) opts.year = "numeric";
|
||||
return d.toLocaleDateString("en-US", opts);
|
||||
};
|
||||
|
||||
const formatTime = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
|
||||
const formatLocation = (event) => {
|
||||
if (event.isOnline) return "Online";
|
||||
if (!event.location) return "";
|
||||
if (event.location.startsWith("#")) return event.location;
|
||||
// Treat any URL as an online link
|
||||
if (event.location.startsWith("http")) return "Online";
|
||||
return event.location;
|
||||
};
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isAlmostFull = (event) => {
|
||||
if (!event.maxAttendees) return false
|
||||
return (event.registeredCount || 0) / event.maxAttendees > 0.8
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
padding: 32px 28px 24px;
|
||||
padding: 32px 32px 24px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -198,142 +140,33 @@ const formatLocation = (event) => {
|
|||
|
||||
/* ---- EVENT LIST ---- */
|
||||
.event-list-full {
|
||||
padding: 0 28px;
|
||||
padding: 0 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr auto;
|
||||
grid-template-columns: 80px 1fr auto auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 14px 0;
|
||||
align-items: baseline;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
transition: padding-left 0.2s;
|
||||
}
|
||||
.event-row:first-child {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.event-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
.event-row:hover {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.event-row.is-cancelled .event-title a {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
.event-row.is-cancelled .event-tagline {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.event-date-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.event-date {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.event-time {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.event-title a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.event-title a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.cancelled-tag {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember);
|
||||
border: 1px solid currentColor;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.registered-tag {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--candle);
|
||||
border: 1px solid currentColor;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-tagline {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.55;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.event-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.event-type-tag {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.sep {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.event-location {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.event-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.members-badge {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 1px 5px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.event-row:first-child { padding-top: 16px; }
|
||||
.event-row:last-child { border-bottom: none; padding-bottom: 16px; }
|
||||
.event-row:hover { padding-left: 4px; }
|
||||
.event-date { color: var(--text-faint); font-size: 12px; white-space: nowrap; }
|
||||
.event-info { min-width: 0; }
|
||||
.event-title { color: var(--text); font-size: 13px; }
|
||||
.event-title a { color: var(--text); text-decoration: none; }
|
||||
.event-title a:hover { color: var(--candle); }
|
||||
.event-type { font-size: 10px; color: var(--text-faint); margin-top: 1px; }
|
||||
.event-capacity { font-size: 11px; color: var(--text-faint); white-space: nowrap; }
|
||||
.seats-warn { color: var(--ember); }
|
||||
|
||||
/* ---- FULL SECTION ---- */
|
||||
.full-section {
|
||||
padding: 32px 28px;
|
||||
padding: 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
|
|
@ -345,26 +178,15 @@ const formatLocation = (event) => {
|
|||
border: 1px dashed var(--border);
|
||||
}
|
||||
.series-box {
|
||||
padding: 20px 24px;
|
||||
padding: 20px;
|
||||
border-right: 1px dashed var(--border);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
border-right: 1px dashed var(--border);
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.series-box:nth-child(2n) {
|
||||
border-right: none;
|
||||
}
|
||||
.series-box:nth-last-child(-n + 2) {
|
||||
border-bottom: none;
|
||||
}
|
||||
.series-box-filler {
|
||||
pointer-events: none;
|
||||
}
|
||||
.series-box:not(.series-box-filler):hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.series-box h2 {
|
||||
font-family: var(--font-display);
|
||||
.series-box:last-child { border-right: none; }
|
||||
.series-box:hover { background: var(--surface-hover); }
|
||||
.series-box h3 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -384,50 +206,40 @@ const formatLocation = (event) => {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.past-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-faint);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
/* ---- PROPOSE ---- */
|
||||
.full-section h3 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.past-toggle:hover {
|
||||
border-color: var(--candle-faint);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.past-toggle:focus-visible {
|
||||
outline: 2px dashed var(--candle);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.past-toggle.active {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
color: var(--candle);
|
||||
}
|
||||
.past-toggle-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.past-toggle-check {
|
||||
.full-section p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
}
|
||||
.cta {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-toggle input {
|
||||
accent-color: var(--candle-dim);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px 0;
|
||||
color: var(--text-faint);
|
||||
|
|
@ -435,37 +247,13 @@ const formatLocation = (event) => {
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero,
|
||||
.event-list-full,
|
||||
.full-section {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.event-row {
|
||||
grid-template-columns: 70px 1fr;
|
||||
grid-template-columns: 60px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.event-badges {
|
||||
display: none;
|
||||
}
|
||||
.series-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.series-box {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.series-box:nth-child(2n) {
|
||||
border-right: none;
|
||||
}
|
||||
.series-box:nth-last-child(-n + 2) {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.series-box:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.series-box-filler {
|
||||
display: none;
|
||||
}
|
||||
.event-capacity { display: none; }
|
||||
.series-grid { grid-template-columns: 1fr; }
|
||||
.series-box { border-right: none; border-bottom: 1px dashed var(--border); }
|
||||
.series-box:last-child { border-bottom: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,34 +2,25 @@
|
|||
<div>
|
||||
<!-- HERO -->
|
||||
<div class="hero">
|
||||
<h1>Ghost Guild is where game developers explore cooperative models.</h1>
|
||||
<p>
|
||||
Resources, events, and a community of people figuring it out. Three
|
||||
circles, pay what you can.
|
||||
</p>
|
||||
<h1>Ghost Guild is where game developers practice cooperative business models.</h1>
|
||||
<p>Resources, events, and a community of people figuring it out. Three circles, no hierarchy. $0–50/mo, pay what you can.</p>
|
||||
<div class="hero-links">
|
||||
<NuxtLink to="/join" class="hero-link primary"
|
||||
>Become a member</NuxtLink
|
||||
>
|
||||
<a href="https://wiki.ghostguild.org" class="hero-link"
|
||||
>Read the wiki</a
|
||||
>
|
||||
<NuxtLink to="/about" class="hero-link">About the Guild</NuxtLink>
|
||||
<NuxtLink to="/join" class="hero-link primary">Become a member</NuxtLink>
|
||||
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
|
||||
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- THREE CIRCLES -->
|
||||
<div class="content-row">
|
||||
<div
|
||||
v-for="circle in circleData"
|
||||
:key="circle.value"
|
||||
class="content-block"
|
||||
>
|
||||
<div class="label" :style="{ color: `var(--c-${circle.value})` }">
|
||||
{{ circle.label }}
|
||||
</div>
|
||||
<div v-for="circle in circleData" :key="circle.value" class="content-block">
|
||||
<div class="label" :style="{ color: `var(--c-${circle.value})` }">{{ circle.label }}</div>
|
||||
<h2>{{ circle.metaphor }}</h2>
|
||||
<p>{{ circle.blurb }}</p>
|
||||
<details>
|
||||
<summary>What's included?</summary>
|
||||
<p>{{ circle.included }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -42,11 +33,9 @@
|
|||
<div v-if="events?.length" class="event-list">
|
||||
<div v-for="event in events" :key="event._id" class="event-item">
|
||||
<div class="block-inset event-item-inner">
|
||||
<span class="event-date">{{ formatDate(event) }}</span>
|
||||
<span class="event-date">{{ formatDate(event.date) }}</span>
|
||||
<span class="event-title">
|
||||
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
||||
event.title
|
||||
}}</NuxtLink>
|
||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
||||
</span>
|
||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||
</div>
|
||||
|
|
@ -60,54 +49,38 @@
|
|||
<div class="block-inset">
|
||||
<div class="label">Recently in the Wiki</div>
|
||||
</div>
|
||||
<div v-if="wikiArticles?.length" class="wiki-list">
|
||||
<div
|
||||
v-for="article in wikiArticles"
|
||||
:key="article._id"
|
||||
class="wiki-item"
|
||||
>
|
||||
<div class="wiki-list">
|
||||
<div class="wiki-item">
|
||||
<div class="block-inset wiki-item-inner">
|
||||
<a :href="article.url" target="_blank">{{ article.title }}</a>
|
||||
<a href="/wiki">Revenue sharing models</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiki-item">
|
||||
<div class="block-inset wiki-item-inner">
|
||||
<a href="/wiki">What is a cooperative studio?</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiki-item">
|
||||
<div class="block-inset wiki-item-inner">
|
||||
<a href="/wiki">Governance structures</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiki-item">
|
||||
<div class="block-inset wiki-item-inner">
|
||||
<a href="/wiki">Legal incorporation guide</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="block-inset">
|
||||
<p class="empty">
|
||||
<a href="https://wiki.ghostguild.org">Browse the wiki →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PARCHMENT INSET -->
|
||||
<ParchmentInset>
|
||||
<div
|
||||
class="label"
|
||||
style="color: var(--candle-faint); margin-bottom: 12px"
|
||||
>
|
||||
From the Wiki
|
||||
</div>
|
||||
<template v-if="hasCustomWikiFeature">
|
||||
<h2>{{ wikiFeature.title || DEFAULT_WIKI_FEATURE_TITLE }}</h2>
|
||||
<p v-for="(para, i) in customWikiParagraphs" :key="i">{{ para }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2>What is a cooperative studio?</h2>
|
||||
<p>
|
||||
A cooperative studio is a game development company owned and governed
|
||||
by the people who work there. Decisions are made collectively. Profits
|
||||
are shared according to contribution, not ownership stake.
|
||||
</p>
|
||||
<p>
|
||||
The games industry is full of stories about crunch, layoffs, and
|
||||
studios that extract value from workers. Cooperatives are one
|
||||
alternative — not the only one, but one worth
|
||||
<a href="https://wiki.ghostguild.org">practicing together</a>.
|
||||
</p>
|
||||
</template>
|
||||
<p>
|
||||
<a href="https://wiki.ghostguild.org">Read more in the wiki →</a>
|
||||
</p>
|
||||
<div class="label" style="color: var(--candle-faint); opacity: 0.6; margin-bottom: 12px;">From the Wiki</div>
|
||||
<h2>What is a cooperative studio?</h2>
|
||||
<p>A cooperative studio is a game development company owned and governed by the people who work there. Decisions are made collectively. Profits are shared according to contribution, not ownership stake.</p>
|
||||
<p>The games industry is full of stories about crunch, layoffs, and studios that extract value from workers. Cooperatives are one alternative — not the only one, but one worth <a href="/wiki">practicing together</a>.</p>
|
||||
<p><a href="/wiki">Read more in the wiki →</a></p>
|
||||
</ParchmentInset>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -115,94 +88,42 @@
|
|||
<script setup>
|
||||
definePageMeta({
|
||||
layout: "default",
|
||||
});
|
||||
})
|
||||
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const siteUrl = (runtimeConfig.public.appUrl || "").replace(/\/$/, "");
|
||||
|
||||
useSiteMeta({
|
||||
title: "Ghost Guild",
|
||||
bareTitle: true,
|
||||
description:
|
||||
"Ghost Guild is where game developers explore cooperative models. Membership, events, and resources for people figuring it out together. Pay what you can.",
|
||||
});
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: "application/ld+json",
|
||||
innerHTML: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: "Ghost Guild",
|
||||
url: siteUrl || "https://ghostguild.org",
|
||||
logo: `${siteUrl || "https://ghostguild.org"}/og/default.png`,
|
||||
description:
|
||||
"A membership community for game developers exploring cooperative models. A program of Baby Ghosts, a Canadian non-profit.",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { data: events } = await useFetch("/api/events", {
|
||||
const { data: events } = await useFetch('/api/events', {
|
||||
query: { limit: 4, upcoming: true },
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
|
||||
query: { limit: 4 },
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
|
||||
|
||||
const { data: wikiFeature } = await useFetch(
|
||||
"/api/site-content/homepage.wiki_feature",
|
||||
{ default: () => ({ title: "", body: "" }) },
|
||||
);
|
||||
|
||||
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
|
||||
|
||||
const customWikiParagraphs = computed(() => {
|
||||
const body = wikiFeature.value?.body?.trim() || "";
|
||||
return body
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
})
|
||||
|
||||
const circleData = [
|
||||
{
|
||||
value: "community",
|
||||
label: "Community",
|
||||
metaphor: "The open hall",
|
||||
blurb:
|
||||
"For anyone exploring cooperative models in game development. Solo devs, researchers, students, people who just heard about this and want to know more.",
|
||||
value: 'community',
|
||||
label: 'Community',
|
||||
metaphor: 'The open hall',
|
||||
blurb: 'Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.',
|
||||
included: 'Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.',
|
||||
},
|
||||
{
|
||||
value: "founder",
|
||||
label: "Founder",
|
||||
metaphor: "The workshop",
|
||||
blurb:
|
||||
"For people actively building cooperative studios. You're working through governance, legal structure, revenue sharing, and all the hard parts.",
|
||||
value: 'founder',
|
||||
label: 'Founder',
|
||||
metaphor: 'The workshop',
|
||||
blurb: 'For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.',
|
||||
included: 'Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.',
|
||||
},
|
||||
{
|
||||
value: "practitioner",
|
||||
label: "Practitioner",
|
||||
metaphor: "The alcove",
|
||||
blurb:
|
||||
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
|
||||
value: 'practitioner',
|
||||
label: 'Practitioner',
|
||||
metaphor: 'The alcove',
|
||||
blurb: 'Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.',
|
||||
included: 'Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -212,7 +133,7 @@ const formatDate = (event) => {
|
|||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -279,11 +200,9 @@ const formatDate = (event) => {
|
|||
padding-left: 28px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
.content-block:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.content-block:last-child { border-right: none; }
|
||||
.content-block h2 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -302,6 +221,26 @@ const formatDate = (event) => {
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ---- DETAILS ---- */
|
||||
details {
|
||||
margin-top: 12px;
|
||||
}
|
||||
details summary {
|
||||
font-size: 12px;
|
||||
color: var(--candle-dim);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
details summary::before {
|
||||
content: '+ ';
|
||||
}
|
||||
details[open] summary::before {
|
||||
content: '− ';
|
||||
}
|
||||
details p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ---- EVENT LIST ---- */
|
||||
.event-item {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
|
|
@ -311,7 +250,7 @@ const formatDate = (event) => {
|
|||
}
|
||||
.event-item-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr auto;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
padding-top: 10px;
|
||||
|
|
@ -321,21 +260,10 @@ const formatDate = (event) => {
|
|||
.content-row.two-col .event-item:hover .event-item-inner {
|
||||
padding-left: calc(28px + 4px);
|
||||
}
|
||||
.event-date {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.event-title {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.event-title a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.event-title a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
.event-date { color: var(--text-faint); font-size: 12px; }
|
||||
.event-title { color: var(--text); font-size: 13px; }
|
||||
.event-title a { color: var(--text); text-decoration: none; }
|
||||
.event-title a:hover { color: var(--candle); }
|
||||
|
||||
/* ---- WIKI LIST ---- */
|
||||
.wiki-item {
|
||||
|
|
@ -349,13 +277,8 @@ const formatDate = (event) => {
|
|||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.wiki-item a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wiki-item a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
.wiki-item a { color: var(--text); text-decoration: none; }
|
||||
.wiki-item a:hover { color: var(--candle); }
|
||||
|
||||
.empty {
|
||||
color: var(--text-faint);
|
||||
|
|
@ -372,9 +295,7 @@ const formatDate = (event) => {
|
|||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.content-block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.content-block:last-child { border-bottom: none; }
|
||||
.hero-links {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
|
|
|||
1046
app/pages/join.vue
1046
app/pages/join.vue
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,251 +1,172 @@
|
|||
<template>
|
||||
<PageShell>
|
||||
<div class="dashboard">
|
||||
<ClientOnly>
|
||||
<!-- Loading State -->
|
||||
<div v-if="authPending" class="loading-state">
|
||||
<!-- Loading State -->
|
||||
<div v-if="authPending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading your dashboard...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated State -->
|
||||
<div v-else-if="!memberData" class="unauth-state">
|
||||
<h2>Sign in required</h2>
|
||||
<p>Please sign in to access your member dashboard.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<div class="dashboard-body">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner :dismissible="true" />
|
||||
|
||||
<!-- Welcome Header -->
|
||||
<div class="welcome">
|
||||
<h1>Welcome back, {{ memberData?.name }}</h1>
|
||||
<div class="meta">
|
||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Events + Quick Actions -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Your Upcoming Events</div>
|
||||
|
||||
<div v-if="loadingEvents" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="registeredEvents.length" class="event-list">
|
||||
<NuxtLink
|
||||
v-for="evt in registeredEvents"
|
||||
:key="evt._id"
|
||||
:to="`/events/${evt.slug || evt._id}`"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-date">{{ formatEventDate(evt.startDate) }}</span>
|
||||
<span class="event-title">{{ evt.title }}</span>
|
||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Calendar subscription -->
|
||||
<button class="calendar-btn" @click="copyCalendarLink">
|
||||
{{ calendarLinkCopied ? 'Link copied!' : 'Subscribe to calendar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>You haven't registered for any upcoming events</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/events" class="section-link">Browse all events →</NuxtLink>
|
||||
|
||||
<!-- Calendar subscription instructions -->
|
||||
<div v-if="registeredEvents.length > 0 && showCalendarInstructions" class="calendar-instructions">
|
||||
<div class="ci-header">
|
||||
<strong>How to Subscribe to Your Calendar</strong>
|
||||
<button @click="showCalendarInstructions = false" class="ci-close">×</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Google Calendar:</strong> Click "+" then "From URL" then paste the link</li>
|
||||
<li><strong>Apple Calendar:</strong> File then New Calendar Subscription then paste the link</li>
|
||||
<li><strong>Outlook:</strong> Add Calendar then Subscribe from web then paste the link</li>
|
||||
</ul>
|
||||
<p class="ci-note">Your calendar will automatically update when you register or unregister from events.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Quick Actions</div>
|
||||
<NuxtLink
|
||||
to="/members?peerSupport=true"
|
||||
class="quick-action"
|
||||
:class="{ disabled: !canPeerSupport }"
|
||||
:title="!canPeerSupport ? 'Complete your membership to book peer sessions' : ''"
|
||||
>
|
||||
Book a peer session<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/member/profile" class="quick-action">
|
||||
Update your profile<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<a href="https://wiki.ghostguild.org" target="_blank" class="quick-action">
|
||||
Browse the wiki<span class="arrow">→</span>
|
||||
</a>
|
||||
<NuxtLink to="/members" class="quick-action">
|
||||
Browse members<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/member/profile#account" class="quick-action">
|
||||
Manage account<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Membership Summary + Peer Support -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Your Membership</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Circle</span>
|
||||
<span class="val" :style="{ color: `var(--c-${memberData?.circle || 'community'})` }">
|
||||
{{ memberData?.circle }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Contribution</span>
|
||||
<span class="val">${{ memberData?.contributionTier }} CAD/month</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Status</span>
|
||||
<span class="val">
|
||||
<span :class="isActive ? 'status-active' : ''">
|
||||
{{ isActive ? 'Active' : statusConfig.label }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="memberData?.createdAt" class="membership-row">
|
||||
<span class="key">Member since</span>
|
||||
<span class="val">{{ formatMemberSince(memberData.createdAt) }}</span>
|
||||
</div>
|
||||
<NuxtLink to="/member/profile#account" class="section-link">
|
||||
Change circle or contribution →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Peer Support</div>
|
||||
<DashedBox>
|
||||
<p class="peer-text">
|
||||
Interested in offering peer support? Set up your profile to connect with other members who share your interests and experience.
|
||||
</p>
|
||||
<NuxtLink to="/member/profile" class="section-link">
|
||||
Set up peer support →
|
||||
</NuxtLink>
|
||||
</DashedBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading your dashboard...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated State -->
|
||||
<div v-else-if="!memberData" class="unauth-state">
|
||||
<h2>Sign in required</h2>
|
||||
<p>Please sign in to access your member dashboard.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
openLoginModal({
|
||||
title: 'Sign in to your dashboard',
|
||||
description: 'Enter your email to access your member dashboard',
|
||||
})
|
||||
"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<OnboardingWidget />
|
||||
|
||||
<ColumnsLayout cols="events-sidebar" :limit="5">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner />
|
||||
|
||||
<!-- Welcome Header -->
|
||||
<PageHeader :title="welcomeTitle">
|
||||
<div class="dashboard-meta">
|
||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
||||
</div>
|
||||
<p v-if="showSlackComingNote" class="slack-coming-note">
|
||||
Slack workspace access is part of your membership. Invitations are
|
||||
sent in monthly onboarding waves — we'll be in touch.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Upcoming Events + Quick Actions -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Your Upcoming Events</div>
|
||||
|
||||
<div v-if="loadingEvents" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="registeredEvents.length" class="event-list">
|
||||
<NuxtLink
|
||||
v-for="evt in registeredEvents"
|
||||
:key="evt._id"
|
||||
:to="`/events/${evt.slug || evt._id}`"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-date">{{ formatEventDate(evt) }}</span>
|
||||
<span class="event-title">{{ evt.title }}</span>
|
||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Calendar subscription -->
|
||||
<button class="calendar-btn" @click="copyCalendarLink">
|
||||
{{
|
||||
calendarLinkCopied
|
||||
? "Link copied!"
|
||||
: "Subscribe to calendar"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>You haven't registered for any upcoming events</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/events" class="section-link"
|
||||
>Browse all events →</NuxtLink
|
||||
>
|
||||
|
||||
<!-- Calendar subscription instructions -->
|
||||
<div
|
||||
v-if="registeredEvents.length > 0 && showCalendarInstructions"
|
||||
class="calendar-instructions"
|
||||
>
|
||||
<div class="ci-header">
|
||||
<strong>How to Subscribe to Your Calendar</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="ci-close"
|
||||
@click="showCalendarInstructions = false"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google Calendar:</strong> Click "+" then "From URL"
|
||||
then paste the link
|
||||
</li>
|
||||
<li>
|
||||
<strong>Apple Calendar:</strong> File then New Calendar
|
||||
Subscription then paste the link
|
||||
</li>
|
||||
<li>
|
||||
<strong>Outlook:</strong> Add Calendar then Subscribe from
|
||||
web then paste the link
|
||||
</li>
|
||||
</ul>
|
||||
<p class="ci-note">
|
||||
Your calendar will automatically update when you register or
|
||||
unregister from events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Quick Actions</div>
|
||||
<NuxtLink
|
||||
to="/board"
|
||||
class="quick-action"
|
||||
:class="{ disabled: !canPeerSupport }"
|
||||
:title="
|
||||
!canPeerSupport
|
||||
? 'Complete your membership to access the board'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
Board<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/member/profile" class="quick-action">
|
||||
Update your profile<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<a
|
||||
href="https://wiki.ghostguild.org"
|
||||
target="_blank"
|
||||
class="quick-action"
|
||||
@click="handleWikiClick"
|
||||
>
|
||||
Browse the wiki<span class="arrow">→</span>
|
||||
</a>
|
||||
<NuxtLink to="/members" class="quick-action">
|
||||
Browse members<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/member/account" class="quick-action">
|
||||
Manage account<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Membership Summary + Peer Support -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Your Membership</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Circle</span>
|
||||
<span
|
||||
class="val"
|
||||
:style="{
|
||||
color: `var(--c-${memberData?.circle || 'community'})`,
|
||||
}"
|
||||
>
|
||||
{{ memberData?.circle }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Contribution</span>
|
||||
<span class="val"
|
||||
>${{ memberData?.contributionAmount ?? 0 }} CAD/month</span
|
||||
>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Status</span>
|
||||
<span class="val">
|
||||
<span :class="isActive ? 'status-active' : ''">
|
||||
{{ isActive ? "Active" : statusConfig.label }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="memberData?.createdAt" class="membership-row">
|
||||
<span class="key">Member since</span>
|
||||
<span class="val">{{
|
||||
formatMemberSince(memberData.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
<NuxtLink to="/member/account" class="section-link">
|
||||
Change circle or contribution →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Bulletin Board</div>
|
||||
<DashedBox>
|
||||
<p class="peer-text">
|
||||
Make offers and requests related to shared interests and
|
||||
cooperative topics.
|
||||
</p>
|
||||
<NuxtLink to="/board" class="section-link">
|
||||
Browse the Bulletin Board →
|
||||
</NuxtLink>
|
||||
</DashedBox>
|
||||
</div>
|
||||
</div>
|
||||
</ColumnsLayout>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading your dashboard...</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</PageShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useSiteMeta({ title: 'Dashboard', noindex: true });
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||
useMemberStatus();
|
||||
|
||||
const route = useRoute();
|
||||
const isNewSignup = computed(() => route.query.welcome === "1");
|
||||
const showSlackComingNote = computed(
|
||||
() =>
|
||||
memberData.value?.status === "active" && !memberData.value?.slackInvited,
|
||||
);
|
||||
const welcomeTitle = computed(() => {
|
||||
const name = memberData.value?.name || "";
|
||||
return isNewSignup.value
|
||||
? `Welcome to Ghost Guild, ${name}`
|
||||
: `Welcome back, ${name}`;
|
||||
});
|
||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
|
||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
|
||||
|
||||
const handleWikiClick = () => {
|
||||
if (!onboardingComplete.value) {
|
||||
trackGoal("wikiClicked");
|
||||
}
|
||||
};
|
||||
|
||||
const registeredEvents = ref([]);
|
||||
const loadingEvents = ref(false);
|
||||
|
|
@ -280,21 +201,22 @@ const copyCalendarLink = async () => {
|
|||
const { openLoginModal } = useLoginModal();
|
||||
|
||||
// Handle authentication check on page load
|
||||
// server: false ensures this always runs on the client, even on a hard page load.
|
||||
// The auth middleware only fires for client-side navigations in Nuxt 4, so we
|
||||
// can't rely on it to open the modal when the user lands directly on this URL.
|
||||
const { pending: authPending } = await useLazyAsyncData(
|
||||
"dashboard-auth",
|
||||
async () => {
|
||||
// Only check authentication on client side
|
||||
if (process.server) return null;
|
||||
|
||||
// If no member data, try to authenticate
|
||||
if (!memberData.value) {
|
||||
const isAuthenticated = await checkMemberStatus();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Show login modal instead of redirecting
|
||||
openLoginModal({
|
||||
title: "Sign in to continue",
|
||||
description: "You need to be signed in to access this page",
|
||||
title: "Sign in to your dashboard",
|
||||
description: "Enter your email to access your member dashboard",
|
||||
dismissible: true,
|
||||
redirectTo: "/member/dashboard",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
|
@ -302,7 +224,6 @@ const { pending: authPending } = await useLazyAsyncData(
|
|||
|
||||
return memberData.value;
|
||||
},
|
||||
{ server: false },
|
||||
);
|
||||
|
||||
// Load registered events
|
||||
|
|
@ -365,22 +286,20 @@ const getEventImageUrl = (featureImage) => {
|
|||
return "";
|
||||
};
|
||||
|
||||
const formatEventDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
const formatEventDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
}).format(new Date(event.startDate));
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatEventTime = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
const formatEventTime = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
}).format(new Date(event.startDate));
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatMemberSince = (dateString) => {
|
||||
|
|
@ -402,6 +321,15 @@ useHead({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ---- DASHBOARD LAYOUT ---- */
|
||||
.dashboard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- LOADING / UNAUTH STATES ---- */
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
|
|
@ -430,9 +358,7 @@ useHead({
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-inline {
|
||||
|
|
@ -455,7 +381,7 @@ useHead({
|
|||
}
|
||||
|
||||
.unauth-state h2 {
|
||||
font-family: var(--font-display);
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -468,21 +394,39 @@ useHead({
|
|||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ---- WELCOME HEADER META ---- */
|
||||
.dashboard-meta {
|
||||
/* ---- WELCOME HEADER ---- */
|
||||
.welcome {
|
||||
padding: 28px 28px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.welcome h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.welcome .meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.slack-coming-note {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.65;
|
||||
/* ---- CONTENT GRID ---- */
|
||||
.dashboard-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
|
|
@ -546,7 +490,7 @@ useHead({
|
|||
|
||||
/* ---- CALENDAR BUTTON ---- */
|
||||
.calendar-btn {
|
||||
font-family: inherit;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--candle-dim);
|
||||
background: none;
|
||||
|
|
@ -630,7 +574,7 @@ useHead({
|
|||
/* ---- QUICK ACTIONS ---- */
|
||||
.quick-action {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 12px 20px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 8px;
|
||||
transition: border-color 0.2s;
|
||||
display: flex;
|
||||
|
|
@ -692,7 +636,7 @@ useHead({
|
|||
}
|
||||
|
||||
.status-active::before {
|
||||
content: "";
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
|
@ -708,7 +652,6 @@ useHead({
|
|||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -723,8 +666,12 @@ useHead({
|
|||
border-bottom: none;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
padding: 20px 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
<script setup>
|
||||
await navigateTo('/members', { redirectCode: 301 })
|
||||
</script>
|
||||
600
app/pages/member/my-updates.vue
Normal file
600
app/pages/member/my-updates.vue
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
<template>
|
||||
<div class="my-updates-page">
|
||||
<PageHeader
|
||||
title="My Updates"
|
||||
subtitle="Your activity and milestones in the Guild"
|
||||
/>
|
||||
|
||||
<!-- Content Area: two-column with events mini sidebar -->
|
||||
<div class="content-area">
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="content-main">
|
||||
<ClientOnly>
|
||||
|
||||
<!-- Stats + New Update row -->
|
||||
<div v-if="isAuthenticated && !pending" class="stats-row">
|
||||
<span class="stats-count">
|
||||
<strong>{{ total }}</strong> {{ total === 1 ? 'update' : 'updates' }} posted
|
||||
</span>
|
||||
<NuxtLink to="/updates/new" class="btn btn-primary">+ New Update</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending && !updates.length" class="state-box">
|
||||
<div class="spinner"></div>
|
||||
<p class="state-text">Loading your updates...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated State -->
|
||||
<div v-else-if="!isAuthenticated" class="state-box">
|
||||
<div class="state-icon">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="state-heading">Sign in required</h2>
|
||||
<p class="state-text">Please sign in to view your updates.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Updates Timeline -->
|
||||
<div v-else-if="updates.length" class="timeline-wrap">
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="update in updates"
|
||||
:key="update._id"
|
||||
class="tl-item"
|
||||
>
|
||||
<div class="tl-dot">✎</div>
|
||||
<div class="tl-time">{{ formatDate(update.createdAt) }}</div>
|
||||
<div class="tl-text">
|
||||
<NuxtLink :to="`/updates/${update._id}`" class="tl-title">
|
||||
{{ getUpdateTitle(update) }}
|
||||
</NuxtLink>
|
||||
<span v-if="isEdited(update)" class="tl-edited">(edited)</span>
|
||||
<span v-if="update.privacy === 'private'" class="badge">Private</span>
|
||||
<span v-if="update.privacy === 'public'" class="badge">Public</span>
|
||||
</div>
|
||||
<div v-if="getUpdatePreview(update)" class="tl-detail">
|
||||
{{ getUpdatePreview(update) }}
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div v-if="update.images?.length" class="tl-images">
|
||||
<img
|
||||
v-for="(image, index) in update.images"
|
||||
:key="index"
|
||||
:src="image.url"
|
||||
:alt="image.alt || 'Update image'"
|
||||
class="tl-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="isAuthor(update)" class="tl-actions">
|
||||
<button class="tl-action-btn" @click="handleEdit(update)">Edit</button>
|
||||
<span class="tl-action-sep">·</span>
|
||||
<button class="tl-action-btn tl-action-danger" @click="handleDelete(update)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-if="hasMore" class="load-more">
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="state-box">
|
||||
<div class="state-icon">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="state-heading">No updates yet</h2>
|
||||
<p class="state-text">Share your first update with the community</p>
|
||||
<NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink>
|
||||
</div>
|
||||
|
||||
<template #fallback>
|
||||
<div class="state-box">
|
||||
<div class="spinner"></div>
|
||||
<p class="state-text">Loading your updates...</p>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Events Mini Sidebar -->
|
||||
<EventsMiniSidebar :events="upcomingEvents" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-heading">Delete Update?</h3>
|
||||
<p class="modal-text">Are you sure you want to delete this update? This action cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
||||
const { openLoginModal } = useLoginModal()
|
||||
|
||||
const updates = ref([])
|
||||
const pending = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(false)
|
||||
const total = ref(0)
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const updateToDelete = ref(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const upcomingEvents = ref([])
|
||||
|
||||
// Check if current user is the author of an update
|
||||
const isAuthor = (update) => {
|
||||
return memberData.value && update.author?._id === memberData.value.id
|
||||
}
|
||||
|
||||
// Check if update was edited
|
||||
const isEdited = (update) => {
|
||||
const created = new Date(update.createdAt).getTime()
|
||||
const updated = new Date(update.updatedAt).getTime()
|
||||
return updated - created > 1000
|
||||
}
|
||||
|
||||
// Extract a title from update content (first line or first ~60 chars)
|
||||
const getUpdateTitle = (update) => {
|
||||
if (!update.content) return 'Untitled update'
|
||||
const firstLine = update.content.split('\n')[0]
|
||||
if (firstLine.length <= 80) return firstLine
|
||||
return firstLine.substring(0, 80) + '...'
|
||||
}
|
||||
|
||||
// Get a preview of the update content (after the first line)
|
||||
const getUpdatePreview = (update) => {
|
||||
if (!update.content) return ''
|
||||
const lines = update.content.split('\n')
|
||||
if (lines.length <= 1 && update.content.length <= 80) return ''
|
||||
// If the first line was truncated, show the full content as preview
|
||||
if (lines.length <= 1) return ''
|
||||
const rest = lines.slice(1).join(' ').trim()
|
||||
if (!rest) return ''
|
||||
return rest.length > 200 ? rest.substring(0, 200) + '...' : rest
|
||||
}
|
||||
|
||||
// Format date with relative time
|
||||
const formatDate = (date) => {
|
||||
const now = new Date()
|
||||
const updateDate = new Date(date)
|
||||
const diffInSeconds = Math.floor((now - updateDate) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) return 'just now'
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||
|
||||
return updateDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: updateDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
onMounted(async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
const authenticated = await checkMemberStatus()
|
||||
if (!authenticated) {
|
||||
openLoginModal({
|
||||
title: 'Sign in to view your updates',
|
||||
description: 'Enter your email to access your updates',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([loadUpdates(), loadUpcomingEvents()])
|
||||
})
|
||||
|
||||
// Load updates
|
||||
const loadUpdates = async () => {
|
||||
pending.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/updates/my-updates', {
|
||||
params: { limit: 20, skip: 0 },
|
||||
})
|
||||
updates.value = response.updates
|
||||
total.value = response.total
|
||||
hasMore.value = response.hasMore
|
||||
} catch (error) {
|
||||
console.error('Failed to load updates:', error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load upcoming events for sidebar
|
||||
const loadUpcomingEvents = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/events', {
|
||||
params: { limit: 3, upcoming: true },
|
||||
})
|
||||
upcomingEvents.value = response.events || response || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load upcoming events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load more updates
|
||||
const loadMore = async () => {
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/updates/my-updates', {
|
||||
params: { limit: 20, skip: updates.value.length },
|
||||
})
|
||||
updates.value.push(...response.updates)
|
||||
hasMore.value = response.hasMore
|
||||
} catch (error) {
|
||||
console.error('Failed to load more updates:', error)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = (update) => {
|
||||
navigateTo(`/updates/${update._id}/edit`)
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = (update) => {
|
||||
updateToDelete.value = update
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = async () => {
|
||||
if (!updateToDelete.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
// Remove from list
|
||||
updates.value = updates.value.filter(
|
||||
(u) => u._id !== updateToDelete.value._id,
|
||||
)
|
||||
total.value--
|
||||
|
||||
showDeleteModal.value = false
|
||||
updateToDelete.value = null
|
||||
} catch (error) {
|
||||
console.error('Failed to delete update:', error)
|
||||
alert('Failed to delete update. Please try again.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'My Updates - Ghost Guild',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-updates-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN LAYOUT ---- */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- STATS ROW ---- */
|
||||
.stats-row {
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-count {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.stats-count strong {
|
||||
color: var(--text-bright);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ---- STATE BOXES (loading, empty, unauth) ---- */
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.state-heading {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 20px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px dashed var(--candle);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- TIMELINE ---- */
|
||||
.timeline-wrap {
|
||||
padding: 24px 32px 48px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
border-left: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.tl-item {
|
||||
position: relative;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.tl-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tl-dot {
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
top: 2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tl-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tl-text {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tl-title {
|
||||
color: var(--text-bright);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tl-title:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tl-edited {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.tl-detail {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tl-images {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tl-image {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.tl-actions {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tl-action-btn {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tl-action-btn:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.tl-action-danger:hover {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.tl-action-sep {
|
||||
color: var(--border);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- LOAD MORE ---- */
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* ---- MODAL ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(42, 32, 21, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 28px 32px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-heading {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.content-area {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.timeline-wrap {
|
||||
padding: 20px 20px 40px;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
padding: 48px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
<template>
|
||||
<PageShell>
|
||||
<ClientOnly>
|
||||
<PageHeader
|
||||
title="Set Up Payment"
|
||||
:subtitle="targetAmount != null ? `Upgrading to $${targetAmount}/month` : 'Payment setup'"
|
||||
/>
|
||||
|
||||
<PageSection>
|
||||
<div v-if="step === 'loading'" class="status-block">
|
||||
<p>Preparing payment setup…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 'error'" class="status-block">
|
||||
<div class="error-box">{{ errorMessage }}</div>
|
||||
<div class="button-row">
|
||||
<button class="btn" @click="initialize">Try again</button>
|
||||
<NuxtLink to="/member/account" class="btn">Back to account</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 'ready'" class="status-block">
|
||||
<p>
|
||||
To upgrade to <strong>${{ targetAmount }}/month</strong>, we need a
|
||||
payment method on file. Click below to open the secure payment
|
||||
form — we'll verify your card with a $0 authorization and then
|
||||
activate your new tier.
|
||||
</p>
|
||||
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="isProcessing"
|
||||
@click="openModal"
|
||||
>
|
||||
{{ isProcessing ? 'Processing…' : 'Enter payment details' }}
|
||||
</button>
|
||||
<NuxtLink to="/member/account" class="btn">Cancel</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 'success'" class="status-block">
|
||||
<p>Payment setup complete. Redirecting to your account…</p>
|
||||
</div>
|
||||
</PageSection>
|
||||
</ClientOnly>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({ middleware: 'auth' });
|
||||
|
||||
useSiteMeta({ title: 'Payment Setup', noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcim } = useHelcimPay();
|
||||
|
||||
const VALID_CIRCLES = ['community', 'founder', 'practitioner'];
|
||||
|
||||
const targetAmount = computed(() => {
|
||||
const n = Number(route.query.tier);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
});
|
||||
const targetCircle = computed(() => {
|
||||
const c = String(route.query.circle || '');
|
||||
return VALID_CIRCLES.includes(c) ? c : null;
|
||||
});
|
||||
|
||||
const step = ref('loading'); // loading | ready | success | error
|
||||
const errorMessage = ref('');
|
||||
const isProcessing = ref(false);
|
||||
const customerId = ref('');
|
||||
const customerCode = ref('');
|
||||
const hasExistingCard = ref(false);
|
||||
|
||||
const initialize = async () => {
|
||||
errorMessage.value = '';
|
||||
step.value = 'loading';
|
||||
|
||||
if (targetAmount.value == null) {
|
||||
errorMessage.value = 'Missing or invalid target amount.';
|
||||
step.value = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||
// AND a card's on file, skip the paid get-or-create-customer round trip.
|
||||
const hasCachedHelcimIds = Boolean(
|
||||
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
||||
);
|
||||
|
||||
let existing = null;
|
||||
let probedExistingCard = false;
|
||||
if (hasCachedHelcimIds) {
|
||||
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
|
||||
return null;
|
||||
});
|
||||
probedExistingCard = true;
|
||||
if (existing?.cardToken) {
|
||||
customerId.value = memberData.value.helcimCustomerId;
|
||||
customerCode.value = memberData.value.helcimCustomerCode;
|
||||
hasExistingCard.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExistingCard.value) {
|
||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||
// to re-save it, breaking retries after a partial-failed signup.
|
||||
const [customer, existingFromFull] = await Promise.all([
|
||||
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
|
||||
probedExistingCard
|
||||
? Promise.resolve(existing)
|
||||
: $fetch('/api/helcim/existing-card').catch((err) => {
|
||||
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
customerId.value = customer.customerId;
|
||||
customerCode.value = customer.customerCode;
|
||||
hasExistingCard.value = Boolean(existingFromFull?.cardToken);
|
||||
|
||||
if (!hasExistingCard.value) {
|
||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||
}
|
||||
}
|
||||
step.value = 'ready';
|
||||
} catch (err) {
|
||||
console.error('Payment setup init failed:', err);
|
||||
errorMessage.value =
|
||||
err.data?.statusMessage || err.message || 'Failed to initialize payment.';
|
||||
step.value = 'error';
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = async () => {
|
||||
if (isProcessing.value) return;
|
||||
isProcessing.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
if (!hasExistingCard.value) {
|
||||
const result = await verifyPayment();
|
||||
if (!result?.success) throw new Error('Payment was not completed.');
|
||||
|
||||
await $fetch('/api/helcim/verify-payment', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cardToken: result.cardToken,
|
||||
customerId: customerId.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update circle first if it changed — update-contribution only touches tier.
|
||||
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
|
||||
await $fetch('/api/members/update-circle', {
|
||||
method: 'POST',
|
||||
body: { circle: targetCircle.value },
|
||||
});
|
||||
}
|
||||
|
||||
await $fetch('/api/members/update-contribution', {
|
||||
method: 'POST',
|
||||
// cadence: annual upgrades go through /join; this page is monthly-only
|
||||
body: { contributionAmount: targetAmount.value, cadence: 'monthly' },
|
||||
});
|
||||
|
||||
await checkMemberStatus();
|
||||
step.value = 'success';
|
||||
toast.add({ title: 'Payment method saved', color: 'success' });
|
||||
setTimeout(() => router.push('/member/account'), 1500);
|
||||
} catch (err) {
|
||||
console.error('Payment setup error:', err);
|
||||
errorMessage.value =
|
||||
err.data?.statusMessage || err.message || 'Payment setup failed.';
|
||||
// Re-initialize Helcim session so the user can try again.
|
||||
cleanupHelcim();
|
||||
await initialize();
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupHelcim();
|
||||
});
|
||||
|
||||
useHead({ title: 'Set Up Payment - Ghost Guild' });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-block {
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-block p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--ember);
|
||||
color: var(--ember);
|
||||
font-size: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue