Compare commits
383 commits
testing-in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a361b6857 | |||
| cc89c28f03 | |||
| 384d3197ce | |||
| 4a05e91715 | |||
| 622cc8e53b | |||
| 2ffaf0ef09 | |||
| 31144617d7 | |||
| 877ef1a220 | |||
| 9e18560ebf | |||
| 96470a604a | |||
| 49f4eae11c | |||
| 75b1f84d18 | |||
| 9dd007657a | |||
| acbd3c0737 | |||
| a76ba2f8c7 | |||
| e6f05b5471 | |||
| 9e4030ccfd | |||
| f5b7a3eeba | |||
| 6fa3e08fe0 | |||
| e1d224e260 | |||
| 2a66b0eb8a | |||
| 397c00125a | |||
| 050d117abf | |||
| 94b242100c | |||
| 790f44b4e9 | |||
| 13c72b5ee0 | |||
| 9858316b30 | |||
| 0927b66b4f | |||
| 84aea08a5f | |||
| 73e67d02bb | |||
| c3695de5ca | |||
| b45f92a574 | |||
| b7d9d91b1a | |||
| 47e106171e | |||
| 6bfb078e45 | |||
| f66189cfd6 | |||
| 1578055a27 | |||
| 6e98720310 | |||
| f428cbb219 | |||
| f05c1f6d40 | |||
| 0985f6acb1 | |||
| 43eda6db04 | |||
| 386cb7e4b2 | |||
| a797f8e17c | |||
| 16aaeddcee | |||
| d1b5107478 | |||
| 9ddb45c4d8 | |||
| f62fd4f586 | |||
| ba84429917 | |||
| 593b1238f9 | |||
| 8dd55ccc09 | |||
| 03dfdab20e | |||
| 6a6f036877 | |||
| 1c8f30fe6f | |||
| 7f0a586311 | |||
| b9fa9f603c | |||
| 33ba082b82 | |||
| a949252915 | |||
| 9b79ae6bf4 | |||
| c6a5e25d06 | |||
| 441a5f5608 | |||
| d9444b022b | |||
| da5e7efcb7 | |||
| d4000c18cf | |||
| 313b8598df | |||
| d06c83cfc4 | |||
| 9c7d6fa446 | |||
| 07943266b7 | |||
| 5a69d6ab75 | |||
| d6cdf45838 | |||
| cb93f14160 | |||
| d93c16fbf7 | |||
| cad57b0083 | |||
| 1c2d1537a8 | |||
| 26791cc0e3 | |||
| 6527bbbe4e | |||
| 90acc35792 | |||
| dbd46cc157 | |||
| a9acc4c2dc | |||
| dadec1a273 | |||
| f85f284ea5 | |||
| 55c57d263d | |||
| 1da76b11cb | |||
| 350d6c219c | |||
| 05c47c4499 | |||
| 59d2be2df8 | |||
| 23154ff232 | |||
| a69c9d9b49 | |||
| dc2becf63e | |||
| e19b16a5cc | |||
| e756170884 | |||
| 7e44809a83 | |||
| f66455eda5 | |||
| 955217a941 | |||
| d15458b30a | |||
| 7b326f879d | |||
| c2999810c6 | |||
| 0981596ea2 | |||
| 55029e7eb7 | |||
| b1d8cb1966 | |||
| 2f6a92ac61 | |||
| 3c49317437 | |||
| be24ae32fb | |||
| cf59931814 | |||
| 3c38333dd1 | |||
| 4d44e7045c | |||
| c1367ebd29 | |||
| ac5e979c78 | |||
| 0a41b30db7 | |||
| 5f93d4c2e3 | |||
| bd4561fea7 | |||
| 2611a2a973 | |||
| 5432dfe8f2 | |||
| 0eeb3c351f | |||
| bafe24b778 | |||
| 00073ec52c | |||
| edef1b86be | |||
| 0d83003f87 | |||
| 521efb0890 | |||
| bb0dbfe53e | |||
| 3f42307c64 | |||
| 0c489cf2c3 | |||
| 0f841912e2 | |||
| e3410c52a5 | |||
| 210a8d588f | |||
| 04eb33df6e | |||
| 1083a1d260 | |||
| a2a8d945c6 | |||
| 0369992cdd | |||
| 7a626b0a82 | |||
| c149fba13a | |||
| 8e76ce9366 | |||
| 51230e5151 | |||
| 208638e374 | |||
| 0f2f1d1cbf | |||
| 8f0648de57 | |||
| 53331cc190 | |||
| dc9c868f75 | |||
| 886c62e7b1 | |||
| b222b14e61 | |||
| e227f29bcd | |||
| 4e1888ae8e | |||
| f34b062f2a | |||
| 6a6c567fd5 | |||
| 1fbe9c3227 | |||
| 0ce61756b7 | |||
| 91711aa39b | |||
| 9c9dc49628 | |||
| 9fecb7d374 | |||
| ef26b57ce2 | |||
| fc09760a41 | |||
| 49cfb47fff | |||
| be7145f96c | |||
| bf5a333117 | |||
| 335a4db7cc | |||
| a80728f0a8 | |||
| 493be2f3bc | |||
| bbf3a47085 | |||
| 7704557f16 | |||
| dfc03f851b | |||
| 9f557d7e7a | |||
| b17e006d65 | |||
| 5ef0cc845f | |||
| 4d10c4e0a2 | |||
| 50a1ffe735 | |||
| 64a31b51a5 | |||
| 57f5152be4 | |||
| 7a2acd4628 | |||
| 613d077eaa | |||
| 6924758f99 | |||
| 1b0a6356a7 | |||
| 9a407c2a38 | |||
| 5d6fcdd78d | |||
| 4c8aff34bf | |||
| 74ea932cd2 | |||
| e4dade18b9 | |||
| 55af652263 | |||
| 03eee45cbd | |||
| 62c606b30a | |||
| 4da0265935 | |||
| e7ad076d6a | |||
| 19c77a3ab6 | |||
| eaff5c6020 | |||
| 101d6a231b | |||
| 4f9c11a755 | |||
| 6888663148 | |||
| b6f5ae8c5e | |||
| 0ca38e5588 | |||
| f2e2cedb67 | |||
| 968a127f96 | |||
| faa5bcbb3c | |||
| f7c6bd88e7 | |||
| 1bb59f07be | |||
| 5a4c09f988 | |||
| a22a576bff | |||
| 67cc488c6a | |||
| 36829eb1ef | |||
| fd9ce5bc2c | |||
| 8ceaebb268 | |||
| 549a849bc0 | |||
| f8e0cf36ba | |||
| daea8b65be | |||
| fb337a4277 | |||
| 748a84d001 | |||
| 673f881b54 | |||
| cd0d3f7167 | |||
| 0eeed94772 | |||
| e8c81cf062 | |||
| 4b5ea9bbd8 | |||
| 8d43804c7f | |||
| be0e6e7699 | |||
| 35197c465b | |||
| 47f2d666dd | |||
| de4bfdcc16 | |||
| 2ae27d6dda | |||
| c816d4b373 | |||
| 4f567e9586 | |||
| 37a58cb0eb | |||
| 15329e3e84 | |||
| c5e901ed24 | |||
| e0d11e47f4 | |||
| 2c834da40a | |||
| 3ba633cce2 | |||
| 5fb2f18cab | |||
| e96d493024 | |||
| b6f6c95c3b | |||
| 6f9e6a3d98 | |||
| 7e7672d52b | |||
| 02222a5c16 | |||
| 39eb9e039a | |||
| 3ad22a8b67 | |||
| 1e9e9c4d97 | |||
| 2394248d53 | |||
| 28040f44f4 | |||
| d1a1484daf | |||
| f691f095dc | |||
| 7292b11c0b | |||
| 08fc3884da | |||
| 9a560f2a3b | |||
| 6f3d088763 | |||
| 5fb069a80e | |||
| f3df1945bd | |||
| 7707068f36 | |||
| 4b3ba411dd | |||
| f8bc5502ba | |||
| 698f786951 | |||
| 61d33f5db3 | |||
| 5bdc3244bd | |||
| c06cdd71fd | |||
| 4d9eb3c198 | |||
| 33d27c5d9e | |||
| 78db4be7ba | |||
| 1fc937a26a | |||
| 6a440a846d | |||
| 8e5f4a2d7c | |||
| 1da59021a3 | |||
| 19d519b153 | |||
| 707447fc88 | |||
| a0f60bcdc0 | |||
| 74b2287d48 | |||
| 49c54764c6 | |||
| cdef868256 | |||
| 3e5cedb1a6 | |||
| f43fff0ba0 | |||
| 091ec58073 | |||
| 59d6e97787 | |||
| fc7ec52574 | |||
| 1d469c3617 | |||
| d4c1664e5c | |||
| a3bd7d4e50 | |||
| a448ea809d | |||
| 4fff0bf4cd | |||
| 7b9448ffd5 | |||
| c6b970a621 | |||
| de3bcc479a | |||
| 98d3610a08 | |||
| e791a0d480 | |||
| 50a358b294 | |||
| a516f172fb | |||
| f585fabf21 | |||
| 2cab40aa65 | |||
| 7c3a10232d | |||
| 3ce559a24c | |||
| 8a673ae158 | |||
| 3ff7cd4e0b | |||
| dbf1f44dce | |||
| e27718f450 | |||
| 795b856d56 | |||
| 5d3b04af48 | |||
| 22530ac1e3 | |||
| 20c961113d | |||
| 8b2f6d5240 | |||
| 337664790f | |||
| e4f2efd6d0 | |||
| d3a5c1a3a7 | |||
| b54b57cd90 | |||
| 3a22a327fe | |||
| 4a475ca5ba | |||
| bda0fe6eb7 | |||
| abca0fb7d6 | |||
| b93c735442 | |||
| 905b5155e2 | |||
| 327f504df9 | |||
| 2166ee32ca | |||
| fcbad24f3e | |||
| 56376d1995 | |||
| 340b739bf2 | |||
| 3144cbe213 | |||
| 9fe8d99808 | |||
| 3797ff7925 | |||
| b234b8483f | |||
| 0b3896d984 | |||
| 9577929e0d | |||
| 130e5bfa9f | |||
| 0d792c7c70 | |||
| 03d6a66b84 | |||
| 7b4b6feb51 | |||
| 07e005ebfc | |||
| 783459106f | |||
| f9be1f3f01 | |||
| 3b5b0d831d | |||
| 4a5b129eeb | |||
| 8365feb970 | |||
| 37fceac3fd | |||
| f267b35214 | |||
| 884cee7951 | |||
| 0d10c43af6 | |||
| b93c8c7b2f | |||
| eb2544a42d | |||
| 89c9a5e4a2 | |||
| 657bc23404 | |||
| e260ed5b37 | |||
| 127d2974c8 | |||
| 797cf60c05 | |||
| 774c124969 | |||
| 728414fffc | |||
| 2737494546 | |||
| 92e7dae74c | |||
| a2af4e31ff | |||
| 653bf78973 | |||
| ba74bfd929 | |||
| c8ac730791 | |||
| 21cf8d79b3 | |||
| f0284c60b4 | |||
| 4f7a11bcf3 | |||
| 0dc1b6ddbc | |||
| ab3f0a8b39 | |||
| 4bae4b0ec3 | |||
| 824364d526 | |||
| d3a961f765 | |||
| 7544424484 | |||
| 89942fac6d | |||
| 0c3bfc3030 | |||
| 4271ed0c6f | |||
| fb25e72215 | |||
| 501be10bfe | |||
| bab53cec9e | |||
| c40f2c7c63 | |||
| dae983734a | |||
| 689548e389 | |||
| 6573e30d31 | |||
| ed33cbb9e7 | |||
| dcb80e6006 | |||
| d69d21abd6 | |||
| 896de2e7fd | |||
| bd07172093 | |||
| 2aa29ba64b | |||
| 3551f19772 | |||
| 2c8529aed9 | |||
| 3faa1f8e85 | |||
| 06ee77592f | |||
| 79d038c724 | |||
| 18b8106405 | |||
| 1cb029a881 | |||
| 4b6ff19d5f | |||
| 8112e5ea47 | |||
| 88c94aaaf4 | |||
| 4aacb26c4b | |||
| 0ae18f495e | |||
| 4e6f5d36b8 | |||
| 418d3cc402 | |||
| 4daec9b624 | |||
| 61c16d8bac |
382 changed files with 39440 additions and 13709 deletions
|
|
@ -6,6 +6,8 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild
|
||||||
# HELCIM_API_TOKEN=your-live-helcim-api-token
|
# HELCIM_API_TOKEN=your-live-helcim-api-token
|
||||||
HELCIM_API_TOKEN=your-test-helcim-api-token
|
HELCIM_API_TOKEN=your-test-helcim-api-token
|
||||||
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
|
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
|
||||||
|
NUXT_HELCIM_MONTHLY_PLAN_ID=<set_after_migration>
|
||||||
|
NUXT_HELCIM_ANNUAL_PLAN_ID=<set_after_migration>
|
||||||
|
|
||||||
# Email Configuration (Resend)
|
# Email Configuration (Resend)
|
||||||
RESEND_API_KEY=your-resend-api-key
|
RESEND_API_KEY=your-resend-api-key
|
||||||
|
|
@ -14,6 +16,8 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org
|
||||||
# Slack Integration
|
# Slack Integration
|
||||||
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
SLACK_WEBHOOK_URL=your-slack-webhook-url
|
||||||
SLACK_OAUTH_TOKEN=your-slack-oauth-token
|
SLACK_OAUTH_TOKEN=your-slack-oauth-token
|
||||||
|
# AdminGhost bot token — used for admin-only channel creation. Falls back to SLACK_BOT_TOKEN if unset.
|
||||||
|
SLACK_ADMIN_BOT_TOKEN=xoxb-adminghost-token
|
||||||
|
|
||||||
# JWT Secret for authentication
|
# JWT Secret for authentication
|
||||||
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
JWT_SECRET=your-jwt-secret-key-change-this-in-production
|
||||||
|
|
@ -28,3 +32,6 @@ BASE_URL=http://localhost:3000
|
||||||
OIDC_CLIENT_ID=outline-wiki
|
OIDC_CLIENT_ID=outline-wiki
|
||||||
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
|
|
||||||
|
# Outline Wiki Integration
|
||||||
|
OUTLINE_API_KEY=
|
||||||
90
.forgejo/workflows/test.yml
Normal file
90
.forgejo/workflows/test.yml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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
94
.github/workflows/test.yml
vendored
|
|
@ -1,94 +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
|
|
||||||
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
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
/docs/
|
/docs/
|
||||||
*.md/
|
/*.md
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.env
|
||||||
|
|
@ -26,6 +26,18 @@ logs
|
||||||
!.env.example
|
!.env.example
|
||||||
scripts/*.js
|
scripts/*.js
|
||||||
|
|
||||||
|
# Migration backup files
|
||||||
|
.migration-backup-*.json
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
e2e/.auth/
|
||||||
|
|
||||||
|
# Worktrees
|
||||||
|
.worktrees/
|
||||||
|
.claude/worktrees/
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
.claude
|
||||||
|
scripts/dump-babyghosts-preregistrations.mjs
|
||||||
|
|
|
||||||
0
.husky/pre-push
Normal file → Executable file
0
.husky/pre-push
Normal file → Executable file
|
|
@ -3,21 +3,26 @@ project_name: "ghostguild-org"
|
||||||
|
|
||||||
|
|
||||||
# list of languages for which language servers are started; choose from:
|
# list of languages for which language servers are started; choose from:
|
||||||
# al bash clojure cpp csharp
|
# al angular ansible bash clojure
|
||||||
# csharp_omnisharp dart elixir elm erlang
|
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||||
# fortran fsharp go groovy haskell
|
# dart elixir elm erlang fortran
|
||||||
# java julia kotlin lua markdown
|
# fsharp go groovy haskell haxe
|
||||||
# matlab nix pascal perl php
|
# hlsl html java json julia
|
||||||
# php_phpactor powershell python python_jedi r
|
# kotlin lean4 lua luau markdown
|
||||||
# rego ruby ruby_solargraph rust scala
|
# matlab msl nix ocaml pascal
|
||||||
# swift terraform toml typescript typescript_vts
|
# perl php php_phpactor powershell python
|
||||||
# vue yaml zig
|
# python_jedi python_ty r rego ruby
|
||||||
|
# ruby_solargraph rust scala scss solidity
|
||||||
|
# swift systemverilog terraform toml typescript
|
||||||
|
# typescript_vts vue yaml zig
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
# Note:
|
# Note:
|
||||||
# - For C, use cpp
|
# - For C, use cpp
|
||||||
# - For JavaScript, use typescript
|
# - For JavaScript, use typescript
|
||||||
|
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||||
|
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
# Special requirements:
|
# Special requirements:
|
||||||
# Some languages require additional setup/installations.
|
# Some languages require additional setup/installations.
|
||||||
|
|
@ -65,53 +70,17 @@ read_only: false
|
||||||
|
|
||||||
# list of tool names to exclude.
|
# list of tool names to exclude.
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
#
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
# Below is the complete list of tools for convenience.
|
|
||||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
|
||||||
# execute `uv run scripts/print_tool_overview.py`.
|
|
||||||
#
|
|
||||||
# * `activate_project`: Activates a project by name.
|
|
||||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
|
||||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
|
||||||
# * `delete_lines`: Deletes a range of lines within a file.
|
|
||||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
|
||||||
# * `execute_shell_command`: Executes a shell command.
|
|
||||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
|
||||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
|
||||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
|
||||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
|
||||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
|
||||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
|
||||||
# Should only be used in settings where the system prompt cannot be set,
|
|
||||||
# e.g. in clients you have no control over, like Claude Desktop.
|
|
||||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
|
||||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
|
||||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
|
||||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
|
||||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
|
||||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
|
||||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
|
||||||
# * `read_file`: Reads a file within the project directory.
|
|
||||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
|
||||||
# * `remove_project`: Removes a project from the Serena configuration.
|
|
||||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
|
||||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
|
||||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
|
||||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
|
||||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
|
||||||
# * `switch_modes`: Activates modes by providing a list of their names
|
|
||||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
|
||||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
|
||||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
|
||||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
|
||||||
excluded_tools: []
|
excluded_tools: []
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
included_optional_tools: []
|
included_optional_tools: []
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
fixed_tools: []
|
fixed_tools: []
|
||||||
|
|
||||||
# list of mode names to that are always to be included in the set of active modes
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
|
@ -122,11 +91,14 @@ fixed_tools: []
|
||||||
# Set this to a list of mode names to always include the respective modes for this project.
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
base_modes:
|
base_modes:
|
||||||
|
|
||||||
# list of mode names that are to be activated by default.
|
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||||
# The full set of modes to be activated is base_modes + default_modes.
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||||
|
# for this project.
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
default_modes:
|
default_modes:
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
|
@ -150,3 +122,19 @@ read_only_memory_patterns: []
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
ignored_memory_patterns: []
|
ignored_memory_patterns: []
|
||||||
|
|
||||||
|
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
added_modes:
|
||||||
|
|
||||||
|
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||||
|
# Paths can be absolute or relative to the project root.
|
||||||
|
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||||
|
# symbols and references across package boundaries.
|
||||||
|
# Currently supported for: TypeScript.
|
||||||
|
# Example:
|
||||||
|
# additional_workspace_folders:
|
||||||
|
# - ../sibling-package
|
||||||
|
# - ../shared-lib
|
||||||
|
additional_workspace_folders: []
|
||||||
|
|
|
||||||
99
CLAUDE.md
99
CLAUDE.md
|
|
@ -1,99 +0,0 @@
|
||||||
# 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,12 +1,19 @@
|
||||||
# Dockerfile
|
# Build stage
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --ignore-scripts && npx nuxt prepare
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage — only the self-contained .output is needed.
|
||||||
|
# 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
|
EXPOSE 3000
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
/*
|
/*
|
||||||
* Font declarations for Ghost Guild — Zine Direction
|
* Self-hosted font declarations for Ghost Guild — Zine Direction
|
||||||
*
|
*
|
||||||
* Brygada 1918: Display/heading serif (Google Fonts, variable 400-700, italic)
|
* Brygada 1918: Display/heading serif
|
||||||
* Commit Mono: Body/UI monospace (Google Fonts)
|
* Commit Mono: Body/UI monospace
|
||||||
*
|
*
|
||||||
* Loaded via Google Fonts link in nuxt.config.ts head.
|
* Fonts are bundled locally via Fontsource.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@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,31 +15,42 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4efe4;
|
--bg: #f4efe4;
|
||||||
|
--input-bg: #faf8f2;
|
||||||
--surface: #e8dfc8;
|
--surface: #e8dfc8;
|
||||||
--surface-hover: #e0d6bc;
|
--surface-hover: #e0d6bc;
|
||||||
--border: #b8a880;
|
--border: #b8a880;
|
||||||
--border-d: #a89470;
|
--border-d: #a89470;
|
||||||
--candle: #7a5a10;
|
--candle: #7a5a10;
|
||||||
--candle-dim: #9a7420;
|
--candle-dim: #866518;
|
||||||
--candle-faint: #c4a448;
|
--candle-faint: #c4a448;
|
||||||
--ember: #8a4420;
|
--ember: #8a4420;
|
||||||
--text: #2a2015;
|
--text: #2a2015;
|
||||||
--text-bright: #1a1008;
|
--text-bright: #1a1008;
|
||||||
--text-dim: #5a5040;
|
--text-dim: #5a5040;
|
||||||
--text-faint: #8a7e6a;
|
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
|
||||||
|
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
|
||||||
|
--text-dim (5.80:1) while meeting AA for small text. */
|
||||||
|
--text-faint: #665c4b;
|
||||||
--parch: #2a2015;
|
--parch: #2a2015;
|
||||||
--parch-hover: #3a3025;
|
--parch-hover: #3a3025;
|
||||||
--parch-text: #ede4d0;
|
--parch-text: #ede4d0;
|
||||||
--parch-text-dim: #b8ae98;
|
--parch-text-dim: #b8ae98;
|
||||||
|
--parch-accent: #c4a448;
|
||||||
|
--parch-border: #b8a880;
|
||||||
--c-community: #7a4838;
|
--c-community: #7a4838;
|
||||||
--c-founder: #8a4420;
|
--c-founder: #8a4420;
|
||||||
--c-practitioner: #2a4650;
|
--c-practitioner: #2a4650;
|
||||||
--green: #4a6a38;
|
--green: #4a6a38;
|
||||||
--green-bg: rgba(74, 106, 56, 0.08);
|
--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 {
|
.dark {
|
||||||
--bg: #131210;
|
--bg: #131210;
|
||||||
|
--input-bg: #1c1a17;
|
||||||
--surface: #1a1815;
|
--surface: #1a1815;
|
||||||
--surface-hover: #252220;
|
--surface-hover: #252220;
|
||||||
--border: #2a2520;
|
--border: #2a2520;
|
||||||
|
|
@ -47,28 +58,33 @@
|
||||||
--candle: #d4a03a;
|
--candle: #d4a03a;
|
||||||
--candle-dim: #b8922e;
|
--candle-dim: #b8922e;
|
||||||
--candle-faint: #8a7030;
|
--candle-faint: #8a7030;
|
||||||
--ember: #c06030;
|
--ember: #ca6a3a;
|
||||||
--text: #a89880;
|
--text: #a89880;
|
||||||
--text-bright: #d0c8b0;
|
--text-bright: #d0c8b0;
|
||||||
--text-dim: #8a7e6a;
|
--text-dim: #958774;
|
||||||
--text-faint: #5a5040;
|
--text-faint: #8b7b62;
|
||||||
--parch: #ede4d0;
|
/* Parch family intentionally stays pinned to light-mode values —
|
||||||
--parch-hover: #d4c8a8;
|
inverted blocks are a consistent zine/terminal inset in both themes.
|
||||||
--parch-text: #2a2015;
|
See: --parch-accent and --parch-border for on-parch accents/borders. */
|
||||||
--parch-text-dim: #5a5040;
|
|
||||||
--c-community: #a06850;
|
--c-community: #a06850;
|
||||||
--c-founder: #c06030;
|
--c-founder: #c06030;
|
||||||
--c-practitioner: #4a7080;
|
--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 ---- */
|
/* ---- TAILWIND @THEME MAPPING ---- */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Commit Mono', monospace;
|
--font-sans: "Commit Mono", monospace;
|
||||||
--font-body: 'Commit Mono', monospace;
|
--font-body: "Commit Mono", monospace;
|
||||||
--font-mono: 'Commit Mono', monospace;
|
--font-mono: "Commit Mono", monospace;
|
||||||
--font-display: 'Brygada 1918', serif;
|
--font-display: "Brygada 1918", serif;
|
||||||
--font-serif: 'Brygada 1918', serif;
|
--font-serif: "Brygada 1918", serif;
|
||||||
|
|
||||||
/* Map primary to candle for Nuxt UI components */
|
/* Map primary to candle for Nuxt UI components */
|
||||||
--color-primary-500: var(--candle);
|
--color-primary-500: var(--candle);
|
||||||
|
|
@ -81,14 +97,35 @@
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: var(--candle); text-decoration: none; }
|
/* ---- NOISE TEXTURE OVERLAY ---- */
|
||||||
a:hover { text-decoration: underline; }
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- SECTION LABELS ---- */
|
/* ---- SECTION LABELS ---- */
|
||||||
.section-label {
|
.section-label {
|
||||||
|
|
@ -108,14 +145,26 @@ a:hover { text-decoration: underline; }
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px dashed;
|
border: 1px dashed;
|
||||||
}
|
}
|
||||||
.badge.community { color: var(--c-community); border-color: rgba(122, 72, 56, 0.35); }
|
.badge.community {
|
||||||
.badge.founder { color: var(--c-founder); border-color: rgba(138, 68, 32, 0.35); }
|
color: var(--c-community);
|
||||||
.badge.practitioner { color: var(--c-practitioner); border-color: rgba(42, 70, 80, 0.35); }
|
border-color: rgba(122, 72, 56, 0.35);
|
||||||
.badge.all { color: var(--text-dim); border-color: var(--border); }
|
}
|
||||||
|
.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 ---- */
|
/* ---- BUTTONS ---- */
|
||||||
.btn {
|
.btn {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 7px 18px;
|
padding: 7px 18px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
|
@ -125,14 +174,26 @@ a:hover { text-decoration: underline; }
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--surface-hover); border-color: var(--border-d); }
|
.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-primary {
|
.btn-primary {
|
||||||
background: var(--candle);
|
background: var(--candle);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
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 {
|
.btn-danger {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
border-color: var(--ember);
|
border-color: var(--ember);
|
||||||
|
|
@ -144,7 +205,9 @@ a:hover { text-decoration: underline; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- FORM FIELDS ---- */
|
/* ---- FORM FIELDS ---- */
|
||||||
.field { margin-bottom: 12px; }
|
.field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
.field label {
|
.field label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|
@ -153,17 +216,21 @@ a:hover { text-decoration: underline; }
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.field input, .field select, .field textarea {
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
background: var(--bg);
|
background: var(--input-bg);
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
outline: none;
|
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-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
@ -174,8 +241,25 @@ a:hover { text-decoration: underline; }
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
.dashed-box:hover { border-color: var(--candle-faint); }
|
.dashed-box:hover {
|
||||||
.dashed-box.no-hover:hover { border-color: var(--border); }
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- SECTION DIVIDERS ---- */
|
/* ---- SECTION DIVIDERS ---- */
|
||||||
.section-divider {
|
.section-divider {
|
||||||
|
|
@ -192,6 +276,98 @@ a:hover { text-decoration: underline; }
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Nuxt UI placeholder contrast ----
|
||||||
|
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
|
||||||
|
AA on cream and white backgrounds (≈2.4:1). Override globally to --text-dim
|
||||||
|
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
|
||||||
|
[data-slot="placeholder"] {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SHARED USelectMenu STYLES ----
|
||||||
|
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
||||||
|
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
||||||
|
button.zine-select,
|
||||||
|
button.timezone-select {
|
||||||
|
display: flex !important;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px !important;
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text-bright) !important;
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
min-height: 0;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.zine-select:hover,
|
||||||
|
button.timezone-select:hover {
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.zine-select:focus,
|
||||||
|
button.zine-select:focus-visible,
|
||||||
|
button.zine-select[aria-expanded="true"],
|
||||||
|
button.timezone-select:focus,
|
||||||
|
button.timezone-select:focus-visible,
|
||||||
|
button.timezone-select[aria-expanded="true"] {
|
||||||
|
border-color: var(--candle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-content {
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
--tw-ring-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-input {
|
||||||
|
border-bottom: 1px dashed var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-input input {
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text-bright) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
--tw-ring-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item {
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item::before {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item[data-highlighted]::before,
|
||||||
|
.tz-item[data-highlighted]:not([data-disabled])::before {
|
||||||
|
background: var(--surface-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item[data-highlighted],
|
||||||
|
.tz-item[data-highlighted]:not([data-disabled]) {
|
||||||
|
color: var(--text-bright) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- MOBILE ---- */
|
/* ---- MOBILE ---- */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
body {
|
body {
|
||||||
|
|
|
||||||
BIN
app/assets/images/noise.webp
Normal file
BIN
app/assets/images/noise.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -17,29 +17,36 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
<span
|
||||||
|
v-if="item.path === '/member/dashboard' && showOnboardingDot"
|
||||||
|
class="onboarding-dot"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Explore</div>
|
<div class="sidebar-section">Explore</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in exploreItems" :key="item.path">
|
<li v-for="item in exploreItems" :key="item.path">
|
||||||
|
<a
|
||||||
|
v-if="item.external"
|
||||||
|
:href="item.path"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
@click="handleNavigate"
|
||||||
|
>
|
||||||
|
{{ item.label
|
||||||
|
}}<span class="external-hint" aria-hidden="true">ext</span>
|
||||||
|
</a>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
v-else
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ 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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -49,11 +56,23 @@
|
||||||
<div class="sidebar-section">Navigate</div>
|
<div class="sidebar-section">Navigate</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in publicItems" :key="item.path">
|
<li v-for="item in publicItems" :key="item.path">
|
||||||
|
<a
|
||||||
|
v-if="item.external"
|
||||||
|
:href="item.path"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
@click="handleNavigate"
|
||||||
|
>
|
||||||
|
{{ item.label
|
||||||
|
}}<span class="external-hint" aria-hidden="true">ext</span>
|
||||||
|
</a>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
v-else
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -64,7 +83,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -74,11 +94,23 @@
|
||||||
<div class="sidebar-section">Navigate</div>
|
<div class="sidebar-section">Navigate</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li v-for="item in publicItems" :key="item.path">
|
<li v-for="item in publicItems" :key="item.path">
|
||||||
|
<a
|
||||||
|
v-if="item.external"
|
||||||
|
:href="item.path"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
@click="handleNavigate"
|
||||||
|
>
|
||||||
|
{{ item.label
|
||||||
|
}}<span class="external-hint" aria-hidden="true">ext</span>
|
||||||
|
</a>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
v-else
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -89,7 +121,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -99,29 +132,17 @@
|
||||||
<!-- Meta at bottom -->
|
<!-- Meta at bottom -->
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<template v-if="isAuthenticated">
|
Part of
|
||||||
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
|
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
||||||
<span
|
A Canadian nonprofit
|
||||||
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>
|
<template #fallback>
|
||||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
Part of
|
||||||
A Canadian nonprofit<br>
|
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
||||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
A Canadian nonprofit
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
|
<DevLoginPanel v-if="isDev" />
|
||||||
<ColorModeToggle />
|
<ColorModeToggle />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -134,68 +155,59 @@ const props = defineProps({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['navigate'])
|
const emit = defineEmits(["navigate"]);
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const { isAuthenticated, logout, memberData } = useAuth()
|
const { isAuthenticated, memberData, logout } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal()
|
const isDev = import.meta.dev;
|
||||||
|
|
||||||
|
const showOnboardingDot = computed(
|
||||||
|
() => isAuthenticated.value && !memberData.value?.onboarding?.completedAt,
|
||||||
|
);
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
if (props.isMobile) {
|
if (props.isMobile) {
|
||||||
emit('navigate')
|
emit("navigate");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout();
|
||||||
handleNavigate()
|
handleNavigate();
|
||||||
}
|
navigateTo("/");
|
||||||
|
};
|
||||||
const openLogin = () => {
|
|
||||||
openLoginModal()
|
|
||||||
handleNavigate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = (path) => {
|
const isActive = (path) => {
|
||||||
if (path === '/') return route.path === '/'
|
if (path === "/") return route.path === "/";
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Public nav items
|
// Public nav items
|
||||||
const publicItems = [
|
const publicItems = [
|
||||||
{ label: 'Home', path: '/' },
|
{ label: "Home", path: "/" },
|
||||||
{ label: 'About', path: '/about' },
|
{ label: "About", path: "/about" },
|
||||||
{ label: 'Events', path: '/events' },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: 'Members', path: '/members' },
|
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
|
||||||
{ label: 'Wiki', path: '/wiki' },
|
];
|
||||||
]
|
|
||||||
|
|
||||||
const joinItems = [
|
const joinItems = [{ label: "Become a member", path: "/join" }];
|
||||||
{ label: 'Become a member', path: '/join' },
|
|
||||||
{ label: 'Propose an event', path: '/events' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Logged-in nav items
|
// Logged-in nav items
|
||||||
const youItems = [
|
const youItems = [
|
||||||
{ label: 'Dashboard', path: '/member/dashboard' },
|
{ label: "Dashboard", path: "/member/dashboard" },
|
||||||
{ label: 'Profile', path: '/member/profile' },
|
{ label: "Profile", path: "/member/profile" },
|
||||||
{ label: 'Account', path: '/member/account' },
|
{ label: "Account", path: "/member/account" },
|
||||||
{ label: 'My Updates', path: '/member/my-updates' },
|
];
|
||||||
]
|
|
||||||
|
|
||||||
const exploreItems = [
|
const exploreItems = [
|
||||||
{ label: 'Events', path: '/events' },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: 'Members', path: '/members' },
|
{ label: "Members", path: "/members" },
|
||||||
{ label: 'Wiki', path: '/wiki' },
|
{ label: "Board", path: "/board" },
|
||||||
{ label: 'About', path: '/about' },
|
{ label: "Wiki", path: "https://wiki.ghostguild.org", external: true },
|
||||||
]
|
{ label: "About", path: "/about" },
|
||||||
|
];
|
||||||
const communityItems = [
|
|
||||||
{ label: 'Peer Support', path: '/members' },
|
|
||||||
{ label: 'Propose an Event', path: '/events' },
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -221,12 +233,14 @@ const communityItems = [
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: block;
|
display: flex;
|
||||||
font-family: 'Brygada 1918', serif;
|
align-items: center;
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
padding: 24px 24px 16px;
|
padding: 0 24px;
|
||||||
|
height: 53px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +251,7 @@ const communityItems = [
|
||||||
.sidebar-body {
|
.sidebar-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
|
|
@ -267,6 +282,11 @@ const communityItems = [
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a.sign-out {
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-nav a:hover {
|
.sidebar-nav a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -291,14 +311,28 @@ const communityItems = [
|
||||||
color: var(--candle-dim);
|
color: var(--candle-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.external-hint {
|
||||||
color: var(--text);
|
font-size: 10px;
|
||||||
font-size: 12px;
|
letter-spacing: 0.05em;
|
||||||
|
margin-left: 4px;
|
||||||
|
position: relative;
|
||||||
|
top: -0.5px;
|
||||||
|
}
|
||||||
|
.external-hint::before {
|
||||||
|
content: "[";
|
||||||
|
}
|
||||||
|
.external-hint::after {
|
||||||
|
content: "]";
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-circle {
|
.onboarding-dot {
|
||||||
font-size: 10px;
|
display: inline-block;
|
||||||
letter-spacing: 0.06em;
|
width: 6px;
|
||||||
text-transform: uppercase;
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
margin-left: 0px;
|
||||||
|
vertical-align: middle;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
386
app/components/BoardPostCard.vue
Normal file
386
app/components/BoardPostCard.vue
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
<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>
|
||||||
265
app/components/BoardPostForm.vue
Normal file
265
app/components/BoardPostForm.vue
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
<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,13 +4,16 @@
|
||||||
v-for="circle in circles"
|
v-for="circle in circles"
|
||||||
:key="circle.value"
|
:key="circle.value"
|
||||||
class="circle-option"
|
class="circle-option"
|
||||||
:class="{ current: modelValue === circle.value }"
|
:class="{
|
||||||
|
selected: modelValue === circle.value,
|
||||||
|
current: savedValue === circle.value,
|
||||||
|
}"
|
||||||
@click="$emit('update:modelValue', circle.value)"
|
@click="$emit('update:modelValue', circle.value)"
|
||||||
>
|
>
|
||||||
<span class="circle-name">{{ circle.label }}</span>
|
<span class="circle-name">{{ circle.label }}</span>
|
||||||
<span class="circle-desc">{{ circle.description }}</span>
|
<span class="circle-desc">{{ circle.description }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="modelValue === circle.value"
|
v-if="savedValue === circle.value"
|
||||||
class="circle-tag"
|
class="circle-tag"
|
||||||
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
|
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
|
||||||
>Current</span>
|
>Current</span>
|
||||||
|
|
@ -21,12 +24,13 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
|
savedValue: { type: String, default: '' },
|
||||||
circles: {
|
circles: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [
|
default: () => [
|
||||||
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
|
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
|
||||||
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
|
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
|
||||||
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative business' },
|
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative practice' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -44,7 +48,7 @@ defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
.circle-option {
|
.circle-option {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 14px 12px;
|
padding: 12px 12px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
@ -54,7 +58,7 @@ defineEmits(['update:modelValue'])
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-option.current {
|
.circle-option.selected {
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -67,19 +71,19 @@ defineEmits(['update:modelValue'])
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-option.current .circle-name {
|
.circle-option.selected .circle-name {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-desc {
|
.circle-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
color: var(--text-dim);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-tag {
|
.circle-tag {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="color-mode-toggle">
|
<div class="color-mode-toggle segmented">
|
||||||
<button
|
<button
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:class="{ active: colorMode.preference === option.value }"
|
:class="{ active: colorMode.preference === option.value }"
|
||||||
@click="colorMode.preference = option.value"
|
@click="colorMode.preference = option.value"
|
||||||
>{{ option.label }}</button>
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode();
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ label: 'Light', value: 'light' },
|
{ label: "Light", value: "light" },
|
||||||
{ label: 'System', value: 'system' },
|
{ label: "System", value: "system" },
|
||||||
{ label: 'Dark', value: 'dark' },
|
{ label: "Dark", value: "dark" },
|
||||||
]
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -28,7 +30,7 @@ const options = [
|
||||||
.color-mode-toggle button {
|
.color-mode-toggle button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -36,10 +38,12 @@ const options = [
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||||
.color-mode-toggle button + button {
|
.color-mode-toggle button + button {
|
||||||
border-left: none;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-mode-toggle button:hover {
|
.color-mode-toggle button:hover {
|
||||||
|
|
@ -51,13 +55,6 @@ const options = [
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background: var(--surface);
|
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>
|
</style>
|
||||||
|
|
|
||||||
103
app/components/ColumnsLayout.vue
Normal file
103
app/components/ColumnsLayout.vue
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<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>
|
||||||
99
app/components/CooperativeTagSelector.vue
Normal file
99
app/components/CooperativeTagSelector.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<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>
|
||||||
95
app/components/CraftTagSelector.vue
Normal file
95
app/components/CraftTagSelector.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<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>
|
||||||
105
app/components/DevLoginPanel.vue
Normal file
105
app/components/DevLoginPanel.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="series-badge p-4 bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-6">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
|
||||||
<span
|
|
||||||
class="series-badge__label text-sm font-semibold text-guild-300 dark:text-guild-300"
|
|
||||||
>
|
|
||||||
Part of a Series
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="totalEvents"
|
|
||||||
class="series-badge__count inline-flex items-center px-2 py-0.5 rounded-md bg-guild-700/50 dark:bg-guild-600/50 text-sm font-medium text-guild-200 dark:text-guild-200"
|
|
||||||
>
|
|
||||||
<template v-if="position">
|
|
||||||
Event {{ position }} of {{ totalEvents }}
|
|
||||||
</template>
|
|
||||||
<template v-else> {{ totalEvents }} events in series </template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3
|
|
||||||
class="series-badge__title text-lg font-semibold text-guild-100 dark:text-guild-100 mb-2"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
v-if="description"
|
|
||||||
class="series-badge__description text-sm text-guild-300 dark:text-guild-300"
|
|
||||||
>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="seriesId" class="flex-shrink-0 self-start">
|
|
||||||
<UButton
|
|
||||||
:to="`/series/${seriesId}`"
|
|
||||||
color="primary"
|
|
||||||
size="md"
|
|
||||||
label="View Series"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
position: {
|
|
||||||
type: Number,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
totalEvents: {
|
|
||||||
type: Number,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
seriesId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,37 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
|
||||||
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
|
||||||
class="bg-gradient-to-br from-candlelight-500 to-candlelight-700 dark:from-candlelight-600 dark:to-candlelight-800 p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<Icon
|
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
|
||||||
name="heroicons:ticket"
|
<span class="text-sm font-semibold" style="color: var(--parch-text)">
|
||||||
class="w-5 h-5 text-candlelight-900 dark:text-candlelight-200"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-semibold text-candlelight-900 dark:text-candlelight-200">
|
|
||||||
Series Pass
|
Series Pass
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-white mb-1">
|
<h3 class="font-display text-xl font-bold mb-1" style="color: var(--parch-text)">
|
||||||
{{ ticket.name }}
|
{{ ticket.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="ticket.description" class="text-sm text-candlelight-900 dark:text-candlelight-200">
|
<p v-if="ticket.description" class="text-sm" style="color: var(--parch-text); opacity: 0.85">
|
||||||
{{ ticket.description }}
|
{{ ticket.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
<div class="text-3xl font-bold text-white text-ui-mono">
|
<div class="text-3xl font-bold" style="color: var(--parch-text)">
|
||||||
{{ formatPrice(ticket.price, ticket.currency) }}
|
{{ formatPrice(ticket.price, ticket.currency) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
|
||||||
v-if="ticket.isEarlyBird"
|
|
||||||
class="text-xs text-candlelight-900 dark:text-candlelight-200 mt-1"
|
|
||||||
>
|
|
||||||
Early Bird Price
|
Early Bird Price
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,29 +29,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="p-6 bg-guild-800/50 dark:bg-guild-700/30">
|
<div class="p-6" style="background: var(--surface)">
|
||||||
<!-- What's Included -->
|
<!-- What's Included -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
||||||
What's Included
|
What's Included
|
||||||
</h4>
|
</h4>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2 text-guild-300 dark:text-guild-300">
|
<div class="flex items-center gap-2" style="color: var(--text)">
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||||
<span>Access to all {{ totalEvents }} events in the series</span>
|
<span>Access to all {{ totalEvents }} events in the series</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
|
||||||
v-if="ticket.isFree && !isMember"
|
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||||
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
|
||||||
<span>Automatic registration for all sessions</span>
|
<span>Automatic registration for all sessions</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
|
||||||
v-if="memberSavings > 0"
|
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
|
||||||
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
|
|
||||||
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
|
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -69,33 +53,31 @@
|
||||||
|
|
||||||
<!-- Events List Preview -->
|
<!-- Events List Preview -->
|
||||||
<div v-if="events && events.length > 0" class="mb-6">
|
<div v-if="events && events.length > 0" class="mb-6">
|
||||||
<h4 class="text-sm font-semibold text-guild-200 dark:text-guild-200 mb-3 uppercase tracking-wide">
|
<h4 class="text-sm font-semibold mb-3 uppercase tracking-wide" style="color: var(--text-faint)">
|
||||||
Series Schedule
|
Series Schedule
|
||||||
</h4>
|
</h4>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="(event, index) in events.slice(0, 3)"
|
v-for="(event, index) in events.slice(0, 3)"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
class="flex items-start gap-3 p-3 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
|
class="flex items-start gap-3 p-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full bg-candlelight-600/20 border border-candlelight-500/30 flex items-center justify-center flex-shrink-0"
|
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||||
|
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-bold text-candlelight-300">{{ index + 1 }}</span>
|
<span class="text-sm font-bold" style="color: var(--candle)">{{ index + 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-guild-100 dark:text-guild-100 text-sm">
|
<div class="font-medium text-sm" style="color: var(--text)">
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-guild-400 dark:text-guild-400 mt-1">
|
<div class="text-xs mt-1" style="color: var(--text-faint)">
|
||||||
{{ formatEventDate(event.startDate) }}
|
{{ formatEventDate(event.startDate) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
|
||||||
v-if="events.length > 3"
|
|
||||||
class="text-center text-sm text-guild-400 dark:text-guild-400 pt-2"
|
|
||||||
>
|
|
||||||
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
|
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,13 +86,14 @@
|
||||||
<!-- Member Benefit Callout -->
|
<!-- Member Benefit Callout -->
|
||||||
<div
|
<div
|
||||||
v-if="ticket.isFree && isMember"
|
v-if="ticket.isFree && isMember"
|
||||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
class="p-4 mb-6"
|
||||||
|
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:sparkles" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
<Icon name="heroicons:sparkles" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
|
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
|
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,13 +103,14 @@
|
||||||
<!-- Public vs Member Pricing -->
|
<!-- Public vs Member Pricing -->
|
||||||
<div
|
<div
|
||||||
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
|
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
|
||||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg mb-6"
|
class="p-4 mb-6"
|
||||||
|
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:tag" class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5" />
|
<Icon name="heroicons:tag" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--candle)" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">Member Savings</div>
|
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
|
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
|
||||||
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
|
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,22 +120,15 @@
|
||||||
|
|
||||||
<!-- Availability -->
|
<!-- Availability -->
|
||||||
<div v-if="availability" class="mb-6">
|
<div v-if="availability" class="mb-6">
|
||||||
<div
|
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
|
||||||
v-if="!availability.unlimited && availability.remaining !== null"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Icon
|
<Icon
|
||||||
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
|
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
|
||||||
:class="[
|
class="w-5 h-5"
|
||||||
'w-5 h-5',
|
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
||||||
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
:class="[
|
class="text-sm font-medium"
|
||||||
'text-sm font-medium',
|
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
|
||||||
availability.remaining > 5 ? 'text-candlelight-300' : 'text-ember-300'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
|
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -160,12 +137,12 @@
|
||||||
|
|
||||||
<!-- Sold Out / Waitlist -->
|
<!-- Sold Out / Waitlist -->
|
||||||
<div v-if="!available" class="space-y-3">
|
<div v-if="!available" class="space-y-3">
|
||||||
<div class="p-4 bg-ember-900/20 border border-ember-700/30 rounded-lg">
|
<div class="p-4" style="background: var(--ember-bg); border: 1px solid var(--ember)">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 flex-shrink-0 mt-0.5" />
|
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 flex-shrink-0 mt-0.5" style="color: var(--ember)" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
|
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
|
||||||
<div class="text-sm text-ember-400">
|
<div class="text-sm" style="color: var(--ember)">
|
||||||
All series passes have been claimed.
|
All series passes have been claimed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,7 +151,7 @@
|
||||||
<UButton
|
<UButton
|
||||||
v-if="availability?.waitlistAvailable"
|
v-if="availability?.waitlistAvailable"
|
||||||
block
|
block
|
||||||
color="gray"
|
color="neutral"
|
||||||
size="lg"
|
size="lg"
|
||||||
@click="$emit('join-waitlist')"
|
@click="$emit('join-waitlist')"
|
||||||
>
|
>
|
||||||
|
|
@ -183,12 +160,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Already Registered -->
|
<!-- Already Registered -->
|
||||||
<div v-else-if="alreadyRegistered" class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg">
|
<div
|
||||||
|
v-else-if="alreadyRegistered"
|
||||||
|
class="p-4"
|
||||||
|
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||||
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon name="heroicons:check-badge" class="w-6 h-6 text-candlelight-400 flex-shrink-0" />
|
<Icon name="heroicons:check-badge" class="w-6 h-6 flex-shrink-0" style="color: var(--candle)" />
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
|
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
You have a series pass and are registered for all {{ totalEvents }} events.
|
You have a series pass and are registered for all {{ totalEvents }} events.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,42 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ticket-card rounded-xl border p-6 transition-all duration-200"
|
class="ticket-card"
|
||||||
:class="[
|
:class="{
|
||||||
isSelected
|
'is-selected': isSelected,
|
||||||
? 'border-primary bg-primary/5'
|
'is-unavailable': !isAvailable || alreadyRegistered,
|
||||||
: 'border-guild-600 bg-guild-800/50',
|
}"
|
||||||
isAvailable && !alreadyRegistered
|
|
||||||
? 'hover:border-primary/50 cursor-pointer'
|
|
||||||
: 'opacity-60 cursor-not-allowed',
|
|
||||||
]"
|
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<!-- Ticket Header -->
|
<!-- Ticket Header -->
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="ticket-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold text-guild-100">
|
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
|
||||||
{{ ticketInfo.name }}
|
<p v-if="ticketInfo.description" class="ticket-desc">
|
||||||
</h3>
|
|
||||||
<p v-if="ticketInfo.description" class="text-sm text-guild-300 mt-1">
|
|
||||||
{{ ticketInfo.description }}
|
{{ ticketInfo.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="ticketInfo.isMember" class="badge">Members Only</span>
|
||||||
<!-- Badge -->
|
|
||||||
<div v-if="ticketInfo.isMember" class="flex-shrink-0 ml-4">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-500 dark:bg-candlelight-900/30 dark:text-candlelight-400"
|
|
||||||
>
|
|
||||||
Members Only
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price Display -->
|
<!-- Price Display -->
|
||||||
<div class="mb-4">
|
<div class="ticket-price-block">
|
||||||
<div class="flex items-baseline gap-2">
|
<div class="ticket-price-row">
|
||||||
<span
|
<span
|
||||||
class="text-3xl font-bold text-ui-mono"
|
class="ticket-price"
|
||||||
:class="ticketInfo.isFree ? 'text-candlelight-400' : 'text-guild-100'"
|
:class="{ 'is-free': ticketInfo.isFree }"
|
||||||
>
|
>
|
||||||
{{ ticketInfo.formattedPrice }}
|
{{ ticketInfo.formattedPrice }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="ticketInfo.isEarlyBird" class="badge early-bird">
|
||||||
<!-- Early Bird Badge -->
|
|
||||||
<span
|
|
||||||
v-if="ticketInfo.isEarlyBird"
|
|
||||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-600 dark:bg-candlelight-900/35 dark:text-candlelight-400"
|
|
||||||
>
|
|
||||||
Early Bird
|
Early Bird
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Regular Price (if early bird) -->
|
<div v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice" class="ticket-regular-price">
|
||||||
<div
|
Regular: {{ ticketInfo.formattedRegularPrice }}
|
||||||
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
|
|
||||||
class="mt-1"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-guild-400 line-through">
|
|
||||||
Regular: {{ ticketInfo.formattedRegularPrice }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Early Bird Countdown -->
|
<div v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" class="ticket-deadline">
|
||||||
<div
|
|
||||||
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
|
|
||||||
class="mt-2 text-xs text-candlelight-500 dark:text-candlelight-400"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
|
|
||||||
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
|
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,59 +44,38 @@
|
||||||
<!-- Member Savings -->
|
<!-- Member Savings -->
|
||||||
<div
|
<div
|
||||||
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
|
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
|
||||||
class="mb-4 p-3 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
class="ticket-savings"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-candlelight-400">
|
<p>You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!</p>
|
||||||
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
|
<p class="ticket-savings-detail">
|
||||||
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-guild-400 mt-1">
|
|
||||||
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
|
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Availability -->
|
<!-- Availability -->
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="ticket-availability">
|
||||||
<div>
|
<span v-if="alreadyRegistered" class="status-registered">
|
||||||
<span
|
You're registered
|
||||||
v-if="alreadyRegistered"
|
</span>
|
||||||
class="text-candlelight-400 flex items-center gap-1"
|
<span v-else-if="!isAvailable" class="status-sold-out">
|
||||||
>
|
Sold Out
|
||||||
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
|
</span>
|
||||||
You're registered
|
<span v-else-if="ticketInfo.remaining !== null" class="status-remaining">
|
||||||
</span>
|
{{ ticketInfo.remaining }} remaining
|
||||||
<span
|
</span>
|
||||||
v-else-if="!isAvailable"
|
<span v-else class="status-remaining">
|
||||||
class="text-ember-400 flex items-center gap-1"
|
Unlimited availability
|
||||||
>
|
</span>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Waitlist Option -->
|
<!-- Waitlist Option -->
|
||||||
<div
|
<div
|
||||||
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
|
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
|
||||||
class="mt-4 pt-4 border-t border-guild-600"
|
class="ticket-waitlist"
|
||||||
>
|
>
|
||||||
<UButton
|
<button class="btn" @click.stop="$emit('join-waitlist')">
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
block
|
|
||||||
@click.stop="$emit('join-waitlist')"
|
|
||||||
>
|
|
||||||
Join Waitlist
|
Join Waitlist
|
||||||
</UButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -164,13 +113,11 @@ const formatDeadline = (deadline) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date - now;
|
const diff = date - now;
|
||||||
|
|
||||||
// If less than 24 hours, show hours
|
|
||||||
if (diff < 24 * 60 * 60 * 1000) {
|
if (diff < 24 * 60 * 60 * 1000) {
|
||||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||||
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
|
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise show date
|
|
||||||
return `on ${date.toLocaleDateString("en-US", {
|
return `on ${date.toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -187,6 +134,103 @@ const formatPrice = (amount) => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ticket-card {
|
.ticket-card {
|
||||||
position: relative;
|
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);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,47 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="event-ticket-purchase">
|
<div class="event-ticket-purchase">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="loading" class="ticket-panel">
|
||||||
<div
|
<div class="box-title">Tickets</div>
|
||||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
<p class="ticket-status">Loading ticket information...</p>
|
||||||
></div>
|
|
||||||
<p class="text-guild-300">Loading ticket information...</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div
|
<div v-else-if="error" class="ticket-panel">
|
||||||
v-else-if="error"
|
<div class="box-title">Tickets</div>
|
||||||
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
|
<p class="ticket-status" style="color: var(--ember)">
|
||||||
>
|
|
||||||
<h3 class="text-lg font-semibold text-ember-300 mb-2">
|
|
||||||
Unable to Load Tickets
|
Unable to Load Tickets
|
||||||
</h3>
|
</p>
|
||||||
<p class="text-ember-400">{{ error }}</p>
|
<p class="ticket-detail">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Pass Required -->
|
<!-- Series Pass Required -->
|
||||||
<div
|
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
|
||||||
v-else-if="ticketInfo?.requiresSeriesPass"
|
<div class="box-title">Tickets</div>
|
||||||
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
|
<p class="ticket-status" style="color: var(--candle)">
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:ticket" class="w-6 h-6" />
|
|
||||||
Series Pass Required
|
Series Pass Required
|
||||||
</h3>
|
</p>
|
||||||
<p class="text-candlelight-400 mb-4">
|
<p class="ticket-detail">
|
||||||
This event is part of
|
This event is part of
|
||||||
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
|
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
|
||||||
pass to attend.
|
pass to attend.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-guild-300 mb-6">
|
<p class="ticket-hint">
|
||||||
Purchase a series pass to get access to all events in this series.
|
Purchase a series pass to get access to all events in this series.
|
||||||
</p>
|
</p>
|
||||||
<UButton
|
<NuxtLink
|
||||||
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
|
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
|
||||||
color="primary"
|
|
||||||
size="lg"
|
|
||||||
block
|
|
||||||
>
|
>
|
||||||
View Series & Purchase Pass
|
<button class="btn btn-primary">View Series & Purchase Pass</button>
|
||||||
</UButton>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Already Registered -->
|
<!-- Already Registered -->
|
||||||
<div
|
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
||||||
v-else-if="ticketInfo?.alreadyRegistered"
|
<p class="ticket-status" style="color: var(--green)">
|
||||||
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
|
|
||||||
You're Registered!
|
You're Registered!
|
||||||
</h3>
|
</p>
|
||||||
<p class="text-candlelight-400 mb-4">
|
<p class="ticket-detail">
|
||||||
<template v-if="ticketInfo.viaSeriesPass">
|
<template v-if="ticketInfo.viaSeriesPass">
|
||||||
You have access to this event via your series pass for
|
You have access to this event via your series pass for
|
||||||
<strong>{{ ticketInfo.series?.title }}</strong
|
<strong>{{ ticketInfo.series?.title }}</strong
|
||||||
|
|
@ -70,7 +52,7 @@
|
||||||
details.
|
details.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-guild-300">
|
<p class="ticket-hint">
|
||||||
See you on {{ formatEventDate(eventStartDate) }}!
|
See you on {{ formatEventDate(eventStartDate) }}!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,128 +65,145 @@
|
||||||
:is-selected="true"
|
:is-selected="true"
|
||||||
:is-available="ticketInfo.available"
|
:is-available="ticketInfo.available"
|
||||||
:already-registered="ticketInfo.alreadyRegistered"
|
:already-registered="ticketInfo.alreadyRegistered"
|
||||||
class="mb-6"
|
|
||||||
@join-waitlist="handleJoinWaitlist"
|
@join-waitlist="handleJoinWaitlist"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Registration Form -->
|
<!-- Registration (logged-in member) -->
|
||||||
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
|
<div
|
||||||
<h3 class="text-xl font-bold text-guild-100 mb-4">
|
v-if="
|
||||||
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
|
||||||
</h3>
|
"
|
||||||
|
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>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
<p
|
||||||
<!-- Name Field -->
|
v-if="!ticketInfo.isFree"
|
||||||
<div>
|
class="ticket-notice"
|
||||||
<label
|
style="color: var(--candle)"
|
||||||
for="name"
|
>
|
||||||
class="block text-sm font-medium text-guild-200 mb-2"
|
Payment of {{ ticketInfo.formattedPrice }} will be processed securely
|
||||||
>
|
</p>
|
||||||
Full Name
|
|
||||||
</label>
|
<button
|
||||||
<UInput
|
class="btn btn-primary"
|
||||||
id="name"
|
: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">
|
||||||
|
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="field">
|
||||||
|
<label for="ticket-name">Full Name</label>
|
||||||
|
<input
|
||||||
|
id="ticket-name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
required
|
required
|
||||||
placeholder="Enter your full name"
|
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email Field -->
|
<div class="field">
|
||||||
<div>
|
<label for="ticket-email">Email Address</label>
|
||||||
<label
|
<input
|
||||||
for="email"
|
id="ticket-email"
|
||||||
class="block text-sm font-medium text-guild-200 mb-2"
|
|
||||||
>
|
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<UInput
|
|
||||||
id="email"
|
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
placeholder="Enter your email"
|
:disabled="processing"
|
||||||
:disabled="processing || isLoggedIn"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="isLoggedIn" class="text-xs text-guild-400 mt-1">
|
|
||||||
Using your member email
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Member Benefits Notice -->
|
<p
|
||||||
<div
|
|
||||||
v-if="ticketInfo.isMember && 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:sparkles" class="w-4 h-4" />
|
|
||||||
This event is free for Ghost Guild members
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Required Notice -->
|
|
||||||
<div
|
|
||||||
v-if="!ticketInfo.isFree"
|
v-if="!ticketInfo.isFree"
|
||||||
class="p-4 bg-candlelight-900/20 rounded-lg border border-candlelight-800"
|
class="ticket-notice"
|
||||||
|
style="color: var(--candle)"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-candlelight-300 flex items-center gap-2">
|
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
||||||
<Icon name="heroicons:credit-card" class="w-4 h-4" />
|
securely
|
||||||
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
</p>
|
||||||
securely
|
|
||||||
|
<div class="consent-block">
|
||||||
|
<label class="consent-field">
|
||||||
|
<input
|
||||||
|
v-model="form.createAccount"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="processing"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
>Create a free guest account so I can manage my
|
||||||
|
registration</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<p class="field-hint consent-hint">
|
||||||
|
Guest accounts let you view your tickets and register faster next
|
||||||
|
time. We won't add you to member communications.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<button
|
||||||
<div class="pt-4">
|
type="submit"
|
||||||
<UButton
|
class="btn btn-primary"
|
||||||
type="submit"
|
:disabled="processing || !form.name || !form.email"
|
||||||
color="primary"
|
>
|
||||||
size="lg"
|
{{
|
||||||
block
|
processing
|
||||||
:loading="processing"
|
? "Processing..."
|
||||||
:disabled="!form.name || !form.email"
|
: ticketInfo.isFree
|
||||||
>
|
? "Complete Registration"
|
||||||
{{
|
: `Pay ${ticketInfo.formattedPrice}`
|
||||||
processing
|
}}
|
||||||
? "Processing..."
|
</button>
|
||||||
: ticketInfo.isFree
|
|
||||||
? "Complete Registration"
|
|
||||||
: `Pay ${ticketInfo.formattedPrice}`
|
|
||||||
}}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sold Out with Waitlist -->
|
<!-- Sold Out with Waitlist -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
|
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
|
||||||
class="text-center py-8"
|
class="ticket-panel"
|
||||||
>
|
>
|
||||||
<Icon
|
<div class="box-title">Waitlist</div>
|
||||||
name="heroicons:ticket"
|
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||||
class="w-16 h-16 text-guild-400 mx-auto mb-4"
|
<p class="ticket-detail">
|
||||||
/>
|
|
||||||
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
|
|
||||||
<p class="text-guild-300 mb-6">
|
|
||||||
This event is currently at capacity. Join the waitlist to be notified
|
This event is currently at capacity. Join the waitlist to be notified
|
||||||
if spots become available.
|
if spots become available.
|
||||||
</p>
|
</p>
|
||||||
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
|
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
|
||||||
Join Waitlist
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sold Out (No Waitlist) -->
|
<!-- Sold Out (No Waitlist) -->
|
||||||
<div v-else-if="!ticketInfo.available" class="text-center py-8">
|
<div v-else-if="!ticketInfo.available" class="ticket-panel">
|
||||||
<Icon
|
<div class="box-title">Tickets</div>
|
||||||
name="heroicons:x-circle"
|
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||||
class="w-16 h-16 text-ember-400 mx-auto mb-4"
|
<p class="ticket-detail">
|
||||||
/>
|
|
||||||
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
|
|
||||||
<p class="text-guild-300">
|
|
||||||
Unfortunately, this event is at capacity and no longer accepting
|
Unfortunately, this event is at capacity and no longer accepting
|
||||||
registrations.
|
registrations.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -220,17 +219,25 @@ const props = defineProps({
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
eventStartDate: {
|
eventStartDate: {
|
||||||
type: Date,
|
type: [String, Date],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
eventTitle: {
|
eventTitle: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
eventTimezone: {
|
||||||
|
type: String,
|
||||||
|
default: "America/Toronto",
|
||||||
|
},
|
||||||
userEmail: {
|
userEmail: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
userName: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["success", "error"]);
|
const emit = defineEmits(["success", "error"]);
|
||||||
|
|
@ -245,8 +252,9 @@ const error = ref(null);
|
||||||
const ticketInfo = ref(null);
|
const ticketInfo = ref(null);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: "",
|
name: props.userName || "",
|
||||||
email: props.userEmail || "",
|
email: props.userEmail || "",
|
||||||
|
createAccount: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!props.userEmail);
|
const isLoggedIn = computed(() => !!props.userEmail);
|
||||||
|
|
@ -256,11 +264,13 @@ onMounted(async () => {
|
||||||
await fetchTicketInfo();
|
await fetchTicketInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTicketInfo = async () => {
|
const fetchTicketInfo = async (emailOverride = null) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const effectiveEmail = emailOverride || props.userEmail;
|
||||||
|
|
||||||
// First check if this event requires a series pass
|
// First check if this event requires a series pass
|
||||||
if (props.userEmail) {
|
if (props.userEmail) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -270,7 +280,6 @@ const fetchTicketInfo = async () => {
|
||||||
|
|
||||||
if (seriesAccess.requiresSeriesPass) {
|
if (seriesAccess.requiresSeriesPass) {
|
||||||
if (seriesAccess.hasSeriesPass) {
|
if (seriesAccess.hasSeriesPass) {
|
||||||
// User has series pass - show as already registered
|
|
||||||
ticketInfo.value = {
|
ticketInfo.value = {
|
||||||
available: true,
|
available: true,
|
||||||
alreadyRegistered: true,
|
alreadyRegistered: true,
|
||||||
|
|
@ -281,7 +290,6 @@ const fetchTicketInfo = async () => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// User needs to buy series pass
|
|
||||||
ticketInfo.value = {
|
ticketInfo.value = {
|
||||||
available: false,
|
available: false,
|
||||||
requiresSeriesPass: true,
|
requiresSeriesPass: true,
|
||||||
|
|
@ -293,13 +301,14 @@ const fetchTicketInfo = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (seriesErr) {
|
} catch (seriesErr) {
|
||||||
// If series check fails, continue with regular ticket check
|
|
||||||
console.warn("Series access check failed:", seriesErr);
|
console.warn("Series access check failed:", seriesErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular ticket availability check
|
// Regular ticket availability check
|
||||||
const params = props.userEmail ? `?email=${props.userEmail}` : "";
|
const params = effectiveEmail
|
||||||
|
? `?email=${encodeURIComponent(effectiveEmail)}`
|
||||||
|
: "";
|
||||||
const response = await $fetch(
|
const response = await $fetch(
|
||||||
`/api/events/${props.eventId}/tickets/available${params}`,
|
`/api/events/${props.eventId}/tickets/available${params}`,
|
||||||
);
|
);
|
||||||
|
|
@ -320,24 +329,19 @@ const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
let transactionId = null;
|
let transactionId = null;
|
||||||
|
|
||||||
// If payment is required, initialize Helcim and process payment
|
|
||||||
if (!ticketInfo.value.isFree) {
|
if (!ticketInfo.value.isFree) {
|
||||||
// Initialize Helcim payment
|
|
||||||
await initializeTicketPayment(
|
await initializeTicketPayment(
|
||||||
props.eventId,
|
props.eventId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
ticketInfo.value.price,
|
|
||||||
props.eventTitle,
|
props.eventTitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show Helcim modal and complete payment
|
|
||||||
const paymentResult = await verifyPayment();
|
const paymentResult = await verifyPayment();
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (!paymentResult.success) {
|
||||||
throw new Error("Payment was not completed");
|
throw new Error("Payment was not completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// For purchase transactions, we get a transactionId
|
|
||||||
transactionId = paymentResult.transactionId;
|
transactionId = paymentResult.transactionId;
|
||||||
|
|
||||||
if (!transactionId) {
|
if (!transactionId) {
|
||||||
|
|
@ -345,32 +349,38 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purchase ticket
|
const body = {
|
||||||
|
name: form.value.name,
|
||||||
|
email: form.value.email,
|
||||||
|
createAccount: form.value.createAccount,
|
||||||
|
};
|
||||||
|
if (transactionId) body.transactionId = transactionId;
|
||||||
|
|
||||||
const response = await $fetch(
|
const response = await $fetch(
|
||||||
`/api/events/${props.eventId}/tickets/purchase`,
|
`/api/events/${props.eventId}/tickets/purchase`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body,
|
||||||
name: form.value.name,
|
|
||||||
email: form.value.email,
|
|
||||||
transactionId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Success!
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
description: ticketInfo.value.isFree
|
description: ticketInfo.value.isFree
|
||||||
? "You're registered for this event"
|
? "You're registered for this event"
|
||||||
: "Ticket purchased successfully!",
|
: "Ticket purchased successfully!",
|
||||||
color: "green",
|
color: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("success", response);
|
emit("success", response);
|
||||||
|
|
||||||
// Refresh ticket info to show registered state
|
if (response?.signedIn) {
|
||||||
await fetchTicketInfo();
|
// 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);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error purchasing ticket:", err);
|
console.error("Error purchasing ticket:", err);
|
||||||
|
|
||||||
|
|
@ -382,7 +392,7 @@ const handleSubmit = async () => {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Registration Failed",
|
title: "Registration Failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
color: "red",
|
color: "error",
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("error", err);
|
emit("error", err);
|
||||||
|
|
@ -393,11 +403,10 @@ const handleSubmit = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinWaitlist = () => {
|
const handleJoinWaitlist = () => {
|
||||||
// TODO: Implement waitlist functionality
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Waitlist",
|
title: "Waitlist",
|
||||||
description: "Waitlist functionality coming soon!",
|
description: "Waitlist functionality coming soon!",
|
||||||
color: "blue",
|
color: "info",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -407,6 +416,64 @@ const formatEventDate = (date) => {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
timeZone: props.eventTimezone || "America/Toronto",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticket-panel {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ticket-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.ticket-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ticket-notice {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: flex-start;
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.consent-field {
|
||||||
|
display: contents;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.consent-field input[type="checkbox"] {
|
||||||
|
margin-top: 3px;
|
||||||
|
accent-color: var(--candle);
|
||||||
|
}
|
||||||
|
.consent-hint {
|
||||||
|
grid-column: 2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,18 @@
|
||||||
<div v-if="events?.length" class="em-rows">
|
<div v-if="events?.length" class="em-rows">
|
||||||
<div v-for="event in events" :key="event._id" class="em-item">
|
<div v-for="event in events" :key="event._id" class="em-item">
|
||||||
<div class="em-inset em-item-body">
|
<div class="em-inset em-item-body">
|
||||||
<span class="em-date">{{ formatDate(event.date) }}</span>
|
<span class="em-date">{{ formatDate(event) }}</span>
|
||||||
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
<NuxtLink
|
||||||
|
:to="`/events/${event.slug || event._id}`"
|
||||||
|
class="em-title"
|
||||||
|
>{{ event.title }}</NuxtLink
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-if="event.circle"
|
v-if="event.circle"
|
||||||
class="em-circle"
|
class="em-circle"
|
||||||
:style="{ color: `var(--c-${event.circle})` }"
|
:style="{ color: `var(--c-${event.circle})` }"
|
||||||
>{{ event.circle }}</span>
|
>{{ event.circle }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,13 +35,16 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
events: { type: Array, default: () => [] },
|
events: { type: Array, default: () => [] },
|
||||||
})
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (event) => {
|
||||||
if (!dateStr) return ''
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr)
|
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
month: "short",
|
||||||
}
|
day: "numeric",
|
||||||
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -99,7 +107,7 @@ const formatDate = (dateStr) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.em-circle {
|
.em-circle {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
padding: 14px 32px;
|
padding: 16px 28px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@
|
||||||
<img
|
<img
|
||||||
:src="transformedImageUrl"
|
:src="transformedImageUrl"
|
||||||
:alt="modelValue.alt || 'Event image'"
|
:alt="modelValue.alt || 'Event image'"
|
||||||
class="w-full h-48 object-cover rounded-lg border border-guild-700"
|
class="w-full h-48 object-cover"
|
||||||
|
style="border: 1px solid var(--border)"
|
||||||
@error="console.log('Image failed to load:', transformedImageUrl)"
|
@error="console.log('Image failed to load:', transformedImageUrl)"
|
||||||
@load="console.log('Image loaded successfully:', transformedImageUrl)"
|
@load="console.log('Image loaded successfully:', transformedImageUrl)"
|
||||||
/>
|
>
|
||||||
<button
|
<button
|
||||||
@click="removeImage"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors"
|
class="absolute top-2 right-2 p-1 rounded-full transition-colors"
|
||||||
|
style="background: var(--ember); color: var(--parch-text)"
|
||||||
|
@click="removeImage"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -21,67 +23,84 @@
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
<div
|
<div
|
||||||
v-if="!modelValue?.url"
|
v-if="!modelValue?.url"
|
||||||
class="border-2 border-dashed border-guild-700 rounded-lg p-6 text-center hover:border-guild-600 transition-colors"
|
class="border-2 border-dashed p-6 text-center transition-colors"
|
||||||
|
:style="
|
||||||
|
isDragging
|
||||||
|
? 'border-color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent)'
|
||||||
|
: 'border-color: var(--border)'
|
||||||
|
"
|
||||||
@dragover.prevent="isDragging = true"
|
@dragover.prevent="isDragging = true"
|
||||||
@dragleave.prevent="isDragging = false"
|
@dragleave.prevent="isDragging = false"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
:class="{ 'border-candlelight-400 bg-candlelight-900/20': isDragging }"
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
@change="handleFileSelect"
|
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
@change="handleFileSelect"
|
||||||
|
>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Icon name="heroicons:photo" class="w-12 h-12 text-guild-400 mx-auto" />
|
<Icon
|
||||||
|
name="heroicons:photo"
|
||||||
|
class="w-12 h-12 mx-auto"
|
||||||
|
style="color: var(--text-dim)"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-guild-400">
|
<p style="color: var(--text-dim)">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="font-medium"
|
||||||
|
style="color: var(--candle)"
|
||||||
@click="$refs.fileInput.click()"
|
@click="$refs.fileInput.click()"
|
||||||
class="text-candlelight-400 hover:text-candlelight-300 font-medium"
|
|
||||||
>
|
>
|
||||||
Click to upload
|
Click to upload
|
||||||
</button>
|
</button>
|
||||||
or drag and drop
|
or drag and drop
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-guild-500">PNG, JPG, GIF up to 10MB</p>
|
<p class="text-sm" style="color: var(--text-faint)">
|
||||||
|
PNG, JPG, GIF up to 10MB
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alt Text Input -->
|
<!-- Alt Text Input -->
|
||||||
<div v-if="modelValue?.url">
|
<div v-if="modelValue?.url">
|
||||||
<label class="block text-sm font-medium text-guild-100 mb-1">
|
<label
|
||||||
|
class="block text-sm font-medium mb-1"
|
||||||
|
style="color: var(--text-bright)"
|
||||||
|
>
|
||||||
Alt Text (for accessibility)
|
Alt Text (for accessibility)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
:value="modelValue.alt || ''"
|
:value="modelValue.alt || ''"
|
||||||
@input="updateAltText($event.target.value)"
|
|
||||||
placeholder="Describe this image..."
|
placeholder="Describe this image..."
|
||||||
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-candlelight-500 focus:border-transparent"
|
class="w-full px-3 py-2 alt-text-input"
|
||||||
/>
|
@input="updateAltText($event.target.value)"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Progress -->
|
<!-- Upload Progress -->
|
||||||
<div v-if="isUploading" class="space-y-2">
|
<div v-if="isUploading" class="space-y-2">
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="text-guild-400">Uploading...</span>
|
<span style="color: var(--text-dim)">Uploading...</span>
|
||||||
<span class="text-guild-400">{{ uploadProgress }}%</span>
|
<span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-guild-800 rounded-full h-2">
|
<div
|
||||||
|
class="w-full rounded-full h-2"
|
||||||
|
style="background: var(--surface)"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
:style="`width: ${uploadProgress}%`"
|
:style="`width: ${uploadProgress}%; background: var(--candle)`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<div v-if="errorMessage" class="text-sm text-ember-400">
|
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,3 +220,16 @@ const updateAltText = (altText) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alt-text-input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-text-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--candle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="your.email@example.com"
|
placeholder="your.email@example.com"
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
|
|
@ -144,6 +144,15 @@ watch(isOpen, (newValue) => {
|
||||||
loginError.value = ''
|
loginError.value = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape' && isOpen.value) {
|
||||||
|
resetAndClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -173,7 +182,7 @@ watch(isOpen, (newValue) => {
|
||||||
|
|
||||||
.modal-overline {
|
.modal-overline {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: 'Brygada 1918', serif;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
@ -209,7 +218,7 @@ watch(isOpen, (newValue) => {
|
||||||
.info-box {
|
.info-box {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
padding: 10px 14px;
|
padding: 12px 16px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,31 @@
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="shouldShowBanner" class="w-full">
|
<div v-if="shouldShowBanner" class="status-banner">
|
||||||
<div
|
<div class="status-banner-inner">
|
||||||
:class="[
|
<div class="status-banner-text">
|
||||||
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
|
<strong class="status-banner-label">{{ statusConfig.label }}</strong>
|
||||||
statusConfig.bgColor,
|
<span class="status-banner-msg">{{ bannerMessage }}</span>
|
||||||
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>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div v-if="nextAction" class="status-banner-actions">
|
||||||
<!-- Payment button for pending payment status -->
|
<!-- Payment button for pending payment status -->
|
||||||
<UButton
|
<button
|
||||||
v-if="isPendingPayment && nextAction"
|
v-if="isPendingPayment"
|
||||||
:color="getButtonColor(nextAction.color)"
|
:disabled="isProcessingPayment"
|
||||||
size="sm"
|
class="btn btn-primary"
|
||||||
:loading="isProcessingPayment"
|
|
||||||
@click="handleActionClick"
|
@click="handleActionClick"
|
||||||
class="whitespace-nowrap"
|
|
||||||
>
|
>
|
||||||
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
|
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
|
||||||
</UButton>
|
</button>
|
||||||
|
|
||||||
<!-- Link button for other actions -->
|
<!-- Link button for other actions -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-else-if="nextAction && nextAction.link"
|
v-else-if="nextAction.link"
|
||||||
:to="nextAction.link"
|
:to="nextAction.link"
|
||||||
:class="[
|
class="btn"
|
||||||
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
|
|
||||||
getActionButtonClass(nextAction.color),
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ nextAction.label }}
|
{{ nextAction.label }}
|
||||||
</NuxtLink>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,17 +33,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
|
||||||
dismissible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
compact: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isPendingPayment,
|
isPendingPayment,
|
||||||
isSuspended,
|
isSuspended,
|
||||||
|
|
@ -81,11 +41,9 @@ const {
|
||||||
getNextAction,
|
getNextAction,
|
||||||
getBannerMessage,
|
getBannerMessage,
|
||||||
} = useMemberStatus();
|
} = useMemberStatus();
|
||||||
|
|
||||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
|
|
||||||
const isDismissed = ref(false);
|
|
||||||
|
|
||||||
// Handle action button click
|
|
||||||
const handleActionClick = async () => {
|
const handleActionClick = async () => {
|
||||||
if (isPendingPayment.value) {
|
if (isPendingPayment.value) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -96,33 +54,57 @@ const handleActionClick = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map color names to UButton color props
|
const shouldShowBanner = computed(
|
||||||
const getButtonColor = (color) => {
|
() => isPendingPayment.value || isSuspended.value || isCancelled.value,
|
||||||
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 bannerMessage = computed(() => getBannerMessage());
|
||||||
const nextAction = computed(() => getNextAction());
|
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>
|
</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,67 +1,40 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-2">
|
<div class="natural-date-input">
|
||||||
<div class="relative">
|
<UInput
|
||||||
<UInput
|
:model-value="rawInput"
|
||||||
v-model="naturalInput"
|
:placeholder="placeholder"
|
||||||
:placeholder="placeholder"
|
:color="trailingState"
|
||||||
:color="
|
@update:model-value="onInputChange"
|
||||||
hasError && naturalInput.trim()
|
|
||||||
? 'error'
|
|
||||||
: isValidParse && naturalInput.trim()
|
|
||||||
? 'success'
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
@input="parseNaturalInput"
|
|
||||||
@blur="onBlur"
|
|
||||||
>
|
|
||||||
<template #trailing>
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<template #trailing>
|
||||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
<Icon
|
||||||
<span>{{ formatParsedDate(parsedDate) }}</span>
|
v-if="isValid && rawInput.trim()"
|
||||||
</div>
|
name="heroicons:check-circle"
|
||||||
</div>
|
class="w-5 h-5"
|
||||||
|
style="color: var(--candle)"
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Icon
|
||||||
</details>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -69,176 +42,197 @@
|
||||||
import * as chrono from "chrono-node";
|
import * as chrono from "chrono-node";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: String, default: "" },
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
|
default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
|
||||||
},
|
|
||||||
inputClass: {
|
|
||||||
type: [String, Object],
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
},
|
||||||
|
displayTimezone: { type: String, default: "" },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
const naturalInput = ref("");
|
const rawInput = ref("");
|
||||||
const parsedDate = ref(null);
|
const isValid = ref(false);
|
||||||
const isValidParse = ref(false);
|
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const datetimeValue = ref("");
|
// previewDate holds the parsed value as a UTC Date so we can format it in
|
||||||
|
// arbitrary timezones without re-parsing. Source of truth for the preview.
|
||||||
|
const previewDate = ref(null);
|
||||||
|
|
||||||
// Initialize with current value
|
const trailingState = computed(() => {
|
||||||
onMounted(() => {
|
if (!rawInput.value.trim()) return undefined;
|
||||||
if (props.modelValue) {
|
if (hasError.value) return "error";
|
||||||
const date = new Date(props.modelValue);
|
if (isValid.value) return "success";
|
||||||
if (!isNaN(date.getTime())) {
|
return undefined;
|
||||||
parsedDate.value = date;
|
|
||||||
datetimeValue.value = formatForDatetimeLocal(date);
|
|
||||||
isValidParse.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for external changes to modelValue
|
const previewText = computed(() => {
|
||||||
watch(
|
if (!previewDate.value) return "";
|
||||||
() => props.modelValue,
|
const tz = activeTZ();
|
||||||
(newValue) => {
|
const date = new Intl.DateTimeFormat("en-US", {
|
||||||
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
|
timeZone: tz,
|
||||||
const date = new Date(newValue);
|
weekday: "short",
|
||||||
if (!isNaN(date.getTime())) {
|
month: "short",
|
||||||
parsedDate.value = date;
|
day: "numeric",
|
||||||
datetimeValue.value = formatForDatetimeLocal(date);
|
year: "numeric",
|
||||||
isValidParse.value = true;
|
|
||||||
naturalInput.value = ""; // Clear natural input when set externally
|
|
||||||
}
|
|
||||||
} else if (!newValue) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const parseNaturalInput = () => {
|
|
||||||
const input = naturalInput.value.trim();
|
|
||||||
|
|
||||||
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 onBlur = () => {
|
|
||||||
// If we have a valid parse but the input changed, try to parse again
|
|
||||||
if (naturalInput.value.trim() && !isValidParse.value) {
|
|
||||||
parseNaturalInput();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDatetimeChange = () => {
|
|
||||||
if (datetimeValue.value) {
|
|
||||||
const date = new Date(datetimeValue.value);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
parsedDate.value = date;
|
|
||||||
isValidParse.value = true;
|
|
||||||
hasError.value = false;
|
|
||||||
naturalInput.value = ""; // Clear natural input when using traditional picker
|
|
||||||
emit("update:modelValue", datetimeValue.value);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
parsedDate.value = null;
|
|
||||||
isValidParse.value = false;
|
|
||||||
hasError.value = false;
|
|
||||||
errorMessage.value = "";
|
|
||||||
emit("update:modelValue", "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const setError = (message) => {
|
|
||||||
isValidParse.value = false;
|
|
||||||
hasError.value = true;
|
|
||||||
errorMessage.value = message;
|
|
||||||
parsedDate.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
});
|
}).format(previewDate.value);
|
||||||
|
const abbr = shortTimezoneName(previewDate.value, tz);
|
||||||
|
return abbr ? `${date} ${abbr}` : date;
|
||||||
|
});
|
||||||
|
|
||||||
if (isToday) {
|
const activeTZ = () =>
|
||||||
return `Today at ${timeStr}`;
|
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
} else if (isTomorrow) {
|
|
||||||
return `Tomorrow at ${timeStr}`;
|
// Seed the input from modelValue without triggering chrono. The parent's
|
||||||
} else {
|
// value is canonical — we just render it as a chrono-friendly readable
|
||||||
return date.toLocaleString("en-US", {
|
// string so the user can backspace and tweak in place.
|
||||||
weekday: "long",
|
const seedFromModelValue = () => {
|
||||||
year: "numeric",
|
if (!props.modelValue) {
|
||||||
month: "long",
|
rawInput.value = "";
|
||||||
day: "numeric",
|
isValid.value = false;
|
||||||
hour: "numeric",
|
hasError.value = false;
|
||||||
minute: "2-digit",
|
errorMessage.value = "";
|
||||||
hour12: true,
|
previewDate.value = null;
|
||||||
});
|
return;
|
||||||
}
|
}
|
||||||
|
const tz = activeTZ();
|
||||||
|
const utc = zonedLocalToUTC(props.modelValue, tz);
|
||||||
|
if (!utc) return;
|
||||||
|
previewDate.value = utc;
|
||||||
|
isValid.value = true;
|
||||||
|
hasError.value = false;
|
||||||
|
errorMessage.value = "";
|
||||||
|
rawInput.value = readableSeed(utc, tz);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(seedFromModelValue);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(next) => {
|
||||||
|
const tz = activeTZ();
|
||||||
|
const expected = previewDate.value
|
||||||
|
? utcToZonedLocal(previewDate.value, tz)
|
||||||
|
: "";
|
||||||
|
if (next === expected) return;
|
||||||
|
seedFromModelValue();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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 onInputChange = (value) => {
|
||||||
|
rawInput.value = value;
|
||||||
|
parse(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parse = (input) => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
isValid.value = false;
|
||||||
|
hasError.value = false;
|
||||||
|
errorMessage.value = "";
|
||||||
|
previewDate.value = null;
|
||||||
|
emit("update:modelValue", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tz = activeTZ();
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = chrono.parse(trimmed, referenceNowInTZ(tz));
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!results.length) {
|
||||||
|
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const date = results[0].date();
|
||||||
|
if (!date || Number.isNaN(date.getTime())) {
|
||||||
|
setError("Couldn't read that date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// chrono returned a Date whose browser-local components match what the
|
||||||
|
// user typed in the event timezone (because we shifted the reference).
|
||||||
|
// Read those components as wall-clock in displayTimezone.
|
||||||
|
const localStr = browserComponentsToString(date);
|
||||||
|
const utc = zonedLocalToUTC(localStr, tz);
|
||||||
|
if (!utc) {
|
||||||
|
setError("Couldn't parse this date");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isValid.value = true;
|
||||||
|
hasError.value = false;
|
||||||
|
errorMessage.value = "";
|
||||||
|
previewDate.value = utc;
|
||||||
|
emit("update:modelValue", localStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (msg) => {
|
||||||
|
isValid.value = false;
|
||||||
|
hasError.value = true;
|
||||||
|
errorMessage.value = msg;
|
||||||
|
previewDate.value = null;
|
||||||
|
emit("update:modelValue", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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 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",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
}).format(utc);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.natural-date-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-line {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
176
app/components/OnboardingWidget.vue
Normal file
176
app/components/OnboardingWidget.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<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>
|
<style scoped>
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 24px 28px 16px;
|
padding: var(--page-pad-y) var(--page-pad-x) 16px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.page-header h1 {
|
.page-header h1 {
|
||||||
|
|
|
||||||
23
app/components/PageSection.vue
Normal file
23
app/components/PageSection.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<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>
|
||||||
24
app/components/PageShell.vue
Normal file
24
app/components/PageShell.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<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);
|
color: var(--parch-text);
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--parch-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.parchment-inset :deep(h2) {
|
.parchment-inset :deep(h2) {
|
||||||
|
|
@ -30,6 +30,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.parchment-inset :deep(a) {
|
.parchment-inset :deep(a) {
|
||||||
color: var(--candle-faint);
|
color: var(--parch-accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
<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,19 +4,16 @@
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="loading" class="text-center py-8">
|
||||||
<div
|
<div
|
||||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||||
></div>
|
/>
|
||||||
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div
|
<div v-else-if="error" class="error-state p-6">
|
||||||
v-else-if="error"
|
<h3 class="error-state__heading text-lg font-semibold mb-2">
|
||||||
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
|
|
||||||
>
|
|
||||||
<h3 class="text-lg font-semibold text-ember-300 mb-2">
|
|
||||||
Unable to Load Series Pass
|
Unable to Load Series Pass
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-ember-400">{{ error }}</p>
|
<p class="error-state__body">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
|
|
@ -48,7 +45,7 @@
|
||||||
<!-- Registration Form -->
|
<!-- Registration Form -->
|
||||||
<div
|
<div
|
||||||
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
||||||
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
|
class="registration-form p-6"
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
||||||
{{
|
{{
|
||||||
|
|
@ -58,7 +55,7 @@
|
||||||
}}
|
}}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
<!-- Name Field -->
|
<!-- Name Field -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|
@ -103,18 +100,20 @@
|
||||||
<!-- Member Benefits Notice -->
|
<!-- Member Benefits Notice -->
|
||||||
<div
|
<div
|
||||||
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
||||||
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
|
class="p-4"
|
||||||
|
style="background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon
|
<Icon
|
||||||
name="heroicons:sparkles"
|
name="heroicons:sparkles"
|
||||||
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
|
class="w-5 h-5 flex-shrink-0 mt-0.5"
|
||||||
|
style="color: var(--candle)"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-candlelight-300 mb-1">
|
<div class="font-semibold mb-1" style="color: var(--candle)">
|
||||||
Member Benefit
|
Member Benefit
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-candlelight-400">
|
<div class="text-sm" style="color: var(--candle)">
|
||||||
This series pass is free for Ghost Guild members!
|
This series pass is free for Ghost Guild members!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,6 +143,7 @@
|
||||||
<p class="text-xs text-[--ui-text-muted] text-center">
|
<p class="text-xs text-[--ui-text-muted] text-center">
|
||||||
By registering, you'll be automatically registered for all
|
By registering, you'll be automatically registered for all
|
||||||
{{ seriesInfo.totalEvents }} events in this series.
|
{{ seriesInfo.totalEvents }} events in this series.
|
||||||
|
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +182,7 @@ const props = defineProps({
|
||||||
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
|
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
@ -264,10 +264,9 @@ const handleSubmit = async () => {
|
||||||
paymentProcessing.value = true;
|
paymentProcessing.value = true;
|
||||||
|
|
||||||
// Initialize Helcim payment for series pass
|
// Initialize Helcim payment for series pass
|
||||||
await initializeTicketPayment(
|
await initializeSeriesTicketPayment(
|
||||||
props.seriesId,
|
props.seriesId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
passInfo.value.ticket.price,
|
|
||||||
props.seriesInfo.title,
|
props.seriesInfo.title,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -286,6 +285,7 @@ const handleSubmit = async () => {
|
||||||
const purchaseBody = {
|
const purchaseBody = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
email: form.value.email,
|
email: form.value.email,
|
||||||
|
ticketType: passInfo.value.ticket.type,
|
||||||
};
|
};
|
||||||
if (transactionId) purchaseBody.paymentId = transactionId;
|
if (transactionId) purchaseBody.paymentId = transactionId;
|
||||||
|
|
||||||
|
|
@ -297,12 +297,17 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh client auth state if server signed us in (guest upgrade)
|
||||||
|
if (purchaseResponse?.signedIn) {
|
||||||
|
await useAuth().checkMemberStatus();
|
||||||
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Series Pass Purchased!",
|
title: "Series Pass Purchased!",
|
||||||
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
||||||
color: "green",
|
color: "green",
|
||||||
timeout: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit success event
|
// Emit success event
|
||||||
|
|
@ -322,7 +327,7 @@ const handleSubmit = async () => {
|
||||||
title: "Purchase Failed",
|
title: "Purchase Failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
color: "red",
|
color: "red",
|
||||||
timeout: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("purchase-error", errorMessage);
|
emit("purchase-error", errorMessage);
|
||||||
|
|
@ -349,3 +354,18 @@ const formatPrice = (price, currency = "CAD") => {
|
||||||
}).format(price);
|
}).format(price);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.error-state {
|
||||||
|
background: color-mix(in srgb, var(--ember) 8%, transparent);
|
||||||
|
border: 1px dashed var(--ember);
|
||||||
|
}
|
||||||
|
.error-state__heading,
|
||||||
|
.error-state__body {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
.registration-form {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
186
app/components/SignupFlowOverlay.vue
Normal file
186
app/components/SignupFlowOverlay.vue
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
<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>
|
|
||||||
106
app/components/TagSuggestModal.vue
Normal file
106
app/components/TagSuggestModal.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="tier-picker">
|
|
||||||
<div
|
|
||||||
v-for="tier in tiers"
|
|
||||||
:key="tier.amount"
|
|
||||||
class="tier-option"
|
|
||||||
:class="{ current: modelValue === tier.amount }"
|
|
||||||
@click="$emit('update:modelValue', tier.amount)"
|
|
||||||
>
|
|
||||||
<span class="tier-amount">{{ tier.display }}</span>
|
|
||||||
<span 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,23 +1,73 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="top-strip">
|
<div class="top-strip">
|
||||||
<span>
|
<span>
|
||||||
<slot name="left">ghostguild.org{{ pagePath ? ` / ${pagePath}` : '' }}</slot>
|
<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>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span class="right">
|
||||||
<slot name="right">
|
<slot name="right">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<template v-if="memberData">
|
<template v-if="memberData">
|
||||||
Signed in as {{ memberData.name }}
|
<NuxtLink to="/member/profile" class="member-link">
|
||||||
<template v-if="memberData.circle">
|
<img
|
||||||
· {{ memberData.circle }}
|
v-if="memberData.avatar"
|
||||||
</template>
|
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
||||||
</template>
|
:alt="memberData.name"
|
||||||
<template v-else>
|
class="member-avatar"
|
||||||
A cooperative for game developers
|
>
|
||||||
</template>
|
<svg
|
||||||
<template #fallback>
|
v-else
|
||||||
A cooperative for game developers
|
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
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else> The Baby Ghosts member program </template>
|
||||||
|
<template #fallback> The Baby Ghosts member program </template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -25,16 +75,38 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
pagePath: { type: String, default: '' },
|
pagePath: { type: String, default: "" },
|
||||||
})
|
});
|
||||||
|
|
||||||
const { memberData } = useAuth()
|
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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.top-strip {
|
.top-strip {
|
||||||
padding: 16px 32px;
|
padding: 0 32px;
|
||||||
|
min-height: 53px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|
@ -42,6 +114,62 @@ const { memberData } = useAuth()
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.top-strip a { color: var(--text-faint); }
|
.top-strip a {
|
||||||
.top-strip a:hover { color: var(--candle); }
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
<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>
|
|
||||||
340
app/components/admin/AdminAlertsPanel.vue
Normal file
340
app/components/admin/AdminAlertsPanel.vue
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
<!-- 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>
|
||||||
19
app/composables/useBoardChannels.js
Normal file
19
app/composables/useBoardChannels.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/composables/useBoardPosts.js
Normal file
50
app/composables/useBoardPosts.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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,85 +1,98 @@
|
||||||
// Utility composable for event date handling with timezone support
|
// Utility composable for event date handling with timezone support.
|
||||||
|
// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ.
|
||||||
export const useEventDateUtils = () => {
|
export const useEventDateUtils = () => {
|
||||||
const TIMEZONE = "America/Toronto";
|
const DEFAULT_TIMEZONE = "America/Toronto";
|
||||||
|
|
||||||
// Format a date to a specific format
|
|
||||||
const formatDate = (date, options = {}) => {
|
const formatDate = (date, options = {}) => {
|
||||||
|
if (!date) return "";
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
const { month = "short", day = "numeric", year = "numeric" } = options;
|
if (isNaN(dateObj.getTime())) return "";
|
||||||
|
const {
|
||||||
|
month = "short",
|
||||||
|
day = "numeric",
|
||||||
|
year = "numeric",
|
||||||
|
weekday,
|
||||||
|
timeZone,
|
||||||
|
} = options;
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
...(weekday && { weekday }),
|
||||||
month,
|
month,
|
||||||
day,
|
day,
|
||||||
year,
|
year,
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
}).format(dateObj);
|
}).format(dateObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format event date range
|
const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
|
||||||
const formatDateRange = (startDate, endDate, compact = false) => {
|
|
||||||
if (!startDate || !endDate) return "No dates";
|
if (!startDate || !endDate) return "No dates";
|
||||||
|
|
||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
|
|
||||||
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
|
const tzOpts = timeZone ? { timeZone } : {};
|
||||||
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
|
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||||
const startDay = start.getDate();
|
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||||
const endDay = end.getDate();
|
const startDay = Number(
|
||||||
const year = end.getFullYear();
|
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
const endDay = Number(
|
||||||
|
end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
const year = Number(
|
||||||
|
end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
const startMonthIdx = startMonth; // compared as label string
|
||||||
|
const endMonthIdx = endMonth;
|
||||||
|
const startYear = Number(
|
||||||
|
start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||||
|
);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
if (
|
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||||
start.getMonth() === end.getMonth() &&
|
|
||||||
start.getFullYear() === end.getFullYear()
|
|
||||||
) {
|
|
||||||
return `${startMonth} ${startDay}-${endDay}`;
|
return `${startMonth} ${startDay}-${endDay}`;
|
||||||
}
|
}
|
||||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
|
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||||
start.getMonth() === end.getMonth() &&
|
|
||||||
start.getFullYear() === end.getFullYear()
|
|
||||||
) {
|
|
||||||
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||||
} else if (start.getFullYear() === end.getFullYear()) {
|
} else if (startYear === year) {
|
||||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||||
} else {
|
} else {
|
||||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if a date is in the past
|
|
||||||
const isPastDate = (date) => {
|
const isPastDate = (date) => {
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
const now = new Date();
|
return dateObj < new Date();
|
||||||
return dateObj < now;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if a date is today
|
const isToday = (date, timeZone) => {
|
||||||
const isToday = (date) => {
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
|
||||||
return (
|
return (
|
||||||
dateObj.getDate() === today.getDate() &&
|
dateObj.toLocaleDateString("en-US", opts) ===
|
||||||
dateObj.getMonth() === today.getMonth() &&
|
today.toLocaleDateString("en-US", opts)
|
||||||
dateObj.getFullYear() === today.getFullYear()
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get a readable time string
|
const formatTime = (date, includeSeconds = false, timeZone) => {
|
||||||
const formatTime = (date, includeSeconds = false) => {
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
const options = {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
...(includeSeconds && { second: "2-digit" }),
|
...(includeSeconds && { second: "2-digit" }),
|
||||||
};
|
...(timeZone && { timeZone }),
|
||||||
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
|
}).format(dateObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
TIMEZONE,
|
DEFAULT_TIMEZONE,
|
||||||
|
// Legacy alias for callers that hard-coded the constant.
|
||||||
|
TIMEZONE: DEFAULT_TIMEZONE,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
isPastDate,
|
isPastDate,
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
// Helcim API integration composable
|
|
||||||
export const useHelcim = () => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const helcimToken = config.public.helcimToken
|
|
||||||
|
|
||||||
// Base URL for Helcim API
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
|
||||||
|
|
||||||
// Helper function to make API requests
|
|
||||||
const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
|
|
||||||
try {
|
|
||||||
const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'api-token': helcimToken
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
})
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Helcim API error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a customer
|
|
||||||
const createCustomer = async (customerData) => {
|
|
||||||
return await makeHelcimRequest('/customers', 'POST', {
|
|
||||||
customerType: 'PERSON',
|
|
||||||
contactName: customerData.name,
|
|
||||||
email: customerData.email,
|
|
||||||
billingAddress: customerData.billingAddress || {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a subscription
|
|
||||||
const createSubscription = async (customerId, planId, cardToken) => {
|
|
||||||
return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
|
|
||||||
customerId,
|
|
||||||
planId,
|
|
||||||
cardToken,
|
|
||||||
startDate: new Date().toISOString().split('T')[0] // Today's date
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get customer details
|
|
||||||
const getCustomer = async (customerId) => {
|
|
||||||
return await makeHelcimRequest(`/customers/${customerId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get subscription details
|
|
||||||
const getSubscription = async (subscriptionId) => {
|
|
||||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update subscription
|
|
||||||
const updateSubscription = async (subscriptionId, updates) => {
|
|
||||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel subscription
|
|
||||||
const cancelSubscription = async (subscriptionId) => {
|
|
||||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get payment plans
|
|
||||||
const getPaymentPlans = async () => {
|
|
||||||
return await makeHelcimRequest('/recurring/plans')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify card token (for testing)
|
|
||||||
const verifyCardToken = async (cardToken) => {
|
|
||||||
return await makeHelcimRequest('/cards/verify', 'POST', {
|
|
||||||
cardToken
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
createCustomer,
|
|
||||||
createSubscription,
|
|
||||||
getCustomer,
|
|
||||||
getSubscription,
|
|
||||||
updateSubscription,
|
|
||||||
cancelSubscription,
|
|
||||||
getPaymentPlans,
|
|
||||||
verifyCardToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ export const useHelcimPay = () => {
|
||||||
let checkoutToken = null;
|
let checkoutToken = null;
|
||||||
let secretToken = null;
|
let secretToken = null;
|
||||||
|
|
||||||
// Initialize HelcimPay.js session
|
// Initialize HelcimPay.js session (membership signup flow)
|
||||||
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||||
|
|
@ -12,6 +12,7 @@ export const useHelcimPay = () => {
|
||||||
customerId,
|
customerId,
|
||||||
customerCode,
|
customerCode,
|
||||||
amount,
|
amount,
|
||||||
|
metadata: { type: "membership_signup" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -28,26 +29,14 @@ export const useHelcimPay = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize payment for event ticket purchase
|
const _initializeTicket = async (metadata, errorPrefix) => {
|
||||||
const initializeTicketPayment = async (
|
|
||||||
eventId,
|
|
||||||
email,
|
|
||||||
amount,
|
|
||||||
eventTitle = null,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
customerId: null,
|
customerId: null,
|
||||||
customerCode: email, // Use email as customer code for event tickets
|
customerCode: metadata.email,
|
||||||
amount,
|
metadata,
|
||||||
metadata: {
|
|
||||||
type: "event_ticket",
|
|
||||||
eventId,
|
|
||||||
email,
|
|
||||||
eventTitle,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,16 +46,29 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
checkoutToken: response.checkoutToken,
|
checkoutToken: response.checkoutToken,
|
||||||
|
amount: response.amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Failed to initialize ticket payment session");
|
throw new Error(`Failed to initialize ${errorPrefix} session`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Ticket payment initialization error:", error);
|
console.error(`${errorPrefix} initialization error:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initializeTicketPayment = (eventId, email, eventTitle = null) =>
|
||||||
|
_initializeTicket(
|
||||||
|
{ type: "event_ticket", eventId, email, eventTitle },
|
||||||
|
"ticket payment",
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
|
||||||
|
_initializeTicket(
|
||||||
|
{ type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
|
||||||
|
"series payment",
|
||||||
|
);
|
||||||
|
|
||||||
// Show payment modal
|
// Show payment modal
|
||||||
const showPaymentModal = () => {
|
const showPaymentModal = () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -139,6 +141,7 @@ export const useHelcimPay = () => {
|
||||||
if (typeof window.appendHelcimPayIframe === "function") {
|
if (typeof window.appendHelcimPayIframe === "function") {
|
||||||
// Set up event listener for HelcimPay.js responses
|
// Set up event listener for HelcimPay.js responses
|
||||||
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
||||||
|
let observerTimer, paymentTimer;
|
||||||
|
|
||||||
const handleHelcimPayEvent = (event) => {
|
const handleHelcimPayEvent = (event) => {
|
||||||
console.log("Received window message:", event.data);
|
console.log("Received window message:", event.data);
|
||||||
|
|
@ -148,6 +151,8 @@ export const useHelcimPay = () => {
|
||||||
|
|
||||||
// Remove event listener to prevent multiple responses
|
// Remove event listener to prevent multiple responses
|
||||||
window.removeEventListener("message", handleHelcimPayEvent);
|
window.removeEventListener("message", handleHelcimPayEvent);
|
||||||
|
clearTimeout(observerTimer);
|
||||||
|
clearTimeout(paymentTimer);
|
||||||
|
|
||||||
// Close the Helcim modal
|
// Close the Helcim modal
|
||||||
if (typeof window.removeHelcimPayIframe === "function") {
|
if (typeof window.removeHelcimPayIframe === "function") {
|
||||||
|
|
@ -237,10 +242,10 @@ export const useHelcimPay = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up observer after a timeout
|
// Clean up observer after a timeout
|
||||||
setTimeout(() => observer.disconnect(), 5000);
|
observerTimer = setTimeout(() => observer.disconnect(), 5000);
|
||||||
|
|
||||||
// Add timeout to clean up if no response (10 minutes for manual card entry)
|
// Add timeout to clean up if no response (10 minutes for manual card entry)
|
||||||
setTimeout(() => {
|
paymentTimer = setTimeout(() => {
|
||||||
console.log("Payment timeout reached, cleaning up event listener...");
|
console.log("Payment timeout reached, cleaning up event listener...");
|
||||||
window.removeEventListener("message", handleHelcimPayEvent);
|
window.removeEventListener("message", handleHelcimPayEvent);
|
||||||
reject(new Error("Payment timeout - no response received"));
|
reject(new Error("Payment timeout - no response received"));
|
||||||
|
|
@ -272,6 +277,7 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
initializeHelcimPay,
|
initializeHelcimPay,
|
||||||
initializeTicketPayment,
|
initializeTicketPayment,
|
||||||
|
initializeSeriesTicketPayment,
|
||||||
verifyPayment,
|
verifyPayment,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,45 +25,81 @@ export const useMemberPayment = () => {
|
||||||
paymentSuccess.value = false
|
paymentSuccess.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get or create Helcim customer
|
// Fast-path: when both Helcim ids are already cached on the member doc
|
||||||
await getOrCreateCustomer()
|
// AND a card's on file, we can skip the paid getOrCreateCustomer round
|
||||||
|
// trip entirely and go straight to subscription creation.
|
||||||
// Step 2: Initialize Helcim payment with $0 for card verification
|
const hasCachedHelcimIds = Boolean(
|
||||||
await initializeHelcimPay(
|
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
|
||||||
customerId.value,
|
|
||||||
customerCode.value,
|
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 3: Show payment modal and get payment result
|
let existing = null
|
||||||
const paymentResult = await verifyPayment()
|
let probedExistingCard = false
|
||||||
console.log('Payment result:', paymentResult)
|
let cardToken = null
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (hasCachedHelcimIds) {
|
||||||
throw new Error('Payment verification failed')
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Verify payment on backend
|
if (!cardToken) {
|
||||||
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||||
method: 'POST',
|
// to re-save it, breaking retries after a partial-failed signup.
|
||||||
body: {
|
const [, existingFromFull] = await Promise.all([
|
||||||
cardToken: paymentResult.cardToken,
|
getOrCreateCustomer(),
|
||||||
customerId: customerId.value,
|
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
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
if (!verifyResult.success) {
|
cardToken = existingFromFull?.cardToken || null
|
||||||
throw new Error('Payment verification failed on backend')
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Create subscription with proper contribution tier
|
|
||||||
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
customerId: customerId.value,
|
customerId: customerId.value,
|
||||||
customerCode: customerCode.value,
|
customerCode: customerCode.value,
|
||||||
contributionTier: memberData.value?.contributionTier || '5',
|
contributionAmount: memberData.value?.contributionAmount ?? 5,
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -71,7 +107,6 @@ export const useMemberPayment = () => {
|
||||||
throw new Error('Subscription creation failed')
|
throw new Error('Subscription creation failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Payment successful - refresh member data
|
|
||||||
paymentSuccess.value = true
|
paymentSuccess.value = true
|
||||||
await checkMemberStatus()
|
await checkMemberStatus()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,137 +4,146 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MEMBER_STATUSES = {
|
export const MEMBER_STATUSES = {
|
||||||
PENDING_PAYMENT: 'pending_payment',
|
PENDING_PAYMENT: "pending_payment",
|
||||||
ACTIVE: 'active',
|
ACTIVE: "active",
|
||||||
SUSPENDED: 'suspended',
|
SUSPENDED: "suspended",
|
||||||
CANCELLED: 'cancelled',
|
CANCELLED: "cancelled",
|
||||||
}
|
};
|
||||||
|
|
||||||
export const MEMBER_STATUS_CONFIG = {
|
export const MEMBER_STATUS_CONFIG = {
|
||||||
pending_payment: {
|
pending_payment: {
|
||||||
label: 'Payment Pending',
|
label: "Setting up payment",
|
||||||
color: 'orange',
|
color: "orange",
|
||||||
bgColor: 'bg-orange-500/10',
|
bgColor: "bg-orange-500/10",
|
||||||
borderColor: 'border-orange-500/30',
|
borderColor: "border-orange-500/30",
|
||||||
textColor: 'text-orange-300',
|
textColor: "text-orange-300",
|
||||||
icon: 'heroicons:exclamation-triangle',
|
icon: "heroicons:exclamation-triangle",
|
||||||
severity: 'warning',
|
severity: "warning",
|
||||||
canRSVP: false,
|
canRSVP: true,
|
||||||
canAccessMembers: true,
|
canAccessMembers: true,
|
||||||
canPeerSupport: false,
|
canPeerSupport: true,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
label: 'Active Member',
|
label: "Active Member",
|
||||||
color: 'green',
|
color: "green",
|
||||||
bgColor: 'bg-green-500/10',
|
bgColor: "bg-green-500/10",
|
||||||
borderColor: 'border-green-500/30',
|
borderColor: "border-green-500/30",
|
||||||
textColor: 'text-green-300',
|
textColor: "text-green-300",
|
||||||
icon: 'heroicons:check-circle',
|
icon: "heroicons:check-circle",
|
||||||
severity: 'success',
|
severity: "success",
|
||||||
canRSVP: true,
|
canRSVP: true,
|
||||||
canAccessMembers: true,
|
canAccessMembers: true,
|
||||||
canPeerSupport: true,
|
canPeerSupport: true,
|
||||||
},
|
},
|
||||||
suspended: {
|
suspended: {
|
||||||
label: 'Membership Suspended',
|
label: "Membership Suspended",
|
||||||
color: 'red',
|
color: "red",
|
||||||
bgColor: 'bg-red-500/10',
|
bgColor: "bg-red-500/10",
|
||||||
borderColor: 'border-red-500/30',
|
borderColor: "border-red-500/30",
|
||||||
textColor: 'text-red-300',
|
textColor: "text-red-300",
|
||||||
icon: 'heroicons:no-symbol',
|
icon: "heroicons:no-symbol",
|
||||||
severity: 'error',
|
severity: "error",
|
||||||
canRSVP: false,
|
canRSVP: false,
|
||||||
canAccessMembers: false,
|
canAccessMembers: false,
|
||||||
canPeerSupport: false,
|
canPeerSupport: false,
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
label: 'Membership Cancelled',
|
label: "Membership Cancelled",
|
||||||
color: 'gray',
|
color: "gray",
|
||||||
bgColor: 'bg-gray-500/10',
|
bgColor: "bg-gray-500/10",
|
||||||
borderColor: 'border-gray-500/30',
|
borderColor: "border-gray-500/30",
|
||||||
textColor: 'text-gray-300',
|
textColor: "text-gray-300",
|
||||||
icon: 'heroicons:x-circle',
|
icon: "heroicons:x-circle",
|
||||||
severity: 'error',
|
severity: "error",
|
||||||
canRSVP: false,
|
canRSVP: false,
|
||||||
canAccessMembers: false,
|
canAccessMembers: false,
|
||||||
canPeerSupport: false,
|
canPeerSupport: false,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useMemberStatus = () => {
|
export const useMemberStatus = () => {
|
||||||
const { memberData } = useAuth()
|
const { memberData } = useAuth();
|
||||||
|
|
||||||
// Get current member status
|
// Get current member status
|
||||||
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT)
|
const status = computed(
|
||||||
|
() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT,
|
||||||
|
);
|
||||||
|
|
||||||
// Get status configuration
|
// 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
|
// Helper methods
|
||||||
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
|
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
|
||||||
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
|
const isPendingPayment = computed(
|
||||||
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
|
() => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
|
||||||
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
|
);
|
||||||
const isInactive = computed(() => !isActive.value)
|
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
|
// Check if member can perform action
|
||||||
const canRSVP = computed(() => statusConfig.value.canRSVP)
|
const canRSVP = computed(() => statusConfig.value.canRSVP);
|
||||||
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
|
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
|
||||||
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
|
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
|
||||||
|
|
||||||
// Get action button text and link based on status
|
// Get action button text and link based on status
|
||||||
const getNextAction = () => {
|
const getNextAction = () => {
|
||||||
if (isPendingPayment.value) {
|
if (isPendingPayment.value) {
|
||||||
return {
|
return {
|
||||||
label: 'Complete Payment',
|
label: "Complete Payment",
|
||||||
link: '/member/profile#account',
|
link: "/member/account",
|
||||||
icon: 'heroicons:credit-card',
|
icon: "heroicons:credit-card",
|
||||||
color: 'orange',
|
color: "orange",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
if (isCancelled.value) {
|
if (isCancelled.value) {
|
||||||
return {
|
return {
|
||||||
label: 'Reactivate Membership',
|
label: "Reactivate Membership",
|
||||||
link: '/member/profile#account',
|
link: "/member/account",
|
||||||
icon: 'heroicons:arrow-path',
|
icon: "heroicons:arrow-path",
|
||||||
color: 'blue',
|
color: "blue",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
if (isSuspended.value) {
|
if (isSuspended.value) {
|
||||||
return {
|
return {
|
||||||
label: 'Contact Support',
|
label: "Contact Support",
|
||||||
link: 'mailto:support@ghostguild.org',
|
link: "mailto:support@ghostguild.org",
|
||||||
icon: 'heroicons:envelope',
|
icon: "heroicons:envelope",
|
||||||
color: 'gray',
|
color: "gray",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get banner message based on status
|
// Get banner message based on status
|
||||||
const getBannerMessage = () => {
|
const getBannerMessage = () => {
|
||||||
if (isPendingPayment.value) {
|
if (isPendingPayment.value) {
|
||||||
return 'Your membership is pending payment. Please complete your payment to unlock full features.'
|
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.";
|
||||||
}
|
}
|
||||||
if (isSuspended.value) {
|
if (isSuspended.value) {
|
||||||
return 'Your membership has been suspended. Please contact support to reactivate your account.'
|
return "Your account is paused while we work through a community issue. We'll be in touch.";
|
||||||
}
|
}
|
||||||
if (isCancelled.value) {
|
if (isCancelled.value) {
|
||||||
return 'Your membership has been cancelled. Would you like to reactivate?'
|
return "Your account is closed. Reach out if you'd like to come back.";
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get RSVP restriction message
|
// Get RSVP restriction message
|
||||||
const getRSVPMessage = () => {
|
const getRSVPMessage = () => {
|
||||||
if (isPendingPayment.value) {
|
|
||||||
return 'Complete your payment to register for events'
|
|
||||||
}
|
|
||||||
if (isSuspended.value || isCancelled.value) {
|
if (isSuspended.value || isCancelled.value) {
|
||||||
return 'Your membership status prevents RSVP. Please reactivate your account.'
|
return "Your account isn't active right now. Reach out if you have questions.";
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|
@ -151,5 +160,5 @@ export const useMemberStatus = () => {
|
||||||
getBannerMessage,
|
getBannerMessage,
|
||||||
getRSVPMessage,
|
getRSVPMessage,
|
||||||
MEMBER_STATUSES,
|
MEMBER_STATUSES,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
208
app/composables/useOnboarding.js
Normal file
208
app/composables/useOnboarding.js
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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 };
|
|
||||||
};
|
|
||||||
58
app/composables/useSiteMeta.js
Normal file
58
app/composables/useSiteMeta.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* useSiteMeta — set page-level SEO + social meta with site defaults baked in.
|
||||||
|
*
|
||||||
|
* Builds absolute URLs from runtimeConfig.public.appUrl so og:image and og:url
|
||||||
|
* resolve for crawlers. Defaults og:type=website, twitter:card=summary_large_image,
|
||||||
|
* og:site_name=Ghost Guild. Set noindex:true to emit robots="noindex, nofollow".
|
||||||
|
*
|
||||||
|
* Pass a function (or refs in fields) to keep tags reactive when content loads
|
||||||
|
* asynchronously via useFetch.
|
||||||
|
*/
|
||||||
|
export function useSiteMeta(input) {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const route = useRoute()
|
||||||
|
const appUrl = (runtimeConfig.public.appUrl || '').replace(/\/$/, '')
|
||||||
|
|
||||||
|
const resolve = () => (typeof input === 'function' ? input() : input) || {}
|
||||||
|
|
||||||
|
const buildAbsolute = (path) => {
|
||||||
|
if (!path) return undefined
|
||||||
|
if (/^https?:\/\//i.test(path)) return path
|
||||||
|
return `${appUrl}${path.startsWith('/') ? '' : '/'}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleGetter = () => resolve().title || 'Ghost Guild'
|
||||||
|
const descGetter = () => resolve().description || undefined
|
||||||
|
const isBareTitle = () => Boolean(resolve().bareTitle)
|
||||||
|
const imageGetter = () => buildAbsolute(resolve().image || '/og/default.png')
|
||||||
|
const typeGetter = () => resolve().type || 'website'
|
||||||
|
const robotsGetter = () =>
|
||||||
|
resolve().noindex ? 'noindex, nofollow' : undefined
|
||||||
|
const canonicalGetter = () => buildAbsolute(route.path)
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: titleGetter,
|
||||||
|
description: descGetter,
|
||||||
|
ogSiteName: 'Ghost Guild',
|
||||||
|
ogTitle: titleGetter,
|
||||||
|
ogDescription: descGetter,
|
||||||
|
ogType: typeGetter,
|
||||||
|
ogUrl: canonicalGetter,
|
||||||
|
ogImage: imageGetter,
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
twitterCard: 'summary_large_image',
|
||||||
|
twitterTitle: titleGetter,
|
||||||
|
twitterDescription: descGetter,
|
||||||
|
twitterImage: imageGetter,
|
||||||
|
robots: robotsGetter,
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
link: [{ rel: 'canonical', href: canonicalGetter }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isBareTitle()) {
|
||||||
|
useHead({ titleTemplate: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ export const CIRCLES = {
|
||||||
shortDescription: "Building your studio",
|
shortDescription: "Building your studio",
|
||||||
description: "For those actively establishing or growing their coop",
|
description: "For those actively establishing or growing their coop",
|
||||||
features: [
|
features: [
|
||||||
"Teams working toward applying for the Peer Accelerator",
|
"Teams working toward applying for Cooperative Foundations",
|
||||||
"Early-stage coop studios",
|
"Early-stage coop studios",
|
||||||
"Studios transitioning to coop model",
|
"Studios transitioning to coop model",
|
||||||
],
|
],
|
||||||
|
|
@ -33,7 +33,7 @@ export const CIRCLES = {
|
||||||
value: "practitioner",
|
value: "practitioner",
|
||||||
label: "Practitioners",
|
label: "Practitioners",
|
||||||
shortDescription: "Leading and mentoring",
|
shortDescription: "Leading and mentoring",
|
||||||
description: "For Peer Accelerator alumni and experienced studio founders",
|
description: "For alumni and experienced studio founders",
|
||||||
features: [
|
features: [
|
||||||
"Those implementing cooperative models",
|
"Those implementing cooperative models",
|
||||||
"Industry mentors and advisors",
|
"Industry mentors and advisors",
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,22 @@
|
||||||
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
|
// Guidance presets for the contribution amount input.
|
||||||
export const CONTRIBUTION_TIERS = {
|
// These are NOT tiers — just suggested amounts with matching guidance copy.
|
||||||
FREE: {
|
export const CONTRIBUTION_PRESETS = [
|
||||||
value: "0",
|
{ amount: 0, label: "I need support right now" },
|
||||||
amount: 0,
|
{ amount: 5, label: "I can contribute" },
|
||||||
label: "$0 - I need support right now",
|
{ amount: 15, label: "I can sustain the community" },
|
||||||
tier: "free",
|
{ amount: 30, label: "I can support others too" },
|
||||||
helcimPlanId: null, // No Helcim plan needed for free tier
|
{ amount: 50, label: "I want to sponsor multiple members" },
|
||||||
},
|
]
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all contribution options as an array (useful for forms)
|
export const requiresPayment = (amount) => amount > 0
|
||||||
export const getContributionOptions = () => {
|
|
||||||
return Object.values(CONTRIBUTION_TIERS);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get valid contribution values for validation
|
export const isValidContributionAmount = (amount) =>
|
||||||
export const getValidContributionValues = () => {
|
Number.isInteger(amount) && amount >= 0
|
||||||
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get contribution tier by value
|
export const getGuidanceLabel = (amount) => {
|
||||||
export const getContributionTierByValue = (value) => {
|
if (amount === null || amount === undefined) return null
|
||||||
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
|
const n = Number(amount)
|
||||||
};
|
if (!Number.isFinite(n) || n < 0) return null
|
||||||
|
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
|
||||||
// Get Helcim plan ID for a contribution tier
|
return match?.label ?? null
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
21
app/config/eventTypes.js
Normal file
21
app/config/eventTypes.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Central configuration for Ghost Guild event types.
|
||||||
|
// Keep values in sync with the `eventType` enum in server/models/event.js.
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
{ value: "talk", label: "Talk / Presentation" },
|
||||||
|
{ value: "workshop", label: "Workshop" },
|
||||||
|
{ value: "community-meetup", label: "Community Meetup" },
|
||||||
|
{ value: "coworking", label: "Co-working Session" },
|
||||||
|
{ value: "peer-session", label: "Peer Session" },
|
||||||
|
{ value: "skills-share", label: "Skills Share" },
|
||||||
|
{ value: "info-session", label: "Info Session" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EVENT_TYPE_VALUES = EVENT_TYPES.map((t) => t.value);
|
||||||
|
|
||||||
|
const labelLookup = Object.fromEntries(
|
||||||
|
EVENT_TYPES.map((t) => [t.value, t.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function eventTypeLabel(value) {
|
||||||
|
return labelLookup[value] || value || "";
|
||||||
|
}
|
||||||
8
app/config/memberStatus.js
Normal file
8
app/config/memberStatus.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const STATUS_LABELS = {
|
||||||
|
active: "Active",
|
||||||
|
pending_payment: "Payment setup incomplete",
|
||||||
|
suspended: "Paused",
|
||||||
|
cancelled: "Closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
|
||||||
39
app/config/timezones.js
Normal file
39
app/config/timezones.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// 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,6 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="site">
|
<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-(--bg) focus:text-(--text)"
|
||||||
|
>Skip to content</a
|
||||||
|
>
|
||||||
|
|
||||||
<!-- Desktop Sidebar -->
|
<!-- Desktop Sidebar -->
|
||||||
<aside class="sidebar sidebar-desktop">
|
<aside class="sidebar sidebar-desktop">
|
||||||
<NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>
|
<NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>
|
||||||
|
|
@ -14,20 +19,61 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }">
|
<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') }"
|
||||||
|
>
|
||||||
Members
|
Members
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<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
|
Events
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<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
|
Series
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/wiki"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/wiki') }"
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/board-channels"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/board-channels') }"
|
||||||
|
>
|
||||||
|
Board Channels
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/site-content"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/site-content') }"
|
||||||
|
>
|
||||||
|
Site Content
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
@ -38,7 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="admin-tag">admin</span><br>
|
<span class="admin-tag">admin</span><br >
|
||||||
<a href="#" @click.prevent="logout">Sign out</a>
|
<a href="#" @click.prevent="logout">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -59,42 +105,109 @@
|
||||||
<USlideover v-model:open="isMobileMenuOpen" side="left">
|
<USlideover v-model:open="isMobileMenuOpen" side="left">
|
||||||
<template #body>
|
<template #body>
|
||||||
<aside class="sidebar sidebar-mobile">
|
<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-body">
|
||||||
<div class="sidebar-section">Admin</div>
|
<div class="sidebar-section">Admin</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/admin" :class="{ active: route.path === '/admin' }" @click="isMobileMenuOpen = false">
|
<NuxtLink
|
||||||
|
to="/admin"
|
||||||
|
:class="{ active: route.path === '/admin' }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }" @click="isMobileMenuOpen = false">
|
<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"
|
||||||
|
>
|
||||||
Members
|
Members
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<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
|
Events
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<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
|
Series
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/wiki"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/wiki') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/board-channels"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/board-channels') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Board Channels
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/site-content"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/site-content') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Site Content
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li><NuxtLink to="/member/dashboard" @click="isMobileMenuOpen = false">Your Dashboard</NuxtLink></li>
|
<li>
|
||||||
<li><NuxtLink to="/" @click="isMobileMenuOpen = false">Public Site</NuxtLink></li>
|
<NuxtLink
|
||||||
|
to="/member/dashboard"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>Your Dashboard</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/" @click="isMobileMenuOpen = false"
|
||||||
|
>Public Site</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="admin-tag">admin</span><br>
|
<span class="admin-tag">admin</span><br >
|
||||||
<a href="#" @click.prevent="logout">Sign out</a>
|
<a href="#" @click.prevent="logout">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -104,23 +217,23 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const route = useRoute()
|
useSiteMeta({ title: "Admin", noindex: true });
|
||||||
const isMobileMenuOpen = ref(false)
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isMobileMenuOpen = ref(false);
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||||
|
|
||||||
const currentPageName = computed(() => {
|
const currentPageName = computed(() => {
|
||||||
const path = route.path
|
const path = route.path;
|
||||||
if (path === '/admin') return 'admin'
|
if (path === "/admin") return "admin";
|
||||||
return path.slice(1).replace(/\//g, ' / ')
|
const segments = path.slice(1).split("/");
|
||||||
})
|
if (pageBreadcrumbTitle.value && segments.length > 1) {
|
||||||
|
return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / ");
|
||||||
const logout = async () => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
|
||||||
await navigateTo('/')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout failed:', error)
|
|
||||||
}
|
}
|
||||||
}
|
return segments.join(" / ");
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -154,16 +267,20 @@ const logout = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: block;
|
display: flex;
|
||||||
font-family: 'Brygada 1918', serif;
|
align-items: center;
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
padding: 24px 24px 16px;
|
padding: 0 24px;
|
||||||
|
height: 53px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.sidebar-brand:hover { text-decoration: none; }
|
.sidebar-brand:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-body {
|
.sidebar-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -239,7 +356,7 @@ const logout = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-guild-900">
|
<div class="coming-soon-layout">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.coming-soon-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="site">
|
<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 -->
|
<!-- Desktop Sidebar -->
|
||||||
<AppNavigation class="sidebar-desktop" />
|
<AppNavigation class="sidebar-desktop" />
|
||||||
|
|
||||||
|
|
@ -24,20 +28,24 @@
|
||||||
<AppNavigation :is-mobile="true" @navigate="isMobileMenuOpen = false" />
|
<AppNavigation :is-mobile="true" @navigate="isMobileMenuOpen = false" />
|
||||||
</template>
|
</template>
|
||||||
</USlideover>
|
</USlideover>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const isMobileMenuOpen = ref(false)
|
const isMobileMenuOpen = ref(false);
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
|
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||||
|
|
||||||
const currentPageName = computed(() => {
|
const currentPageName = computed(() => {
|
||||||
const path = route.path
|
const path = route.path;
|
||||||
if (path === '/') return ''
|
if (path === "/") return "";
|
||||||
// Convert /member/dashboard → member / dashboard
|
const segments = path.slice(1).split("/");
|
||||||
return path.slice(1).replace(/\//g, ' / ')
|
if (pageBreadcrumbTitle.value && segments.length > 1) {
|
||||||
})
|
return [...segments.slice(0, -1), pageBreadcrumbTitle.value].join(" / ");
|
||||||
|
}
|
||||||
|
return segments.join(" / ");
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -71,7 +79,7 @@ const currentPageName = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,38 @@
|
||||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
// Skip on server-side rendering
|
// Skip on server-side rendering
|
||||||
if (process.server) {
|
if (process.server) {
|
||||||
console.log('🛡️ Auth middleware - skipping on server')
|
console.log("🛡️ Auth middleware - skipping on server");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth()
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal()
|
const { openLoginModal } = useLoginModal();
|
||||||
|
|
||||||
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
|
console.log("🛡️ Auth middleware (CLIENT) - route:", to.path);
|
||||||
console.log(' - memberData exists:', !!memberData.value)
|
console.log(" - memberData exists:", !!memberData.value);
|
||||||
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT')
|
console.log(" - Running on:", process.server ? "SERVER" : "CLIENT");
|
||||||
|
|
||||||
// If no member data, try to check authentication
|
// If no member data, try to check authentication
|
||||||
if (!memberData.value) {
|
if (!memberData.value) {
|
||||||
console.log(' - No member data, checking authentication...')
|
console.log(" - No member data, checking authentication...");
|
||||||
const isAuthenticated = await checkMemberStatus()
|
const isAuthenticated = await checkMemberStatus();
|
||||||
console.log(' - Authentication result:', isAuthenticated)
|
console.log(" - Authentication result:", isAuthenticated);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
console.log(' - ❌ Authentication failed, showing login modal')
|
console.log(" - ❌ Authentication failed, showing login modal");
|
||||||
// Open login modal instead of redirecting
|
// Open login modal instead of redirecting
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
title: 'Sign in to continue',
|
title: "Sign in to continue",
|
||||||
description: 'You need to be signed in to access this page',
|
description: "You need to be signed in to access this page",
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
redirectTo: to.fullPath,
|
redirectTo: to.fullPath,
|
||||||
})
|
});
|
||||||
// Abort navigation - stay on current page with modal open
|
// Let navigation proceed — the page renders its own unauthenticated
|
||||||
return abortNavigation()
|
// fallback, and the modal opens on top. abortNavigation() on an initial
|
||||||
|
// page load resets client state, which closes the modal before it shows.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(' - ✅ Authentication successful for:', memberData.value?.email)
|
console.log(" - ✅ Authentication successful for:", memberData.value?.email);
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,24 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
if (
|
if (
|
||||||
to.path === "/coming-soon" ||
|
to.path === "/coming-soon" ||
|
||||||
to.path === "/auth/wiki-login" ||
|
to.path === "/auth/wiki-login" ||
|
||||||
|
to.path === "/auth/oidc-error" ||
|
||||||
|
to.path === "/auth/logout-confirm" ||
|
||||||
|
to.path === "/auth/logout-success" ||
|
||||||
|
to.path === "/verify" ||
|
||||||
to.path.startsWith("/admin")
|
to.path.startsWith("/admin")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
|
||||||
|
try {
|
||||||
|
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
|
||||||
|
const member = await $fetch("/api/auth/member", { headers });
|
||||||
|
if (member?.role === "admin") return;
|
||||||
|
} catch {
|
||||||
|
// Not authenticated — fall through to redirect
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect all other routes to coming-soon
|
// Redirect all other routes to coming-soon
|
||||||
return navigateTo("/coming-soon");
|
return navigateTo("/coming-soon");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
app/middleware/members-auth.js
Normal file
12
app/middleware/members-auth.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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,95 +1,118 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="about-page">
|
<PageShell>
|
||||||
<!-- ABOUT HERO (side by side) -->
|
<!-- ABOUT HERO (side by side) -->
|
||||||
<div class="about-hero">
|
<div class="about-hero">
|
||||||
<div class="about-hero-left">
|
<div class="about-hero-left">
|
||||||
<h1>About Ghost Guild</h1>
|
<h1>About Ghost Guild</h1>
|
||||||
<p>A membership community for game developers exploring cooperative business models.</p>
|
<p>
|
||||||
|
A membership community for game developers exploring cooperative
|
||||||
|
models.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-hero-right">
|
<div class="about-hero-right">
|
||||||
<div class="section-label">Our Story</div>
|
<div class="section-label">Our Story</div>
|
||||||
<p>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>
|
||||||
<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>
|
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
|
||||||
<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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||||
<div class="content-area">
|
<ColumnsLayout cols="events-sidebar" :limit="3">
|
||||||
<div class="content-main">
|
<!-- THE CIRCLES -->
|
||||||
|
<div class="about-section" id="circles">
|
||||||
|
<div class="section-label">The Circles</div>
|
||||||
|
<div class="circles-grid">
|
||||||
|
<div id="community" class="circle-cell">
|
||||||
|
<h2 style="color: var(--c-community)">Community</h2>
|
||||||
|
|
||||||
<!-- THE CIRCLES -->
|
<p>For anyone exploring cooperative models.</p>
|
||||||
<div class="about-section" id="circles">
|
</div>
|
||||||
<div class="section-label">The Circles</div>
|
<div id="founder" class="circle-cell">
|
||||||
<div class="circles-grid">
|
<h2 style="color: var(--c-founder)">Founder</h2>
|
||||||
<div id="community" class="circle-cell">
|
<p>For people actively building cooperatives.</p>
|
||||||
<h3 style="color: var(--c-community);">Community</h3>
|
</div>
|
||||||
<div class="circle-subtitle">"The open hall"</div>
|
<div id="practitioner" class="circle-cell">
|
||||||
<p>For anyone exploring cooperative models. Wiki access, public events, Slack community, monthly meetings.</p>
|
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
|
||||||
</div>
|
<p>For experienced practitioners sharing what they know.</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
<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">$30</span> I can support others too</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>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EVENTS MINI SIDEBAR -->
|
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
|
||||||
<EventsMiniSidebar :events="upcomingEvents" />
|
<div class="two-col-row">
|
||||||
</div>
|
<div class="about-section">
|
||||||
</div>
|
<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>
|
||||||
|
<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">$30</span> I can support others too</li>
|
||||||
|
<li>
|
||||||
|
<span class="tier-amt">$50</span> I want to sponsor multiple
|
||||||
|
members
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<NuxtLink to="/join" class="cta">Join the Guild →</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ABOUT BABY GHOSTS -->
|
||||||
|
<div class="about-section">
|
||||||
|
<div class="section-label">About Baby Ghosts</div>
|
||||||
|
<p>
|
||||||
|
Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing
|
||||||
|
cooperative models in game development.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://babyghosts.org" target="_blank"
|
||||||
|
>babyghosts.org →</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ColumnsLayout>
|
||||||
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { data: upcomingEvents } = await useFetch('/api/events', {
|
useSiteMeta({
|
||||||
query: { limit: 3, upcoming: true },
|
title: 'About',
|
||||||
default: () => [],
|
description:
|
||||||
|
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 ---- */
|
||||||
.about-hero {
|
.about-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -104,7 +127,7 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
.about-hero-left h1 {
|
.about-hero-left h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -128,24 +151,6 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
margin-bottom: 10px;
|
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 ---- */
|
/* ---- SECTIONS ---- */
|
||||||
.about-section {
|
.about-section {
|
||||||
padding: 28px 32px;
|
padding: 28px 32px;
|
||||||
|
|
@ -176,9 +181,11 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-right: 1px dashed var(--border);
|
border-right: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.circle-cell:last-child { border-right: none; }
|
.circle-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
.circle-cell h3 {
|
.circle-cell h3 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
@ -196,6 +203,19 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
line-height: 1.65;
|
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 ---- */
|
||||||
.tier-list {
|
.tier-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -209,7 +229,9 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.tier-list li:last-child { border-bottom: none; }
|
.tier-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.tier-amt {
|
.tier-amt {
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -218,13 +240,23 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.content-area { grid-template-columns: 1fr; }
|
.circles-grid {
|
||||||
.circles-grid { grid-template-columns: 1fr; }
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.circle-cell {
|
.circle-cell {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.circle-cell:last-child { border-bottom: none; }
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.about-hero {
|
.about-hero {
|
||||||
|
|
|
||||||
728
app/pages/accept-invite.vue
Normal file
728
app/pages/accept-invite.vue
Normal file
|
|
@ -0,0 +1,728 @@
|
||||||
|
<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>
|
||||||
596
app/pages/admin/board-channels.vue
Normal file
596
app/pages/admin/board-channels.vue
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
<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,23 +16,12 @@
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<div class="field" style="margin-bottom: 0; flex: 1;">
|
<div class="field" style="margin-bottom: 0; flex: 1;">
|
||||||
<input v-model="searchQuery" placeholder="Search events..." />
|
<input v-model="searchQuery" placeholder="Search events..." >
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0;">
|
<div class="field" style="margin-bottom: 0;">
|
||||||
<select v-model="typeFilter">
|
<select v-model="typeFilter">
|
||||||
<option value="all">All Types</option>
|
<option value="all">All Types</option>
|
||||||
<option value="community">Community</option>
|
<option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
<option value="workshop">Workshop</option>
|
|
||||||
<option value="social">Social</option>
|
|
||||||
<option value="showcase">Showcase</option>
|
|
||||||
</select>
|
|
||||||
</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0;">
|
<div class="field" style="margin-bottom: 0;">
|
||||||
|
|
@ -44,124 +33,214 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Events Table -->
|
<!-- Loading / Error -->
|
||||||
<div class="table-wrap">
|
<div v-if="pending" class="loading-state">
|
||||||
<div v-if="pending" class="loading-state">
|
<div class="spinner" />
|
||||||
<div class="spinner" />
|
<span>Loading events...</span>
|
||||||
<span>Loading events...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 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>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</template>
|
|
||||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<td class="col-actions">
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/events/${event.slug || String(event._id)}`"
|
|
||||||
class="link-btn"
|
|
||||||
title="View"
|
|
||||||
>View</NuxtLink>
|
|
||||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
|
||||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
|
||||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div v-else class="empty-state">
|
|
||||||
No events found matching your criteria
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
</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>
|
||||||
|
<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 upcoming events matching your filters</div>
|
||||||
|
|
||||||
|
<div v-if="upcomingPageCount > 1" class="pagination">
|
||||||
|
<button class="page-btn" :disabled="upcomingPage === 1" @click="upcomingPage--">←</button>
|
||||||
|
<span class="page-info">{{ upcomingPage }} / {{ upcomingPageCount }}</span>
|
||||||
|
<button class="page-btn" :disabled="upcomingPage === upcomingPageCount" @click="upcomingPage++">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Past Events ── -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<span class="section-label">Past Events</span>
|
||||||
|
<span class="event-count">{{ pastFiltered.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table v-if="pastPaged.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-title">Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Registration</th>
|
||||||
|
<th>Tickets</th>
|
||||||
|
<th class="col-actions-head">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="event in pastPaged" :key="event._id">
|
||||||
|
<td class="col-title">
|
||||||
|
<div class="event-title-cell">
|
||||||
|
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||||
|
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="event-name">{{ event.title }}</span>
|
||||||
|
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||||
|
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||||
|
<span class="series-pos">{{ event.series.position }}</span>
|
||||||
|
{{ event.series.title }}
|
||||||
|
</div>
|
||||||
|
<div class="event-flags">
|
||||||
|
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||||
|
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||||
|
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-date">
|
||||||
|
<span class="date-main">{{ formatDate(event) }}</span>
|
||||||
|
<span class="date-time">{{ formatTime(event) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
|
{{ getEventStatus(event) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||||
|
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||||
|
<span v-if="event.maxAttendees" class="reg-count">
|
||||||
|
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-tickets">
|
||||||
|
<template v-if="event.tickets?.enabled">
|
||||||
|
<span class="ticket-on">Ticketing On</span>
|
||||||
|
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||||
|
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||||
|
</span>
|
||||||
|
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||||
|
Public: ${{ event.tickets.public.price || 0 }}
|
||||||
|
<template v-if="event.tickets.public.quantity">
|
||||||
|
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||||
|
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||||
|
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||||
|
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">No past events matching your filters</div>
|
||||||
|
|
||||||
|
<div v-if="pastPageCount > 1" class="pagination">
|
||||||
|
<button class="page-btn" :disabled="pastPage === 1" @click="pastPage--">←</button>
|
||||||
|
<span class="page-info">{{ pastPage }} / {{ pastPageCount }}</span>
|
||||||
|
<button class="page-btn" :disabled="pastPage === pastPageCount" @click="pastPage++">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Confirm Delete Modal -->
|
<!-- Confirm Delete Modal -->
|
||||||
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
|
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
@ -185,6 +264,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
middleware: 'admin',
|
middleware: 'admin',
|
||||||
|
|
@ -199,33 +280,12 @@ const {
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const typeFilter = ref('all')
|
const typeFilter = ref('all')
|
||||||
const statusFilter = ref('all')
|
|
||||||
const seriesFilter = ref('all')
|
const seriesFilter = ref('all')
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
const upcomingPage = ref(1)
|
||||||
if (!events.value) return []
|
const pastPage = ref(1)
|
||||||
|
const UPCOMING_PAGE_SIZE = 10
|
||||||
return events.value.filter((event) => {
|
const PAST_PAGE_SIZE = 5
|
||||||
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 getEventStatus = (event) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
@ -237,19 +297,74 @@ const getEventStatus = (event) => {
|
||||||
return 'Past'
|
return 'Past'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const applyBaseFilters = (list) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
if (!list) return []
|
||||||
month: 'short',
|
return list.filter((event) => {
|
||||||
day: 'numeric',
|
const matchesSearch =
|
||||||
year: 'numeric',
|
!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 formatTime = (dateString) => {
|
const upcomingFiltered = computed(() => {
|
||||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
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', {
|
||||||
|
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', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
|
timeZone: event.displayTimezone || 'America/Toronto',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,10 +424,7 @@ const editEvent = (event) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-events {
|
.admin-events {}
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -350,9 +462,34 @@ const editEvent = (event) => {
|
||||||
flex-wrap: wrap;
|
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 ---- */
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
padding: 0 28px 28px;
|
padding: 12px 28px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
|
|
@ -436,7 +573,7 @@ tbody td {
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--c-founder);
|
color: var(--c-founder);
|
||||||
border: 1px dashed rgba(138, 68, 32, 0.3);
|
border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,7 +586,7 @@ tbody td {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--c-founder);
|
color: var(--c-founder);
|
||||||
border: 1px dashed rgba(138, 68, 32, 0.4);
|
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -498,12 +635,12 @@ tbody td {
|
||||||
|
|
||||||
.status-upcoming {
|
.status-upcoming {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
border-color: rgba(122, 90, 16, 0.3);
|
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-ongoing {
|
.status-ongoing {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: rgba(74, 106, 56, 0.3);
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-past {
|
.status-past {
|
||||||
|
|
@ -513,7 +650,7 @@ tbody td {
|
||||||
|
|
||||||
.status-cancelled {
|
.status-cancelled {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
border-color: rgba(138, 68, 32, 0.3);
|
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,6 +717,41 @@ tbody td {
|
||||||
color: var(--ember);
|
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 ---- */
|
/* ---- STATES ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -597,7 +769,7 @@ tbody td {
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 24px;
|
padding: 32px 24px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
@ -699,11 +871,15 @@ tbody td {
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px 20px;
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
padding: 16px 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
padding: 0 12px 20px;
|
padding: 12px 20px 20px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,113 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-dash">
|
<PageShell
|
||||||
<!-- Page Header -->
|
title="Admin Dashboard"
|
||||||
<div class="page-header">
|
subtitle="Members, events, and community operations"
|
||||||
<h1>Admin Dashboard</h1>
|
>
|
||||||
<p>Members, events, and community operations</p>
|
<AdminAlertsPanel />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats + Quick Actions row -->
|
<!-- Stats + Quick Actions row -->
|
||||||
<div class="content-row">
|
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||||
<div class="content-block">
|
<template #left>
|
||||||
<div class="section-label">Overview</div>
|
<div class="admin-block">
|
||||||
<div class="stat-row">
|
<div class="section-label">Overview</div>
|
||||||
<span class="stat-key">Total Members</span>
|
<div class="stat-row">
|
||||||
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
|
<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>
|
</div>
|
||||||
<div class="stat-row">
|
</template>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="content-block">
|
<template #right>
|
||||||
<div class="section-label">Quick Actions</div>
|
<div class="admin-block">
|
||||||
<NuxtLink to="/admin/members" class="action-link">
|
<div class="section-label">Quick Actions</div>
|
||||||
Manage Members<span class="arrow">→</span>
|
<NuxtLink to="/admin/members" class="action-link">
|
||||||
</NuxtLink>
|
Manage Members<span class="arrow">→</span>
|
||||||
<NuxtLink to="/admin/events" class="action-link">
|
</NuxtLink>
|
||||||
Manage Events<span class="arrow">→</span>
|
<NuxtLink to="/admin/events" class="action-link">
|
||||||
</NuxtLink>
|
Manage Events<span class="arrow">→</span>
|
||||||
<NuxtLink to="/admin/events/create" class="action-link">
|
</NuxtLink>
|
||||||
Create Event<span class="arrow">→</span>
|
<NuxtLink to="/admin/events/create" class="action-link">
|
||||||
</NuxtLink>
|
Create Event<span class="arrow">→</span>
|
||||||
<NuxtLink to="/admin/series/create" class="action-link">
|
</NuxtLink>
|
||||||
Create Series<span class="arrow">→</span>
|
<NuxtLink to="/admin/series/create" class="action-link">
|
||||||
</NuxtLink>
|
Create Series<span class="arrow">→</span>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</ColumnsLayout>
|
||||||
|
|
||||||
<!-- Recent Activity row -->
|
<!-- Recent Activity row -->
|
||||||
<div class="content-row">
|
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||||
<div class="content-block">
|
<template #left>
|
||||||
<div class="section-label">Recent Members</div>
|
<div class="admin-block">
|
||||||
|
<div class="section-label">Recent Members</div>
|
||||||
|
|
||||||
<div v-if="pending" class="loading-inline">
|
<div v-if="pending" class="loading-inline">
|
||||||
<div class="spinner spinner-sm" />
|
<div class="spinner spinner-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="recentMembers.length" class="item-list">
|
<div v-else-if="recentMembers.length" class="item-list">
|
||||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||||
<div>
|
<div>
|
||||||
<span class="item-name">{{ member.name }}</span>
|
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
|
||||||
<span class="item-sub">{{ member.email }}</span>
|
<span class="item-sub">{{ member.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-meta">
|
<div class="item-meta">
|
||||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
<CircleBadge :circle="member.circle" />
|
||||||
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="empty-state">No recent members</div>
|
||||||
|
|
||||||
|
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">No recent members</div>
|
</template>
|
||||||
|
|
||||||
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
<template #right>
|
||||||
</div>
|
<div class="admin-block">
|
||||||
|
<div class="section-label">Upcoming Events</div>
|
||||||
|
|
||||||
<div class="content-block">
|
<div v-if="pending" class="loading-inline">
|
||||||
<div class="section-label">Upcoming Events</div>
|
<div class="spinner spinner-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="pending" class="loading-inline">
|
<div v-else-if="upcomingEvents.length" class="item-list">
|
||||||
<div class="spinner spinner-sm" />
|
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
|
||||||
</div>
|
<div>
|
||||||
|
<span class="item-name">{{ event.title }}</span>
|
||||||
<div v-else-if="upcomingEvents.length" class="item-list">
|
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||||
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
|
</div>
|
||||||
<div>
|
<div class="item-meta">
|
||||||
<span class="item-name">{{ event.title }}</span>
|
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
<div v-else class="empty-state">No upcoming events</div>
|
||||||
<div v-else class="empty-state">No upcoming events</div>
|
|
||||||
|
|
||||||
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</ColumnsLayout>
|
||||||
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { eventTypeLabel } from '~/config/eventTypes'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
middleware: 'admin',
|
middleware: 'admin',
|
||||||
|
|
@ -125,48 +134,16 @@ const formatDateTime = (dateString) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-dash {
|
/* ---- ROWS ---- */
|
||||||
max-width: 960px;
|
.admin-row {
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
|
||||||
.page-header {
|
|
||||||
padding: 28px 28px 20px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h1 {
|
.admin-block {
|
||||||
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;
|
padding: 24px 28px;
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-block:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- STATS ---- */
|
/* ---- STATS ---- */
|
||||||
.stat-row {
|
.stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -241,6 +218,12 @@ const formatDateTime = (dateString) => {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.item-name:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-sub {
|
.item-sub {
|
||||||
|
|
@ -308,24 +291,7 @@ const formatDateTime = (dateString) => {
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content-row {
|
.admin-block {
|
||||||
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;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
858
app/pages/admin/members/[id].vue
Normal file
858
app/pages/admin/members/[id].vue
Normal file
|
|
@ -0,0 +1,858 @@
|
||||||
|
<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>
|
||||||
1387
app/pages/admin/members/index.vue
Normal file
1387
app/pages/admin/members/index.vue
Normal file
File diff suppressed because it is too large
Load diff
857
app/pages/admin/pre-registrants/index.vue
Normal file
857
app/pages/admin/pre-registrants/index.vue
Normal file
|
|
@ -0,0 +1,857 @@
|
||||||
|
<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 class="series-title-row">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
||||||
<h3>{{ series.title }}</h3>
|
<h2>{{ series.title }}</h2>
|
||||||
<p class="series-desc">{{ series.description }}</p>
|
<p class="series-desc">{{ series.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="series-meta">
|
<div class="series-meta">
|
||||||
|
|
@ -112,7 +112,6 @@
|
||||||
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
|
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
|
||||||
<button @click="editSeries(series)" class="link-btn">Edit</button>
|
<button @click="editSeries(series)" class="link-btn">Edit</button>
|
||||||
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
|
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
|
||||||
<button @click="duplicateSeries(series)" class="link-btn">Duplicate</button>
|
|
||||||
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
|
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -171,15 +170,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="section-label">Series Management Tools</div>
|
<div class="section-label">Series Management Tools</div>
|
||||||
<button @click="reorderAllSeries" class="bulk-action">
|
<button @click="exportSeriesData" class="btn 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>
|
<strong>Export Series Data</strong>
|
||||||
<span>Download series information as JSON</span>
|
<span>Download series information as JSON</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -575,10 +566,6 @@ const addEventToSeries = (series) => {
|
||||||
navigateTo('/admin/events/create?series=true')
|
navigateTo('/admin/events/create?series=true')
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateSeries = () => {
|
|
||||||
// TODO: Implement
|
|
||||||
}
|
|
||||||
|
|
||||||
const editSeries = (series) => {
|
const editSeries = (series) => {
|
||||||
editingSeriesId.value = series.id
|
editingSeriesId.value = series.id
|
||||||
editingSeriesData.value = {
|
editingSeriesData.value = {
|
||||||
|
|
@ -696,9 +683,6 @@ const saveTicketsEdit = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reorderAllSeries = () => { /* TODO */ }
|
|
||||||
const validateAllSeries = () => { /* TODO */ }
|
|
||||||
|
|
||||||
const exportSeriesData = () => {
|
const exportSeriesData = () => {
|
||||||
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||||
|
|
@ -714,10 +698,7 @@ const exportSeriesData = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-series {
|
.admin-series {}
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -813,7 +794,7 @@ const exportSeriesData = () => {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-header h3 {
|
.series-header h2 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: 'Brygada 1918', serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -869,7 +850,7 @@ const exportSeriesData = () => {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--c-founder);
|
color: var(--c-founder);
|
||||||
border: 1px dashed rgba(138, 68, 32, 0.4);
|
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -950,12 +931,12 @@ const exportSeriesData = () => {
|
||||||
|
|
||||||
.status-active {
|
.status-active {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: rgba(74, 106, 56, 0.3);
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-upcoming {
|
.status-upcoming {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
border-color: rgba(122, 90, 16, 0.3);
|
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-completed {
|
.status-completed {
|
||||||
|
|
@ -965,7 +946,7 @@ const exportSeriesData = () => {
|
||||||
|
|
||||||
.status-ongoing {
|
.status-ongoing {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: rgba(74, 106, 56, 0.3);
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- LINK BUTTONS ---- */
|
/* ---- LINK BUTTONS ---- */
|
||||||
|
|
@ -1217,7 +1198,7 @@ const exportSeriesData = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-list {
|
.series-list {
|
||||||
padding: 16px 12px;
|
padding: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-header,
|
.series-header,
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,6 @@ const createAndAddEvent = async () => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.create-form {
|
.create-form {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -271,13 +270,14 @@ const createAndAddEvent = async () => {
|
||||||
.page-header p { font-size: 12px; color: var(--text-dim); }
|
.page-header p { font-size: 12px; color: var(--text-dim); }
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--candle);
|
color: var(--text-faint);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.back-link:hover { text-decoration: underline; }
|
.back-link:hover { color: var(--candle); text-decoration: none; }
|
||||||
|
|
||||||
.form-body { padding: 24px 28px; }
|
.form-body { padding: 24px 28px; }
|
||||||
|
|
||||||
|
|
|
||||||
225
app/pages/admin/site-content.vue
Normal file
225
app/pages/admin/site-content.vue
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
<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>
|
||||||
1499
app/pages/admin/wiki.vue
Normal file
1499
app/pages/admin/wiki.vue
Normal file
File diff suppressed because it is too large
Load diff
121
app/pages/auth/logout-confirm.vue
Normal file
121
app/pages/auth/logout-confirm.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<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>
|
||||||
71
app/pages/auth/logout-success.vue
Normal file
71
app/pages/auth/logout-success.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<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>
|
||||||
115
app/pages/auth/oidc-error.vue
Normal file
115
app/pages/auth/oidc-error.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<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,12 +2,14 @@
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: false,
|
layout: false,
|
||||||
});
|
});
|
||||||
|
useSiteMeta({ title: "Wiki Sign In", noindex: true });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const uid = route.query.uid as string;
|
const uid = route.query.uid as string;
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const sent = ref(false);
|
const sent = ref(false);
|
||||||
|
const notRegistered = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
|
||||||
|
|
@ -15,53 +17,85 @@ async function sendMagicLink() {
|
||||||
if (!email.value || !uid) return;
|
if (!email.value || !uid) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
notRegistered.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch("/oidc/interaction/login", {
|
const response = await $fetch<{ success: boolean; registered: boolean }>(
|
||||||
method: "POST",
|
"/oidc/interaction/login",
|
||||||
body: { email: email.value, uid },
|
{
|
||||||
});
|
method: "POST",
|
||||||
sent.value = true;
|
body: { email: email.value, uid },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.registered === false) {
|
||||||
|
notRegistered.value = true;
|
||||||
|
} else {
|
||||||
|
sent.value = true;
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
sent.value = false;
|
||||||
|
notRegistered.value = false;
|
||||||
|
email.value = "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wiki-login">
|
<main class="wiki-login">
|
||||||
<div class="wiki-login-card">
|
<div class="dashed-box wiki-login-box">
|
||||||
<div class="wiki-login-header">
|
<header class="wiki-login-header">
|
||||||
<span class="wiki-login-overline">Ghost Guild</span>
|
<p class="section-label">Ghost Guild</p>
|
||||||
<h1 class="wiki-login-title">Wiki</h1>
|
<h1 class="wiki-login-title">Wiki</h1>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="wiki-login-divider" />
|
<hr class="section-divider" >
|
||||||
|
|
||||||
<Transition name="wiki-fade" mode="out-in">
|
<Transition name="wiki-fade" mode="out-in">
|
||||||
<form v-if="!sent" key="form" @submit.prevent="sendMagicLink" class="wiki-login-form">
|
<form
|
||||||
<label for="email" class="wiki-login-label">Email address</label>
|
v-if="!sent && !notRegistered"
|
||||||
<input
|
key="form"
|
||||||
id="email"
|
class="wiki-login-form"
|
||||||
v-model="email"
|
@submit.prevent="sendMagicLink"
|
||||||
type="email"
|
>
|
||||||
required
|
<div class="field">
|
||||||
autocomplete="email"
|
<label for="email">Email address</label>
|
||||||
placeholder="you@example.com"
|
<input
|
||||||
class="wiki-login-input"
|
id="email"
|
||||||
:disabled="loading"
|
v-model="email"
|
||||||
/>
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="wiki-login-error">{{ error }}</p>
|
<p
|
||||||
|
v-if="error"
|
||||||
|
class="wiki-login-error"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
class="btn btn-primary wiki-login-submit"
|
||||||
:disabled="loading || !email"
|
:disabled="loading || !email"
|
||||||
class="wiki-login-button"
|
|
||||||
>
|
>
|
||||||
<span v-if="loading" class="wiki-login-spinner" />
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="wiki-login-spinner"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{{ loading ? "Sending" : "Continue" }}
|
{{ loading ? "Sending" : "Continue" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -70,187 +104,130 @@ async function sendMagicLink() {
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-else key="sent" class="wiki-login-sent">
|
<div
|
||||||
<p class="wiki-login-sent-heading">Check your inbox</p>
|
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>
|
||||||
<p class="wiki-login-sent-detail">
|
<p class="wiki-login-sent-detail">
|
||||||
A sign-in link was sent to <strong>{{ email }}</strong>
|
A sign-in link was sent to <strong>{{ email }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button class="wiki-login-reset" @click="resetForm">
|
||||||
@click="sent = false; email = '';"
|
Try a different email
|
||||||
class="wiki-login-link"
|
</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">
|
||||||
Try a different email
|
Try a different email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wiki-login {
|
.wiki-login {
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1.5rem;
|
min-height: 100vh;
|
||||||
background:
|
min-height: 100dvh;
|
||||||
radial-gradient(ellipse at 30% 70%, rgba(184, 135, 58, 0.06) 0%, transparent 60%),
|
padding: var(--page-pad-y) var(--page-pad-x);
|
||||||
radial-gradient(ellipse at 70% 30%, rgba(178, 104, 64, 0.04) 0%, transparent 60%),
|
|
||||||
var(--color-guild-900);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .wiki-login {
|
.wiki-login-box {
|
||||||
background:
|
|
||||||
radial-gradient(ellipse at 30% 70%, rgba(224, 184, 110, 0.05) 0%, transparent 60%),
|
|
||||||
radial-gradient(ellipse at 70% 30%, rgba(218, 154, 114, 0.03) 0%, transparent 60%),
|
|
||||||
var(--color-guild-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-card {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
padding: 2.5rem 2rem 2rem;
|
padding: 24px 28px;
|
||||||
background: var(--color-guild-800);
|
|
||||||
border: 1px solid var(--color-guild-700);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.06),
|
|
||||||
0 8px 24px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .wiki-login-card {
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.2),
|
|
||||||
0 8px 32px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-header {
|
.wiki-login-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-overline {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-guild-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-title {
|
.wiki-login-title {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-display);
|
||||||
font-size: 2rem;
|
font-size: 36px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
letter-spacing: -0.02em;
|
line-height: 1.1;
|
||||||
line-height: 1.15;
|
letter-spacing: -0.01em;
|
||||||
color: var(--color-candlelight-400);
|
color: var(--candle);
|
||||||
margin-top: 0.25rem;
|
margin: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent,
|
|
||||||
var(--color-guild-600),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-form {
|
.wiki-login-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-label {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-guild-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-guild-100);
|
|
||||||
background: var(--color-guild-900);
|
|
||||||
border: 1px solid var(--color-guild-600);
|
|
||||||
border-radius: 8px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-input::placeholder {
|
|
||||||
color: var(--color-guild-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-input:focus {
|
|
||||||
border-color: var(--color-candlelight-500);
|
|
||||||
box-shadow: 0 0 0 3px rgba(184, 135, 58, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-error {
|
.wiki-login-error {
|
||||||
font-size: 0.8125rem;
|
font-size: 13px;
|
||||||
color: var(--color-ember-400);
|
color: var(--ember);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-button {
|
.wiki-login-submit {
|
||||||
display: flex;
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 8px;
|
||||||
width: 100%;
|
margin-top: 4px;
|
||||||
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-button:hover:not(:disabled) {
|
.wiki-login-submit:disabled {
|
||||||
background: var(--color-candlelight-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-button:active:not(:disabled) {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wiki-login-button:disabled {
|
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-spinner {
|
.wiki-login-spinner {
|
||||||
width: 14px;
|
display: inline-block;
|
||||||
height: 14px;
|
width: 10px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
height: 10px;
|
||||||
border-top-color: white;
|
border: 1.5px solid color-mix(in srgb, var(--bg) 35%, transparent);
|
||||||
|
border-top-color: var(--bg);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: wiki-spin 0.6s linear infinite;
|
animation: wiki-spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes wiki-spin {
|
@keyframes wiki-spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-hint {
|
.wiki-login-hint {
|
||||||
font-size: 0.75rem;
|
font-size: 11px;
|
||||||
color: var(--color-guild-500);
|
color: var(--text-faint);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent {
|
.wiki-login-sent {
|
||||||
|
|
@ -258,45 +235,58 @@ async function sendMagicLink() {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent-heading {
|
.wiki-login-sent-heading {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-display);
|
||||||
font-size: 1.25rem;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-guild-100);
|
color: var(--text-bright);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent-detail {
|
.wiki-login-sent-detail {
|
||||||
font-size: 0.8125rem;
|
font-size: 13px;
|
||||||
color: var(--color-guild-400);
|
color: var(--text-dim);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-sent-detail strong {
|
.wiki-login-sent-detail strong {
|
||||||
color: var(--color-guild-200);
|
color: var(--text-bright);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-link {
|
.wiki-login-link {
|
||||||
font-size: 0.8125rem;
|
color: var(--candle);
|
||||||
color: var(--color-candlelight-500);
|
text-decoration: underline;
|
||||||
background: none;
|
text-underline-offset: 2px;
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-login-link:hover {
|
.wiki-login-link:hover {
|
||||||
color: var(--color-candlelight-400);
|
color: var(--candle-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition */
|
.wiki-login-reset {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--candle);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-login-reset:hover {
|
||||||
|
color: var(--candle-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State transition */
|
||||||
.wiki-fade-enter-active,
|
.wiki-fade-enter-active,
|
||||||
.wiki-fade-leave-active {
|
.wiki-fade-leave-active {
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
|
|
||||||
395
app/pages/board.vue
Normal file
395
app/pages/board.vue
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
<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,44 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen w-full flex flex-col items-center justify-center px-4">
|
<div class="coming-soon">
|
||||||
<h1 class="text-display-xl font-bold mb-2 uppercase font-sans!">Ghost Guild</h1>
|
<h1 class="coming-soon-title">Ghost Guild</h1>
|
||||||
<p
|
<p v-if="!isAuthenticated" class="coming-soon-subtitle">Coming Soon</p>
|
||||||
v-if="!isAuthenticated"
|
|
||||||
class="text-display-sm text-guild-400 mb-10 uppercase py-4 text-center font-sans!">
|
|
||||||
Coming Soon
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Logged-in state -->
|
<!-- Logged-in state -->
|
||||||
<div v-if="isAuthenticated" class="w-full max-w-sm flex flex-col items-center space-y-4 text-center mt-8">
|
<div v-if="isAuthenticated" class="coming-soon-auth">
|
||||||
<p class="text-guild-200 font-sans py-4 text-center">
|
<p>
|
||||||
Welcome, <strong class="text-guild-100">{{ memberData.name || memberData.email }}</strong>
|
Welcome, <strong>{{ memberData.name || memberData.email }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a href="https://wiki.ghostguild.org" class="coming-soon-btn">
|
||||||
href="https://wiki.ghostguild.org"
|
|
||||||
class="block w-full py-3 px-6 bg-candlelight-500 hover:bg-candlelight-600 text-guild-900 font-semibold rounded-full uppercase tracking-wide transition-colors font-sans text-center">
|
|
||||||
Go to Wiki
|
Go to Wiki
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button class="coming-soon-signout" @click="handleLogout">
|
||||||
class="block w-full text-sm text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide transition-colors"
|
|
||||||
@click="handleLogout">
|
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login form -->
|
<!-- Login form -->
|
||||||
<div v-else class="w-full max-w-sm">
|
<div v-else class="coming-soon-form">
|
||||||
<!-- Success state -->
|
<!-- Success state -->
|
||||||
<div v-if="loginSuccess" class="text-center py-4">
|
<div v-if="loginSuccess" class="coming-soon-success">
|
||||||
<div
|
<h3>Check your email</h3>
|
||||||
class="w-16 h-16 bg-candlelight-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<p>
|
||||||
<UIcon name="i-heroicons-check-circle" class="w-10 h-10 text-candlelight-400" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-guild-100 mb-2">
|
|
||||||
Check your email
|
|
||||||
</h3>
|
|
||||||
<p class="text-guild-300">
|
|
||||||
We've sent a magic link to
|
We've sent a magic link to
|
||||||
<strong class="text-guild-100">{{ email }}</strong>.
|
<strong>{{ email }}</strong
|
||||||
Click the link to sign in.
|
>. Click the link to sign in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -50,32 +36,28 @@
|
||||||
type="email"
|
type="email"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="your.email@example.com" />
|
placeholder="your.email@example.com"
|
||||||
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<div v-if="loginError" class="mb-4 p-3 bg-ember-500/10 border border-ember-500/30 rounded-lg">
|
<div v-if="loginError" class="coming-soon-error">
|
||||||
<p class="text-ember-400 text-sm">{{ loginError }}</p>
|
<p>{{ loginError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="coming-soon-actions">
|
||||||
<UButton
|
<UButton
|
||||||
type="submit"
|
type="submit"
|
||||||
:loading="isLoggingIn"
|
:loading="isLoggingIn"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="rounded-full uppercase tracking-wide font-semibold whitespace-nowrap">
|
class="uppercase tracking-wide font-semibold whitespace-nowrap"
|
||||||
|
>
|
||||||
Send Magic Link
|
Send Magic Link
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center pt-6 border-t border-guild-700 mt-6">
|
<div class="coming-soon-preregister">
|
||||||
<p class="text-guild-400 text-sm">
|
<a href="https://babyghosts.org/ghost-guild/">Pre-Register</a>
|
||||||
<a
|
|
||||||
href="https://babyghosts.fund/ghost-guild/"
|
|
||||||
class="text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide">
|
|
||||||
Pre-Register
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</UForm>
|
</UForm>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,3 +109,138 @@ const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.coming-soon {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-subtitle {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-auth {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-auth strong {
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--parch);
|
||||||
|
color: var(--parch-text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-btn:hover {
|
||||||
|
background: var(--parch-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-signout {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--candle);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-signout:hover {
|
||||||
|
color: var(--candle-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-success {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-success h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-success p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-success strong {
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-error {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--ember-bg);
|
||||||
|
border: 1px dashed var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-error p {
|
||||||
|
color: var(--ember);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-preregister {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-preregister a {
|
||||||
|
color: var(--candle);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
397
app/pages/community-guidelines.vue
Normal file
397
app/pages/community-guidelines.vue
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
`
|
||||||
|
<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>
|
||||||
|
`
|
||||||
3
app/pages/connections.vue
Normal file
3
app/pages/connections.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<script setup>
|
||||||
|
await navigateTo("/board", { replace: true });
|
||||||
|
</script>
|
||||||
3
app/pages/ecology.vue
Normal file
3
app/pages/ecology.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<script setup>
|
||||||
|
await navigateTo("/board", { replace: true });
|
||||||
|
</script>
|
||||||
|
|
@ -1,511 +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>
|
|
||||||
<!-- 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>
|
|
||||||
520
app/pages/events/[slug].vue
Normal file
520
app/pages/events/[slug].vue
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
<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,14 +3,26 @@
|
||||||
<!-- HERO (compact) -->
|
<!-- HERO (compact) -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Events</h1>
|
<h1>Events</h1>
|
||||||
<p>Workshops, meetups, and gatherings for game developers practicing cooperative models.</p>
|
<p>
|
||||||
|
Workshops, meetups, and gatherings for game developers practicing
|
||||||
|
cooperative models. Some events are open to the public.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FILTER BAR -->
|
<!-- FILTER BAR -->
|
||||||
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
||||||
<label class="filter-toggle">
|
<button
|
||||||
<input type="checkbox" v-model="includePastEvents"> Show past events
|
type="button"
|
||||||
</label>
|
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>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<!-- EVENT LIST -->
|
<!-- EVENT LIST -->
|
||||||
|
|
@ -19,24 +31,40 @@
|
||||||
v-for="event in filteredEvents"
|
v-for="event in filteredEvents"
|
||||||
:key="event._id"
|
:key="event._id"
|
||||||
class="event-row"
|
class="event-row"
|
||||||
|
:class="{ 'is-cancelled': event.isCancelled }"
|
||||||
>
|
>
|
||||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
<div class="event-date-col">
|
||||||
|
<span class="event-date">{{ formatDate(event) }}</span>
|
||||||
|
<span class="event-time">{{ formatTime(event) }}</span>
|
||||||
|
</div>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<div class="event-title">
|
<div class="event-title">
|
||||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="event.eventType" class="event-type">{{ event.eventType }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="event-capacity">
|
<div class="event-badges">
|
||||||
<template v-if="event.maxAttendees">
|
<span v-if="event.membersOnly" class="members-badge">Members</span>
|
||||||
<span :class="{ 'seats-warn': isAlmostFull(event) }">
|
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||||
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
<span v-else class="badge all">Public</span>
|
||||||
</span>
|
</div>
|
||||||
</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>
|
||||||
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
|
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,84 +75,114 @@
|
||||||
<div class="series-grid">
|
<div class="series-grid">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="series in activeSeries"
|
v-for="series in activeSeries"
|
||||||
:key="series._id"
|
:key="series.id"
|
||||||
:to="`/series/${series._id}`"
|
:to="`/series/${series.id}`"
|
||||||
class="series-box"
|
class="series-box"
|
||||||
>
|
>
|
||||||
<h3>{{ series.title }}</h3>
|
<h2>{{ series.title }}</h2>
|
||||||
<p class="series-desc">{{ series.description }}</p>
|
<p class="series-desc">{{ series.description }}</p>
|
||||||
<div class="series-meta">
|
<div class="series-meta">
|
||||||
<span>{{ series.eventCount || series.events?.length || 0 }} sessions</span>
|
<span
|
||||||
<span v-if="series.startDate">{{ formatDate(series.startDate) }} – {{ formatDate(series.endDate) }}</span>
|
>{{
|
||||||
|
series.eventCount || series.events?.length || 0
|
||||||
|
}}
|
||||||
|
sessions</span
|
||||||
|
>
|
||||||
|
<span v-if="series.startDate"
|
||||||
|
>{{ formatDate(series.startDate) }} –
|
||||||
|
{{ formatDate(series.endDate) }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<div
|
||||||
|
v-if="activeSeries.length % 2"
|
||||||
|
class="series-box series-box-filler"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PROPOSE AN EVENT -->
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const activeFilter = ref('all')
|
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
|
||||||
const includePastEvents = ref(false)
|
|
||||||
|
useSiteMeta({
|
||||||
|
title: "Events",
|
||||||
|
description:
|
||||||
|
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeFilter = ref("all");
|
||||||
|
const includePastEvents = ref(false);
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ label: 'All', value: 'all' },
|
{ label: "All", value: "all" },
|
||||||
{ label: 'Workshops', value: 'workshop' },
|
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
|
||||||
{ label: 'Community', value: 'community' },
|
];
|
||||||
{ label: 'Social', value: 'social' },
|
|
||||||
{ label: 'Showcase', value: 'showcase' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const { data: eventsData } = await useFetch('/api/events')
|
const { data: eventsData } = await useFetch("/api/events");
|
||||||
const { data: seriesData } = await useFetch('/api/series')
|
const { data: seriesData } = await useFetch("/api/series");
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
const filteredEvents = computed(() => {
|
||||||
if (!eventsData.value) return []
|
const now = new Date();
|
||||||
|
if (!eventsData.value) return [];
|
||||||
return eventsData.value.filter((event) => {
|
return eventsData.value.filter((event) => {
|
||||||
if (!includePastEvents.value && new Date(event.startDate) < now) return false
|
if (!includePastEvents.value && new Date(event.startDate) < now)
|
||||||
if (activeFilter.value !== 'all' && event.eventType !== activeFilter.value) return false
|
return false;
|
||||||
return true
|
if (activeFilter.value !== "all" && event.eventType !== activeFilter.value)
|
||||||
})
|
return false;
|
||||||
})
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const activeSeries = computed(() => {
|
const activeSeries = computed(() => {
|
||||||
if (!seriesData.value) return []
|
if (!seriesData.value) return [];
|
||||||
return seriesData.value.filter(
|
return seriesData.value.filter(
|
||||||
(s) => s.status === 'active' || s.isOngoing || s.isUpcoming,
|
(s) => s.status === "active" || s.isOngoing || s.isUpcoming,
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (event) => {
|
||||||
if (!dateStr) return ''
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr)
|
const tz = event.displayTimezone || "America/Toronto";
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
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 isAlmostFull = (event) => {
|
|
||||||
if (!event.maxAttendees) return false
|
|
||||||
return (event.registeredCount || 0) / event.maxAttendees > 0.8
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.hero {
|
.hero {
|
||||||
padding: 32px 32px 24px;
|
padding: 32px 28px 24px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: var(--font-display);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -140,33 +198,142 @@ const isAlmostFull = (event) => {
|
||||||
|
|
||||||
/* ---- EVENT LIST ---- */
|
/* ---- EVENT LIST ---- */
|
||||||
.event-list-full {
|
.event-list-full {
|
||||||
padding: 0 32px;
|
padding: 0 28px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-row {
|
.event-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr auto auto;
|
grid-template-columns: 90px 1fr auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: baseline;
|
align-items: start;
|
||||||
padding: 12px 0;
|
padding: 14px 0;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
transition: padding-left 0.2s;
|
transition: padding-left 0.2s;
|
||||||
}
|
}
|
||||||
.event-row:first-child { padding-top: 16px; }
|
.event-row:first-child {
|
||||||
.event-row:last-child { border-bottom: none; padding-bottom: 16px; }
|
padding-top: 18px;
|
||||||
.event-row:hover { padding-left: 4px; }
|
}
|
||||||
.event-date { color: var(--text-faint); font-size: 12px; white-space: nowrap; }
|
.event-row:last-child {
|
||||||
.event-info { min-width: 0; }
|
border-bottom: none;
|
||||||
.event-title { color: var(--text); font-size: 13px; }
|
padding-bottom: 18px;
|
||||||
.event-title a { color: var(--text); text-decoration: none; }
|
}
|
||||||
.event-title a:hover { color: var(--candle); }
|
.event-row:hover {
|
||||||
.event-type { font-size: 10px; color: var(--text-faint); margin-top: 1px; }
|
padding-left: 4px;
|
||||||
.event-capacity { font-size: 11px; color: var(--text-faint); white-space: nowrap; }
|
}
|
||||||
.seats-warn { color: var(--ember); }
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- FULL SECTION ---- */
|
/* ---- FULL SECTION ---- */
|
||||||
.full-section {
|
.full-section {
|
||||||
padding: 32px;
|
padding: 32px 28px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,15 +345,26 @@ const isAlmostFull = (event) => {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.series-box {
|
.series-box {
|
||||||
padding: 20px;
|
padding: 20px 24px;
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.series-box:last-child { border-right: none; }
|
.series-box:nth-child(2n) {
|
||||||
.series-box:hover { background: var(--surface-hover); }
|
border-right: none;
|
||||||
.series-box h3 {
|
}
|
||||||
font-family: 'Brygada 1918', serif;
|
.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);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -206,38 +384,48 @@ const isAlmostFull = (event) => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- PROPOSE ---- */
|
|
||||||
.full-section h3 {
|
.past-toggle {
|
||||||
font-family: 'Brygada 1918', serif;
|
display: inline-flex;
|
||||||
font-size: 16px;
|
align-items: center;
|
||||||
font-weight: 500;
|
gap: 8px;
|
||||||
color: var(--text-bright);
|
margin-left: auto;
|
||||||
margin-bottom: 4px;
|
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;
|
||||||
}
|
}
|
||||||
.full-section p {
|
.past-toggle:hover {
|
||||||
font-size: 12px;
|
border-color: var(--candle-faint);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.7;
|
|
||||||
max-width: 560px;
|
|
||||||
}
|
}
|
||||||
.cta {
|
.past-toggle:focus-visible {
|
||||||
display: inline-block;
|
outline: 2px dashed var(--candle);
|
||||||
margin-top: 8px;
|
outline-offset: 3px;
|
||||||
font-size: 12px;
|
}
|
||||||
|
.past-toggle.active {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
.past-toggle-box {
|
||||||
.filter-toggle {
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: center;
|
||||||
margin-left: auto;
|
width: 12px;
|
||||||
font-size: 11px;
|
height: 12px;
|
||||||
color: var(--text-faint);
|
border: 1px solid currentColor;
|
||||||
cursor: pointer;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.filter-toggle input {
|
.past-toggle-check {
|
||||||
accent-color: var(--candle-dim);
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|
@ -247,13 +435,37 @@ const isAlmostFull = (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.hero,
|
||||||
|
.event-list-full,
|
||||||
|
.full-section {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
.event-row {
|
.event-row {
|
||||||
grid-template-columns: 60px 1fr;
|
grid-template-columns: 70px 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.event-capacity { display: none; }
|
.event-badges {
|
||||||
.series-grid { grid-template-columns: 1fr; }
|
display: none;
|
||||||
.series-box { border-right: none; border-bottom: 1px dashed var(--border); }
|
}
|
||||||
.series-box:last-child { border-bottom: 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,34 @@
|
||||||
<div>
|
<div>
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Ghost Guild is where game developers practice cooperative business models.</h1>
|
<h1>Ghost Guild is where game developers explore cooperative 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>
|
<p>
|
||||||
|
Resources, events, and a community of people figuring it out. Three
|
||||||
|
circles, pay what you can.
|
||||||
|
</p>
|
||||||
<div class="hero-links">
|
<div class="hero-links">
|
||||||
<NuxtLink to="/join" class="hero-link primary">Become a member</NuxtLink>
|
<NuxtLink to="/join" class="hero-link primary"
|
||||||
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
|
>Become a member</NuxtLink
|
||||||
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
|
>
|
||||||
|
<a href="https://wiki.ghostguild.org" class="hero-link"
|
||||||
|
>Read the wiki</a
|
||||||
|
>
|
||||||
|
<NuxtLink to="/about" class="hero-link">About the Guild</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- THREE CIRCLES -->
|
<!-- THREE CIRCLES -->
|
||||||
<div class="content-row">
|
<div class="content-row">
|
||||||
<div v-for="circle in circleData" :key="circle.value" class="content-block">
|
<div
|
||||||
<div class="label" :style="{ color: `var(--c-${circle.value})` }">{{ circle.label }}</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>
|
<h2>{{ circle.metaphor }}</h2>
|
||||||
<p>{{ circle.blurb }}</p>
|
<p>{{ circle.blurb }}</p>
|
||||||
<details>
|
|
||||||
<summary>What's included?</summary>
|
|
||||||
<p>{{ circle.included }}</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -33,9 +42,11 @@
|
||||||
<div v-if="events?.length" class="event-list">
|
<div v-if="events?.length" class="event-list">
|
||||||
<div v-for="event in events" :key="event._id" class="event-item">
|
<div v-for="event in events" :key="event._id" class="event-item">
|
||||||
<div class="block-inset event-item-inner">
|
<div class="block-inset event-item-inner">
|
||||||
<span class="event-date">{{ formatDate(event.date) }}</span>
|
<span class="event-date">{{ formatDate(event) }}</span>
|
||||||
<span class="event-title">
|
<span class="event-title">
|
||||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
||||||
|
event.title
|
||||||
|
}}</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,38 +60,54 @@
|
||||||
<div class="block-inset">
|
<div class="block-inset">
|
||||||
<div class="label">Recently in the Wiki</div>
|
<div class="label">Recently in the Wiki</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wiki-list">
|
<div v-if="wikiArticles?.length" class="wiki-list">
|
||||||
<div class="wiki-item">
|
<div
|
||||||
|
v-for="article in wikiArticles"
|
||||||
|
:key="article._id"
|
||||||
|
class="wiki-item"
|
||||||
|
>
|
||||||
<div class="block-inset wiki-item-inner">
|
<div class="block-inset wiki-item-inner">
|
||||||
<a href="/wiki">Revenue sharing models</a>
|
<a :href="article.url" target="_blank">{{ article.title }}</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>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="block-inset">
|
||||||
|
<p class="empty">
|
||||||
|
<a href="https://wiki.ghostguild.org">Browse the wiki →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PARCHMENT INSET -->
|
<!-- PARCHMENT INSET -->
|
||||||
<ParchmentInset>
|
<ParchmentInset>
|
||||||
<div class="label" style="color: var(--candle-faint); opacity: 0.6; margin-bottom: 12px;">From the Wiki</div>
|
<div
|
||||||
<h2>What is a cooperative studio?</h2>
|
class="label"
|
||||||
<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>
|
style="color: var(--candle-faint); margin-bottom: 12px"
|
||||||
<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>
|
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>
|
||||||
</ParchmentInset>
|
</ParchmentInset>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -88,42 +115,94 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "default",
|
layout: "default",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: events } = await useFetch('/api/events', {
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
const siteUrl = (runtimeConfig.public.appUrl || "").replace(/\/$/, "");
|
||||||
|
|
||||||
|
useSiteMeta({
|
||||||
|
title: "Ghost Guild",
|
||||||
|
bareTitle: true,
|
||||||
|
description:
|
||||||
|
"Ghost Guild is where game developers explore cooperative models. Membership, events, and resources for people figuring it out together. Pay what you can.",
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
type: "application/ld+json",
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Ghost Guild",
|
||||||
|
url: siteUrl || "https://ghostguild.org",
|
||||||
|
logo: `${siteUrl || "https://ghostguild.org"}/og/default.png`,
|
||||||
|
description:
|
||||||
|
"A membership community for game developers exploring cooperative models. A program of Baby Ghosts, a Canadian non-profit.",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: events } = await useFetch("/api/events", {
|
||||||
query: { limit: 4, upcoming: true },
|
query: { limit: 4, upcoming: true },
|
||||||
default: () => [],
|
default: () => [],
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
|
||||||
|
query: { limit: 4 },
|
||||||
|
default: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
|
||||||
|
|
||||||
|
const { data: wikiFeature } = await useFetch(
|
||||||
|
"/api/site-content/homepage.wiki_feature",
|
||||||
|
{ default: () => ({ title: "", body: "" }) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
|
||||||
|
|
||||||
|
const customWikiParagraphs = computed(() => {
|
||||||
|
const body = wikiFeature.value?.body?.trim() || "";
|
||||||
|
return body
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
const circleData = [
|
const circleData = [
|
||||||
{
|
{
|
||||||
value: 'community',
|
value: "community",
|
||||||
label: 'Community',
|
label: "Community",
|
||||||
metaphor: 'The open hall',
|
metaphor: "The open hall",
|
||||||
blurb: 'Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.',
|
blurb:
|
||||||
included: 'Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.',
|
"For anyone exploring cooperative models in game development. Solo devs, researchers, students, people who just heard about this and want to know more.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'founder',
|
value: "founder",
|
||||||
label: 'Founder',
|
label: "Founder",
|
||||||
metaphor: 'The workshop',
|
metaphor: "The workshop",
|
||||||
blurb: 'For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.',
|
blurb:
|
||||||
included: 'Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.',
|
"For people actively building cooperative studios. You're working through governance, legal structure, revenue sharing, and all the hard parts.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'practitioner',
|
value: "practitioner",
|
||||||
label: 'Practitioner',
|
label: "Practitioner",
|
||||||
metaphor: 'The alcove',
|
metaphor: "The alcove",
|
||||||
blurb: 'Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.',
|
blurb:
|
||||||
included: 'Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.',
|
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (event) => {
|
||||||
if (!dateStr) return ''
|
if (!event?.startDate) return "";
|
||||||
const d = new Date(dateStr)
|
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
month: "short",
|
||||||
}
|
day: "numeric",
|
||||||
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -133,7 +212,7 @@ const formatDate = (dateStr) => {
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -200,9 +279,11 @@ const formatDate = (dateStr) => {
|
||||||
padding-left: 28px;
|
padding-left: 28px;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
}
|
}
|
||||||
.content-block:last-child { border-right: none; }
|
.content-block:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
.content-block h2 {
|
.content-block h2 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -221,26 +302,6 @@ const formatDate = (dateStr) => {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- DETAILS ---- */
|
|
||||||
details {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
details summary {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--candle-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
details summary::before {
|
|
||||||
content: '+ ';
|
|
||||||
}
|
|
||||||
details[open] summary::before {
|
|
||||||
content: '− ';
|
|
||||||
}
|
|
||||||
details p {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- EVENT LIST ---- */
|
/* ---- EVENT LIST ---- */
|
||||||
.event-item {
|
.event-item {
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
@ -250,7 +311,7 @@ details p {
|
||||||
}
|
}
|
||||||
.event-item-inner {
|
.event-item-inner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr auto;
|
grid-template-columns: 60px 1fr auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
|
@ -260,10 +321,21 @@ details p {
|
||||||
.content-row.two-col .event-item:hover .event-item-inner {
|
.content-row.two-col .event-item:hover .event-item-inner {
|
||||||
padding-left: calc(28px + 4px);
|
padding-left: calc(28px + 4px);
|
||||||
}
|
}
|
||||||
.event-date { color: var(--text-faint); font-size: 12px; }
|
.event-date {
|
||||||
.event-title { color: var(--text); font-size: 13px; }
|
color: var(--text-faint);
|
||||||
.event-title a { color: var(--text); text-decoration: none; }
|
font-size: 12px;
|
||||||
.event-title a:hover { color: var(--candle); }
|
}
|
||||||
|
.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 LIST ---- */
|
||||||
.wiki-item {
|
.wiki-item {
|
||||||
|
|
@ -277,8 +349,13 @@ details p {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
.wiki-item a { color: var(--text); text-decoration: none; }
|
.wiki-item a {
|
||||||
.wiki-item a:hover { color: var(--candle); }
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.wiki-item a:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
@ -295,7 +372,9 @@ details p {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.content-block:last-child { border-bottom: none; }
|
.content-block:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.hero-links {
|
.hero-links {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
1036
app/pages/join.vue
1036
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,172 +1,251 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<PageShell>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="authPending" class="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" />
|
<div class="spinner" />
|
||||||
<p>Loading your dashboard...</p>
|
<p>Loading your dashboard...</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
<!-- 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>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
useSiteMeta({ title: 'Dashboard', noindex: true });
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
|
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||||
|
useMemberStatus();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isNewSignup = computed(() => route.query.welcome === "1");
|
||||||
|
const showSlackComingNote = computed(
|
||||||
|
() =>
|
||||||
|
memberData.value?.status === "active" && !memberData.value?.slackInvited,
|
||||||
|
);
|
||||||
|
const welcomeTitle = computed(() => {
|
||||||
|
const name = memberData.value?.name || "";
|
||||||
|
return isNewSignup.value
|
||||||
|
? `Welcome to Ghost Guild, ${name}`
|
||||||
|
: `Welcome back, ${name}`;
|
||||||
|
});
|
||||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
|
const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
|
||||||
|
|
||||||
|
const handleWikiClick = () => {
|
||||||
|
if (!onboardingComplete.value) {
|
||||||
|
trackGoal("wikiClicked");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const registeredEvents = ref([]);
|
const registeredEvents = ref([]);
|
||||||
const loadingEvents = ref(false);
|
const loadingEvents = ref(false);
|
||||||
|
|
@ -201,22 +280,21 @@ const copyCalendarLink = async () => {
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal();
|
||||||
|
|
||||||
// Handle authentication check on page load
|
// 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(
|
const { pending: authPending } = await useLazyAsyncData(
|
||||||
"dashboard-auth",
|
"dashboard-auth",
|
||||||
async () => {
|
async () => {
|
||||||
// Only check authentication on client side
|
|
||||||
if (process.server) return null;
|
|
||||||
|
|
||||||
// If no member data, try to authenticate
|
|
||||||
if (!memberData.value) {
|
if (!memberData.value) {
|
||||||
const isAuthenticated = await checkMemberStatus();
|
const isAuthenticated = await checkMemberStatus();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Show login modal instead of redirecting
|
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
title: "Sign in to your dashboard",
|
title: "Sign in to continue",
|
||||||
description: "Enter your email to access your member dashboard",
|
description: "You need to be signed in to access this page",
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
|
redirectTo: "/member/dashboard",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +302,7 @@ const { pending: authPending } = await useLazyAsyncData(
|
||||||
|
|
||||||
return memberData.value;
|
return memberData.value;
|
||||||
},
|
},
|
||||||
|
{ server: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load registered events
|
// Load registered events
|
||||||
|
|
@ -286,20 +365,22 @@ const getEventImageUrl = (featureImage) => {
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventDate = (dateString) => {
|
const formatEventDate = (event) => {
|
||||||
const date = new Date(dateString);
|
if (!event?.startDate) return "";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(date);
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
}).format(new Date(event.startDate));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventTime = (dateString) => {
|
const formatEventTime = (event) => {
|
||||||
const date = new Date(dateString);
|
if (!event?.startDate) return "";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(date);
|
timeZone: event.displayTimezone || "America/Toronto",
|
||||||
|
}).format(new Date(event.startDate));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMemberSince = (dateString) => {
|
const formatMemberSince = (dateString) => {
|
||||||
|
|
@ -321,15 +402,6 @@ useHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ---- DASHBOARD LAYOUT ---- */
|
|
||||||
.dashboard {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- LOADING / UNAUTH STATES ---- */
|
/* ---- LOADING / UNAUTH STATES ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -358,7 +430,9 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-inline {
|
.loading-inline {
|
||||||
|
|
@ -381,7 +455,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.unauth-state h2 {
|
.unauth-state h2 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: var(--font-display);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -394,39 +468,21 @@ useHead({
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- WELCOME HEADER ---- */
|
/* ---- WELCOME HEADER META ---- */
|
||||||
.welcome {
|
.dashboard-meta {
|
||||||
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;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CONTENT GRID ---- */
|
.slack-coming-note {
|
||||||
.dashboard-body {
|
margin-top: 12px;
|
||||||
flex: 1;
|
font-size: 12px;
|
||||||
display: flex;
|
color: var(--text-dim);
|
||||||
flex-direction: column;
|
line-height: 1.65;
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-row {
|
.content-row {
|
||||||
|
|
@ -490,7 +546,7 @@ useHead({
|
||||||
|
|
||||||
/* ---- CALENDAR BUTTON ---- */
|
/* ---- CALENDAR BUTTON ---- */
|
||||||
.calendar-btn {
|
.calendar-btn {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--candle-dim);
|
color: var(--candle-dim);
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -574,7 +630,7 @@ useHead({
|
||||||
/* ---- QUICK ACTIONS ---- */
|
/* ---- QUICK ACTIONS ---- */
|
||||||
.quick-action {
|
.quick-action {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
padding: 14px 20px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -636,7 +692,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-active::before {
|
.status-active::before {
|
||||||
content: '';
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|
@ -652,6 +708,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content-row {
|
.content-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -666,12 +723,8 @@ useHead({
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-block {
|
.content-block {
|
||||||
padding: 20px;
|
padding: 20px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item {
|
.event-item {
|
||||||
|
|
|
||||||
3
app/pages/member/index.vue
Normal file
3
app/pages/member/index.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<script setup>
|
||||||
|
await navigateTo('/members', { redirectCode: 301 })
|
||||||
|
</script>
|
||||||
|
|
@ -1,600 +0,0 @@
|
||||||
<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>
|
|
||||||
226
app/pages/member/payment-setup.vue
Normal file
226
app/pages/member/payment-setup.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
<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