Compare commits

..

383 commits

Author SHA1 Message Date
6a361b6857 fix(og): strip ltag table from Commit Mono WOFFs
Some checks failed
Test / vitest (push) Successful in 14m3s
Test / playwright (push) Failing after 21m14s
Test / Notify on failure (push) Successful in 3s
Satori's bundled @shuding/opentype.js@1.4.0-beta.0 throws
"ReferenceError: ltagTable is not defined" when parsing fonts that
contain an Apple ltag (language tag) table, breaking /og/events/*.png
in production. Stripped the unused ltag table from both WOFFs via
fontTools; satori only needs glyph data for layout.
2026-05-21 19:04:56 +01:00
cc89c28f03 test(events): update list mock chain after removing .select() projection
GET /api/events now does find().sort().lean() (no .select()) since the
handler strips registrations in the map step. The mock chain in
members-only-visibility.test.js still expected .select(), causing all
list tests to throw "lean is not a function".
2026-05-21 17:52:06 +01:00
384d3197ce feat(admin-tags): POST /api/admin/tags to create craft/cooperative tags
Admin-only route that validates a label + pool via zod, slugifies the
label, returns the existing tag if the slug already exists, otherwise
creates a new active Tag document.
2026-05-21 17:51:01 +01:00
4a05e91715 feat(admin-events): form layout overhaul + agenda input + date input rewrite
Admin event create form:
- Wraps body in a form-layout/form-main container for upcoming sidebar work.
- Bigger autoresize textareas for description/content; adds an Agenda
  textarea (one item per line, persisted as event.agenda).
- Reorganises settings into Event Settings + conditional Cancellation
  Message sections.
- Pulls event-type options from EVENT_TYPES; location becomes optional;
  passes displayTimezone through to NaturalDateInput.

NaturalDateInput: rewritten to a single always-visible UInput with chrono
parsing and trailing status icon, instead of toggling between input and
parsed-summary blocks. Cleaner state model (rawInput / parsedDate /
isValid / hasError) and timezone-aware update emission.
2026-05-21 17:50:56 +01:00
622cc8e53b feat(events): markdown body, registered indicator, drop capacity counters
- Public event detail page renders description/series-description/content
  as markdown via useMarkdown, with prose styles; agenda becomes an
  unordered list with the same custom bullets.
- Events list API returns `isRegistered` per event (derived from the
  requester's registrations, ignoring cancelled rows), and the list page
  shows a "Registered" tag. Stops exposing the full registrations array
  in the list response.
- Removes the seats/sold-out/limited capacity UI from list and detail
  pages.
- EventTicketPurchase: minor template formatting (self-closing inputs,
  prettier wrapping).
2026-05-21 17:50:48 +01:00
2ffaf0ef09 refactor(events): expand eventType taxonomy with central config
Replaces the four-value enum (community/workshop/social/showcase) with
seven values: talk, workshop, community-meetup, coworking, peer-session,
skills-share, info-session. Default is now community-meetup.

Adds app/config/eventTypes.js as the single source of truth for value→label
mapping. Updates the model enum, seed scripts, and admin event list/filter
+ admin dashboard to read from it via EVENT_TYPES and eventTypeLabel().
2026-05-21 17:50:40 +01:00
31144617d7 feat(seo): site meta composable + Open Graph image generation
Adds `useSiteMeta()` composable that wraps useSeoMeta with site defaults
(title template, canonical URL, og/twitter image, og:site_name) and
absolute-URL handling via runtimeConfig.public.appUrl.

Adds /og/events/[slug].png route that renders per-event OG images via
satori + @resvg/resvg-js, cached on disk by slug + updatedAt. Bundles
Brygada 1918 + Commit Mono fonts as server assets, ships a fallback
default.png, and patches @shuding/opentype.js via patch-package.

Converts ~25 pages from useHead to useSiteMeta and adds noindex on
private/auth/admin pages.
2026-05-21 17:50:34 +01:00
877ef1a220 Merge branch 'feature/event-timezone' into main 2026-05-19 13:54:48 +01:00
9e18560ebf Update project config and documentation, add admin invite script,
implement membersOnly event visibility
2026-05-19 13:26:05 +01:00
96470a604a fix(natural-date-input): preserve input on edit, use reliable update event
Two reliability bugs in the natural-language date input:

1. Clicking Edit on a saved-date pill and changing the value immediately
   re-showed the saved value. clearAndEdit pre-fills the input with the
   formatted saved date so the admin doesn't start over, but chrono
   parses that string on the very first keystroke, re-sets parsedDate,
   and the auto-hide template flips the pill back. Added an isEditing
   flag that keeps the input visible across re-parses and clears on
   blur once we have a valid parse.

2. Typing "tomorrow at 2pm" sometimes committed "tomorrow at <current
   time>". UInput's template spreads \$attrs onto the inner <input>
   alongside its own @input="onInput", and Vue's listener-array merge
   intermittently drops the fall-through @input mid-typing — in the
   reproduction, the final 'm' never reached parseNaturalInput, so
   chrono's last successful read was "tomorrow at 2p" matching just
   "tomorrow". Switched to @update:model-value (a declared emit on
   UInput, so it goes through the reliable component-emit path) and
   made onBlur always re-parse the final value as a backup.
2026-05-19 12:19:35 +01:00
49f4eae11c feat(events): admin list and homepage render events in displayTimezone
Two more sites that used viewer-local formatting: the admin events
index and the homepage event blocks. Switch both to take the event and
pass event.displayTimezone to the formatter so admins see events at
their intended wall-clock (and admins viewing across the world see
the same time).
2026-05-19 10:48:50 +01:00
75b1f84d18 feat(emails): render event reminders in event displayTimezone
Registration, waitlist, and series-pass confirmation emails formatted
dates with Intl.DateTimeFormat in the server's local TZ. Switch to
event.displayTimezone (fallback America/Toronto) so the email shows
the event's intended wall-clock + zone suffix regardless of where the
Resend worker runs.

Inline formatters in three exports collapsed to two module-level
helpers that take a timeZone argument.
2026-05-19 10:47:54 +01:00
9dd007657a feat(events): pipe displayTimezone through list and sidebar formatters
Sidebar (EventsMiniSidebar), public events list, member dashboard
"Upcoming" block, and EventTicketPurchase all formatted dates in
viewer-local TZ. Switch each formatter to accept the event (or an
eventTimezone prop) and pass it to Intl.DateTimeFormat so the
displayed wall-clock matches the event's intended zone.
2026-05-19 10:46:44 +01:00
acbd3c0737 feat(events): render public detail page in event timezone
Event detail page formatted dates in viewer-local time, so a Lisbon
viewer of a Toronto ET event saw the time in WEST. Format with
event.displayTimezone instead so attendees see the event's intended
wall-clock + zone suffix ("6:00 AM EDT") regardless of where they sit.

useEventDateUtils.formatDate / formatTime / formatDateRange / isToday
now accept a { timeZone } option and pass it to Intl.DateTimeFormat.
Existing call sites that don't pass timeZone fall through to viewer-
local, matching prior behaviour.
2026-05-19 10:45:11 +01:00
a76ba2f8c7 feat(admin-events): event timezone picker and zoned save/load
Add a USelectMenu for displayTimezone in Event Details (defaults to
America/Toronto). On submit, convert each datetime-local string
(startDate, endDate, registrationDeadline, earlyBirdDeadline) from the
event's TZ to a UTC ISO string so the wall-clock time the admin entered
is preserved regardless of their browser TZ. On edit, render stored
UTC back through the event's TZ so the round-trip is stable.

Reuses TIMEZONE_OPTIONS from ~/config/timezones and the picker pattern
from member/profile.vue. Auto-imported helpers from app/utils/timezones
do the math via Intl.
2026-05-19 10:44:03 +01:00
e6f05b5471 feat(events): add displayTimezone field and zoned datetime helpers
Events stored UTC have always been interpreted in viewer-local TZ at
the admin form and render layer. Adding an event-owned IANA timezone
unblocks accurate scheduling and display regardless of the admin's or
viewer's browser TZ.

- Event.displayTimezone (default "America/Toronto") on the model.
- displayTimezone added to admin create/update Zod schemas.
- app/utils/timezones.js: zonedLocalToUTC, utcToZonedLocal,
  shortTimezoneName — Intl-based helpers, no new dependencies.
2026-05-19 10:39:27 +01:00
9e4030ccfd chore(admin-events): reword ticketing note and move next to public toggle
The old "Members always get free access" sat at the bottom of the
Ticketing section next to the top-level Enable Ticketing toggle, which
conflated the member-vs-public audience split with the ticketing
mechanism. Admins read it as "I need to enable ticketing for free
public events," the opposite of how the system works.

Move the note next to Public Tickets Available (where the audience
split actually matters) and rephrase: public pricing applies to
non-members; members register from their dashboard regardless.
2026-05-19 10:37:41 +01:00
f5b7a3eeba feat(natural-date-input): hide raw input once date is parsed
The natural-language input box kept its placeholder visible after a
date was parsed, with the green confirmation pill rendering below.
Several admins read this as "the input is empty." Hide the input once
parsedDate is set; show only the green pill with an Edit link that
clears the parse and re-opens the input.
2026-05-19 10:37:08 +01:00
6fa3e08fe0 feat(events): accept 'TBD' as a valid location
Events are often scheduled before the platform (Zoom link, Slack
channel) is chosen. The current workaround is a placeholder URL like
"https://us02web.zoom.us/j/TBD", which leaks to the public page as a
broken link.

Accept the literal "TBD" (case-insensitive) in both the Mongoose
validator and the form-side validator. The public detail page renders
"Platform TBD" instead of a link when the location matches.
2026-05-19 10:35:49 +01:00
e1d224e260 feat(admin-events): expose membersOnly toggle in the form
The Event model and Zod schemas already supported membersOnly, but the
admin form never exposed it — public/private was implicit and not
editable from the UI.

Add a fifth checkbox alongside the other Event Settings, hydrate it on
edit, reset it in saveAndCreateAnother.
2026-05-19 10:34:48 +01:00
2a66b0eb8a fix(topstrip): wrap breadcrumb-current in ClientOnly
The breadcrumb's trailing span pulls its label from pageBreadcrumbTitle,
which pages set in script setup after a useFetch. SSR rendered the URL
slug there; client rendered the title; Vue logged a hydration text
mismatch on every detail page.

Wrap the last segment's span in ClientOnly with an nbsp fallback so the
SSR DOM stays the same shape but defers the text to the client. The
prior attempt at this in layouts/default.vue + layouts/admin.vue was
reverted on this branch — it changed segment counts and produced a
worse node-structure mismatch.
2026-05-19 10:33:26 +01:00
397c00125a Revert "fix(layouts): drop URL-slug breadcrumb fallback"
This reverts commit 94b242100c.
2026-05-19 00:16:07 +01:00
050d117abf fix(dashboard): bind Upcoming sidebar to reactive useFetch data
ColumnsLayout mounts inside <ClientOnly> on /member/dashboard, so the
SSR fetch returned the empty default and the snapshot assignment to
upcomingEvents never re-ran after the client-side fetch resolved.

Skip the SSR phase explicitly (server: false) and expose data to the
template via a computed so post-fetch updates propagate to
EventsMiniSidebar.
2026-05-19 00:10:27 +01:00
94b242100c fix(layouts): drop URL-slug breadcrumb fallback
The breadcrumb computed fell back to the URL slug when
pageBreadcrumbTitle was empty. SSR rendered the slug; the client
re-rendered the page-supplied title after useFetch, triggering a
hydration text-content mismatch on span.breadcrumb-current on every
detail page.

Intermediate segments are stable across server and client, so render
them unconditionally. Only include the trailing segment when a page
provides a title.
2026-05-19 00:10:10 +01:00
790f44b4e9 fix(admin-events): switch edit-mode load to useFetch
Top-level $fetch in <script setup> does not forward auth cookies to the
SSR request, so requireAdmin rejected and the form hydrated empty.
Client refetch then triggered hydration mismatches; in dev the
description textarea stayed DOM-empty and the browser's native required
validation blocked saves.

Switch to useFetch (SSR-aware, forwards cookies). Mirror the
admin/members/[id].vue pattern: extract populateEditForm, call it with
the initial payload, watch for client-side updates.
2026-05-18 23:18:55 +01:00
13c72b5ee0 fix(admin-events): polish form papercuts
- Hide the location field's static help text when a validation error is
  shown so the two near-identical messages stop stacking.
- Replace `process.client` with `import.meta.client` (Nuxt 3+ pattern).
- Accept either String or Date for EventTicketPurchase.eventStartDate;
  the parent passes the API's ISO string, which was logging a Vue prop
  type warning on every public event page render.
2026-05-18 17:56:45 +01:00
9858316b30 fix(admin-events): preserve datetime on edit round-trip
Editing an event was pulling its UTC startDate/endDate, slicing off the
"Z" with toISOString().slice(0, 16), and then handing the bare digits to
a datetime-local input. The input reinterprets them as local time, so
each save shifted the time by the browser's UTC offset. Same pattern
for registrationDeadline and earlyBirdDeadline.

Format the value using local-time components instead so the round-trip
matches what the admin sees.
2026-05-18 17:56:17 +01:00
0927b66b4f fix(coming-soon): let logged-in admins bypass the gate
All checks were successful
Test / vitest (push) Successful in 11m15s
Test / playwright (push) Successful in 16m10s
Test / Notify on failure (push) Has been skipped
Admins can now load the public site and their dashboard while coming-soon
mode is on, instead of being redirected to /coming-soon for everything
outside /admin/*.
2026-05-01 14:13:43 +01:00
84aea08a5f chore(ci): drop visual regression suite
All checks were successful
Test / vitest (push) Successful in 11m51s
Test / playwright (push) Successful in 16m24s
Test / Notify on failure (push) Has been skipped
Visual snapshots were generated on macOS but CI runs on Linux, and
font hinting differences between the two would always produce false
positives. The job was already continue-on-error and the baselines
weren't giving trustworthy signal — remove the spec, baselines, CI
job, and now-unneeded snapshot config / --ignore-snapshots flag.
Functional e2e coverage in the playwright job is unaffected.
2026-05-01 13:37:06 +01:00
73e67d02bb build(playwright): drop OS suffix from snapshot path
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Visual baselines were generated as chromium-darwin.png on macOS; CI on
Linux looked for chromium-linux.png and every test failed with
"snapshot doesn't exist". Override snapshotPathTemplate to omit the
platform suffix so darwin and linux share the same baseline. Pixel
diffs from font hinting are an accepted trade-off — visual regression
gives signal for big visual breaks, not 1-pixel differences.

Existing 26 baselines renamed from *-chromium-darwin.png to
*-chromium.png to match.
2026-05-01 13:35:50 +01:00
c3695de5ca fix(ci): set BASE_URL so pre-registrant invite route doesn't 500
Some checks failed
Test / vitest (push) Successful in 11m20s
Test / playwright (push) Successful in 17m22s
Test / visual (push) Failing after 11m15s
Test / Notify on failure (push) Has been skipped
invite.post.js requires process.env.BASE_URL to build the invite link,
returning 500 when unset. The CI workflow stubbed Resend / Mongo / JWT
but missed BASE_URL, so the admin-pre-registrants invite spec timed
out waiting for the success toast. Set BASE_URL to the test server's
URL on both jobs.
2026-05-01 12:08:41 +01:00
b45f92a574 fix(board-channels): dev stub slackChannelId must match update schema
The ALLOW_DEV_TEST_ENDPOINTS short-circuit on create wrote
'dev-stub-<ms>' as the channel ID. boardChannelUpdateSchema requires
^[A-Z0-9]+$, so the very next edit on the same channel hit a 400 from
Zod and the table never updated. Use base36-uppercased timestamp with
a 'CDEV' prefix so the stub survives a round-trip through the patch
route. Live path is unchanged.
2026-05-01 12:08:35 +01:00
b7d9d91b1a fix(events): replace 50% opacity on cancelled rows with strikethrough
The .is-cancelled row used opacity:0.5, which dragged --text-faint
(#665c4b) on the cream background to a 2.1:1 ratio against #f4efe4 —
serious axe violation flagged in CI. Strikethrough on the title and
tagline conveys the cancelled state without crushing contrast; the
existing .cancelled-tag in --ember still flags the row.
2026-05-01 12:08:29 +01:00
47e106171e fix(ci): force-add seed-pre-registrants.js
Some checks failed
Test / vitest (push) Successful in 11m13s
Test / playwright (push) Failing after 13m48s
Test / visual (push) Failing after 11m14s
Test / Notify on failure (push) Successful in 2s
scripts/*.js is gitignored; specific seed scripts are force-added.
seed-pre-registrants.js was created locally but never tracked, so CI
checkouts couldn't find it when seed-all.js execSync'd it. Force-add
to unblock the seed step.
2026-05-01 11:12:14 +01:00
6bfb078e45 fix(mongoose): fall back to process.env when run outside Nitro
Some checks failed
Test / vitest (push) Successful in 11m8s
Test / playwright (push) Failing after 6m57s
Test / visual (push) Failing after 6m47s
Test / Notify on failure (push) Successful in 2s
connectDB() called useRuntimeConfig() unconditionally — works inside
the Nuxt/Nitro runtime but throws ReferenceError for standalone Node
scripts (seed-members.js, seed-tags.js, etc.). CI exposed this when
trying to run seed-all.js. Detect the auto-import and fall back to
process.env when it's not available; preserves Nitro behavior.
2026-05-01 10:46:47 +01:00
f66189cfd6 fix(ci): seed test data before booting the server
Some checks failed
Test / vitest (push) Successful in 11m17s
Test / playwright (push) Failing after 6m48s
Test / visual (push) Failing after 6m41s
Test / Notify on failure (push) Successful in 2s
The runner's Mongo is empty per run, so any e2e test that referenced
seeded members (riley.johnson, etc.) or tags failed with 404 from
loginAsMember or 'no tags visible'. Run seed-all.js + seed-tags.js
between Mongo readiness and 'npm run build'.
2026-05-01 10:13:24 +01:00
1578055a27 feat(board-channels): skip Slack createChannel in dev/test mode
Mirrors the dev-mode short-circuit in invite.post.js. Without
SLACK_BOT_TOKEN, board-channel create returned 500 'Slack integration
not configured', breaking e2e in CI. With ALLOW_DEV_TEST_ENDPOINTS=true,
generate a stub channel ID and proceed; everything DB-side still runs.
2026-05-01 10:13:21 +01:00
6e98720310 test(seed): add pending_payment persona for wave-slack §7.3
The §7.3 test referenced jennie@jenniefaber.com — the user's real
email, never seeded — so the test only worked locally on the
maintainer's machine. Add a generic 'pending-payment-test@example.test'
persona to seed-members.js and point the test at it.
2026-05-01 10:13:18 +01:00
f428cbb219 fix(ci): reuse existing server + downgrade upload-artifact to v3
Some checks failed
Test / playwright (push) Failing after 13m11s
Test / vitest (push) Successful in 11m11s
Test / visual (push) Failing after 12m7s
Test / Notify on failure (push) Successful in 2s
Playwright's webServer config tried to spin up its own server in CI
('reuseExistingServer: !process.env.CI' = false), but the workflow
already started one manually — port 3000 was busy and Playwright
errored before any test ran. Set reuseExistingServer: true always:
Playwright reuses whatever's responsive and only runs the command
when nothing is.

Forgejo doesn't support actions/upload-artifact@v4 (GHES-not-supported
error). Downgrade to @v3.
2026-05-01 09:40:24 +01:00
f05c1f6d40 fix(ci): attach Mongo to the runner container's network
Some checks failed
Test / vitest (push) Successful in 11m11s
Test / playwright (push) Failing after 10m8s
Test / visual (push) Failing after 9m2s
Test / Notify on failure (push) Successful in 2s
The Forgejo runner is itself a container (visible in 'docker ps' from
inside the job: GITEA-ACTIONS-TASK-N_WORKFLOW-Test_JOB-playwright).
'--network host' for Mongo binds to the outer Docker host's network
namespace, which the runner container can't see — that's why
mongodb://localhost:27017 from the Nuxt server returned ECONNREFUSED.

Drop --network host. Instead, after starting Mongo, look up the
runner container's own network via 'docker inspect $HOSTNAME' and
attach Mongo to it. MONGODB_URI now references the container by
name (mongodb://mongo-ci:27017/...).
2026-05-01 09:08:21 +01:00
0985f6acb1 fix(ci): wait for Mongo via docker exec mongosh, not nc
Some checks failed
Test / vitest (push) Successful in 11m17s
Test / playwright (push) Failing after 10m2s
Test / visual (push) Failing after 10m5s
Test / Notify on failure (push) Successful in 3s
The Forgejo runner image doesn't ship netcat — 30 retries of
'sh: 1: nc: not found' just burned the timeout. Use mongosh from
inside the container; no host-side tooling needed.
2026-05-01 08:29:00 +01:00
43eda6db04 fix(ci): clean up leftover mongo-ci container before starting
Some checks failed
Test / vitest (push) Successful in 11m8s
Test / playwright (push) Failing after 9m4s
Test / visual (push) Failing after 8m18s
Test / Notify on failure (push) Successful in 2s
Previous run's container persisted across CI runs (runner shares the
host Docker daemon), so 'docker run --name mongo-ci' hit a name
conflict. 'docker rm -f mongo-ci || true' at the start of the step
makes it idempotent.
2026-05-01 08:00:40 +01:00
386cb7e4b2 fix(ci): use --network host for Mongo + add diagnostics
Some checks failed
Test / vitest (push) Successful in 11m11s
Test / playwright (push) Failing after 7m21s
Test / visual (push) Failing after 7m40s
Test / Notify on failure (push) Successful in 2s
Wait-for-Mongo timed out at 30s after Start-MongoDB succeeded — typical
Docker-in-Docker symptom where -p port mapping binds to a network the
runner's node process can't see. --network host puts Mongo in the
runner's network namespace so localhost:27017 reaches it.

Also dump 'docker ps' after start and 'docker logs mongo-ci' on failure
so the next-step debugging isn't blind.
2026-05-01 07:36:11 +01:00
a797f8e17c fix(ci): start MongoDB explicitly via docker run
Some checks failed
Test / vitest (push) Successful in 11m12s
Test / playwright (push) Failing after 7m43s
Test / visual (push) Failing after 7m39s
Test / Notify on failure (push) Successful in 2s
The Forgejo runner isn't honoring the 'services:' block — the playwright
job booted the server cleanly but every Mongo query returned ECONNREFUSED
on 127.0.0.1:27017. Replace 'services:' with an explicit 'docker run -d'
step + nc-based readiness wait.
2026-05-01 07:03:16 +01:00
16aaeddcee fix(ci): set OIDC_COOKIE_SECRET to satisfy production-mode bundle
Some checks failed
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Failing after 10m4s
Test / visual (push) Failing after 10m25s
Test / Notify on failure (push) Successful in 3s
server/utils/oidc-provider.ts throws at module-load when
OIDC_COOKIE_SECRET is unset and NODE_ENV is 'production'. Vite
substitutes process.env.NODE_ENV as a literal at build time, so
'production' is baked into the .output bundle regardless of the
runtime NODE_ENV=development env. Setting OIDC_COOKIE_SECRET
clears the throw; the value isn't used for real OIDC traffic
in CI since no test exercises the OIDC interaction routes.
2026-05-01 06:40:43 +01:00
d1b5107478 chore(ci): capture server stderr + dump on failure
Some checks failed
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Failing after 10m1s
Test / visual (push) Failing after 9m51s
Test / Notify on failure (push) Successful in 2s
Backgrounding 'node .output/server/index.mjs &' swallowed startup
crashes — failures presented as a useless 30s 'Wait for server'
timeout. Pipe stderr to a log file and cat it on failure so the
next crash is one click away.
2026-05-01 00:23:14 +01:00
9ddb45c4d8 fix(ci): add stub RESEND_API_KEY + HELCIM_API_TOKEN to satisfy validate-env
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
server/plugins/validate-env.js process.exit(1)s on boot when these
are missing — the playwright job's 'Start server' step backgrounds
the process and returns instantly, so the crash was silent until
'Wait for server' timed out at 30s.

Stub values are safe: ALLOW_DEV_TEST_ENDPOINTS=true short-circuits
the Resend call in invite.post.js, and Helcim API calls are mocked
at the page.route level in join-flow.spec.js.
2026-05-01 00:21:17 +01:00
f62fd4f586 fix(ci): set ALLOW_DEV_TEST_ENDPOINTS=true for e2e + visual jobs
Some checks failed
Test / vitest (push) Successful in 11m6s
Test / playwright (push) Failing after 9m40s
Test / visual (push) Failing after 11m41s
Test / Notify on failure (push) Successful in 3s
Without this flag, server/middleware/03.rate-limit.js applies the
100-req/60s general limit to /api/dev/test-login and trips during
parallel e2e runs. The invite.post.js dev short-circuit also
depends on this env var to skip the Resend call (no RESEND_API_KEY
in CI).
2026-04-30 23:59:15 +01:00
ba84429917 docs(BACKLOG): file findings from e2e expansion
Some checks failed
Test / vitest (push) Successful in 11m4s
Test / playwright (push) Failing after 9m59s
Test / visual (push) Failing after 9m20s
Test / Notify on failure (push) Successful in 2s
A11y bug: /board contrast violations (since fixed via --text-faint).
Wave-Slack: /api/auth/member missing slackInvited (fixed), markSlackInvited
non-reactive (fixed), deprecated slackInviteStatus serialization (fixed),
spec-vs-UI wave-language mismatch.
Known gotchas: /admin/series-management Delete is a no-op for empty
series; past-deadline and sold-out events render identically.
Simplify follow-ups: STATUS_LABELS dedup completed.
E2e infrastructure gaps: other email routes still send live in dev,
no dev seeder for arbitrary member status, SSR useFetch blocks
page.route mocking, self-cancel paid registrations not e2e-tested,
visual snapshot regen process.
2026-04-30 22:26:38 +01:00
593b1238f9 test(visual): regenerate baselines after shipped UI changes
Driven by:
- contribution-amount redesign on /join and /accept-invite
- board post card text color fix (a11y)
- --text-faint variable adjustment (a11y)
- STATUS_LABELS softer member-facing copy
- dev-DB seed drift on /events and /connections
2026-04-30 22:26:17 +01:00
8dd55ccc09 test(e2e): expand coverage and harden cross-file isolation
New specs (4):
- accept-invite: pre-registrant flow happy path + cadence/preset UX
- admin-pre-registrants: list, filter, action gating, redirect
- admin-series: list, create, edit (delete skipped — button no-ops)
- admin-site-content: list whitelist, edit + roundtrip on /

Extended specs (6):
- join-flow: cadence ×12 math, guidance label, paid-tier success
- events: series-pass-required, member-savings gating
- admin-events: full CRUD via /admin/events/create?edit=<id>
- admin-members: add-member submit, status select, detail nav
- a11y: add /accept-invite, /member/account, /board, /admin/pre-registrants
- wave-slack-onboarding: 9 of 16 scaffold tests now passing

Cross-file isolation hardening:
- admin-events CRUD: refresh auth cookie (auth.spec.js logout test
  bumps tokenVersion on the shared admin), wait for hydration
  before form fill, search by unique title to dodge pagination.
- board: switch memberPage from shared admin to dedicated seeded
  member to avoid the same tokenVersion race.
- wave-slack §6.4: create dedicated test member, filter by email
  before clicking, removing the "first row" anchor.

Also fixed board heading drift ("Board" → "Bulletin Board").
2026-04-30 22:26:11 +01:00
03dfdab20e style(a11y): meet WCAG AA on --text-faint
Bump --text-faint from #746a58 (4.01:1 on cream surfaces — fails AA)
to #665c4b (4.94:1 — passes AA for small text). Preserves the "quieter
than --text-dim" semantic the variable was named for. Lifts ~33 sites
into compliance with one diff.

Also keeps the BoardPostCard per-selector swap to --text-dim that
shipped with the original /board fix; can revert to --text-faint
in a follow-up now that the variable itself is accessible.
2026-04-30 22:25:57 +01:00
6a6f036877 refactor(admin/members): dedupe STATUS_LABELS + reactive row update
Promote inline STATUS_LABELS copies (admin/members/index.vue,
member/account.vue) into app/config/memberStatus.js, matching the
app/config/circles.js pattern. Drive admin/members/[id].vue status
select from the same constant — completes the alignment started in
441a5f5.

Use the softer member-facing copy as canonical: "Paused" / "Closed"
instead of "Suspended" / "Cancelled".

Also fix markSlackInvited's non-reactive Object.assign on a plain
object inside a useFetch array — replace with index-find + element
reassignment so the row UI refreshes without a manual reload.
2026-04-30 22:25:49 +01:00
1c8f30fe6f feat(invite): skip Resend dispatch when ALLOW_DEV_TEST_ENDPOINTS=true
Pre-registrant invite was the only email route calling Resend directly
(bypassing server/utils/resend.js), so dev/e2e runs were dispatching
real email. Gate just the network call; DB updates (jti, status,
activity log) still run. Mirrors the bypass pattern in
server/middleware/03.rate-limit.js.

Other email routes via server/utils/resend.js still send live in dev
mode — wrapper refactor tracked in BACKLOG.
2026-04-30 22:25:41 +01:00
7f0a586311 fix(api): expose slackInvited + drop slackInviteStatus from member payloads
/api/auth/member now returns slackInvited and slackInvitedAt so the
dashboard's Slack-coming note can correctly hide for already-invited
members (previously always undefined client-side, so the note showed
for every active member).

Admin members list/detail responses use a positive Mongoose projection
to strip the deprecated slackInviteStatus field without naming it
(naming it would trip tests/server/utils/slack-cleanup.test.js's
literal-string gate). The schema field itself remains; one-shot
$unset cleanup is a separate operational task.
2026-04-30 22:25:35 +01:00
b9fa9f603c fix(e2e): rebuild auth helpers + tune playwright config
Login helpers now hit dev endpoints via APIRequestContext instead of
page.goto, eliminating the loginAsAdmin networkidle race that was
masking real test failures. Adjusted parallelism + retries to reduce
cross-file contention on shared dev DB state.
2026-04-30 22:25:28 +01:00
33ba082b82 docs: consolidate open issues into BACKLOG.md
Some checks failed
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Failing after 9m38s
Test / visual (push) Failing after 9m31s
Test / Notify on failure (push) Successful in 2s
Single source of truth for every open issue across the codebase. Pulls
from LAUNCH_READINESS.md (post-launch sections), TODO.md (deferred
features + simplify follow-ups + wave-Slack pilot), and a fresh sweep
of in-code TODO/FIXME comments.

LAUNCH_READINESS.md now keeps only the pre-cutover deploy checklist and
points to BACKLOG.md for everything else. Cutover note corrected — it
has not happened yet.

Force-added BACKLOG.md despite the /docs/ gitignore rule because
LAUNCH_READINESS.md is tracked and now references it.
2026-04-30 15:37:26 +01:00
a949252915 Merge branch 'chore/simplify-followups-and-backlog-consolidation'
Three small wins from the 2026-04-29 simplify-pass review:
- STATUS_LABELS triplication in admin/members/index.vue replaced with v-for
- ImageUpload alt-text input now has :focus styling via scoped CSS
- paymentBridge → signupBridge rename (cookie + functions + JWT scope)
2026-04-30 15:36:00 +01:00
9b79ae6bf4 refactor(auth): rename paymentBridge → signupBridge
After commit 90acc35 issued the cookie for $0 signups too, the "payment"
framing was wrong — there's no payment in a $0 signup. The cookie is
about bridging the gap between signup-form submit and email verify, not
about payment specifically.

Changes:
- setPaymentBridgeCookie  → setSignupBridgeCookie
- getPaymentBridgeMember  → getSignupBridgeMember
- Cookie wire name        payment-bridge → signup-bridge
- JWT scope               payment_bridge → signup_bridge

Touches both /api/helcim/subscription (signup activation) and
/api/helcim/initialize-payment (paid Helcim checkout) which both consume
the cookie. In-flight signup sessions started before this lands will
need to re-submit the form (cookie name mismatch); cutover hasn't
happened yet, so the only impact is local dev sessions.
2026-04-30 15:31:54 +01:00
c6a5e25d06 fix(ImageUpload): restore :focus styling on alt-text input
The alt-text input was hard-coding border/bg via inline style="..." after
the phantom-Tailwind sweep, which can't carry pseudo-class rules.
Per CLAUDE.md, inputs focus to --candle. Moved to a scoped style block
with a real :focus rule.
2026-04-30 15:29:35 +01:00
441a5f5608 refactor(admin): drive members status <select>s from STATUS_LABELS
The status options were duplicated three times in admin/members/index.vue
(filter dropdown, edit-modal dropdown, statusLabel helper). The recent
"Pending Payment" → "Payment setup incomplete" rename only landed in
two of the three sites. Both <select>s now v-for over the existing
STATUS_LABELS const, so any future label change happens in one place.

Side effect: the edit-modal dropdown order is now
(active, pending_payment, suspended, cancelled) to match the filter
dropdown — was previously pending_payment-first.
2026-04-30 15:28:36 +01:00
d9444b022b Merge branch 'fix/launch-flow-copy-and-pre-reg-link'
Ships the 5 launch-flow fixes decided 2026-04-30:
- /join, dashboard, welcome-email copy aligned to monthly-waves model
- welcome email now sends on free /accept-invite activations
- /join signups auto-link to matching PreRegistration records
2026-04-30 15:06:32 +01:00
da5e7efcb7 fix(launch-flow): auto-link /join signups to existing PreRegistration
When a /join submitter's email matches a pending/selected/invited
PreRegistration, mark the pre-reg as accepted and link memberId to the
new Member. Prevents the same person from appearing as both an active
member and an unaccepted pre-registrant. Silent — no email, no UI.

Adds the PreRegistration mock to helcim-customer and free-signup-flow
test suites, since both invoke the customer handler at runtime.
2026-04-30 14:43:02 +01:00
d4000c18cf fix(launch-flow): send welcome email on free /accept-invite activation
Free invite acceptance previously created a Member and signed them in
without sending the welcome email — pre-registrants got nothing as the
join confirmation. Wire sendWelcomeEmail into the free branch matching
the pattern in members/create.post.js.

Paid /accept-invite activations continue to receive the welcome email
via /api/helcim/subscription on the pending_payment → active transition,
so this only changes the free path.
2026-04-30 14:40:13 +01:00
313b8598df fix(launch-flow): align Slack-wait copy across join, dashboard, welcome email
- /join "How membership works" lists community (not Slack) as a benefit;
  adds a note that Slack invitations come in monthly onboarding waves.
- Dashboard slack-coming note drops "2–3 weeks" timeline; uses the same
  monthly-waves phrasing.
- Welcome email no longer points new members to Slack (which they don't
  yet have access to); directs them to reply instead.
2026-04-30 14:39:47 +01:00
d06c83cfc4 Merge pull request 'chore(serena): update project.yml to current schema' (#3) from chore/serena-config-update into main
Some checks failed
Test / vitest (push) Successful in 11m6s
Test / playwright (push) Failing after 9m36s
Test / visual (push) Failing after 9m23s
Test / Notify on failure (push) Successful in 2s
Reviewed-on: #3
2026-04-30 12:51:00 +00:00
9c7d6fa446 Merge pull request 'chore/visual-fidelity-fixes' (#2) from chore/visual-fidelity-fixes into main
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 11m11s
Test / playwright (push) Has been cancelled
Reviewed-on: #2
2026-04-30 12:36:08 +00:00
07943266b7 chore(serena): update project.yml to current schema
Some checks failed
Test / vitest (pull_request) Successful in 11m6s
Test / playwright (pull_request) Failing after 9m39s
Test / visual (pull_request) Failing after 9m31s
Test / Notify on failure (pull_request) Successful in 1s
Auto-generated update from Serena — adds new language entries
(ansible, crystal, haxe, hlsl, json, lean4, luau, msl, ocaml,
python_ty, solidity, systemverilog), trims the inline tool list
in favor of a docs link, and adds the 'added_modes' field.
2026-04-30 12:57:21 +01:00
5a69d6ab75 style(visual-fidelity): missed Batch B row in admin/members
Some checks failed
Test / vitest (pull_request) Successful in 11m59s
Test / playwright (pull_request) Failing after 9m53s
Test / visual (pull_request) Failing after 9m20s
Test / Notify on failure (pull_request) Successful in 1s
.row-error background was the one rgba leftover from the
pages-admin slice — line had shifted from 1309 to 1307 after
earlier Batch B edits.
2026-04-30 11:47:44 +01:00
d6cdf45838 style(visual-fidelity): components — batches B,E,G,H
- B: token-equivalent rgba → color-mix in SignupFlowOverlay, OnboardingWidget
- E: drop text-white Tailwind utility from ImageUpload remove-button (now color: var(--parch-text) inline)
- G: typography off-scale snaps (9→10, 14→13, 15→16, 19→18 px)
- H: padding off-scale snaps in BoardPostCard/Form, CirclePicker, FilterBar, LoginModal
2026-04-30 00:13:13 +01:00
cb93f14160 style(visual-fidelity): pages-admin — batches B,C,F
- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode
- C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css)
- F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
2026-04-30 00:13:09 +01:00
d93c16fbf7 style(visual-fidelity): pages-auth — batches D,G
font-weight 700 → 600 across auth pages; wiki-login hero 32→36
2026-04-30 00:13:05 +01:00
cad57b0083 style(visual-fidelity): pages-public — batches A,D,F,G,H
- about.vue: promote h3 → h2 on circle headings (h1→h2→h2→h2)
- coming-soon.vue: font-weight 700 → 600
- members/[id].vue: inline circle badge → <CircleBadge/>; hero size 42→36
- community-guidelines.vue: padding + font-size off-scale snaps
- board.vue: loading/empty padding 60→64
- series/index.vue, join.vue: padding off-scale snaps
2026-04-30 00:13:02 +01:00
1c2d1537a8 docs(backlog): log 2026-04-29 simplify-pass and deferred follow-ups 2026-04-29 21:50:43 +01:00
26791cc0e3 chore(simplify): trim narrating comments and dedup test body
Test file: drop step markers, regression explainers, and the lead
comment block that restated the contract; hoist the shared subscription
request body to a const; move Member mock defaults into the test that
uses them. Two it() cases unchanged.

Events page: drop WCAG comment that narrated what the
.past-toggle:focus-visible selector already says.
2026-04-29 21:50:00 +01:00
6527bbbe4e test(api): cover free-signup → subscription bridge-cookie hand-off
Two tests guarding the regression where /api/helcim/customer skipped
setPaymentBridgeCookie for $0 signups and left the user unable to
complete activation. Second test confirms the auth gate on
/api/helcim/subscription still rejects fresh unauthenticated calls.
2026-04-29 21:00:27 +01:00
90acc35792 fix(helcim): always issue payment-bridge cookie on signup
Free ($0) signups need the same short-lived bridge cookie as paid signups
so /api/helcim/subscription can identify the member during activation
without a verified auth session. Drops the contributionAmount > 0 guard
that broke free-tier activation in the same flow.
2026-04-29 21:00:22 +01:00
dbd46cc157 docs(backlog): strike EventSeriesBadge dead-code follow-up as shipped 2026-04-29 20:57:06 +01:00
a9acc4c2dc docs(backlog): strike past-events toggle as audited and fixed 2026-04-29 20:56:21 +01:00
dadec1a273 fix(events): add focus-visible outline to past-events toggle
Custom .past-toggle button had no focus indicator — keyboard users
got nothing. Match the canonical WCAG 2.4.7 outline used on .btn
and .zine-select (dashed candle, 3px offset).
2026-04-29 20:39:31 +01:00
f85f284ea5 chore(series): delete unused EventSeriesBadge component
Zero usages across app/ and server/. Migrated to design tokens in commit
350d6c2 before the dead-code status was confirmed; safe to remove now.
2026-04-29 20:38:29 +01:00
55c57d263d docs(backlog): strike shipped items in launch-readiness post-launch list
Strikes:
- memberSavings inactive-member block (shipped f66455e)
- Success-state color convention 4-instances (gold chosen, shipped dc2becf)
- Sidebar 1024px breakpoint verified clean
- EventTicketPurchase magic 24px padding (shipped 7e44809)
- .section-label extraction (already extracted at main.css:128)
- Contribution-amount cosmetic cleanup (shipped 955217a)
- Reconcile customerCode bug (shipped 3c38333, pre-existing on main)

Adds:
- Pointer noting EventSeriesBadge.vue is unused — delete in a future pass.
- Pointer noting Simplify-pass follow-ups are documented in memory.
2026-04-29 20:26:52 +01:00
1da76b11cb fix(series): replace phantom Tailwind on SeriesPassPurchase
Error state and main registration card swap bg-ember-*/border-ember-* and
bg-guild-*/border-guild-* utilities for design tokens in a scoped style
block. Error state uses the codebase's --ember + 8% color-mix pattern;
registration card uses --surface + dashed --border per the zine spec.
2026-04-29 20:22:35 +01:00
350d6c219c fix(series): replace phantom guild Tailwind on EventSeriesBadge
Swap bg-guild-*/border-guild-*/text-guild-* utility classes for design tokens
in a scoped style block. Drops rounded-* per the no-rounded-corners rule and
uses dashed borders for the structural block per the zine spec.
2026-04-29 20:22:30 +01:00
05c47c4499 docs(backlog): close out admin layout token migration as stale
Verified clean 2026-04-29: grep for guild-[0-9]|candlelight-[0-9]|ember-[0-9]
across app/layouts/, app/pages/admin/, and app/components/admin/ returns zero
matches. All admin surfaces already use design tokens.
2026-04-29 20:22:25 +01:00
59d2be2df8 docs(backlog): close out a11y triage items
Strike two stale entries (verified 2026-04-29) and the OIDC routing
quirk (fixed in 23154ff).
2026-04-29 20:10:38 +01:00
23154ff232 fix(oidc): disable devInteractions so custom interactions.url runs in dev
oidc-provider's devInteractions is a quick-start scaffold that, when
enabled, mutates configuration.url to its own urlFor('interaction')
helper — emitting /interaction/UID instead of our /oidc/interaction/UID.
That made /oidc/auth redirect to a 404 in local dev and forced a stale
TODO entry. We already have our own interaction handler at
server/routes/oidc/interaction/[uid].get.ts, so devInteractions is
unnecessary; disabling it makes dev match prod and clears the
oidc-provider warning "your configuration is not in effect".
2026-04-29 19:59:49 +01:00
a69c9d9b49 fix(uploads): replace phantom Tailwind palette with design tokens
Sibling sweep to dc2becf: NaturalDateInput.vue and ImageUpload.vue used
candlelight-/ember-/guild-* utility classes that aren't defined in the
project's Tailwind palette and rendered as no-ops. Swapped to inline
styles using --candle, --ember, --text-dim/faint/bright, --border,
--input-bg, --surface. Drag-state and parsed-date notices follow the
color-mix(... 15%) + 1px solid pattern from dc2becf.
2026-04-29 19:46:59 +01:00
dc2becf63e fix(events): replace phantom candlelight Tailwind with --candle var 2026-04-29 18:30:29 +01:00
e19b16a5cc chore(members): TODO comment for cadence-switch sub-replacement flow 2026-04-29 18:26:40 +01:00
e756170884 feat(admin): warn that contribution edit doesn't sync Helcim 2026-04-29 18:25:59 +01:00
7e44809a83 fix(events): grid-align consent hint, drop magic 24px padding 2026-04-29 18:22:45 +01:00
f66455eda5 fix(tickets): gate memberSavings on hasMemberAccess
Previously the publicTicket comparison block ran whenever a Member record
existed, which surfaced "$0 saved" for cancelled/suspended/guest accounts.
Use the canonical hasMemberAccess helper so only active/pending_payment
members see the savings comparison.
2026-04-29 17:54:58 +01:00
955217a941 chore(admin): rename pending_payment label and tier→contribution
Backlog cleanup from docs/LAUNCH_READINESS.md:
- B4: admin status filter + form options + STATUS_LABELS now read
  "Payment setup incomplete" so admins stop conflating with membership state
- CSV import preview header "Tier" → "Contribution"
- handleUpdateTier → handleUpdateContribution on /member/account
- update-contribution error log "tier" → "amount"
2026-04-29 17:54:53 +01:00
d15458b30a chore(slack): remove dead invite path, archive checkSlackJoins poller
Some checks failed
Test / vitest (push) Successful in 12m6s
Test / playwright (push) Failing after 9m39s
Test / visual (push) Failing after 9m28s
Test / Notify on failure (push) Successful in 2s
Wave-based onboarding makes the auto-invite + polling path obsolete.

- Removes SlackService.inviteUserToSlack — admins now send invites
  through Slack's UI and flip the flag in our admin endpoint.
- Removes the slack_invite_failed admin alert + its detector. The
  alert no longer has a meaningful trigger (we don't attempt invites).
- Archives server/utils/checkSlackJoins.js (and its test) under
  _archive/ in case the polling pattern is needed again post-pilot.
- Deletes the Nitro plugin that scheduled checkSlackJoins on boot
  + hourly. Nothing in nitro.config / nuxt.config / package.json
  registered it elsewhere.
- Drops the slack_invite_failed branch from adminAlerts.test; the
  enum slug stays in adminAlertDismissal so historical dismissal
  rows continue to validate.

notifyNewMember (vetting-channel notification) and findUserByEmail
(used by the auto-flag helper) are retained.
2026-04-29 12:34:21 +01:00
7b326f879d feat(dashboard): one-line note for active members awaiting Slack invite
Renders only when status==='active' && !slackInvited. Hidden for
pending_payment, suspended, cancelled, guest, and any member already
flagged as invited. Lives inside the existing ClientOnly tree at the
top of the dashboard so it never SSRs.

Plain inline text in the welcome region — no banner, no callout. The
2–3 week window is admin-side workflow; the copy avoids cohort/wave
language.
2026-04-29 12:26:51 +01:00
c2999810c6 feat(admin/members): mark-as-Slack-invited button + date display
Replaces the placeholder Slack-invite handler with a call to the new
PATCH /api/admin/members/:id/slack-status endpoint. Status labels are
reworded to match reality (no Slack API call from this app):

- Pending → Not yet invited
- Invited → Invited <slackInvitedAt>
- Action button copy → 'Mark as Slack invited'
- Removes slackInviteStatus reads from the member detail page (the
  remaining repo-wide sweep lands in the cleanup task).
2026-04-29 12:25:18 +01:00
0981596ea2 feat(admin): PATCH /api/admin/members/:id/slack-status
Endpoint that flips a member's slackInvited flag manually after the
admin has actually sent the Slack invitation through Slack's UI. No
Slack API call is made from this app.

- Body validated via Zod literal-true schema (no undo path for the
  pilot — admins correct mistakes in the database if needed).
- Idempotent: re-marking an already-invited member is a no-op,
  preserving the original slackInvitedAt and not duplicating the
  activity log entry.
- Activity log: slack_invited_manually, actor = admin from
  requireAdmin, subject = the target member.
2026-04-29 12:23:07 +01:00
55029e7eb7 feat(activation): wire autoFlagPreExistingSlackAccess into self-serve paths
Replaces the per-file inviteToSlack helpers with a single auto-flag
call. Self-serve activation paths now check for pre-existing workspace
membership (silent on miss) instead of attempting an admin-only invite.

- helcim/subscription.post.js: removed local inviteToSlack; both
  free- and paid-tier activation branches now call the helper, then
  notifyNewMember with the canonical 'manual_invitation_required' arg.
- members/create.post.js: same shape — helper + canonical notify arg.
- invite/accept.post.js (free-tier branch): added the helper call after
  member creation. Free-tier had no prior Slack call (audit confirmed);
  paid-tier remains untouched and activates via the Helcim webhook.

Admin-created and CSV-imported members intentionally do NOT call the
helper — admins flip the flag manually after sending the invite.

Test stub for autoFlagPreExistingSlackAccess added to server setup.
2026-04-29 12:21:12 +01:00
b1d8cb1966 feat(slack): autoFlagPreExistingSlackAccess helper
Best-effort lookup of an activating member's email in the Slack
workspace. On a hit, flips slackInvited:true and stamps slackInvitedAt
without sending a fresh invite. Races against a 3s timeout and swallows
all errors so activation never blocks on Slack.

- Promotes SlackService.findUserByEmail from private to public so the
  helper can call it without a wrapper.
- New activity-log action: slack_access_auto_detected (actor = subject).
- Idempotent: short-circuits when slackInvited is already true.

Callers wired in next commit.
2026-04-29 12:13:59 +01:00
2f6a92ac61 refactor(member): replace slackInviteStatus with slackInvitedAt
Schema change for wave-based Slack onboarding. The string enum
slackInviteStatus is replaced with a simple slackInvitedAt: Date —
boolean slackInvited is the source of truth, the date records when.

Call sites that flip slackInvited:true must stamp slackInvitedAt
in the same update (no pre-save hook, per findByIdAndUpdate convention).

Sweeps of remaining slackInviteStatus references land in later tasks.
2026-04-29 12:11:25 +01:00
3c49317437 chore: remove dead TierPicker + strike resolved gotchas
Some checks failed
Test / vitest (push) Successful in 10m57s
Test / playwright (push) Failing after 9m32s
Test / visual (push) Failing after 9m16s
Test / Notify on failure (push) Successful in 2s
TierPicker.vue is a 5-tier preset picker from before the arbitrary-
amount contribution redesign. Zero imports across app/ and server/ —
purely dead code (99 lines).

Strike two LAUNCH_READINESS bullets that describe already-fixed
issues: the "stale tier comment" in useMemberPayment.js (no `tier`
references remain in that file), and the SeriesPassPurchase auto-
refresh gotcha (fetchPassInfo() already runs after the success path
at line 318).
2026-04-27 21:07:17 +01:00
be24ae32fb fix(toast): rename Nuxt UI 4 toast.add timeout → duration
Some checks failed
Test / vitest (push) Successful in 11m3s
Test / playwright (push) Failing after 9m29s
Test / visual (push) Failing after 9m26s
Test / Notify on failure (push) Successful in 3s
Nuxt UI 4's Toast component reads `duration` (default 5000ms), not
`timeout` — the property was silently ignored. Behavior unchanged
since 5000ms matched the default. Fix the call site to be honest.

Strike the now-resolved gotcha from LAUNCH_READINESS.md.
2026-04-27 19:50:38 +01:00
cf59931814 fix(helcim): read dateBilling on subscription CREATE to populate next-billing cache
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Helcim returns next-charge as `dateBilling` on POST /subscriptions, but the
two CREATE sites were reading `subscription.nextBillingDate`, leaving
`member.nextBillingDate` empty after every signup and free→paid upgrade.
The lazy refresh in subscription.get.js (which already accepts both shapes)
masked it on next account-page load, so renders eventually populated — but
the success response we returned to the client also had `nextBillingDate:
undefined`. Mirror the GET-side resolution at both CREATE sites: prefer
`dateBilling`, fall back to `nextBillingDate`. Existing Number.isNaN guard
unchanged; defensively rejects malformed strings from either field.
2026-04-27 19:44:35 +01:00
3c38333dd1 fix(reconcile): pass customerCode (not helcimCustomerId) to Helcim transactions API
Some checks failed
Test / vitest (push) Successful in 11m5s
Test / playwright (push) Has been cancelled
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
2026-04-27 19:31:59 +01:00
4d44e7045c refactor(rate-limit): delegate auth limiting to handlers, add dev bypass
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Main's middleware-level auth limiter (5 req / 5 min, IP-only) duplicated
the handler-level limiter introduced earlier on this branch (5/hr IP +
3/hr per-email, blocks email enumeration across IPs). Drop the
middleware version and let the handlers own it.

Added ALLOW_DEV_TEST_ENDPOINTS bypass to the rateLimit utility so
parallel E2E runs from 127.0.0.1 don't exhaust per-IP/email budgets,
mirroring the existing middleware bypass.

Trimmed the obsolete middleware auth test; handler-level coverage lives
in tests/server/api/auth-{login,verify}.test.js. Switched IP-isolation
test to the payment path so it still exercises the limiter.
2026-04-27 19:18:34 +01:00
c1367ebd29 refactor(helcim): collapse redundant Member queries in subscription.post.js 2026-04-27 19:16:32 +01:00
ac5e979c78 feat(payments): persist helcimCustomerCode + skip getOrCreateCustomer on card-on-file 2026-04-27 19:16:32 +01:00
0a41b30db7 refactor(helcim): normalize listHelcimCustomerCards return shape 2026-04-27 19:16:32 +01:00
5f93d4c2e3 refactor(series): extract loadPublicSeries helper 2026-04-27 19:16:32 +01:00
bd4561fea7 refactor(events): move 'now' into filteredEvents computed 2026-04-27 19:16:32 +01:00
2611a2a973 perf(reconcile): chunked Promise.all in member loop 2026-04-27 19:16:32 +01:00
5432dfe8f2 refactor(payments): extract PAYMENT_METADATA_TYPE constants 2026-04-27 19:16:32 +01:00
0eeb3c351f feat(security): rate-limit auth/login + auth/verify 2026-04-27 19:16:32 +01:00
bafe24b778 chore(tests): replace source-grep tests with handler tests 2026-04-27 19:16:32 +01:00
00073ec52c E2e tests
Some checks failed
Test / vitest (push) Successful in 12m20s
Test / playwright (push) Failing after 9m52s
Test / visual (push) Failing after 9m22s
Test / Notify on failure (push) Successful in 2s
2026-04-27 14:51:25 +01:00
edef1b86be Merge pull request 'Stabilize e2e suite: rate-limit, spec drift, a11y, visual baselines' (#1) from fix/e2e-stabilization-2026-04-26 into main
Some checks failed
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Failing after 9m33s
Test / visual (push) Failing after 9m32s
Test / Notify on failure (push) Successful in 2s
Reviewed-on: #1
2026-04-26 19:16:21 +00:00
0d83003f87 test(visual): regenerate baselines to match current page state
Some checks failed
Test / vitest (pull_request) Successful in 11m51s
Test / playwright (pull_request) Failing after 9m37s
Test / visual (pull_request) Failing after 9m34s
Test / Notify on failure (pull_request) Successful in 3s
23 baselines updated to reflect intentional content evolution. Layouts
and design-system structure are unchanged — diffs are copy edits, refreshed
data, and (for /coming-soon) added pre-register / magic-link affordances.

Driven by: home hero copy + button labels changed; about/events/members
reflect updated content; admin pages reflect current member/event data;
SignupFlowOverlay structure on join; auth-gated routes redirect unauth
visitors to /join (members-mobile, members-desktop snapshots).

Spot-checked: coming-soon, members-mobile, home — all look right.
2026-04-26 18:34:37 +01:00
521efb0890 fix(a11y,test): USelect placeholder contrast + auth logout hydration wait
a11y (main.css):
- Nuxt UI's default placeholder color (text-dimmed = #a6a09b) failed WCAG
  AA contrast on cream (2.43:1) and white (2.58:1) backgrounds, blocking
  axe checks on /member/profile (timezone) and /admin/events/create
  (tags). Override [data-slot="placeholder"] globally to var(--text-dim)
  (#5a5040), comfortably above 4.5:1 on both surfaces.

auth.spec.js (logout):
- Same hydration race as the board/admin-board-channels click tests:
  /admin's sidebar Sign-out @click handler isn't bound when Playwright
  fires the click immediately after admin-tag visibility, so the click
  no-ops and waitForResponse for /api/auth/logout times out.
- Add waitForLoadState('networkidle') after goto so hydration completes
  before the click.
2026-04-26 18:30:32 +01:00
bb0dbfe53e test(e2e): align specs with current page structure
join-flow:
- Form now requires Community Guidelines agreement; tests check the
  checkbox before expecting submit to enable.
- Contribution input is a numeric field with preset chip buttons, not a
  USelect with $0/mo options — fill the input directly.
- Success state lives in SignupFlowOverlay ("Welcome to Ghost Guild!");
  no .success-box exists. Match by heading instead.
- Inline .error-box renders OUTSIDE <form>, so duplicate-email assertion
  uses .signup-flow-overlay .error-box (which is the user-facing error).

member-profile:
- "How you appear to other members" copy was retired; replace with the
  stable "Show in Member Directory" structural label.
- Add waitForLoadState('networkidle') after goto for ClientOnly auth
  hydration so "Edit Profile" reliably appears within timeout.

board:
- Add waitForLoadState('networkidle') after goto so the action-bar's
  "+ New Post" click handler is bound before the test clicks.
- Submit button is named exactly "Post" — disambiguate from "+ New Post"
  buttons with { exact: true }.
- Delete is a two-step in-card confirm (Delete → Confirm), not a native
  browser dialog; drop the page.once('dialog') listener.

admin-board-channels:
- Channel name placeholder is "e.g., coop-formation" (no leading #).
- Slack Channel ID input only appears in the Edit modal (v-if="editingId"),
  not on Create — Slack channel is auto-created server-side. Drop the
  slack ID fill from the Create step.
- Add waitForLoadState('networkidle') before opening the modal.
2026-04-26 18:28:14 +01:00
3f42307c64 fix(rate-limit): bypass middleware when ALLOW_DEV_TEST_ENDPOINTS=true
Parallel Playwright runs (6 workers, all from 127.0.0.1) burned through the
100 req/min generalLimiter budget within the first ~30s, causing every API
call (including /api/dev/test-login and /api/dev/member-login) to return 429
for the rest of the window. Auth helper waitForURL then timed out at 45s with
no redirect ever firing — surfacing as 8 cascading test failures across
auth.spec.js, board.spec.js, and admin-members.spec.js.

The bypass mirrors the existing gate used by /api/dev/* endpoints: the env
var is opt-in and only set in development (.env) or by Playwright's
webServer config. Production never sets it, so rate limiting remains active.
2026-04-26 18:06:32 +01:00
0c489cf2c3 style: underline contributor links + timezone select placeholder color
- join.vue: underline links inside .checkbox-label
- profile.vue: underline .posts-empty-link by default; remove hover-only
  underline rule; tint timezone select placeholder via :deep slot
2026-04-26 17:55:54 +01:00
0f841912e2 fix(helcim): skip HelcimPay verify when a card is already on file
Some checks failed
Test / vitest (push) Successful in 11m5s
Test / playwright (push) Failing after 9m18s
Test / visual (push) Failing after 9m24s
Test / Notify on failure (push) Successful in 2s
Helcim refuses paymentType:'verify' for cards already saved on a
customer ("A new card must be entered for saving the payment method"),
breaking every "Complete Payment" retry after a partial-failed signup.

Add GET /api/helcim/existing-card and short-circuit HelcimPay verify in
useMemberPayment + payment-setup.vue when a saved card is found, going
straight to subscription creation. The two existence-check fetches run
in parallel with get-or-create-customer so no extra round-trip latency
in the common path.
2026-04-26 17:27:40 +01:00
e3410c52a5 fix(csp): allow secure.helcim.app for HelcimPay.js
Some checks failed
Test / vitest (push) Successful in 11m5s
Test / playwright (push) Failing after 9m24s
Test / visual (push) Failing after 9m20s
Test / Notify on failure (push) Successful in 3s
The HelcimPay modal loads from secure.helcim.app, but the CSP only
listed myposjs.helcim.com (script/connect) and secure.helcim.com
(frame, likely a stale typo). Add secure.helcim.app to script-src,
connect-src, and frame-src so the join flow's payment modal can load.
2026-04-26 15:59:36 +01:00
210a8d588f fix(build): disable @nuxt/fonts external providers
Some checks failed
Test / vitest (push) Successful in 11m3s
Test / playwright (push) Failing after 9m27s
Test / visual (push) Failing after 9m23s
Test / Notify on failure (push) Successful in 2s
Build was failing on Dokploy when fonts.bunny.net was unreachable from the
build container. Fonts are already loaded at runtime via the explicit
<link rel="stylesheet"> in app.head, so the auto-resolver is redundant —
disable all external providers to remove the build-time network dependency.
2026-04-26 15:22:08 +01:00
04eb33df6e refactor(env): unify required-env validation through useRuntimeConfig
Some checks failed
Test / vitest (push) Successful in 11m10s
Test / playwright (push) Failing after 14m51s
Test / visual (push) Failing after 11m1s
Test / Notify on failure (push) Successful in 3s
validate-env.js now reads all four required vars (MONGODB_URI, JWT_SECRET,
RESEND_API_KEY, HELCIM_API_TOKEN) from useRuntimeConfig() instead of mixing
direct process.env reads with a JWT-only special case. Mongoose and the six
Resend instantiations follow suit. Either bare or NUXT_-prefixed env names
are accepted, so Dokploy no longer needs duplicate entries.
2026-04-26 14:47:02 +01:00
1083a1d260 chore(docker): bump node 20 → 22
Silences oidc-provider's "Unsupported runtime" warning on every boot.
2026-04-26 14:46:55 +01:00
a2a8d945c6 fix(reconcile): connect mongoose before querying members
Some checks failed
Test / vitest (push) Successful in 10m59s
Test / playwright (push) Failing after 10m22s
Test / visual (push) Failing after 10m11s
Test / Notify on failure (push) Successful in 2s
Route was the only DB-using endpoint that didn't call connectDB(); other
routes warm the connection incidentally, but on a freshly-booted container
with no SLACK_BOT_TOKEN the slack-joins plugin skips and nothing else
opens the pool — first reconcile request hung 10s on buffered Member.find()
and returned 500.
2026-04-26 13:47:03 +01:00
0369992cdd fix(docker): add bash + curl to runtime image for Dokploy scheduled tasks
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Dokploy wraps scheduled-task commands in `bash -c "..."` and the daily
reconcile cron uses curl. node:20-alpine ships neither — first manual
"Run now" failed with `exec: "bash": executable file not found in $PATH`.

apk add --no-cache bash curl adds ~5MB to the runtime image; trivial cost
for the cron use case.
2026-04-26 13:36:13 +01:00
7a626b0a82 fix(csrf): exempt /api/internal/ from double-submit check
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 11m1s
Test / playwright (push) Has been cancelled
The reconcile-payments cron POSTs to /api/internal/reconcile-payments with
an X-Reconcile-Token header but no csrf-token cookie/header. The CSRF
middleware was 403ing the request before the route handler could check
the shared secret — breaking Fix #6 (daily reconciliation cron).

Found while wiring the Dokploy scheduled task. The Netlify scheduled
function would have hit the same 403; nobody noticed because the site
hasn't been deployed yet.

Removing CSRF protection from /api/internal/ is safe: every route under
that prefix is machine-to-machine and gates on its own shared-secret
header. CSRF protects against browser-driven cross-origin POSTs, which
isn't the threat model for these endpoints.

Tests: 758 passing (CSRF middleware unit tests still cover the exempt
list shape).
2026-04-26 13:16:11 +01:00
c149fba13a chore(deploy): retarget launch checklist from Netlify to Dokploy on Hetzner
Some checks failed
Test / vitest (push) Successful in 11m42s
Test / playwright (push) Failing after 9m53s
Test / visual (push) Failing after 9m28s
Test / Notify on failure (push) Successful in 2s
Production hosting is Dokploy on Hetzner, not Netlify. The Nitro app
itself is host-agnostic — Dockerfile + Traefik-aware OIDC + xff-aware
rate limiting were already in place — but the Deploy checklist and the
daily reconcile cron were Netlify-specific.

- LAUNCH_READINESS.md: split Deploy checklist into one-time host setup
  + cutover; replace "Netlify scheduled function" with a Dokploy
  Scheduled Task (curl + X-Reconcile-Token); call out the BASE_URL
  exact-match origin gotcha at customer.post.js:11-15 and the
  NODE_ENV=production requirement.
- Delete netlify.toml and netlify/functions/reconcile-payments.mjs.
  The Nitro route at server/api/internal/reconcile-payments.post.js
  stays — it's host-agnostic; only the trigger moves into Dokploy.

No code changes. validate-env.js still hard-fails on missing
MONGODB_URI / JWT_SECRET / RESEND_API_KEY / HELCIM_API_TOKEN at boot.
Tests: 758 passing, 2 skipped, 0 failing.
2026-04-26 12:29:48 +01:00
8e76ce9366 refactor(launch): collapse helcim-pay duplication and use setAuthCookie helper
Some checks failed
Test / vitest (push) Successful in 11m49s
Test / playwright (push) Failing after 9m43s
Test / visual (push) Failing after 9m24s
Test / Notify on failure (push) Successful in 2s
Follow-up to 51230e5. /simplify review surfaced residual duplication
and a timer leak.

- useHelcimPay: extract _initializeTicket(metadata, errorPrefix) to
  collapse initializeTicketPayment + initializeSeriesTicketPayment
  (95% identical bodies). Drop the dead `amount` arg from initialize-
  TicketPayment — server re-derives ticket amounts in initialize-
  payment.post.js and never reads body.amount for ticket types.
  Capture timer ids and clearTimeout on resolve/reject so the 10-min
  payment timer and 5-second observer timer stop leaking after every
  payment.
- EventTicketPurchase: caller updated for the dropped arg.
- verify.post.js: replace inline jwt.sign + setCookie block with the
  setAuthCookie(event, member) helper. verify was the last hand-rolled
  caller after the helper was extracted in 208638e.
- LAUNCH_READINESS: add simplify-pass-followups bullet pointing to the
  six deferred items in docs/TODO.md.

Tests: 758 passing, 2 skipped, 0 failing.
2026-04-25 22:13:24 +01:00
51230e5151 refactor(launch): simplify launch-readiness fixes
Follow-up to 208638e. Code review surfaced a few real issues; this
commit addresses them.

- login.post.js now uses the new sendMagicLink util instead of
  duplicating the jti/jwt/Resend/logActivity logic. Reduces 60 lines.
- sendMagicLink accepts an optional pre-loaded Member doc, skipping
  the redundant findOne when the caller already has one. customer.post.js
  passes the just-created/upgraded member, dropping signup from 3
  Mongo round-trips to 1 (lookup is gone; jti burn remains).
- sendMagicLink now lowercases the email defensively so callers don't
  have to remember.
- rateLimit.js: replaced an effectively-dead eviction line with a
  probabilistic sweep (~1% of calls scan and evict keys whose newest
  entry has aged out). Caps unbounded Map growth under random-key
  spraying.
- reconcile-payments.post.js: 401/403/404 from Helcim now bails out
  immediately instead of burning all 3 retry attempts; dry-run
  summary filters via the same RECONCILABLE_STATUSES set as apply
  mode so counts match.
- Deleted WHAT-comments and section banners per CLAUDE.md no-comment
  rule. Kept genuine WHY-comments (validateBeforeSave rationale,
  amount-IGNORED-for-tickets, sendConfirmation deliberately-omitted).

Tests: 758/760 passing (unchanged).
2026-04-25 19:34:16 +01:00
208638e374 feat(launch): security and correctness fixes for 2026-05-01 launch
Day-of-launch deep-dive audit and remediation. 11 issues fixed across
security, correctness, and reliability. Tests: 698 → 758 passing
(+60), 0 failing, 2 skipped.

CRITICAL (security)

Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead
useHelcim.js deleted. Production token MUST BE ROTATED post-deploy
(was previously exposed in window.__NUXT__ payload).

Fix #2 — /api/helcim/customer gated with origin check + per-IP/email
rate limit + magic-link email verification (replaces unauthenticated
setAuthCookie). Adds payment-bridge token for paid-tier signup so
users can complete Helcim checkout before email verify. New utils:
server/utils/{magicLink,rateLimit}.js. UX: signup success copy now
prompts user to check email.

Fix #3 — /api/events/[id]/payment deleted (dead code with unauth
member-spoof bypass — processHelcimPayment was a permanent stub).
Removes processHelcimPayment export and eventPaymentSchema.

Fix #4 — /api/helcim/initialize-payment re-derives ticket amount
server-side via calculateTicketPrice and calculateSeriesTicketPrice.
Adds new series_ticket metadata type (was being shoved through
event_ticket with seriesId in metadata.eventId).

Fix #5 — /api/helcim/customer upgrades existing status:guest members
in place rather than rejecting with 409. Lowercases email at lookup;
preserves _id so prior event registrations stay linked.

HIGH (correctness / reliability)

Fix #6 — Daily reconciliation cron via Netlify scheduled function
(@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs,
server/api/internal/reconcile-payments.post.js. Shared-secret auth
via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff
on Helcim transactions API.

Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist
endpoints) to dodge legacy location validators.

Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest
Member when caller is unauthenticated, mirrors event-ticket flow
byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and
client auth refresh on signedIn:true response.

Fix #9 — /api/members/cancel-subscription leaves status active per
ratified bylaws (was pending_payment). Adds lastCancelledAt audit
field on Member model. Indirectly fixes false-positive
detectStuckPendingPayment admin alert for cancelled members.

Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema
(verifyMagicLinkSchema, max 2000 chars).

Fix #11 — 8 vitest cases for cancel-subscription handler (was
uncovered).

Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and
docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md.
LAUNCH_READINESS.md updated with new test count, 3 deploy-time
tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify
Netlify scheduled function), and Fixed-2026-04-25 fix log.
2026-04-25 18:42:36 +01:00
0f2f1d1cbf chore(visual): Phase 4 audit polish on event/series surface
Migrates event/series UI from Tailwind/Nuxt UI form components to the
zine pattern (dashed borders, CSS-var palette, native inputs). Restructures
single-event and series detail/index pages to the two-column grid pattern
matching about.vue and member/dashboard.vue.

Touches:
- app/components/EventSeriesTicketCard.vue — phantom-palette → CSS-var
  migration (--candle, --ember, --surface), color="gray" → "neutral"
- app/components/EventTicketCard.vue — --candle-faint border var
- app/components/EventTicketPurchase.vue — accent-color: var(--candle)
- app/pages/events/[slug].vue — page-fill flex chain, .event-body grid
- app/pages/events/index.vue — series link uses series.id (was _id)
- app/pages/series/[id].vue — two-column layout (1fr/280px) + sidebar
- app/pages/series/index.vue — full rewrite to dashed-border zine pattern

Per docs/specs/events-visual-audit-findings.md Phase 4. Behavior unchanged.
2026-04-25 18:41:04 +01:00
8f0648de57 fix(events): surface series-pass-required in ticket availability response
Some checks failed
Test / vitest (push) Successful in 10m52s
Test / playwright (push) Failing after 9m35s
Test / visual (push) Failing after 9m32s
Test / Notify on failure (push) Successful in 2s
When a series requires a pass and doesn't allow drop-ins, the
per-event availability endpoint returned a generic "No tickets
available" reason, leaving the UI to render an "Event Sold Out"
block for guests (logged-in users short-circuit via
check-series-access first).

Detect the gate server-side and return
{available:false, reason:"series_pass_required", requiresSeriesPass:true,
series:{id,title,slug}} so EventTicketPurchase's existing
requiresSeriesPass branch renders a pass-required CTA with a link to
the series page. The register and purchase handlers already enforce
the gate server-side; this is a messaging fix only.
2026-04-20 20:13:36 +01:00
53331cc190 fix(events): gate members-only events in calculateTicketPrice
Legacy events (tickets.enabled=false) with membersOnly=true were
returning a free guest ticket for unauthenticated callers, causing
GET /api/events/[id]/tickets/available to report available:true. The
UI then rendered the registration form and register.post.js 403'd on
submit. Short-circuit early when membersOnly && !hasMemberAccess so the
availability endpoint's existing null-ticketInfo branch surfaces the
correct "members only" reason.
2026-04-20 20:12:24 +01:00
dc9c868f75 docs(launch): add prod series-pass bypass audit to deploy checklist
Some checks failed
Test / vitest (push) Successful in 10m50s
Test / playwright (push) Failing after 9m35s
Test / visual (push) Failing after 9m44s
Test / Notify on failure (push) Successful in 2s
Pre-fix (before f34b062 / 4e1888a) prod may contain drop-in
registrations on pass-only series events. Defer audit + remediation
until deploy time; local was scrubbed separately on 2026-04-20.
2026-04-20 19:36:59 +01:00
886c62e7b1 docs(launch): condense LAUNCH_READINESS and ignore prereg dump script
Collapse completed launch sections (receipts Phase 1, cadence UX,
contribution-amount manual tests) into one-liners; move them to the
archive memory. Move the three known post-launch gotchas to their own
subsection. Ignore the local one-off preregistration dump script.
2026-04-20 19:34:38 +01:00
b222b14e61 fix(schemas): coerce empty strings to undefined in admin event schemas
Admin event create/update forms submit empty strings for unset numeric
and date fields (maxAttendees, registrationDeadline, ticket quantity,
early-bird pricing), which Zod was rejecting. Preprocess empty strings
to undefined so the existing optional/nullable validators accept them.
2026-04-20 19:34:10 +01:00
e227f29bcd feat(events): block self-cancel of paid registrations, add refunds policy
Self-cancel endpoint now rejects paid registrations (public, series_pass,
or paid member tickets) with a 403 pointing to /policies/refunds. Free
and $0-member registrations still self-cancel as before. Adds the
refunds policy page referenced in the error message.
2026-04-20 19:34:04 +01:00
4e1888ae8e fix(events): read allowIndividualEventTickets from series.tickets
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 10m59s
Test / playwright (push) Has been cancelled
The series-pass gate in register.post.js was checking
`series.allowIndividualEventTickets` at the top level, but the field
lives under `series.tickets.allowIndividualEventTickets` per the
Series schema. Top-level access was always undefined, so `!undefined`
always fired the pass check — blocking drop-in registration even when
an admin enabled `(requiresSeriesTicket=true, allowIndividualEventTickets=true)`.

The bug failed closed (overprotective), so no bypass was possible.
The existing test mirrored the bug by mocking the field at the top
level; updated the three mocks to nest it under `tickets` so the test
shape matches the real schema.
2026-04-20 19:25:24 +01:00
f34b062f2a fix(events): enforce series-pass, hidden, and deadline gates
Pre-launch P0 fixes surfaced by docs/specs/events-functional-test-matrix.md
(Findings 1, 2, 3).

1. Series-pass bypass (Finding 1 / matrix S1 P3): register.post.js now
   loads the linked Series when tickets.requiresSeriesTicket is set and
   rejects drop-in registration unless series.allowIndividualEventTickets
   is true or the user has a valid pass. Data-integrity 500 if the
   referenced series is missing.

2. Hidden-event leak (Finding 2 / matrix E11): extract loadPublicEvent
   into server/utils/loadEvent.js. All five public event endpoints
   ([id].get, register, tickets/available, tickets/reserve,
   tickets/purchase) now go through the helper, which 404s when
   isVisible === false and the requester is not an admin. Admin detection
   uses a new non-throwing getOptionalMember() in server/utils/auth.js
   (extracted from the pattern already inlined in api/auth/status.get.js).

3. Deadline enforcement + legacy pricing retirement (Finding 3 / matrix
   E8): register.post.js and tickets/reserve.post.js delegate gating to
   validateTicketPurchase (which already covers deadline, cancelled,
   started, members-only, sold-out, and already-registered);
   tickets/available.get.js gets an explicit registrationDeadline check.
   Legacy pricing.paymentRequired 402 branch removed from register.post.js.
2026-04-20 19:03:34 +01:00
6a6c567fd5 test(helcim): mock text() not json() to match helcimFetch contract
Some checks failed
Test / vitest (push) Successful in 12m42s
Test / playwright (push) Failing after 9m54s
Test / visual (push) Failing after 9m32s
Test / Notify on failure (push) Successful in 2s
2026-04-20 14:01:19 +01:00
1fbe9c3227 docs(launch): mark receipts Phase 1 complete, add branch-merge checkbox
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Flips the P1 section from 'none of it is implemented' to a shipped/remaining
breakdown citing commits bf5a333..91711aa, and adds a one-line current-state
bullet pointing at the unmerged feature branch.
2026-04-20 13:51:20 +01:00
0ce61756b7 feat(emails): warmer copy across invite, welcome, and event emails
Friendlier tone + ghost emoji on invite/welcome subjects; invite
templates now offer a reply-to-this-email fallback; tighten OIDC
wiki sign-in and event registration confirmation copy.
2026-04-20 13:48:38 +01:00
91711aa39b docs(launch): add receipts Phase 1 deploy-checklist bullets
Captures the three post-Phase-1 deploy steps: run
reconcile-helcim-payments.mjs against prod Mongo after the new code
is serving, disable the default Helcim confirmation email for plans
50302 + 50303 (Branch B — we send our own via Resend), and run a
real staging test charge to verify the Payment doc + single
CRA-compliant confirmation email.
2026-04-20 13:34:13 +01:00
9c9dc49628 feat(join): add charity / tax-receipt factual note near contribution tiers
Adds a small paragraph directly below the tier list stating the
Baby Ghosts Studio Development Fund charity status, noting that
Canadian taxpayers can claim contributions, and that setup for
receipts happens after joining. Styled in parallel to
.solidarity-note (12px, --text-dim, 1.65 line-height) so it reads as
a bullet, not a banner.

Scope is /join only — /accept-invite and /member/account copy is
untouched per spec §3.
2026-04-20 13:23:49 +01:00
9fecb7d374 feat(members): add taxReceiptPreferences schema field (Phase 1 forward-compat)
Nested object with filesCanadianTaxes, middleInitial, confirmedAddress
(street/city/province/country/postalCode), setupCompletedAt. All
default to null/false so existing members read as 'not set up'.

Schema-only: no Zod, no API route, no UI. Phase 2 will build the
account-page preferences flow and bind to these fields without
needing a migration.
2026-04-20 13:22:19 +01:00
ef26b57ce2 feat(payments): add reconcile-helcim-payments script for backfill + ongoing sync
Iterates every Member with helcimCustomerId, pulls their recent
transactions via listHelcimCustomerTransactions, and upserts a Payment
row per tx using the shared upsertPaymentFromHelcim helper (idempotent
via unique helcimTransactionId index). No confirmation emails are
sent during reconciliation.

Dry-run by default; pass --apply to write. Uses:
- Launch-day backfill for the ~34 pre-existing members.
- Nightly cron/scheduled function post-launch to catch recurring
  Helcim charges that bypass the synchronous write paths in
  subscription.post.js and update-contribution.post.js.

Written as .mjs rather than .cjs (scripts/*.js is gitignored; .cjs
would need dynamic imports for ESM server utils). Shims Nitro's
useRuntimeConfig + createError globals so helcim.js loads outside
the Nitro runtime.
2026-04-20 13:21:56 +01:00
fc09760a41 feat(payments): log Helcim charge on free-to-paid upgrade
In the Case 1 (free→paid) branch of update-contribution, after the
subscription is created and the member row is updated, fetch the
newest paid Helcim transaction and upsert a Payment with
paymentType=cadence and sendConfirmation=true.

Paid→paid (Case 3) is intentionally NOT wired — no new transaction
occurs at amount change; the next recurring charge is captured by the
reconciliation script.
2026-04-20 13:19:21 +01:00
49cfb47fff feat(payments): log initial Helcim charge to Payment on subscription creation
After a paid subscription is created and the Member row is flipped to
active, fetches the newest paid transaction from Helcim and upserts a
Payment row. Passes paymentType from the chosen cadence and
sendConfirmation: true.

Wrapped in try/catch: a logging failure here never breaks subscription
creation — the reconcile-helcim-payments script will pick up any
misses on the next run.
2026-04-20 13:16:53 +01:00
be7145f96c feat(payments): add upsertPaymentFromHelcim helper with idempotent insert
Takes a Member doc + a normalized Helcim transaction and inserts a
Payment row if helcimTransactionId is unseen. Maps helcim status
paid→success, refunded→refunded, failed→failed; skips 'other'.

opts.paymentType overrides the cadence fallback for mid-flight cadence
changes. opts.sendConfirmation triggers a Resend payment-confirmation
email ONLY on new inserts — swallows send failures so email trouble
cannot break the upstream payment flow.

The Resend template lives in server/emails/paymentConfirmation.js. It
is CRA-safe (charity name + 'not an official donation receipt / tax
receipts available later in 2026' disclaimer) so it can be used in
either Task 8 branch without copy changes.
2026-04-20 13:15:38 +01:00
bf5a333117 feat(payments): add Payment model for Phase 1 receipt data capture
Introduces the payments collection with fields Phase 2 will need to
generate official donation receipts: amount, paymentDate, paymentType
(monthly|annual), helcimTransactionId (unique), and receiptIssued /
receiptId placeholders Phase 2 will populate. Schema-only; no routes
or UI in this commit.
2026-04-20 13:12:17 +01:00
335a4db7cc fix(account): show payment history + next-charge for paid-then-$0 members
Three related changes on /member/account:

1. Payment History section now renders when contributionAmount > 0 OR
   past payments exist. Previously a paid member who switched to $0 lost
   visibility of their own past charges.

2. New "Next charge: $X on DATE" row renders above the transaction list
   when nextPaymentDate is available, using --candle dashed border.

3. server/api/helcim/subscription.get.js now reads dateBilling from
   Helcim's GET response and handles data as either object or array.
   Helcim's real shape is {data: {id, dateBilling, ...}} — the old code
   expected {data: [{nextBillingDate}]} and returned empty strings, so
   the Membership-card "Next payment" row never rendered for members
   whose cached date was missing. subscription.post.js and
   update-contribution.post.js have the same wrong field name in their
   CREATE flows; left for a follow-up — the GET refresh masks it.

Manual edit-flow and admin-flow tests also recorded in
docs/LAUNCH_READINESS.md.
2026-04-20 12:36:18 +01:00
a80728f0a8 feat(signup): unify cadence UX across accept-invite, join, and account
Extract shared SignupFlowOverlay component. Static "Monthly Contribution"
label on all three contribution inputs (was misleadingly dynamic).
"Per Year"/"Per Month" toggle copy; Per Year default on accept-invite,
Per Month default on join. Live billing-summary card on both signup
flows. Welcome-heading on dashboard via ?welcome=1 for new signups.
$0-member polish on account page (hide payment-history + Solidarity
Fund prompts). State-aware contribution-change hint. Invite accept now
creates Helcim customer and sets auth cookie server-side for both free
and paid branches. Pre-registrant invite + /join signup flows manually
verified against Cleo Nguyen preReg and $0-$50 variants.
2026-04-20 12:34:59 +01:00
493be2f3bc docs(launch): tidy post-merge state, expand remaining manual tests
Branch merges and 7/9 manual tests are done — moved to archive. Live
doc now only carries open work: charitable receipts Phase 1, prod
contribution-amount migration + Helcim plan env vars, and two manual
tests (pre-registrant invite, contribution-amount end-to-end). Both
remaining tests now include setup, test steps, assertions, and the
file references needed to complete them without additional context.
2026-04-20 09:02:40 +01:00
bbf3a47085 docs(launch): add contribution-amount merge + migration to deploy checklist
Un-defer pre-registrant invite manual test (refactor landed), add
contribution-amount end-to-end manual test, and list the cosmetic
cleanup items (admin column, dead TierPicker, stale comments) in the
post-launch backlog.
2026-04-20 00:08:51 +01:00
7704557f16 merge: catch up with feature/helcim-plan-consolidation base
# Conflicts:
#	server/api/auth/member.get.js
#	server/api/members/update-contribution.post.js
#	tests/server/api/update-contribution.test.js
2026-04-19 21:33:40 +01:00
dfc03f851b fix(review): accept arbitrary amounts in payment-setup; rename m.tier → m.amount in activity text 2026-04-19 19:15:32 +01:00
9f557d7e7a chore(scripts): rename contributionTier → contributionAmount in seed + legacy migration 2026-04-19 19:10:37 +01:00
b17e006d65 feat(frontend): rename contributionTier → contributionAmount across remaining pages 2026-04-19 19:08:57 +01:00
5ef0cc845f feat(account): replace tier control with amount input + guidance chips 2026-04-19 19:03:08 +01:00
4d10c4e0a2 feat(join): replace tier dropdown with amount input + guidance chips 2026-04-19 18:59:24 +01:00
50a1ffe735 chore(contributions): remove unused /api/contributions/options endpoint 2026-04-19 18:56:13 +01:00
64a31b51a5 test(server): update member tests for contributionAmount rename 2026-04-19 18:55:46 +01:00
57f5152be4 feat(server): rename contributionTier → contributionAmount in routes + utils 2026-04-19 18:44:29 +01:00
7a2acd4628 feat(members): use contributionAmount in update-contribution route, inline ×12 2026-04-19 18:38:14 +01:00
613d077eaa feat(helcim): use contributionAmount, inline ×12 annual math 2026-04-19 18:35:25 +01:00
6924758f99 docs(launch): check off change-card, magic-link, ticket manual tests
Event ticket purchase, magic-link login, and in-app change-card
verified 2026-04-19. Pre-registrant invite flow deferred pending
no-tiers refactor on parallel worktree.
2026-04-19 18:32:25 +01:00
1b0a6356a7 feat(activity): add member onboarding activity types
Reserve member_onboarding_goal_completed and member_onboarding_completed
so upcoming onboarding instrumentation can log without schema churn.
2026-04-19 18:32:13 +01:00
9a407c2a38 fix(billing): exclude verify + zero-amount rows from payment history
Helcim's card-transactions list includes auth-only "verify" rows
and $0 entries that have no value on the member-facing history.
2026-04-19 18:32:08 +01:00
5d6fcdd78d feat(account): show next payment date with lazy Helcim refresh
Persist nextBillingDate on subscription create/update; unset on
cancel or downgrade to free. Account page displays the cached
date and lazily refreshes from Helcim when the cached value is
within 24h of now (or missing).
2026-04-19 18:32:04 +01:00
4c8aff34bf feat(scripts): add migrate-contribution-amount 2026-04-19 18:31:49 +01:00
74ea932cd2 feat(member): rename contributionTier → contributionAmount (Number) 2026-04-19 18:27:35 +01:00
e4dade18b9 feat(validation): rename contributionTier → contributionAmount in Zod schemas 2026-04-19 18:16:47 +01:00
55af652263 feat(contributions): rewrite server config as preset-based helpers 2026-04-19 18:12:44 +01:00
03eee45cbd refactor(contributions): tighten requiresPayment contract; use findLast 2026-04-19 18:10:57 +01:00
62c606b30a feat(contributions): rewrite client config as preset-based helpers 2026-04-19 18:07:43 +01:00
4da0265935 docs(launch): check off manual tests verified 2026-04-19
Guest signup, mobile responsive, WCAG contrast, and in-app payment
history all verified via tunnel. Payment history's per-row receipt
link requirement accepted as satisfied by the 'Advanced billing in
Helcim' escape hatch (Helcim's card-transactions API doesn't expose
per-row receipt URLs). Also corrects the mobile breakpoint note —
chrome sidebar hides at 768px, in-page columns collapse at 1024px.
2026-04-19 17:24:05 +01:00
e7ad076d6a fix(a11y): raise circle description contrast to WCAG AA
The .circle-desc text used --text-faint, which failed WCAG AA on the
selected/hover tile surfaces (4.01:1 light / 4.31:1 dark). Promote to
--text-dim to clear 4.5:1 against all tile states.
2026-04-19 17:23:19 +01:00
19c77a3ab6 feat(account): in-app payment history + change card
Add Payment history section (live-read from Helcim, with loading/empty/error states)
and Change card flow (HelcimPay.js zero-dollar auth -> POST /api/helcim/update-card)
to /member/account. Relabel Helcim portal link to "Advanced billing in Helcim →"
and demote it to a secondary link at the bottom of the billing group.
2026-04-19 16:36:19 +01:00
eaff5c6020 feat(activity): add billing_card_updated activity type
Required by POST /api/helcim/update-card to persist audit log entries
when a member updates their card.
2026-04-19 16:30:37 +01:00
101d6a231b feat(billing): add update-card API route with rollback + status gate
POST /api/helcim/update-card updates the customer's default card, then
best-effort patches the active subscription payment method. Status-gated
to {active, pending_payment}; verifies the submitted cardToken is
attached to the member's helcimCustomerId via listHelcimCustomerCards.
On subscription PATCH 5xx we revert the customer default to the prior
card token; 4xx (schema rejection — cardToken is not a documented
subscription PATCH field) is tolerated since the customer default is
the authoritative billing driver.
2026-04-19 16:29:23 +01:00
4f9c11a755 feat(billing): add payment history API route
Add GET /api/helcim/payment-history returning the authenticated
member's normalized Helcim card transactions (newest first, capped
at 50). Resolves helcimCustomerId -> customerCode via getHelcimCustomer
before calling listHelcimCustomerTransactions. Returns
{ transactions: [] } when the member has no helcimCustomerId, and
{ transactions: [], error: 'unavailable' } (HTTP 200) on Helcim
failures so the UI can render fallback copy.

Covered by unit tests at tests/server/api/helcim-payment-history.test.js
(auth, missing customer id, happy path, both Helcim failure paths,
missing customerCode).
2026-04-19 16:26:19 +01:00
6888663148 feat(helcim): add transaction list + card update helpers
- listHelcimCustomerTransactions(customerCode): GET /card-transactions/
  with customerCode filter, sorts newest-first, caps at 50, normalizes
  Helcim status (APPROVED/DECLINED) + type (refund) into
  paid/refunded/failed/other.
- updateHelcimCustomerDefaultPaymentMethod(customerId, cardToken):
  resolves cardToken -> cardId via /customers/{id}/cards, then PATCHes
  /customers/{id}/cards/{cardId}/default.
- updateHelcimSubscriptionPaymentMethod(subscriptionId, cardToken):
  wraps updateHelcimSubscription with a cardToken payload.
- helcimUpdateCardSchema: Zod schema { cardToken: string } for the
  upcoming /api/helcim/update-card route.
- Unit tests for all three helpers (success + error paths).
2026-04-19 16:24:16 +01:00
b6f5ae8c5e docs(launch): P1 — in-app billing management, demote Helcim portal 2026-04-19 13:13:45 +01:00
0ca38e5588 fix(auth): expose helcimCustomerId on /api/auth/member
The member account page gates the Helcim customer portal link on
`memberData.helcimCustomerId`, but this endpoint (the source for
`useAuth().memberData`) omitted the field, so the link hid for every
member regardless of Helcim enrollment. Add the field to the response.
2026-04-19 13:04:46 +01:00
f2e2cedb67 fix(join): redirect immediately on subscription success
Removes the 3-second setTimeout that deferred navigateTo('/welcome').
The overlay success state was a holdover from the pre-refactor Step-3
inline block; now that /welcome is the single welcome surface, the
delay just stalls a completed action and fights the continuous-flow
goal of the overlay.
2026-04-19 12:58:07 +01:00
968a127f96 fix(join): move flow overlay outside v-if so it survives auth flip
After createSubscription() calls checkMemberStatus(), isAuthenticated
flips to true and the <template v-else> branch unmounts, taking the
Teleport (and its overlay) with it. The authenticated 'You're already a
member' UI then showed for the 3-second pre-redirect delay, producing a
visible flash before navigateTo('/welcome') fired.

Teleport now lives at the root div alongside the v-if/v-else branches,
so the overlay stays mounted through the auth state transition and
covers the page continuously until the redirect.
2026-04-19 12:53:50 +01:00
faa5bcbb3c docs(launch): remove /join UX polish from P1 list 2026-04-19 12:24:22 +01:00
f7c6bd88e7 refactor(join): move success state into overlay; remove step 2/3 UI 2026-04-19 12:23:19 +01:00
1bb59f07be refactor(join): auto-open Helcim modal after form submit 2026-04-19 12:21:10 +01:00
5a4c09f988 feat(join): add flow overlay scaffolding for submit→redirect states 2026-04-19 12:19:01 +01:00
a22a576bff fix(join): raise signup form above supporting copy 2026-04-19 12:16:30 +01:00
67cc488c6a docs(launch): consolidate launch readiness; archive completed P0/P1 2026-04-19 12:14:18 +01:00
36829eb1ef docs(launch): check off Helcim cadence manual tests (4, 5, 6; 3 covered by annual swap) 2026-04-18 22:06:48 +01:00
fd9ce5bc2c fix(ui): disambiguate annual tier labels
"$50/yr" was ambiguous — could mean the $5 tier in annual mode or the
$50 tier in monthly mode. On /join the dropdown now shows both prices
("$5/mo → $50/yr") in annual mode. On the account page TierPicker
gains a subtitle slot; annual mode shows "$N/mo tier" beneath the
annual price so members recognize which tier they're on.
2026-04-18 22:06:38 +01:00
8ceaebb268 fix(helcim): tolerate empty response body on DELETE (204)
helcimFetch called response.json() unconditionally, which threw
"Unexpected end of JSON input" on Helcim's 204 No Content responses
(e.g. DELETE /subscriptions/:id). The error was silently swallowed by
the best-effort cancel path in cancel-subscription, masking cases where
the Helcim-side cancel actually succeeded.
2026-04-18 22:06:33 +01:00
549a849bc0 fix(scripts): helcim plan-create payload shape + empty-GET handling
Verified against the live Helcim v2 API during the deploy migration:
- POST /payment-plans requires { paymentPlans: [plan] } wrapper (mirrors
  the POST /subscriptions shape), and response is { data: [plan] }.
- taxType 'customer' rejects as ERR_VALIDATION_FAILED; must be 'no_tax'
  with taxCalculation 'country_province'.
- termLength:1 rejects when termType:'forever' — drop the field.
- GET /subscriptions returns an empty body (not JSON) when no subs exist;
  tolerate that instead of failing with 'Unexpected end of JSON input'.

Plans created in the Helcim account: Monthly=50302, Annual=50303.
2026-04-18 20:58:17 +01:00
f8e0cf36ba docs(launch): add annual cadence tests + plan-consolidation runbook step 2026-04-18 18:16:23 +01:00
daea8b65be feat(scripts): helcim plan consolidation migration (dry-run default) 2026-04-18 18:12:43 +01:00
fb337a4277 feat(account): display cadence and annual pricing in tier selector 2026-04-18 18:08:10 +01:00
748a84d001 chore(join): drop unused contributionOptions + formatTierAmount label param 2026-04-18 18:04:54 +01:00
673f881b54 refactor(join): use getTierAmount helper for cadence pricing 2026-04-18 18:02:04 +01:00
cd0d3f7167 feat(join): cadence selector with annual pricing (monthly×10)
Radio-pair cadence selector (Monthly / Annual) added to the join form,
reusing the existing .circle-radio styling. contributionItems computed
reactively; all tier labels and the left-column price list update on
toggle. cadence submitted with the subscription payload. payment-setup
hardcoded to monthly (annual upgrades go through /join).
2026-04-18 17:59:10 +01:00
0eeed94772 feat(contribution): free-to-paid uses cadence plan id, persists billingCadence 2026-04-18 17:37:35 +01:00
e8c81cf062 feat(contribution): paid-to-paid tier swap via recurringAmount PATCH 2026-04-18 17:32:22 +01:00
4b5ea9bbd8 fix(helcim): restore subscriptionStartDate on paid-tier activation 2026-04-18 17:28:05 +01:00
8d43804c7f feat(helcim): create subscription by cadence with recurringAmount
Replace tier-based plan lookup with cadence-keyed lookup, compute
recurringAmount via getTierAmount, persist billingCadence on member.
Delete both manual-fallback blocks; Helcim failure now surfaces as 500.
2026-04-18 17:25:14 +01:00
be0e6e7699 refactor(config): cadence-keyed plan id, add getTierAmount, drop per-tier helcimPlanId 2026-04-18 17:19:05 +01:00
35197c465b feat(schemas): accept cadence field on subscription + contribution updates 2026-04-18 17:16:09 +01:00
47f2d666dd fix(helcim): use Number(id) in wrapped PATCH /subscriptions body 2026-04-18 17:14:24 +01:00
de4bfdcc16 feat(member): add billingCadence field to schema 2026-04-18 17:12:45 +01:00
2ae27d6dda feat(helcim): add cadence-keyed plan id runtime config 2026-04-18 17:10:50 +01:00
c816d4b373 style(payment-setup): drop ColumnsLayout wrapper 2026-04-18 17:06:34 +01:00
4f567e9586 refactor(helcim): wrapped PATCH body, first-activation welcome email guard
Moves updateHelcimSubscription to the live-verified wrapped shape
(PATCH /subscriptions { subscriptions: [{ id, ...payload }] }), adds a prior-
status check so sendWelcomeEmail only fires on pending_payment to active
transitions, short-circuits get-or-create-customer when a valid
helcimCustomerId is already on file, and replaces member.save() Slack-status
writes with findByIdAndUpdate({ runValidators: false }) to avoid save-time
validator pitfalls.
2026-04-18 17:06:30 +01:00
37a58cb0eb feat(member): pending_payment retains access, soften status copy
pending_payment now grants the same RSVP/peer-support capabilities as active,
and status banner/label copy is rewritten to be non-threatening ("Setting up
payment", "Paused", "Closed"). Aligns member-facing copy across the account
page with the capability model.
2026-04-18 17:06:22 +01:00
15329e3e84 refactor(events): gate member benefits on hasMemberAccess
Extracts hasMemberAccess(member) in tickets.js and uses it across event
registration, ticket purchase, and series purchase flows so guest, suspended,
and cancelled records no longer count as members while pending_payment still
does.
2026-04-18 17:06:17 +01:00
c5e901ed24 feat(signup): community guidelines agreement and policies routes
Introduces /community-guidelines and /policies/{privacy,terms,[slug]} pages,
swaps the signup/invite checkbox from agreedToTerms to agreedToGuidelines,
adds Member.agreement.acceptedAt, and stamps the field when a Helcim
customer is created.
2026-04-18 17:06:10 +01:00
e0d11e47f4 chore: remove admin series-management stub actions 2026-04-17 17:27:27 +01:00
2c834da40a chore: remove placeholder payment block from members/create 2026-04-17 17:16:32 +01:00
3ba633cce2 chore: remove dead guest-register event route
The /api/events/[id]/guest-register endpoint has no production
callers: it's superseded by tickets/purchase.post.js, which
handles guest Member upsert via status:"guest" when
body.createAccount is true. Drops the route file, its
source-assertion tests, guestRegisterSchema, and its validation
coverage.
2026-04-17 16:36:34 +01:00
5fb2f18cab test: align board-channels and wiki-sync mocks with current source
Some checks failed
Test / vitest (push) Successful in 12m0s
Test / playwright (push) Failing after 10m1s
Test / visual (push) Failing after 9m30s
Test / Notify on failure (push) Successful in 2s
board-channels: source renamed getSlackServiceNoVetting → getSlackAdminService.
wiki-sync: syncWikiArticles now also calls fetchCollections; URLs starting
with / are normalized to https://wiki.ghostguild.org.
2026-04-17 09:50:50 +01:00
e96d493024 Merge branch 'feature/guest-event-accounts' 2026-04-17 09:36:11 +01:00
b6f6c95c3b Helcim testing config changes 2026-04-17 09:36:01 +01:00
6f9e6a3d98 feat(events): guest accounts for public event registration
Non-members who register for an event now get a persistent identity:
with consent, a status:"guest" Member is upserted and an auth cookie is
set so the "You're Registered" state survives a page refresh.

Tiered auto-login matches passwordless-auth norms — auto-login is only
safe when the account holds no privileges:
- New email → create guest + cookie
- Returning guest → cookie
- Existing non-guest (active/pending/etc.) → attach ticket only, no
  cookie, confirmation email includes a sign-in link

Guests are gated on status === "guest", so admin/middleware code that
keys on status === "active" naturally excludes them. Guests are also
treated as non-members for ticket pricing/validation to prevent picking
up member-only pricing on their second registration.
2026-04-16 21:23:31 +01:00
7e7672d52b New SiteContent. 2026-04-16 21:11:14 +01:00
02222a5c16 Copy and layout improvements. 2026-04-16 21:11:05 +01:00
39eb9e039a fix(auth): auto-submit OIDC logout form to eliminate xsrf desync
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
Users clicking sign-out in the wiki were getting 'xsrf token invalid'.
The old logoutSource extracted the xsrf from oidc-provider's form into
a separate short-lived cookie and bounced through /auth/logout-confirm,
but that dance kept desyncing — the xsrf on the eventual submit didn't
always match the session state on /oidc/session/end/confirm.

Drop the custom confirmation page and auto-submit oidc-provider's own
form inline from logoutSource. The xsrf stays inside the original form
HTML the provider generated, so the validation is guaranteed to match.
Clicking sign-out in the wiki is already confirmation enough.

Also clear the Ghost Guild auth-token cookie in postLogoutSuccessSource
so signing out of the wiki fully signs the user out rather than leaving
a stale ghostguild.org session behind.
2026-04-15 18:26:51 +01:00
3ad22a8b67 fix(auth): survive missing OIDC interaction cookie on magic-link click
Some checks failed
Test / vitest (push) Failing after 6m13s
Test / visual (push) Has been skipped
Test / playwright (push) Has been skipped
Test / Notify on failure (push) Successful in 3s
Clicking the wiki magic-link email was producing SessionNotFound:
'interaction session id cookie not found' from
provider.interactionFinished, because that call requires the short-lived
_interaction cookie to be present on the request. It isn't, when:

- the user clicks the email on a different device or browser
- the interaction cookie already expired
- the user is in private/incognito browsing

Those unhandled errors previously bounced to /coming-soon via the
coming-soon middleware, stranding users on the pre-register page.

Instead of relying on the interaction cookie at the magic-link step:

1. Verify the JWT, look up the member, set the auth-token cookie.
2. Redirect the user back to https://wiki.ghostguild.org.
3. Outline re-initiates OIDC, which creates a fresh interaction whose
   cookie IS present on the same request, and [uid].get.ts SSOs the user
   in via the auth-token cookie we just set.

Also swap the createError throws for sendRedirect to /auth/oidc-error so
token/member/status failures land on the styled error page rather than
Nitro's default unhandled-error response.
2026-04-15 18:18:33 +01:00
1e9e9c4d97 fix(auth): stop wiki login loop to coming-soon and surface non-member state
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
Members (and pre-registrants) hitting wiki.ghostguild.org were getting bounced
to /coming-soon with a "Pre-Register" link, even when the OIDC flow was
working correctly.

- Allowlist /auth/oidc-error, /auth/logout-confirm, /auth/logout-success,
  and /verify in the coming-soon middleware so OIDC errors and main-site
  magic links stop redirecting to the pre-register page.
- Raise OIDC Interaction TTL from 10m to 15m so it outlives the magic-link
  JWT and legitimate members don't hit expired-interaction errors when they
  click the email a few minutes late.
- Differentiate the "email isn't a registered member" response on the wiki
  login route and show a dedicated "Not a member yet" state with a
  pre-register link and contact email, instead of the misleading
  "Check your inbox" that silently failed.
2026-04-15 17:55:55 +01:00
2394248d53 Updates
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / visual (push) Has been skipped
Test / playwright (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
2026-04-15 17:45:09 +01:00
28040f44f4 refactor(board): atomic delete + query limit + composable cleanup
Some checks failed
Test / vitest (push) Failing after 7m17s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 1s
Delete uses findOneAndDelete with author match (no TOCTOU window);
existence check only runs on miss to distinguish 403 vs 404. Posts
list capped at 200. Drop unused resolveTagChannel and refreshParams;
route slack URL building through the composable's slackUrl helper.
2026-04-15 12:47:53 +01:00
d1a1484daf chore(gitignore): ignore .claude directory 2026-04-15 12:47:47 +01:00
f691f095dc feat(board): inline delete confirmation + a11y polish
Some checks failed
Test / vitest (push) Failing after 6m2s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
Replace window.confirm with an inline Delete? / Cancel / Confirm flow on
post cards. Add focus-visible outlines, initials in avatar placeholders,
and promote post/form titles from h3 to h2 for heading order.
2026-04-14 22:15:50 +01:00
7292b11c0b feat(member): account/profile polish + tier upgrade flow
- Timezone: curated USelectMenu dropdown (app/config/timezones.js), preserves unknown saved values
- Profile save now uses useToast() for success/error; remove inline save banner
- Nav onboarding dot nudged down 1px for optical alignment with lowercase text
- Onboarding: skip a suggestion with POST /api/onboarding/track {skip}; member.onboarding.skipped map; does not affect graduation
- CirclePicker takes :saved-value so 'Current' badge stays until save completes
- PrivacyToggle is binary (USwitch labeled Private); member schema enum reduced to ['members','private']; zod coerces legacy 'public'
- New /member/payment-setup page: HelcimPay $0 verify + update-contribution, wired from account.vue via requiresPaymentSetup redirect
- Helcim portal: NUXT_PUBLIC_HELCIM_PORTAL_URL env + account.vue 'Manage billing in Helcim' link
- Migration script: scripts/migrate-privacy-public-to-members.js
2026-04-14 20:35:37 +01:00
08fc3884da Merge branch 'board-classifieds-redesign'
Some checks failed
Test / vitest (push) Failing after 6m5s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
2026-04-14 20:20:31 +01:00
9a560f2a3b feat(board): redesign classifieds + Slack channel creation
Adds AdminGhost bot token for admin-only Slack channel creation, refreshes
BoardPostCard/Form layouts, and expands admin board-channels management.
2026-04-14 20:20:17 +01:00
6f3d088763 fix(board): surface delete errors via toast 2026-04-14 17:38:32 +01:00
5fb069a80e test(board): unit + e2e tests for board posts and channels 2026-04-14 17:36:12 +01:00
f3df1945bd chore(board): remove old board tests, update seed + onboarding tests 2026-04-14 17:31:46 +01:00
7707068f36 feat(board): admin page for managing board channels 2026-04-14 17:27:06 +01:00
4b3ba411dd fix(board): unwrap API envelope in composables, isolate member profile fetch 2026-04-14 17:24:30 +01:00
f8bc5502ba feat(board): replace board/peer support with posts list on member profile 2026-04-14 17:22:04 +01:00
698f786951 refactor(board): accept refresh params in useBoardPosts mutators 2026-04-14 17:19:48 +01:00
61d33f5db3 feat(board): replace profile board section with posts list 2026-04-14 17:17:09 +01:00
5bdc3244bd fix(board): handle submit errors + tolerate tag fetch failure 2026-04-14 17:14:22 +01:00
c06cdd71fd feat(board): rewrite board.vue with classifieds layout 2026-04-14 17:11:45 +01:00
4d9eb3c198 fix(board): address review feedback on components 2026-04-14 17:08:52 +01:00
33d27c5d9e feat(board): BoardPostCard, BoardPostForm, simplify CooperativeTagSelector 2026-04-14 17:06:25 +01:00
78db4be7ba feat: add useBoardPosts + useBoardChannels composables, remove useBoard
- useBoardPosts: CRUD with useState('board.posts','board.loading')
- useBoardChannels: fetch + resolveTagChannel + slackUrl helpers
- useBoard.js removed (old suggestions wrapper); only app/pages/board.vue still imports it, will be rewritten in Phase 5
2026-04-14 17:02:07 +01:00
1fc937a26a refactor(board): delete old board routes, absorb slackHandle into profile PATCH
- Delete server/api/members/me/board.patch.js and server/api/board/suggestions.get.js
- Add boardSlackHandle to memberProfileUpdateSchema; remove boardPrivacy
- profile.patch.js: write boardSlackHandle -> board.slackHandle; drop boardPrivacy
- Remove privacy.board field from Member model
- onboarding/status.get.js: hasProfileTags now requires only craftTags; hasEngagedBoard uses BoardPost.exists
- onboarding/track.post.js: graduation check uses BoardPost.exists instead of board.topics elemMatch
- members/[id].get.js and directory.get.js: reduce board response to slackHandle only; drop connectionTag and peerSupport filters
2026-04-14 16:29:45 +01:00
6a440a846d feat: board post + channel API routes
Implements Phase 2a of board classifieds redesign:

- GET/POST /api/board/posts (list with tag/author filters, create)
- PATCH/DELETE /api/board/posts/:id (author-only)
- GET /api/board/channels (member)
- POST /api/admin/board-channels (admin)
- PATCH/DELETE /api/admin/board-channels/:id (admin)

Adds board_post_created activity type.
2026-04-14 16:25:42 +01:00
8e5f4a2d7c add unique index on slackChannelId in BoardChannel model 2026-04-14 16:23:23 +01:00
1da59021a3 feat(board): add BoardPost + BoardChannel models and zod schemas
- Add BoardPost model (author, title, seeking/offering, note, tags) with
  validator requiring at least one of seeking/offering
- Add BoardChannel model (name, slackChannelId, tagSlugs)
- Add boardPost/boardChannel create+update Zod schemas
- Trim Member.board subdoc to only slackHandle (drop topics, details,
  offerPeerSupport, availability, personalMessage)
- Remove old boardUpdateSchema
2026-04-14 16:21:04 +01:00
19d519b153 Event fixes 2026-04-14 16:17:55 +01:00
707447fc88 spec: board classifieds redesign
Replace passive tag-matching with active classifieds posts.
Corkboard/zine card UI, Slack topic channel integration,
admin channel mapping, simplified profile board section.
2026-04-14 15:09:40 +01:00
a0f60bcdc0 fix: rename hasEngagedEcology → hasEngagedBoard in onboarding status, clean up stale ecology references 2026-04-14 12:25:24 +01:00
74b2287d48 feat: update tests + seed script, add ecology→board migration
- useOnboarding.test.js: hasEngagedEcology→hasEngagedBoard, /api/ecology/suggestions→/api/board/suggestions, ecology key/route→board in test assertions
- onboarding-status.test.js: stale description strings updated
- seed-welcome-tester.cjs: communityEcology→board, ecologyPageVisited→boardPageVisited
- migrate-ecology-to-board.cjs: one-time migration renames three member fields and activity log action values
2026-04-14 12:20:46 +01:00
49c54764c6 rename ecologyTopics → boardTopics in member detail page 2026-04-14 12:18:16 +01:00
cdef868256 Rename communityEcology → board across frontend, add Board nav, update redirects
- Add Board to exploreItems in AppNavigation
- Update ecology.vue + connections.vue redirects to /board
- Rename all communityEcology refs to board in member profiles, dashboard, admin, onboarding
- Update API path /api/members/me/community-ecology → /api/members/me/board
2026-04-14 12:15:51 +01:00
3e5cedb1a6 refactor(board): rename ecology-prefixed vars to board-prefixed, remove duplicate count div
- Renamed ecologyTagOptions, ecologyFilterTags, ecologyTagLabel → board* throughout refs, computed, helpers, and template
- Removed .filter-bar div (duplicate count display)
- Updated pageSubtitle to use filteredSuggestions.length so subtitle reflects active tag filtering
2026-04-14 12:11:47 +01:00
f43fff0ba0 Extract ecology view into standalone /board page, simplify members to directory-only
- Create app/pages/board.vue with ecology suggestions, tag filtering, clipboard
- Create app/composables/useBoard.js (calls /api/board/suggestions)
- Delete app/composables/useEcology.js
- Strip all ecology code from members/index.vue (view toggle, ecology state,
  ecology template, ecology styles, conditional computeds)
2026-04-14 12:08:58 +01:00
091ec58073 rename communityEcology → board across backend
Model, schemas, API routes, activity log, and all server handlers
updated. Old ecology/ and community-ecology routes removed, new
board/ routes added. Tests updated and new board-suggestions tests
written (10 cases).
2026-04-14 12:00:15 +01:00
59d6e97787 Member/Ecology revamp.
Some checks failed
Test / vitest (push) Failing after 7m23s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
2026-04-14 09:25:09 +01:00
fc7ec52574 restrict members page to authenticated users only, remove public access 2026-04-13 22:26:14 +01:00
1d469c3617 fix: use CircleBadge consistently, load directory on 401 revert, skip redundant API call in ecology mode 2026-04-13 21:13:50 +01:00
d4c1664e5c fix: mobile filter layout + ecology count reflects filtered set 2026-04-13 21:11:09 +01:00
a3bd7d4e50 feat: rewrite /members as unified directory + ecology page
Merge the directory and ecology views into a single page with a
segmented toggle. Directory mode is public; ecology mode requires auth.
URL-driven view state (?view=ecology), collapsible tags drawer,
filter-state reset on mode switch, onboarding tracking, and responsive
breakpoints at 1024/768/375px.
2026-04-13 21:08:27 +01:00
a448ea809d refactor: merge ecology into /members, redirect old routes
- ecology.vue replaced with redirect to /members?view=ecology
- connections.vue updated to skip the /ecology hop, redirects directly
- Remove Ecology nav entry; Members covers it
2026-04-13 21:03:21 +01:00
4fff0bf4cd content: fix hero copy, drop "business" from cooperative models 2026-04-13 12:47:11 +01:00
7b9448ffd5 style(coming-soon): align with design system tokens
Some checks failed
Test / vitest (push) Successful in 11m46s
Test / playwright (push) Failing after 9m34s
Test / visual (push) Failing after 9m19s
Test / Notify on failure (push) Successful in 2s
Replace Tailwind utility color classes with CSS custom properties,
remove rounded corners, use dashed borders and parch button style.
2026-04-12 11:16:53 +01:00
c6b970a621 Design token updates.
Some checks failed
Test / vitest (push) Successful in 10m47s
Test / playwright (push) Failing after 9m11s
Test / visual (push) Failing after 9m11s
Test / Notify on failure (push) Successful in 2s
2026-04-11 23:24:38 +01:00
de3bcc479a fix(auth): rewire OIDC logout/error flow through Nuxt pages
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Migrate three render callbacks in oidc-provider (logoutSource,
postLogoutSuccessSource, renderError) from the baked guildPageShell
helper to Nuxt pages under app/pages/auth/, so they go through the
font module and design system instead of a shadow copy.

- Delete guildPageShell (~103 lines of shadow design system).
- Add /auth/logout-success, /auth/oidc-error, /auth/logout-confirm
  pages built on dashed-box + btn + main.css tokens.
- renderError now allow-lists error + error_description into query
  params and lets Vue default interpolation escape them, closing an
  XSS where OIDC error fields were concatenated into raw HTML.
- logoutSource extracts the xsrf from oidc-provider's stable form
  output, sets it as an httpOnly 2-minute cookie, and redirects to
  /auth/logout-confirm. The confirm page reads the cookie during SSR,
  persists the value to useState, and clears the cookie so it's
  strictly one-time use. Defensive fallback keeps the raw auto-submit
  form if oidc-provider ever changes its form format.
- Fix form actions emitting http:// in production at the root cause:
  oidc-provider extends Koa but calls super() with no args, so
  app.proxy defaults to false and ctx.protocol ignores
  X-Forwarded-Proto. Set _provider.proxy = true after construction;
  remove the bogus proxy:true config key (silently ignored) and the
  form.replace('http://', 'https://') symptom patch. Make the
  x-forwarded-proto override in the catchall conditional on
  production + missing header (was unconditional + dead code).
- Add site-wide .btn:focus-visible rule in main.css for WCAG 2.4.7.

Verified in browser: Brygada 1918 loads on all three pages, contrast
ratios pass AA in dark + light, XSS payload escapes to text nodes
only, Set-Cookie: Max-Age=0 enforces one-time xsrf use, no
horizontal overflow at 500px, no console errors.
2026-04-11 23:21:46 +01:00
98d3610a08 fix(auth): rewrite wiki-login page against real design system
Some checks failed
Test / vitest (push) Successful in 11m48s
Test / playwright (push) Failing after 9m42s
Test / visual (push) Failing after 9m25s
Test / Notify on failure (push) Successful in 2s
The page referenced phantom tokens (--color-guild-*, --color-candlelight-*,
--color-ember-400) that don't exist, leaving the card, input, and button
transparent with no borders. Rewrote the template and styles using the
real design system utilities (.dashed-box, .field, .btn-primary,
.section-label, .section-divider) and tokens (--candle, --ember, --bg,
--border, --text-*), plus semantic landmarks and aria-live status roles.
2026-04-11 15:40:36 +01:00
e791a0d480 fix(onboarding): fix widget links, isComplete logic, and event slugs
Some checks failed
Test / vitest (push) Successful in 10m44s
Test / playwright (push) Failing after 9m15s
Test / visual (push) Failing after 9m7s
Test / Notify on failure (push) Successful in 2s
Use dynamic href for external links, check completedAt for graduation,
link events by slug instead of _id, and remove stale click handler.
2026-04-09 23:52:04 +01:00
50a358b294 feat(wiki): add batch tag remove mode to admin wiki page
Add add/remove toggle to batch tag picker. Clean up unused requireAdmin
import from wiki sync route.
2026-04-09 23:52:00 +01:00
a516f172fb refactor: extract escapeRegex and validateTagSlugs server utils
Deduplicate tag validation and regex escaping into shared auto-imported
utils. Add tag validation to wiki patch/batch-tag routes. Remove
duplicate tags field from event schema.
2026-04-09 23:51:56 +01:00
f585fabf21 Merge branch 'worktree-agent-a975576d' 2026-04-09 22:46:52 +01:00
2cab40aa65 Merge branch 'worktree-agent-a0328c91' 2026-04-09 22:46:52 +01:00
7c3a10232d feat(onboarding): add tracking calls to event, ecology, and wiki pages 2026-04-09 22:46:41 +01:00
3ce559a24c feat(onboarding): integrate widget into dashboard and sidebar indicator 2026-04-09 22:46:39 +01:00
8a673ae158 Merge branch 'worktree-agent-afb1f376' 2026-04-09 22:44:55 +01:00
3ff7cd4e0b feat(onboarding): add OnboardingWidget component 2026-04-09 22:44:41 +01:00
dbf1f44dce Merge branch 'worktree-agent-a244f119' 2026-04-09 22:43:01 +01:00
e27718f450 Merge branch 'worktree-agent-ad42524c' 2026-04-09 22:43:01 +01:00
795b856d56 feat(wiki): add admin wiki management page 2026-04-09 22:42:43 +01:00
5d3b04af48 feat(onboarding): add useOnboarding composable 2026-04-09 22:41:30 +01:00
22530ac1e3 Merge branch 'worktree-agent-a2b84f8b' 2026-04-09 22:38:36 +01:00
20c961113d Merge branch 'worktree-agent-ac00ecc9' 2026-04-09 22:38:36 +01:00
8b2f6d5240 Merge branch 'worktree-agent-abf17134' 2026-04-09 22:38:36 +01:00
337664790f feat(events): add tag selector to admin event form 2026-04-09 22:38:20 +01:00
e4f2efd6d0 feat(wiki): add admin wiki management API routes 2026-04-09 22:36:44 +01:00
d3a5c1a3a7 feat(wiki): add tag-based wiki recommendations API 2026-04-09 22:36:19 +01:00
b54b57cd90 Merge branch 'worktree-agent-aff832dd' 2026-04-09 22:34:09 +01:00
3a22a327fe Merge branch 'worktree-agent-a0ee41bb' 2026-04-09 22:34:09 +01:00
4a475ca5ba Merge branch 'worktree-agent-a54bb856'
# Conflicts:
#	server/models/wikiArticle.js
2026-04-09 22:34:09 +01:00
bda0fe6eb7 Merge branch 'worktree-agent-acfdfab5'
# Conflicts:
#	server/models/event.js
2026-04-09 22:33:54 +01:00
abca0fb7d6 Merge branch 'worktree-agent-a5a0c7d9' 2026-04-09 22:33:41 +01:00
b93c735442 Merge branch 'worktree-agent-a53b58a7' 2026-04-09 22:33:41 +01:00
905b5155e2 feat(wiki): add Outline utility and wiki sync API 2026-04-09 22:33:06 +01:00
327f504df9 feat(slack): add background job to detect Slack workspace joins 2026-04-09 22:32:48 +01:00
2166ee32ca feat(events): add tag validation to admin event create/edit routes 2026-04-09 22:32:32 +01:00
fcbad24f3e feat(events): add tag-based event recommendations API 2026-04-09 22:32:11 +01:00
56376d1995 feat(onboarding): add onboarding status and track API routes with tests 2026-04-09 22:31:57 +01:00
340b739bf2 feat(onboarding): show onboarding progress on admin member detail 2026-04-09 22:31:30 +01:00
3144cbe213 feat(onboarding): redirect /welcome to /member/dashboard 2026-04-09 22:28:57 +01:00
9fe8d99808 feat(onboarding): add Member onboarding subdocument, Event tags, and WikiArticle model 2026-04-09 22:28:51 +01:00
3797ff7925 refactor(peer-support): clean up stale references (Phase 5)
Some checks failed
Test / vitest (push) Successful in 10m33s
Test / playwright (push) Failing after 9m11s
Test / visual (push) Failing after 9m13s
Test / Notify on failure (push) Successful in 2s
Update CSS comment from "Community Connections" to "Community
Ecology" in member detail page. Docs, CLAUDE.md, and activity log
spec also updated (gitignored, local only).
2026-04-09 09:31:37 +01:00
b234b8483f refactor(peer-support): update remaining UI references (Phase 2)
Dashboard: replace "Peer Support" card and "Book a peer session"
quick action with community ecology links.

Welcome: replace "Peer Support" resource card with "Community
Ecology" heading and link to /ecology.

Members index: rename active filter tag from "Offering Peer
Support" to "Offering Support". The underlying filter still works
correctly (queries communityEcology.offerPeerSupport).
2026-04-09 09:16:40 +01:00
0b3896d984 refactor(community): rename Community Connections → Community Ecology
Some checks failed
Test / vitest (push) Successful in 11m42s
Test / playwright (push) Failing after 9m27s
Test / visual (push) Failing after 9m53s
Test / Notify on failure (push) Successful in 2s
Simplify the feature to pure discovery (filter by topic, see matching
members, copy Slack handle). Drop the connection request/confirm flow
entirely — Connection model, 7 API endpoints, useConnections composable,
and TagInput component deleted.

- Rename communityConnections → communityEcology in schema, API, pages
- Delete legacy fields: offering, lookingFor, peerSupport
- New /ecology page, /api/ecology/suggestions, community-ecology.patch
- Nav: "Connections" → "Ecology", remove pending-count badge
- Fix auth/member.get.js missing craftTags + communityEcology
- Add community_ecology_updated activity log type
- Expose slackHandle conditionally when offerPeerSupport is true
- Add migration script at scripts/migrate-to-ecology.js (run before deploy)
2026-04-09 09:07:15 +01:00
9577929e0d refactor(peer-support): delete provably dead code (Phase 1)
The Skills Exchange + Peer Support feature was replaced by Community
Connections on 2026-04-05, but several files and code paths were left
in place as backward-compat. None are reachable from the live UI:

- usePeerSupport.js composable: not imported anywhere
- PeerSupportBadge.vue: not imported anywhere
- peer-support.vue: stub redirect with no incoming links
- /api/peer-support.get.js: only consumed by usePeerSupport
- /api/members/me/peer-support.patch.js: same
- profile.patch.js offering/lookingFor write branches: profile form
  no longer sends these fields (only writes communityConnections.*)
- PEER_SUPPORT_ENABLED/DISABLED activity types and renderers: only
  written by the deleted peer-support.patch endpoint. The activityText
  formatter has a fallback for unknown types so existing records
  still display ("peer support enabled" with a generic icon).

Tests updated to drop peerSupportUpdateSchema coverage and the
offering/lookingFor passthrough assertion.

schemas.js cleanup deferred — concurrent communityConnections →
communityEcology rename is in flight in the working tree.
2026-04-08 22:28:35 +01:00
130e5bfa9f refactor(helcim): use helper in unused admin endpoints 2026-04-08 22:11:25 +01:00
0d792c7c70 refactor(members): use helcim helper + fix wrong card-lookup URL 2026-04-08 22:03:42 +01:00
03d6a66b84 refactor(helcim): use helper in subscription endpoint 2026-04-08 21:53:26 +01:00
7b4b6feb51 refactor(helcim): use centralized helper in 5 simple endpoints 2026-04-08 21:44:18 +01:00
07e005ebfc refactor(helcim): make helcimFetch body check consistent 2026-04-08 21:40:53 +01:00
783459106f refactor(helcim): introduce centralized helcim helper 2026-04-08 21:37:11 +01:00
f9be1f3f01 Merge branch 'feature/page-shell-refactor'
Page Layout Simplification — Member Area Full-Bleed Pattern.

Converges the member-area surface on a canonical PageShell + ColumnsLayout +
PageSection vocabulary, replacing 3 ad-hoc two-column implementations and ~12
hand-rolled flex-chain blocks. PageShell owns the flex chain so individual
pages can no longer break it; PageSection enforces symmetric padding so
asymmetric drift is structurally impossible.

Visual snapshot coverage was expanded BEFORE component changes (15 of the 26
authoritative snapshots are new in this branch), then 9 pages were migrated
one commit at a time. Hydration mismatch on auth-conditional UI fixed by
wrapping v-if/v-else chains in <ClientOnly>. SidebarLayout deleted.

463/463 unit tests + 26/26 visual regressions green.
2026-04-08 21:05:59 +01:00
3b5b0d831d refactor: phase 4 cleanup — delete SidebarLayout, drop dashboard-body
Now that all member-area pages have migrated to PageShell +
ColumnsLayout, SidebarLayout has no consumers and can be deleted.
PageShell owns the flex chain, so the .dashboard-body wrapper on
member/dashboard.vue (flex: 1; display: flex; flex-direction: column;
min-height: 0) is redundant. Update stale SidebarLayout comments on
members/[id].vue to reference ColumnsLayout.
2026-04-08 17:58:01 +01:00
4a5b129eeb fix(profile,account): wrap auth-conditional UI in ClientOnly
Vue hydration silently drops class attribute updates when SSR and client
render different branches of a v-if chain — per the project's Auth SSR
Pattern, useAuth is client-only and server always renders unauthenticated,
so PageHeader (v-else branch) was rendering inside a leftover .loading /
.loading-state div from the v-else-if branch. On mobile that div was
being masked by the visual-test commonMasks (.loading-state), producing
a large fuchsia block in the snapshot.

Wrapping the v-if/v-else-if/v-else chain in <ClientOnly> ensures the
server renders nothing for the auth-gated content and the client performs
a clean first render, matching the pattern already used in dashboard.vue.

Also update admin-dashboard-desktop for minor anti-aliasing drift.
2026-04-08 17:41:01 +01:00
8365feb970 refactor(profile): migrate member/profile to PageShell vocabulary
- Replace .profile-page wrapper + nested form with <PageShell as=form @submit.prevent>
- Replace .profile-columns grid with <ColumnsLayout cols=2> + named slots
- Replace all 5 .profile-col-inset wrappers with <PageSection> components
- Replace <hr class=section-divider> separators with <PageSection divider=top>
- Add type=button to Sign In CTA (prevent accidental form submit)
- Delete .profile-page, .profile-authenticated, .page-content, .profile-main, .profile-columns, .profile-col-left/right, .profile-col-inset asymmetric padding, and collapse rules
2026-04-08 17:10:21 +01:00
37fceac3fd refactor(account): migrate member/account to PageShell vocabulary
- Replace .member-account-page + .account-authenticated flex chains with <PageShell>
- Replace <SidebarLayout> with <ColumnsLayout cols=events-sidebar>
- Replace .account-columns grid with nested <ColumnsLayout cols=2> + named slots
- Replace all 5 .account-col-inset wrappers with <PageSection> components
- Rename .account-section--danger → .danger-section
- Delete ~75 lines of layout CSS (flex chains, grid, asymmetric paddings, collapse rules)
2026-04-08 17:07:30 +01:00
f267b35214 refactor(admin): migrate admin/index to PageShell + ColumnsLayout
- Replace .admin-dash wrapper + bespoke .page-header with <PageShell title/subtitle>
- Replace two .content-row grids with <ColumnsLayout cols=2 collapse=768> + named slots
- Rename .content-block to .admin-block (no border-right; ColumnsLayout provides divider)
- Drop .content-row, .content-block, .page-header scoped CSS
2026-04-08 17:04:38 +01:00
884cee7951 refactor(member-profile): migrate members/[id] to PageShell + ColumnsLayout
- Replace .profile-page wrapper with <PageShell>
- Replace <SidebarLayout> with <ColumnsLayout cols=events-sidebar>
- Drop .profile-page flex-chain CSS
- Leave bespoke .profile-hero alone (intentional two-col hero)
2026-04-08 17:02:07 +01:00
0d10c43af6 refactor(about): migrate about page to PageShell + ColumnsLayout
- Replace .about-page wrapper with <PageShell>
- Replace <SidebarLayout> with <ColumnsLayout cols=events-sidebar :limit=3>
- Drop .about-page flex-chain CSS
- Leave bespoke .about-hero alone (intentional two-column hero)
2026-04-08 17:00:46 +01:00
b93c8c7b2f refactor(dashboard): migrate member/dashboard to PageShell + ColumnsLayout
- Replace outer .dashboard wrapper with <PageShell>
- Replace <SidebarLayout> with <ColumnsLayout cols=events-sidebar :limit=5>
- Replace bespoke <div class=welcome> with <PageHeader> containing slotted meta
- Drop .dashboard and .welcome scoped CSS (flex chain + bespoke header)
- Update visual snapshots (welcome header now uses canonical PageHeader padding)
2026-04-08 16:59:26 +01:00
eb2544a42d refactor(activity): migrate to PageShell + ColumnsLayout
Replace .activity-page wrapper + SidebarLayout with PageShell +
ColumnsLayout cols="events-sidebar". Delete the now-redundant
flex-chain CSS that PageShell owns. Adds .loading-state to the
visual mask list to handle the connections-mobile race between
the loading and loaded states.
2026-04-08 16:45:12 +01:00
89c9a5e4a2 refactor(members): migrate members/index to PageShell
Wrap members directory page in PageShell. Also expand visual mask
selectors to cover .filter-bar, .skills-bar, and .connections-section
because filter content varies based on dynamic tag/topic state and
async fetch ordering. Rebaselines several existing snapshots that now
mask wider regions but capture the same structural layout.
2026-04-08 16:38:34 +01:00
657bc23404 refactor(connections): migrate to PageShell
Replace outer div.connections-page wrapper and explicit PageHeader with
PageShell component. Update connections-mobile-auth snapshot to match
the stable full-suite render state (filter bar absent when test admin
has no cooperative topics configured).
2026-04-08 16:29:52 +01:00
e260ed5b37 test(visual): allow playwright port override and rebaseline connections-mobile
Add PLAYWRIGHT_PORT env var to playwright.config.js so a worktree can run
its own dev server on a different port without disrupting the main dev
server. Rebaseline connections-mobile-auth which had drifted from the
suggestions data state captured during Phase 1.
2026-04-08 16:03:48 +01:00
127d2974c8 feat(layout): add PageShell, ColumnsLayout, PageSection primitives
Introduces three new layout primitives (no consumers yet). Adds
--page-pad-x/y/collapse CSS tokens to :root and .dark. Updates
PageHeader to read padding from tokens. Removes ignored size="large"
props from welcome and series pages. Fixes stray markdown in SidebarLayout.
2026-04-08 15:51:38 +01:00
797cf60c05 test(visual): add authenticated about-mobile snapshot
Add about to authenticatedMobilePages to capture an authenticated baseline
needed for regression detection during the Phase 3.5 about.vue migration.

Rename all auth-mobile snapshots from *-mobile to *-mobile-auth to avoid
name collision with the public loop's about-mobile snapshot.
2026-04-08 15:47:29 +01:00
774c124969 test(visual): expand snapshot coverage for member-area pages
Adds 13 new visual regression baselines:
- Public: about (desktop + mobile), members (desktop + mobile)
- Authenticated desktop: member-account, member-activity, connections,
  admin-dashboard, members-detail
- Authenticated mobile: member-dashboard, member-profile,
  member-account, connections

Switches to a single serial test.describe with a beforeAll that logs in
once and saves the auth cookie via storageState. This avoids repeated
/api/dev/test-login calls that exhausted the dev server's MongoDB
connections under parallel execution.

Masks added: .tl-time, .stat-val, .item-date, .mc-avatar, .cc-avatar,
.profile-avatar, .filter-count — covering activity timestamps, stat
values, member join dates, avatars, and member counts.
2026-04-08 15:39:13 +01:00
728414fffc Don't try to access external font data at build time.
Some checks failed
Test / vitest (push) Successful in 10m47s
Test / playwright (push) Failing after 9m31s
Test / visual (push) Failing after 9m18s
Test / Notify on failure (push) Successful in 2s
2026-04-08 13:32:48 +01:00
2737494546 test(visual): update snapshots for alert fixture and profile changes
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:26:16 +01:00
92e7dae74c feat(admin): add restore dismissed alerts flow
Some checks failed
Test / vitest (push) Successful in 11m48s
Test / playwright (push) Failing after 9m50s
Test / visual (push) Failing after 9m19s
Test / Notify on failure (push) Successful in 2s
Admins can now surface dismissed alert types without waiting for the
underlying data to change. Adds a collapsible "Restore dismissed"
section below the active alerts with per-type checkboxes.

- ALERT_METADATA map in adminAlerts.js as the single source of truth
  for slug → title/severity; detectors refactored to reference it
- GET /api/admin/alerts/dismissed returns this admin's dismissals
  joined with metadata (title, severity, dismissedAt)
- POST /api/admin/alerts/restore deletes dismissals by alertType[],
  returns the deleted count
- AdminAlertsPanel fetches both active + dismissed; stays visible
  when either is non-empty; checkboxes + "Restore selected" button
- adminAlertRestoreSchema validates the POST body against the enum
- Auth guards test covers both new routes
2026-04-08 12:22:35 +01:00
a2af4e31ff fix(admin): drop ClientOnly wrapper around alerts panel
Top-level `await useFetch` inside a ClientOnly boundary prevented the
component from mounting at all — the slot stayed an empty span and no
alerts ever rendered. Nuxt forwards request cookies on same-origin
server calls, so `requireAdmin` is satisfied during SSR and the panel
can render normally.
2026-04-08 12:22:25 +01:00
653bf78973 feat(admin): show alerts panel on dashboard 2026-04-08 11:24:00 +01:00
ba74bfd929 feat(admin): add AdminAlertsPanel component 2026-04-08 11:22:56 +01:00
c8ac730791 test(admin): include alerts routes in admin auth guards check 2026-04-08 11:21:16 +01:00
21cf8d79b3 feat(admin): add POST /api/admin/alerts/dismiss endpoint 2026-04-08 11:20:10 +01:00
f0284c60b4 feat(admin): add GET /api/admin/alerts endpoint 2026-04-08 11:17:50 +01:00
4f7a11bcf3 feat(admin): add alert aggregator with dismissal filtering 2026-04-08 11:14:54 +01:00
0dc1b6ddbc feat(admin): add pending tag suggestions detector 2026-04-08 11:12:52 +01:00
ab3f0a8b39 feat(admin): add event alert detectors 2026-04-08 11:11:32 +01:00
4bae4b0ec3 feat(admin): add pre-registrant alert detectors 2026-04-08 11:09:39 +01:00
824364d526 feat(admin): add member onboarding alert detectors 2026-04-08 11:08:09 +01:00
d3a961f765 feat(admin): add adminAlerts module shell with thresholds and signature helper 2026-04-08 11:06:02 +01:00
7544424484 feat(admin): add adminAlertDismissSchema 2026-04-08 11:04:27 +01:00
89942fac6d test(admin): cover AdminAlertDismissal dismissedAt default 2026-04-08 11:03:31 +01:00
0c3bfc3030 feat(admin): add AdminAlertDismissal model 2026-04-08 11:00:31 +01:00
4271ed0c6f fix: add auth middleware to profile page and update visual snapshots
- Add `middleware: 'auth'` to member/profile.vue (was missing)
- Harden loginAsAdmin helper to wait for networkidle after redirect so
  auth-init plugin and admin middleware finish before tests navigate
- Regenerate visual baselines to reflect updated profile page UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:00:23 +01:00
fb25e72215 Huge bunch of UI/UX improvements and tweaks!
Some checks failed
Test / vitest (push) Successful in 10m36s
Test / playwright (push) Failing after 9m23s
Test / visual (push) Failing after 9m13s
Test / Notify on failure (push) Successful in 2s
2026-04-06 16:17:12 +01:00
501be10bfe feat: pre-registrant management and invitation system
Admin interface to review, filter, and batch-invite the 95 pre-registrants
from Baby Ghosts. Accept-invitation page pre-fills their data and collects
circle, pronouns, motivation, contribution tier, and agreement before
creating their member record.
2026-04-06 14:46:11 +01:00
bab53cec9e merge: worktree-a11y-fixes into main
Some checks failed
Test / vitest (push) Successful in 12m45s
Test / playwright (push) Failing after 10m5s
Test / visual (push) Failing after 9m16s
Accessibility fixes (aria-labels, color contrast, html lang, inline link
underlines), atomic dev login endpoints, and E2E test hardening.
2026-04-05 22:05:00 +01:00
c40f2c7c63 fix: accessibility improvements and test infrastructure hardening
Add aria-labels to form controls (selects, checkboxes, switches), set
html lang attribute and page title, fix color contrast for --candle-dim
and --text-faint tokens, underline inline links, remove opacity hack.
Harden dev login endpoints with atomic findOneAndUpdate and tokenVersion
in JWT. Update Playwright timeouts and E2E test helpers.
2026-04-05 21:59:02 +01:00
dae983734a Accessibility fixes. 2026-04-05 19:27:25 +01:00
689548e389 Merge feature/community-connections into main
Adds Community Connections system: predefined tags with engagement states,
suggested connections page, and member discovery based on shared interests.
2026-04-05 17:05:58 +01:00
6573e30d31 fix: wire showHidden param through suggestions API, remove dead code 2026-04-05 17:00:06 +01:00
ed33cbb9e7 feat: add connections page, composable, nav badge, and peer-support redirect
- useConnections composable wrapping all /api/connections endpoints
- Connections page with suggestions, filters, and connection management
- Pending connection count badge in sidebar navigation
- peer-support.vue now redirects to /connections
2026-04-05 16:56:40 +01:00
dcb80e6006 feat: add connection API endpoints
Suggestions, create/confirm/hide/withdraw actions, my connections list,
and pending count for nav badge.
2026-04-05 16:48:10 +01:00
d69d21abd6 fix: restore external Wiki URL in exploreItems navigation 2026-04-05 16:43:41 +01:00
896de2e7fd feat: add craft tags and community connections to directory and profiles
Update member directory and public profile APIs to include craftTags
and communityConnections with privacy-aware filtering. Directory now
uses predefined tags from the Tag model for filter bars and supports
craftTag/connectionTag query filters. Frontend shows craft tag pills
and cooperative topics with state labels, falling back to old
offering/lookingFor fields. Add Connections nav item.
2026-04-05 16:40:10 +01:00
bd07172093 fix: add connectionRequests to notification schema, remove dead notifyPeerRequests 2026-04-05 16:31:49 +01:00
2aa29ba64b feat: restructure profile page for community connections
Replace Skills Exchange section with CraftTagSelector in About You.
Replace Peer Support section with Community Connections using
CooperativeTagSelector. Update form data, load/save logic, and
notifications to use new field names with backward-compatible
fallbacks to old peerSupport data.
2026-04-05 16:28:34 +01:00
3551f19772 fix: correct POST body field name and state enum values in tag components 2026-04-05 16:25:10 +01:00
2c8529aed9 Add CraftTagSelector, CooperativeTagSelector, and TagSuggestModal components
Pill-toggle grid for craft tags, 3-state segmented control for cooperative
tags (matching PrivacyToggle visual pattern), and a minimal modal for
submitting tag suggestions via /api/tags/suggest.
2026-04-05 16:23:22 +01:00
3faa1f8e85 feat: add community-connections API endpoint and update profile handler
New PATCH /api/members/me/community-connections endpoint following peer-support.patch.js pattern (requireAuth, validateBody, dot-notation $set, Slack user lookup when offerPeerSupport+slackHandle set, logActivity).

Profile endpoint updated with craftTags handling, craftTagsPrivacy and communityConnectionsPrivacy in privacy fields, and craftTags in response.
2026-04-05 16:19:49 +01:00
06ee77592f feat: add community connections activity log types
Adds COMMUNITY_CONNECTIONS_UPDATED, CONNECTION_REQUESTED, CONNECTION_CONFIRMED,
and TAG_SUGGESTED to ACTIVITY_TYPES, ACTIVITY_TYPE_DEFAULTS, the Mongoose enum,
and activityText formatters. All four default to member visibility.
2026-04-05 16:17:25 +01:00
79d038c724 feat: add Tags API endpoints and validation schemas
- GET /api/tags — public, filterable by ?pool=craft|cooperative, active only, sorted by label
- POST /api/tags/suggest — auth-required, creates TagSuggestion doc
- Add tagSuggestionSchema and communityConnectionsUpdateSchema to schemas.js
- Extend memberProfileUpdateSchema with craftTags, craftTagsPrivacy, communityConnectionsPrivacy
2026-04-05 16:15:29 +01:00
18b8106405 fix: use Map for abbreviations to handle mixed-case DevOps label 2026-04-05 16:13:23 +01:00
1cb029a881 feat: add seed-tags and migrate-community-connections scripts
Idempotent seed for 16 craft + 20 cooperative tags by slug. Migration
maps existing offering/lookingFor tags to communityConnections.topics
and copies peerSupport fields without deleting originals.
2026-04-05 16:11:52 +01:00
4b6ff19d5f fix: add state enums to Connection matchingTags, index to TagSuggestion 2026-04-05 16:09:20 +01:00
8112e5ea47 feat: add Tag, TagSuggestion, Connection models and extend Member schema
Adds three new Mongoose models for the community connections feature. Extends
Member with craftTags, communityConnections block, privacy fields for both,
and a connectionRequests notification preference.
2026-04-05 16:06:03 +01:00
88c94aaaf4 Accessibility fixes.
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
2026-04-05 16:03:10 +01:00
4aacb26c4b Add .worktrees/ to .gitignore 2026-04-05 15:57:36 +01:00
0ae18f495e Tests, UX improvements. 2026-04-05 14:25:29 +01:00
4e6f5d36b8 UX/UI improvements. 2026-04-05 13:26:51 +01:00
418d3cc402 UI/UX tweaks and improvements. 2026-04-05 12:28:41 +01:00
4daec9b624 fix: move CI workflow from GitHub Actions to Forgejo Actions 2026-04-04 17:03:54 +01:00
61c16d8bac fix: multi-stage Dockerfile and guard husky for Docker builds
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Multi-stage build produces a smaller production image with only .output.
Husky prepare script now tolerates missing .git (Docker, CI).
2026-04-04 16:44:55 +01:00
382 changed files with 39440 additions and 13709 deletions

View file

@ -6,6 +6,8 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild
# HELCIM_API_TOKEN=your-live-helcim-api-token
HELCIM_API_TOKEN=your-test-helcim-api-token
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id
NUXT_HELCIM_MONTHLY_PLAN_ID=<set_after_migration>
NUXT_HELCIM_ANNUAL_PLAN_ID=<set_after_migration>
# Email Configuration (Resend)
RESEND_API_KEY=your-resend-api-key
@ -14,6 +16,8 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org
# Slack Integration
SLACK_WEBHOOK_URL=your-slack-webhook-url
SLACK_OAUTH_TOKEN=your-slack-oauth-token
# AdminGhost bot token — used for admin-only channel creation. Falls back to SLACK_BOT_TOKEN if unset.
SLACK_ADMIN_BOT_TOKEN=xoxb-adminghost-token
# JWT Secret for authentication
JWT_SECRET=your-jwt-secret-key-change-this-in-production
@ -28,3 +32,6 @@ BASE_URL=http://localhost:3000
OIDC_CLIENT_ID=outline-wiki
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
# Outline Wiki Integration
OUTLINE_API_KEY=

View file

@ -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\"}"

View file

@ -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
View file

@ -18,7 +18,7 @@ logs
.fleet
.idea
/docs/
*.md/
/*.md
# Local env files
.env
@ -26,6 +26,18 @@ logs
!.env.example
scripts/*.js
# Migration backup files
.migration-backup-*.json
# Playwright
e2e/test-results/
playwright-report/
e2e/.auth/
# Worktrees
.worktrees/
.claude/worktrees/
.superpowers/
.claude
scripts/dump-babyghosts-preregistrations.mjs

0
.husky/pre-push Normal file → Executable file
View file

View file

@ -3,21 +3,26 @@ project_name: "ghostguild-org"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@ -65,53 +70,17 @@ read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# 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.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@ -122,11 +91,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@ -150,3 +122,19 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []

View file

@ -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

View file

@ -1,12 +1,19 @@
# Dockerfile
FROM node:20-alpine
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm ci --ignore-scripts && npx nuxt prepare
COPY . .
RUN npm run build
# Production stage — only the self-contained .output is needed.
# bash + curl are added so Dokploy scheduled tasks (which wrap commands in
# `bash -c "..."`) can run; alpine ships only ash and has no curl by default.
FROM node:22-alpine
RUN apk add --no-cache bash curl
WORKDIR /app
COPY --from=builder /app/.output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View file

@ -1,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)
* Commit Mono: Body/UI monospace (Google Fonts)
* Brygada 1918: Display/heading serif
* 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";

View file

@ -15,31 +15,42 @@
:root {
--bg: #f4efe4;
--input-bg: #faf8f2;
--surface: #e8dfc8;
--surface-hover: #e0d6bc;
--border: #b8a880;
--border-d: #a89470;
--candle: #7a5a10;
--candle-dim: #9a7420;
--candle-dim: #866518;
--candle-faint: #c4a448;
--ember: #8a4420;
--text: #2a2015;
--text-bright: #1a1008;
--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-hover: #3a3025;
--parch-text: #ede4d0;
--parch-text-dim: #b8ae98;
--parch-accent: #c4a448;
--parch-border: #b8a880;
--c-community: #7a4838;
--c-founder: #8a4420;
--c-practitioner: #2a4650;
--green: #4a6a38;
--green-bg: rgba(74, 106, 56, 0.08);
--ember-bg: rgba(138, 68, 32, 0.1);
--page-pad-x: 28px;
--page-pad-y: 24px;
--page-collapse: 1024px;
}
.dark {
--bg: #131210;
--input-bg: #1c1a17;
--surface: #1a1815;
--surface-hover: #252220;
--border: #2a2520;
@ -47,28 +58,33 @@
--candle: #d4a03a;
--candle-dim: #b8922e;
--candle-faint: #8a7030;
--ember: #c06030;
--ember: #ca6a3a;
--text: #a89880;
--text-bright: #d0c8b0;
--text-dim: #8a7e6a;
--text-faint: #5a5040;
--parch: #ede4d0;
--parch-hover: #d4c8a8;
--parch-text: #2a2015;
--parch-text-dim: #5a5040;
--text-dim: #958774;
--text-faint: #8b7b62;
/* Parch family intentionally stays pinned to light-mode values
inverted blocks are a consistent zine/terminal inset in both themes.
See: --parch-accent and --parch-border for on-parch accents/borders. */
--c-community: #a06850;
--c-founder: #c06030;
--c-practitioner: #4a7080;
--green: #6e9c52;
--green-bg: rgba(110, 156, 82, 0.12);
--ember-bg: rgba(202, 106, 58, 0.14);
--page-pad-x: 28px;
--page-pad-y: 24px;
--page-collapse: 1024px;
}
/* ---- TAILWIND @THEME MAPPING ---- */
@theme {
--font-sans: 'Commit Mono', monospace;
--font-body: 'Commit Mono', monospace;
--font-mono: 'Commit Mono', monospace;
--font-display: 'Brygada 1918', serif;
--font-serif: 'Brygada 1918', serif;
--font-sans: "Commit Mono", monospace;
--font-body: "Commit Mono", monospace;
--font-mono: "Commit Mono", monospace;
--font-display: "Brygada 1918", serif;
--font-serif: "Brygada 1918", serif;
/* Map primary to candle for Nuxt UI components */
--color-primary-500: var(--candle);
@ -81,14 +97,35 @@
body {
background: var(--bg);
color: var(--text);
font-family: 'Commit Mono', monospace;
font-family: "Commit Mono", monospace;
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--candle); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---- NOISE TEXTURE OVERLAY ---- */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: url("~/assets/images/noise.webp") repeat;
opacity: 0.025;
}
a {
color: var(--candle);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
p a, blockquote a {
text-decoration: underline;
text-underline-offset: 2px;
}
/* ---- SECTION LABELS ---- */
.section-label {
@ -108,14 +145,26 @@ a:hover { text-decoration: underline; }
padding: 2px 8px;
border: 1px dashed;
}
.badge.community { color: var(--c-community); border-color: rgba(122, 72, 56, 0.35); }
.badge.founder { color: var(--c-founder); border-color: rgba(138, 68, 32, 0.35); }
.badge.practitioner { color: var(--c-practitioner); border-color: rgba(42, 70, 80, 0.35); }
.badge.all { color: var(--text-dim); border-color: var(--border); }
.badge.community {
color: var(--c-community);
border-color: rgba(122, 72, 56, 0.35);
}
.badge.founder {
color: var(--c-founder);
border-color: rgba(138, 68, 32, 0.35);
}
.badge.practitioner {
color: var(--c-practitioner);
border-color: rgba(42, 70, 80, 0.35);
}
.badge.all {
color: var(--text-dim);
border-color: var(--border);
}
/* ---- BUTTONS ---- */
.btn {
font-family: 'Commit Mono', monospace;
font-family: "Commit Mono", monospace;
font-size: 12px;
padding: 7px 18px;
border: 1px dashed var(--border);
@ -125,14 +174,26 @@ a:hover { text-decoration: underline; }
letter-spacing: 0.04em;
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 {
background: var(--candle);
color: var(--bg);
border-color: var(--candle);
border-style: solid;
}
.btn-primary:hover { background: var(--candle-dim); border-color: var(--candle-dim); }
.btn-primary:hover {
background: var(--candle-dim);
border-color: var(--candle-dim);
}
.btn-danger {
color: var(--ember);
border-color: var(--ember);
@ -144,7 +205,9 @@ a:hover { text-decoration: underline; }
}
/* ---- FORM FIELDS ---- */
.field { margin-bottom: 12px; }
.field {
margin-bottom: 12px;
}
.field label {
font-size: 10px;
letter-spacing: 0.08em;
@ -153,17 +216,21 @@ a:hover { text-decoration: underline; }
margin-bottom: 3px;
display: block;
}
.field input, .field select, .field textarea {
.field input,
.field select,
.field textarea {
width: 100%;
padding: 5px 8px;
font-family: 'Commit Mono', monospace;
font-family: "Commit Mono", monospace;
font-size: 13px;
color: var(--text-bright);
background: var(--bg);
background: var(--input-bg);
border: 1px dashed var(--border);
outline: none;
}
.field input:focus, .field select:focus, .field textarea:focus {
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--candle);
border-style: solid;
}
@ -174,8 +241,25 @@ a:hover { text-decoration: underline; }
padding: 20px 24px;
transition: border-color 0.2s;
}
.dashed-box:hover { border-color: var(--candle-faint); }
.dashed-box.no-hover:hover { border-color: var(--border); }
.dashed-box:hover {
border-color: var(--candle-faint);
}
.dashed-box.no-hover:hover {
border-color: var(--border);
}
/* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */
/* Negative-margin overlap: every item keeps all 4 borders,
siblings overlap by 1px, active item paints on top via z-index. */
.segmented {
display: flex;
}
.segmented > * {
position: relative;
}
.segmented > * + * {
margin-left: -1px;
}
/* ---- SECTION DIVIDERS ---- */
.section-divider {
@ -192,6 +276,98 @@ a:hover { text-decoration: underline; }
min-width: 0;
}
/* ---- Nuxt UI placeholder contrast ----
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
AA on cream and white backgrounds (2.4:1). Override globally to --text-dim
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
[data-slot="placeholder"] {
color: var(--text-dim);
}
/* ---- SHARED USelectMenu STYLES ----
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
button.zine-select,
button.timezone-select {
display: flex !important;
width: 100%;
padding: 5px 8px !important;
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: none !important;
outline: none !important;
min-height: 0;
--tw-ring-shadow: 0 0 #0000;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-color: transparent;
}
button.zine-select:hover,
button.timezone-select:hover {
background: var(--input-bg) !important;
}
button.zine-select:focus,
button.zine-select:focus-visible,
button.zine-select[aria-expanded="true"],
button.timezone-select:focus,
button.timezone-select:focus-visible,
button.timezone-select[aria-expanded="true"] {
border-color: var(--candle) !important;
}
.tz-content {
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
font-family: "Commit Mono", monospace !important;
}
.tz-input {
border-bottom: 1px dashed var(--border) !important;
}
.tz-input input {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: transparent !important;
border-radius: 0 !important;
padding: 6px 8px !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
}
.tz-item {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text) !important;
border-radius: 0 !important;
padding: 6px 8px !important;
}
.tz-item::before {
border-radius: 0 !important;
}
.tz-item[data-highlighted]::before,
.tz-item[data-highlighted]:not([data-disabled])::before {
background: var(--surface-hover) !important;
}
.tz-item[data-highlighted],
.tz-item[data-highlighted]:not([data-disabled]) {
color: var(--text-bright) !important;
}
/* ---- MOBILE ---- */
@media (max-width: 1023px) {
body {

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

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

View 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 }} &rarr;</a>
<details v-else-if="slackLinks.length > 1" class="slack-menu">
<summary class="slack-link">Discuss on Slack &#9662;</summary>
<ul class="slack-menu-list">
<li v-for="link in slackLinks" :key="link.id">
<a :href="link.url" target="_blank" rel="noopener" class="slack-link">#{{ link.name }}</a>
</li>
</ul>
</details>
</footer>
</article>
</template>
<script setup>
const props = defineProps({
post: { type: Object, required: true },
channels: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
editable: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false },
})
defineEmits(['edit', 'delete', 'confirm-delete', 'cancel-delete'])
const { slackUrl } = useBoardChannels()
const capitalizeAvatar = (str) => {
if (str.toLowerCase() === 'wtf') return 'WTF'
return str
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('-')
}
const authorAvatar = computed(() => {
const a = props.post.author?.avatar
if (!a) return null
return `/ghosties/Ghost-${capitalizeAvatar(a)}.png`
})
const slackHandle = computed(() => props.post.author?.board?.slackHandle || '')
const authorInitial = computed(() => {
const name = props.post.author?.name || ''
return name.trim().charAt(0).toUpperCase() || '?'
})
const copied = ref(false)
const copySlackHandle = async () => {
if (!slackHandle.value) return
try {
await navigator.clipboard.writeText(`@${slackHandle.value}`)
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
} catch {
// clipboard unavailable
}
}
const tagLabelMap = computed(() => {
const map = {}
for (const t of props.tags) map[t.slug] = t.label || t.name || t.slug
return map
})
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
const hasSeeking = computed(() => !!(props.post.seeking && props.post.seeking.trim()))
const hasOffering = computed(() => !!(props.post.offering && props.post.offering.trim()))
const typeLabel = computed(() => {
if (hasSeeking.value && hasOffering.value) return 'SEEKING + OFFERING'
if (hasSeeking.value) return 'SEEKING'
if (hasOffering.value) return 'OFFERING'
return ''
})
const slackLinks = computed(() => {
const postTags = props.post.tags || []
if (!postTags.length) return []
return props.channels
.filter((c) => {
if (!c.slackChannelId) return false
const slugs = c.tagSlugs || []
return slugs.some((s) => postTags.includes(s))
})
.map((c) => ({
id: c.slackChannelId,
name: c.slackChannelName || c.name || c.slackChannelId,
url: slackUrl(c.slackChannelId),
}))
})
</script>
<style scoped>
.board-post {
border: 1px dashed var(--border);
padding: 20px 24px;
background: var(--surface);
break-inside: avoid;
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
}
.post-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 6px;
}
.post-meta {
font-family: "Commit Mono", monospace;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
}
.post-actions {
display: flex;
gap: 6px;
align-items: center;
}
.post-actions.confirm .confirm-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ember);
margin-right: 2px;
}
.action-btn {
font-family: "Commit Mono", monospace;
font-size: 10px;
letter-spacing: 0.04em;
padding: 3px 9px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: all 0.12s;
}
.action-btn:hover {
color: var(--text-bright);
border-color: var(--border-d);
}
.action-btn.danger:hover {
color: var(--ember);
border-color: var(--ember);
}
.action-btn:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.post-title {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 12px;
line-height: 1.2;
}
.post-block {
margin-bottom: 10px;
}
.block-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
margin-bottom: 2px;
}
.block-text {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
}
.post-note {
font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-style: italic;
margin: 8px 0;
white-space: pre-wrap;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 10px 0;
}
.tag-pill {
display: inline-block;
font-size: 10px;
font-family: "Commit Mono", monospace;
color: var(--text-dim);
padding: 2px 8px;
border: 1px dashed var(--border);
}
.post-footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.author {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
}
.author-avatar {
width: 20px;
height: 20px;
object-fit: cover;
}
.avatar-placeholder {
background: transparent;
border: 1px dashed var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-family: "Commit Mono", monospace;
}
.author-name {
font-size: 11px;
color: var(--text-dim);
font-family: "Commit Mono", monospace;
}
.slack-handle-wrap {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.slack-handle {
font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-family: "Commit Mono", monospace;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
.slack-handle:hover {
color: var(--candle);
}
.slack-handle:focus-visible,
.copy-link:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 2px;
}
.copy-link {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--candle);
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.copy-link:hover {
color: var(--candle-dim);
}
.copy-link.copied {
color: var(--candle);
text-decoration: none;
}
.slack-menu {
position: relative;
}
.slack-menu > summary {
list-style: none;
cursor: pointer;
}
.slack-menu > summary::-webkit-details-marker {
display: none;
}
.slack-menu-list {
position: absolute;
right: 0;
top: 100%;
margin-top: 6px;
padding: 6px 10px;
list-style: none;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
flex-direction: column;
gap: 4px;
white-space: nowrap;
z-index: 10;
}
.slack-link {
font-size: 11px;
font-family: "Commit Mono", monospace;
color: var(--candle);
text-decoration: none;
border-bottom: 1px dashed var(--candle-faint);
}
.slack-link:hover {
color: var(--candle-dim);
text-decoration: none;
border-bottom-style: solid;
}
</style>

View file

@ -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>

View file

@ -4,13 +4,16 @@
v-for="circle in circles"
:key="circle.value"
class="circle-option"
:class="{ current: modelValue === circle.value }"
:class="{
selected: modelValue === circle.value,
current: savedValue === circle.value,
}"
@click="$emit('update:modelValue', circle.value)"
>
<span class="circle-name">{{ circle.label }}</span>
<span class="circle-desc">{{ circle.description }}</span>
<span
v-if="modelValue === circle.value"
v-if="savedValue === circle.value"
class="circle-tag"
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
>Current</span>
@ -21,12 +24,13 @@
<script setup>
defineProps({
modelValue: { type: String, default: '' },
savedValue: { type: String, default: '' },
circles: {
type: Array,
default: () => [
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative business' },
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative practice' },
],
},
})
@ -44,7 +48,7 @@ defineEmits(['update:modelValue'])
.circle-option {
border: 1px dashed var(--border);
padding: 14px 12px;
padding: 12px 12px;
background: var(--bg);
cursor: pointer;
transition: all 0.15s;
@ -54,7 +58,7 @@ defineEmits(['update:modelValue'])
background: var(--surface-hover);
}
.circle-option.current {
.circle-option.selected {
border-color: var(--candle);
border-style: solid;
background: var(--surface);
@ -67,19 +71,19 @@ defineEmits(['update:modelValue'])
margin-bottom: 4px;
}
.circle-option.current .circle-name {
.circle-option.selected .circle-name {
color: var(--candle);
}
.circle-desc {
font-size: 11px;
color: var(--text-faint);
color: var(--text-dim);
line-height: 1.5;
display: block;
}
.circle-tag {
font-size: 9px;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 6px;

View file

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

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -1,37 +1,27 @@
<template>
<div
class="series-ticket-card border border-guild-600 dark:border-guild-600 rounded-xl overflow-hidden"
>
<div class="series-ticket-card" style="border: 1px solid var(--border); overflow: hidden">
<!-- Header -->
<div
class="bg-gradient-to-br from-candlelight-500 to-candlelight-700 dark:from-candlelight-600 dark:to-candlelight-800 p-6"
>
<div class="p-6" style="background: var(--candle); color: var(--parch-text)">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Icon
name="heroicons:ticket"
class="w-5 h-5 text-candlelight-900 dark:text-candlelight-200"
/>
<span class="text-sm font-semibold text-candlelight-900 dark:text-candlelight-200">
<Icon name="heroicons:ticket" class="w-5 h-5" style="color: var(--parch-text)" />
<span class="text-sm font-semibold" style="color: var(--parch-text)">
Series Pass
</span>
</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 }}
</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 }}
</p>
</div>
<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) }}
</div>
<div
v-if="ticket.isEarlyBird"
class="text-xs text-candlelight-900 dark:text-candlelight-200 mt-1"
>
<div v-if="ticket.isEarlyBird" class="text-xs mt-1" style="color: var(--parch-text); opacity: 0.85">
Early Bird Price
</div>
</div>
@ -39,29 +29,23 @@
</div>
<!-- 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 -->
<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
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2 text-guild-300 dark:text-guild-300">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<div class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<span>Access to all {{ totalEvents }} events in the series</span>
</div>
<div
v-if="ticket.isFree && !isMember"
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<div v-if="ticket.isFree && !isMember" class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<span>Automatic registration for all sessions</span>
</div>
<div
v-if="memberSavings > 0"
class="flex items-center gap-2 text-guild-300 dark:text-guild-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400" />
<div v-if="memberSavings > 0" class="flex items-center gap-2" style="color: var(--text)">
<Icon name="heroicons:check-circle" class="w-5 h-5" style="color: var(--candle)" />
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
</div>
</div>
@ -69,33 +53,31 @@
<!-- Events List Preview -->
<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
</h4>
<div class="space-y-2">
<div
v-for="(event, index) in events.slice(0, 3)"
:key="event.id"
class="flex items-start gap-3 p-3 bg-guild-700/50 dark:bg-guild-600/30 rounded-lg"
class="flex items-start gap-3 p-3"
>
<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 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 }}
</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) }}
</div>
</div>
</div>
<div
v-if="events.length > 3"
class="text-center text-sm text-guild-400 dark:text-guild-400 pt-2"
>
<div v-if="events.length > 3" class="text-center text-sm pt-2" style="color: var(--text-faint)">
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
</div>
</div>
@ -104,13 +86,14 @@
<!-- Member Benefit Callout -->
<div
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">
<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 class="font-semibold text-candlelight-300 mb-1">Member Benefit</div>
<div class="text-sm text-candlelight-400">
<div class="font-semibold mb-1" style="color: var(--candle)">Member Benefit</div>
<div class="text-sm" style="color: var(--candle)">
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
</div>
</div>
@ -120,13 +103,14 @@
<!-- Public vs Member Pricing -->
<div
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">
<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="font-semibold text-candlelight-300 mb-1">Member Savings</div>
<div class="text-sm text-candlelight-400">
<div class="font-semibold mb-1" style="color: var(--candle)">Member Savings</div>
<div class="text-sm" style="color: var(--candle)">
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
</div>
@ -136,22 +120,15 @@
<!-- Availability -->
<div v-if="availability" class="mb-6">
<div
v-if="!availability.unlimited && availability.remaining !== null"
class="flex items-center gap-2"
>
<div v-if="!availability.unlimited && availability.remaining !== null" class="flex items-center gap-2">
<Icon
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
:class="[
'w-5 h-5',
availability.remaining > 5 ? 'text-candlelight-400' : 'text-ember-400'
]"
class="w-5 h-5"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
/>
<span
:class="[
'text-sm font-medium',
availability.remaining > 5 ? 'text-candlelight-300' : 'text-ember-300'
]"
class="text-sm font-medium"
:style="{ color: availability.remaining > 5 ? 'var(--candle)' : 'var(--ember)' }"
>
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
</span>
@ -160,12 +137,12 @@
<!-- Sold Out / Waitlist -->
<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">
<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 class="font-semibold text-ember-300 mb-1">Series Pass Sold Out</div>
<div class="text-sm text-ember-400">
<div class="font-semibold mb-1" style="color: var(--ember)">Series Pass Sold Out</div>
<div class="text-sm" style="color: var(--ember)">
All series passes have been claimed.
</div>
</div>
@ -174,7 +151,7 @@
<UButton
v-if="availability?.waitlistAvailable"
block
color="gray"
color="neutral"
size="lg"
@click="$emit('join-waitlist')"
>
@ -183,12 +160,16 @@
</div>
<!-- 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">
<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 class="font-semibold text-candlelight-300 mb-1">You're Registered!</div>
<div class="text-sm text-candlelight-400">
<div class="font-semibold mb-1" style="color: var(--candle)">You're Registered!</div>
<div class="text-sm" style="color: var(--candle)">
You have a series pass and are registered for all {{ totalEvents }} events.
</div>
</div>

View file

@ -1,72 +1,42 @@
<template>
<div
class="ticket-card rounded-xl border p-6 transition-all duration-200"
:class="[
isSelected
? 'border-primary bg-primary/5'
: 'border-guild-600 bg-guild-800/50',
isAvailable && !alreadyRegistered
? 'hover:border-primary/50 cursor-pointer'
: 'opacity-60 cursor-not-allowed',
]"
class="ticket-card"
:class="{
'is-selected': isSelected,
'is-unavailable': !isAvailable || alreadyRegistered,
}"
@click="handleClick"
>
<!-- Ticket Header -->
<div class="flex items-start justify-between mb-4">
<div class="ticket-header">
<div>
<h3 class="text-lg font-semibold text-guild-100">
{{ ticketInfo.name }}
</h3>
<p v-if="ticketInfo.description" class="text-sm text-guild-300 mt-1">
<h3 class="ticket-name">{{ ticketInfo.name }}</h3>
<p v-if="ticketInfo.description" class="ticket-desc">
{{ ticketInfo.description }}
</p>
</div>
<!-- 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>
<span v-if="ticketInfo.isMember" class="badge">Members Only</span>
</div>
<!-- Price Display -->
<div class="mb-4">
<div class="flex items-baseline gap-2">
<div class="ticket-price-block">
<div class="ticket-price-row">
<span
class="text-3xl font-bold text-ui-mono"
:class="ticketInfo.isFree ? 'text-candlelight-400' : 'text-guild-100'"
class="ticket-price"
:class="{ 'is-free': ticketInfo.isFree }"
>
{{ ticketInfo.formattedPrice }}
</span>
<!-- 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"
>
<span v-if="ticketInfo.isEarlyBird" class="badge early-bird">
Early Bird
</span>
</div>
<!-- Regular Price (if early bird) -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
class="mt-1"
>
<span class="text-sm text-guild-400 line-through">
Regular: {{ ticketInfo.formattedRegularPrice }}
</span>
<div v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice" class="ticket-regular-price">
Regular: {{ ticketInfo.formattedRegularPrice }}
</div>
<!-- Early Bird Countdown -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
class="mt-2 text-xs text-candlelight-500 dark:text-candlelight-400"
>
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
<div v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" class="ticket-deadline">
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
</div>
</div>
@ -74,59 +44,38 @@
<!-- Member Savings -->
<div
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">
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
</p>
<p class="text-xs text-guild-400 mt-1">
<p>You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!</p>
<p class="ticket-savings-detail">
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
</p>
</div>
<!-- Availability -->
<div class="flex items-center justify-between text-sm">
<div>
<span
v-if="alreadyRegistered"
class="text-candlelight-400 flex items-center gap-1"
>
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
You're registered
</span>
<span
v-else-if="!isAvailable"
class="text-ember-400 flex items-center gap-1"
>
<Icon name="heroicons:x-circle-solid" class="w-4 h-4" />
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="text-guild-300">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="text-guild-300"> Unlimited availability </span>
</div>
<!-- Selection Indicator -->
<div v-if="isSelected && isAvailable && !alreadyRegistered">
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
</div>
<div class="ticket-availability">
<span v-if="alreadyRegistered" class="status-registered">
You're registered
</span>
<span v-else-if="!isAvailable" class="status-sold-out">
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="status-remaining">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="status-remaining">
Unlimited availability
</span>
</div>
<!-- Waitlist Option -->
<div
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
class="mt-4 pt-4 border-t border-guild-600"
class="ticket-waitlist"
>
<UButton
color="gray"
size="sm"
block
@click.stop="$emit('join-waitlist')"
>
<button class="btn" @click.stop="$emit('join-waitlist')">
Join Waitlist
</UButton>
</button>
</div>
</div>
</template>
@ -164,13 +113,11 @@ const formatDeadline = (deadline) => {
const now = new Date();
const diff = date - now;
// If less than 24 hours, show hours
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
}
// Otherwise show date
return `on ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@ -187,6 +134,103 @@ const formatPrice = (amount) => {
<style scoped>
.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>

View file

@ -1,65 +1,47 @@
<template>
<div class="event-ticket-purchase">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-guild-300">Loading ticket information...</p>
<div v-if="loading" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status">Loading ticket information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
<div v-else-if="error" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)">
Unable to Load Tickets
</h3>
<p class="text-ember-400">{{ error }}</p>
</p>
<p class="ticket-detail">{{ error }}</p>
</div>
<!-- Series Pass Required -->
<div
v-else-if="ticketInfo?.requiresSeriesPass"
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
>
<h3
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:ticket" class="w-6 h-6" />
<div v-else-if="ticketInfo?.requiresSeriesPass" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--candle)">
Series Pass Required
</h3>
<p class="text-candlelight-400 mb-4">
</p>
<p class="ticket-detail">
This event is part of
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
pass to attend.
</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.
</p>
<UButton
<NuxtLink
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
color="primary"
size="lg"
block
>
View Series & Purchase Pass
</UButton>
<button class="btn btn-primary">View Series &amp; Purchase Pass</button>
</NuxtLink>
</div>
<!-- Already Registered -->
<div
v-else-if="ticketInfo?.alreadyRegistered"
class="p-6 bg-candlelight-900/20 rounded-xl border border-candlelight-800"
>
<h3
class="text-lg font-semibold text-candlelight-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
<p class="ticket-status" style="color: var(--green)">
You're Registered!
</h3>
<p class="text-candlelight-400 mb-4">
</p>
<p class="ticket-detail">
<template v-if="ticketInfo.viaSeriesPass">
You have access to this event via your series pass for
<strong>{{ ticketInfo.series?.title }}</strong
@ -70,7 +52,7 @@
details.
</template>
</p>
<p class="text-sm text-guild-300">
<p class="ticket-hint">
See you on {{ formatEventDate(eventStartDate) }}!
</p>
</div>
@ -83,128 +65,145 @@
:is-selected="true"
:is-available="ticketInfo.available"
:already-registered="ticketInfo.alreadyRegistered"
class="mb-6"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration Form -->
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
<h3 class="text-xl font-bold text-guild-100 mb-4">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</h3>
<!-- Registration (logged-in member) -->
<div
v-if="
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
"
class="ticket-panel"
>
<p
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
This event is free for Ghost Guild members
</p>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-guild-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
<p
v-if="!ticketInfo.isFree"
class="ticket-notice"
style="color: var(--candle)"
>
Payment of {{ ticketInfo.formattedPrice }} will be processed securely
</p>
<button
class="btn btn-primary"
:disabled="processing"
@click="handleSubmit"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Register for this event"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
</div>
<!-- Registration Form (guest) -->
<div
v-else-if="ticketInfo.available && !ticketInfo.alreadyRegistered"
class="ticket-panel"
>
<div class="box-title">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</div>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="ticket-name">Full Name</label>
<input
id="ticket-name"
v-model="form.name"
name="name"
type="text"
autocomplete="name"
required
placeholder="Enter your full name"
:disabled="processing"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-guild-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
<div class="field">
<label for="ticket-email">Email Address</label>
<input
id="ticket-email"
v-model="form.email"
name="email"
type="email"
autocomplete="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
:disabled="processing"
/>
<p v-if="isLoggedIn" class="text-xs text-guild-400 mt-1">
Using your member email
</p>
</div>
<!-- Member Benefits Notice -->
<div
v-if="ticketInfo.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
<p
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">
<Icon name="heroicons:credit-card" class="w-4 h-4" />
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
<div class="consent-block">
<label class="consent-field">
<input
v-model="form.createAccount"
type="checkbox"
:disabled="processing"
/>
<span
>Create a free guest account so I can manage my
registration</span
>
</label>
<p class="field-hint consent-hint">
Guest accounts let you view your tickets and register faster next
time. We won't add you to member communications.
</p>
</div>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="processing"
:disabled="!form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</UButton>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="processing || !form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</button>
</form>
</div>
<!-- Sold Out with Waitlist -->
<div
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
class="text-center py-8"
class="ticket-panel"
>
<Icon
name="heroicons:ticket"
class="w-16 h-16 text-guild-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
<p class="text-guild-300 mb-6">
<div class="box-title">Waitlist</div>
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
<p class="ticket-detail">
This event is currently at capacity. Join the waitlist to be notified
if spots become available.
</p>
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
Join Waitlist
</UButton>
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
</div>
<!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="text-center py-8">
<Icon
name="heroicons:x-circle"
class="w-16 h-16 text-ember-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-guild-100 mb-2">Event Sold Out</h3>
<p class="text-guild-300">
<div v-else-if="!ticketInfo.available" class="ticket-panel">
<div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
<p class="ticket-detail">
Unfortunately, this event is at capacity and no longer accepting
registrations.
</p>
@ -220,17 +219,25 @@ const props = defineProps({
required: true,
},
eventStartDate: {
type: Date,
type: [String, Date],
required: true,
},
eventTitle: {
type: String,
required: true,
},
eventTimezone: {
type: String,
default: "America/Toronto",
},
userEmail: {
type: String,
default: null,
},
userName: {
type: String,
default: null,
},
});
const emit = defineEmits(["success", "error"]);
@ -245,8 +252,9 @@ const error = ref(null);
const ticketInfo = ref(null);
const form = ref({
name: "",
name: props.userName || "",
email: props.userEmail || "",
createAccount: true,
});
const isLoggedIn = computed(() => !!props.userEmail);
@ -256,11 +264,13 @@ onMounted(async () => {
await fetchTicketInfo();
});
const fetchTicketInfo = async () => {
const fetchTicketInfo = async (emailOverride = null) => {
loading.value = true;
error.value = null;
try {
const effectiveEmail = emailOverride || props.userEmail;
// First check if this event requires a series pass
if (props.userEmail) {
try {
@ -270,7 +280,6 @@ const fetchTicketInfo = async () => {
if (seriesAccess.requiresSeriesPass) {
if (seriesAccess.hasSeriesPass) {
// User has series pass - show as already registered
ticketInfo.value = {
available: true,
alreadyRegistered: true,
@ -281,7 +290,6 @@ const fetchTicketInfo = async () => {
loading.value = false;
return;
} else {
// User needs to buy series pass
ticketInfo.value = {
available: false,
requiresSeriesPass: true,
@ -293,13 +301,14 @@ const fetchTicketInfo = async () => {
}
}
} catch (seriesErr) {
// If series check fails, continue with regular ticket check
console.warn("Series access check failed:", seriesErr);
}
}
// Regular ticket availability check
const params = props.userEmail ? `?email=${props.userEmail}` : "";
const params = effectiveEmail
? `?email=${encodeURIComponent(effectiveEmail)}`
: "";
const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`,
);
@ -320,24 +329,19 @@ const handleSubmit = async () => {
try {
let transactionId = null;
// If payment is required, initialize Helcim and process payment
if (!ticketInfo.value.isFree) {
// Initialize Helcim payment
await initializeTicketPayment(
props.eventId,
form.value.email,
ticketInfo.value.price,
props.eventTitle,
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
// For purchase transactions, we get a transactionId
transactionId = paymentResult.transactionId;
if (!transactionId) {
@ -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(
`/api/events/${props.eventId}/tickets/purchase`,
{
method: "POST",
body: {
name: form.value.name,
email: form.value.email,
transactionId,
},
body,
},
);
// Success!
toast.add({
title: "Success!",
description: ticketInfo.value.isFree
? "You're registered for this event"
: "Ticket purchased successfully!",
color: "green",
color: "success",
});
emit("success", response);
// Refresh ticket info to show registered state
await fetchTicketInfo();
if (response?.signedIn) {
// New guest account or returning guest refresh client auth state so the
// rest of the app sees them as logged in.
await useAuth().checkMemberStatus();
}
await fetchTicketInfo(form.value.email);
} catch (err) {
console.error("Error purchasing ticket:", err);
@ -382,7 +392,7 @@ const handleSubmit = async () => {
toast.add({
title: "Registration Failed",
description: errorMessage,
color: "red",
color: "error",
});
emit("error", err);
@ -393,11 +403,10 @@ const handleSubmit = async () => {
};
const handleJoinWaitlist = () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist",
description: "Waitlist functionality coming soon!",
color: "blue",
color: "info",
});
};
@ -407,6 +416,64 @@ const formatEventDate = (date) => {
month: "long",
day: "numeric",
year: "numeric",
timeZone: props.eventTimezone || "America/Toronto",
});
};
</script>
<style scoped>
.ticket-panel {
padding: 20px 24px;
border-bottom: 1px dashed var(--border);
}
.ticket-status {
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.ticket-detail {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 10px;
line-height: 1.6;
}
.ticket-hint {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 10px;
}
.ticket-notice {
font-size: 11px;
margin-bottom: 10px;
}
.field-hint {
font-size: 10px;
color: var(--text-faint);
margin-top: 2px;
}
.consent-block {
display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start;
column-gap: 8px;
row-gap: 4px;
margin-bottom: 14px;
}
.consent-field {
display: contents;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.consent-field input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--candle);
}
.consent-hint {
grid-column: 2;
margin: 0;
}
</style>

View file

@ -6,13 +6,18 @@
<div v-if="events?.length" class="em-rows">
<div v-for="event in events" :key="event._id" class="em-item">
<div class="em-inset em-item-body">
<span class="em-date">{{ formatDate(event.date) }}</span>
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
<span class="em-date">{{ formatDate(event) }}</span>
<NuxtLink
:to="`/events/${event.slug || event._id}`"
class="em-title"
>{{ event.title }}</NuxtLink
>
<span
v-if="event.circle"
class="em-circle"
:style="{ color: `var(--c-${event.circle})` }"
>{{ event.circle }}</span>
>{{ event.circle }}</span
>
</div>
</div>
</div>
@ -30,13 +35,16 @@
<script setup>
defineProps({
events: { type: Array, default: () => [] },
})
});
const formatDate = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const formatDate = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
};
</script>
<style scoped>
@ -99,7 +107,7 @@ const formatDate = (dateStr) => {
}
.em-circle {
font-size: 9px;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 2px;

View file

@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
<style scoped>
.filter-bar {
padding: 14px 32px;
padding: 16px 28px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: center;

View file

@ -5,14 +5,16 @@
<img
:src="transformedImageUrl"
: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)"
@load="console.log('Image loaded successfully:', transformedImageUrl)"
/>
>
<button
@click="removeImage"
type="button"
class="absolute top-2 right-2 p-1 bg-ember-500 text-white rounded-full hover:bg-ember-600 transition-colors"
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" />
</button>
@ -21,67 +23,84 @@
<!-- Upload Area -->
<div
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"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
:class="{ 'border-candlelight-400 bg-candlelight-900/20': isDragging }"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
@change="handleFileSelect"
>
<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>
<p class="text-guild-400">
<p style="color: var(--text-dim)">
<button
type="button"
class="font-medium"
style="color: var(--candle)"
@click="$refs.fileInput.click()"
class="text-candlelight-400 hover:text-candlelight-300 font-medium"
>
Click to upload
</button>
or drag and drop
</p>
<p class="text-sm 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>
<!-- Alt Text Input -->
<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)
</label>
<input
:value="modelValue.alt || ''"
@input="updateAltText($event.target.value)"
placeholder="Describe this image..."
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-candlelight-500 focus:border-transparent"
/>
class="w-full px-3 py-2 alt-text-input"
@input="updateAltText($event.target.value)"
>
</div>
<!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-guild-400">Uploading...</span>
<span class="text-guild-400">{{ uploadProgress }}%</span>
<span style="color: var(--text-dim)">Uploading...</span>
<span style="color: var(--text-dim)">{{ uploadProgress }}%</span>
</div>
<div class="w-full bg-guild-800 rounded-full h-2">
<div
class="w-full rounded-full h-2"
style="background: var(--surface)"
>
<div
class="bg-candlelight-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%`"
class="h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%; background: var(--candle)`"
/>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="text-sm text-ember-400">
<div v-if="errorMessage" class="text-sm" style="color: var(--ember)">
{{ errorMessage }}
</div>
</div>
@ -201,3 +220,16 @@ const updateAltText = (altText) => {
});
};
</script>
<style scoped>
.alt-text-input {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.alt-text-input:focus {
outline: none;
border-color: var(--candle);
}
</style>

View file

@ -40,7 +40,7 @@
type="email"
placeholder="your.email@example.com"
required
/>
>
</div>
<div class="info-box">
@ -144,6 +144,15 @@ watch(isOpen, (newValue) => {
loginError.value = ''
}
})
const handleKeydown = (e) => {
if (e.key === 'Escape' && isOpen.value) {
resetAndClose()
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>
@ -173,7 +182,7 @@ watch(isOpen, (newValue) => {
.modal-overline {
font-family: 'Brygada 1918', serif;
font-size: 14px;
font-size: 13px;
font-weight: 600;
color: var(--candle);
margin-bottom: 12px;
@ -209,7 +218,7 @@ watch(isOpen, (newValue) => {
.info-box {
font-size: 11px;
color: var(--text-faint);
padding: 10px 14px;
padding: 12px 16px;
border: 1px dashed var(--border);
margin-bottom: 16px;
line-height: 1.6;

View file

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

View file

@ -1,67 +1,40 @@
<template>
<div class="space-y-2">
<div class="relative">
<UInput
v-model="naturalInput"
:placeholder="placeholder"
:color="
hasError && naturalInput.trim()
? 'error'
: isValidParse && naturalInput.trim()
? 'success'
: undefined
"
@input="parseNaturalInput"
@blur="onBlur"
>
<template #trailing>
<Icon
v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5 text-candlelight-500"
/>
<Icon
v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5 text-ember-500"
/>
</template>
</UInput>
</div>
<div
v-if="parsedDate && isValidParse"
class="text-sm text-candlelight-400 bg-candlelight-900/20 px-3 py-2 rounded-lg border border-candlelight-800"
<div class="natural-date-input">
<UInput
:model-value="rawInput"
:placeholder="placeholder"
:color="trailingState"
@update:model-value="onInputChange"
>
<div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" />
<span>{{ formatParsedDate(parsedDate) }}</span>
</div>
</div>
<div
v-if="hasError && naturalInput.trim()"
class="text-sm text-ember-400 bg-ember-900/20 px-3 py-2 rounded-lg border border-ember-800"
>
<div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer text-guild-400 hover:text-guild-100">
Use traditional date picker
</summary>
<div class="mt-2">
<UInput
v-model="datetimeValue"
type="datetime-local"
@change="onDatetimeChange"
<template #trailing>
<Icon
v-if="isValid && rawInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5"
style="color: var(--candle)"
/>
</div>
</details>
<Icon
v-else-if="hasError && rawInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5"
style="color: var(--ember)"
/>
</template>
</UInput>
<p
v-if="rawInput.trim() && isValid"
class="preview-line"
style="color: var(--candle)"
>
&rarr; {{ previewText }}
</p>
<p
v-else-if="rawInput.trim() && hasError"
class="preview-line"
style="color: var(--ember)"
>
{{ errorMessage }}
</p>
</div>
</template>
@ -69,176 +42,197 @@
import * as chrono from "chrono-node";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
modelValue: { type: String, default: "" },
placeholder: {
type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
},
inputClass: {
type: [String, Object],
default: "",
},
required: {
type: Boolean,
default: false,
default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
},
displayTimezone: { type: String, default: "" },
required: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const naturalInput = ref("");
const parsedDate = ref(null);
const isValidParse = ref(false);
const rawInput = ref("");
const isValid = ref(false);
const hasError = ref(false);
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
onMounted(() => {
if (props.modelValue) {
const date = new Date(props.modelValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
}
}
const trailingState = computed(() => {
if (!rawInput.value.trim()) return undefined;
if (hasError.value) return "error";
if (isValid.value) return "success";
return undefined;
});
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newValue) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
const date = new Date(newValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
naturalInput.value = ""; // Clear natural input when set externally
}
} else if (!newValue) {
reset();
}
},
);
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", {
const previewText = computed(() => {
if (!previewDate.value) return "";
const tz = activeTZ();
const date = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}).format(previewDate.value);
const abbr = shortTimezoneName(previewDate.value, tz);
return abbr ? `${date} ${abbr}` : date;
});
if (isToday) {
return `Today at ${timeStr}`;
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`;
} else {
return date.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
const activeTZ = () =>
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Seed the input from modelValue without triggering chrono. The parent's
// value is canonical we just render it as a chrono-friendly readable
// string so the user can backspace and tweak in place.
const seedFromModelValue = () => {
if (!props.modelValue) {
rawInput.value = "";
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
return;
}
const tz = activeTZ();
const utc = zonedLocalToUTC(props.modelValue, tz);
if (!utc) return;
previewDate.value = utc;
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
rawInput.value = readableSeed(utc, tz);
};
onMounted(seedFromModelValue);
watch(
() => 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>
<style scoped>
.natural-date-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
font-size: 12px;
margin: 0;
}
</style>

View 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">&gt; welcome</div>
<div class="ow-message">You are in the <strong>Ghost Guild</strong>. A few passages remain unexplored.</div>
<div class="ow-hint">Next: {{ currentSuggestion.text }}</div>
<NuxtLink
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
:to="currentSuggestion.action"
class="ow-action"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
<a
v-else-if="currentSuggestion.isExternal"
:href="currentSuggestion.action"
target="_blank"
rel="noopener"
class="ow-action"
@click="trackGoal('wikiClicked')"
>
{{ currentSuggestion.actionText }} &rarr;
</a>
<div class="ow-progress">
<span class="ow-bar"><span class="ow-bar-fill">{{ barFill }}</span><span class="ow-bar-empty">{{ barEmpty }}</span></span>
{{ completedCount }} of 4 explored
<button
v-if="currentSuggestion.key"
type="button"
class="ow-skip"
@click="handleSkip"
>Skip this</button>
</div>
</template>
<!-- Suggestion mode: onboarding complete -->
<template v-else>
<!-- Empty state -->
<div v-if="currentSuggestion.key === 'empty'" class="ow-prompt">&gt; look</div>
<div v-if="currentSuggestion.key === 'empty'" class="ow-message ow-message--dim">{{ currentSuggestion.text }}</div>
<!-- Recommendation (event, board, or wiki) -->
<template v-if="currentSuggestion.key !== 'empty'">
<div class="ow-prompt">&gt; look</div>
<div class="ow-message">{{ currentSuggestion.text }}</div>
<a
v-if="currentSuggestion.isExternal && currentSuggestion.action"
:href="currentSuggestion.action"
target="_blank"
rel="noopener"
class="ow-action"
>
{{ currentSuggestion.actionText }} &rarr;
</a>
<NuxtLink
v-else-if="currentSuggestion.action"
:to="currentSuggestion.action"
class="ow-action"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
</template>
</template>
</div>
</ClientOnly>
</template>
<script setup>
const { goals, isComplete, currentSuggestion, trackGoal, skipSuggestion, loading } = useOnboarding()
const handleSkip = () => {
const key = currentSuggestion.value?.key
if (key) skipSuggestion(key)
}
const completedCount = computed(() => {
const g = goals.value
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedBoard, g.hasClickedWiki]
.filter(Boolean).length
})
const barFill = computed(() => '[' + '#'.repeat(completedCount.value * 2))
const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']')
</script>
<style scoped>
.onboarding-widget {
padding: 16px 20px;
border-bottom: 1px dashed var(--parch-border);
background: var(--parch);
color: var(--parch-text);
font-size: 12px;
line-height: 1.7;
}
.ow-prompt {
color: var(--parch-accent);
margin-bottom: 6px;
}
.ow-message {
color: var(--parch-text);
margin-bottom: 2px;
}
.ow-message--dim {
color: var(--parch-text-dim);
}
.ow-hint {
color: var(--parch-text-dim);
font-size: 11px;
}
.ow-action {
display: inline-block;
margin-top: 8px;
padding: 4px 12px;
border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
color: var(--parch-accent);
font-size: 11px;
text-decoration: none;
letter-spacing: 0.04em;
}
.ow-action:hover {
border-color: var(--parch-accent);
border-style: solid;
text-decoration: none;
}
.ow-progress {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
font-size: 11px;
color: var(--parch-text-dim);
display: flex;
align-items: center;
gap: 6px;
}
.ow-bar {
display: inline-flex;
gap: 0;
letter-spacing: 0;
}
.ow-bar-fill {
color: var(--parch-accent);
}
.ow-bar-empty {
color: color-mix(in srgb, var(--parch-text) 20%, transparent);
}
.ow-skip {
margin-left: auto;
background: none;
border: none;
color: var(--parch-text-dim);
font-family: inherit;
font-size: 11px;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
}
.ow-skip:hover {
color: var(--parch-accent);
}
</style>

View file

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

View 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>

View 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>

View file

@ -10,7 +10,7 @@
color: var(--parch-text);
padding: 32px;
margin: 0;
border-bottom: 1px dashed var(--border);
border-bottom: 1px dashed var(--parch-border);
}
.parchment-inset :deep(h2) {
@ -30,6 +30,6 @@
}
.parchment-inset :deep(a) {
color: var(--candle-faint);
color: var(--parch-accent);
}
</style>

View file

@ -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>

View file

@ -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>

View file

@ -4,19 +4,16 @@
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
/>
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
>
<h3 class="text-lg font-semibold text-ember-300 mb-2">
<div v-else-if="error" class="error-state p-6">
<h3 class="error-state__heading text-lg font-semibold mb-2">
Unable to Load Series Pass
</h3>
<p class="text-ember-400">{{ error }}</p>
<p class="error-state__body">{{ error }}</p>
</div>
<!-- Content -->
@ -48,7 +45,7 @@
<!-- Registration Form -->
<div
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">
{{
@ -58,7 +55,7 @@
}}
</h3>
<form @submit.prevent="handleSubmit" class="space-y-6">
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- Name Field -->
<div>
<label
@ -103,18 +100,20 @@
<!-- Member Benefits Notice -->
<div
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">
<Icon
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 class="font-semibold text-candlelight-300 mb-1">
<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!
</div>
</div>
@ -144,6 +143,7 @@
<p class="text-xs text-[--ui-text-muted] text-center">
By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series.
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
</p>
</form>
</div>
@ -182,7 +182,7 @@ const props = defineProps({
const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast();
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
// State
const loading = ref(true);
@ -264,10 +264,9 @@ const handleSubmit = async () => {
paymentProcessing.value = true;
// Initialize Helcim payment for series pass
await initializeTicketPayment(
await initializeSeriesTicketPayment(
props.seriesId,
form.value.email,
passInfo.value.ticket.price,
props.seriesInfo.title,
);
@ -286,6 +285,7 @@ const handleSubmit = async () => {
const purchaseBody = {
name: form.value.name,
email: form.value.email,
ticketType: passInfo.value.ticket.type,
};
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
toast.add({
title: "Series Pass Purchased!",
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
color: "green",
timeout: 5000,
duration: 5000,
});
// Emit success event
@ -322,7 +327,7 @@ const handleSubmit = async () => {
title: "Purchase Failed",
description: errorMessage,
color: "red",
timeout: 5000,
duration: 5000,
});
emit("purchase-error", errorMessage);
@ -349,3 +354,18 @@ const formatPrice = (price, currency = "CAD") => {
}).format(price);
};
</script>
<style scoped>
.error-state {
background: color-mix(in srgb, var(--ember) 8%, transparent);
border: 1px dashed var(--ember);
}
.error-state__heading,
.error-state__body {
color: var(--ember);
}
.registration-form {
background: var(--surface);
border: 1px dashed var(--border);
}
</style>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View 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>

View 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,
}
}

View 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,
}
}

View file

@ -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 = () => {
const TIMEZONE = "America/Toronto";
const DEFAULT_TIMEZONE = "America/Toronto";
// Format a date to a specific format
const formatDate = (date, options = {}) => {
if (!date) return "";
const dateObj = date instanceof Date ? date : new Date(date);
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", {
...(weekday && { weekday }),
month,
day,
year,
...(timeZone && { timeZone }),
}).format(dateObj);
};
// Format event date range
const formatDateRange = (startDate, endDate, compact = false) => {
const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
const tzOpts = timeZone ? { timeZone } : {};
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
const startDay = Number(
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
);
const endDay = Number(
end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
);
const year = Number(
end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
);
const startMonthIdx = startMonth; // compared as label string
const endMonthIdx = endMonth;
const startYear = Number(
start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
);
if (compact) {
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
if (startMonthIdx === endMonthIdx && startYear === year) {
return `${startMonth} ${startDay}-${endDay}`;
}
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
}
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
if (startMonthIdx === endMonthIdx && startYear === year) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) {
} else if (startYear === year) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} 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 dateObj = date instanceof Date ? date : new Date(date);
const now = new Date();
return dateObj < now;
return dateObj < new Date();
};
// Check if a date is today
const isToday = (date) => {
const isToday = (date, timeZone) => {
const dateObj = date instanceof Date ? date : new Date(date);
const today = new Date();
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
return (
dateObj.getDate() === today.getDate() &&
dateObj.getMonth() === today.getMonth() &&
dateObj.getFullYear() === today.getFullYear()
dateObj.toLocaleDateString("en-US", opts) ===
today.toLocaleDateString("en-US", opts)
);
};
// Get a readable time string
const formatTime = (date, includeSeconds = false) => {
const formatTime = (date, includeSeconds = false, timeZone) => {
const dateObj = date instanceof Date ? date : new Date(date);
const options = {
return new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
...(includeSeconds && { second: "2-digit" }),
};
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
...(timeZone && { timeZone }),
}).format(dateObj);
};
return {
TIMEZONE,
DEFAULT_TIMEZONE,
// Legacy alias for callers that hard-coded the constant.
TIMEZONE: DEFAULT_TIMEZONE,
formatDate,
formatDateRange,
isPastDate,

View file

@ -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
}
}

View file

@ -3,7 +3,7 @@ export const useHelcimPay = () => {
let checkoutToken = null;
let secretToken = null;
// Initialize HelcimPay.js session
// Initialize HelcimPay.js session (membership signup flow)
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
@ -12,6 +12,7 @@ export const useHelcimPay = () => {
customerId,
customerCode,
amount,
metadata: { type: "membership_signup" },
},
});
@ -28,26 +29,14 @@ export const useHelcimPay = () => {
}
};
// Initialize payment for event ticket purchase
const initializeTicketPayment = async (
eventId,
email,
amount,
eventTitle = null,
) => {
const _initializeTicket = async (metadata, errorPrefix) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
method: "POST",
body: {
customerId: null,
customerCode: email, // Use email as customer code for event tickets
amount,
metadata: {
type: "event_ticket",
eventId,
email,
eventTitle,
},
customerCode: metadata.email,
metadata,
},
});
@ -57,16 +46,29 @@ export const useHelcimPay = () => {
return {
success: true,
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) {
console.error("Ticket payment initialization error:", error);
console.error(`${errorPrefix} initialization error:`, error);
throw error;
}
};
const initializeTicketPayment = (eventId, email, eventTitle = null) =>
_initializeTicket(
{ type: "event_ticket", eventId, email, eventTitle },
"ticket payment",
);
const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
_initializeTicket(
{ type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
"series payment",
);
// Show payment modal
const showPaymentModal = () => {
return new Promise((resolve, reject) => {
@ -139,6 +141,7 @@ export const useHelcimPay = () => {
if (typeof window.appendHelcimPayIframe === "function") {
// Set up event listener for HelcimPay.js responses
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
let observerTimer, paymentTimer;
const handleHelcimPayEvent = (event) => {
console.log("Received window message:", event.data);
@ -148,6 +151,8 @@ export const useHelcimPay = () => {
// Remove event listener to prevent multiple responses
window.removeEventListener("message", handleHelcimPayEvent);
clearTimeout(observerTimer);
clearTimeout(paymentTimer);
// Close the Helcim modal
if (typeof window.removeHelcimPayIframe === "function") {
@ -237,10 +242,10 @@ export const useHelcimPay = () => {
);
// 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)
setTimeout(() => {
paymentTimer = setTimeout(() => {
console.log("Payment timeout reached, cleaning up event listener...");
window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error("Payment timeout - no response received"));
@ -272,6 +277,7 @@ export const useHelcimPay = () => {
return {
initializeHelcimPay,
initializeTicketPayment,
initializeSeriesTicketPayment,
verifyPayment,
cleanup,
};

View file

@ -25,45 +25,81 @@ export const useMemberPayment = () => {
paymentSuccess.value = false
try {
// Step 1: Get or create Helcim customer
await getOrCreateCustomer()
// Step 2: Initialize Helcim payment with $0 for card verification
await initializeHelcimPay(
customerId.value,
customerCode.value,
0,
// Fast-path: when both Helcim ids are already cached on the member doc
// AND a card's on file, we can skip the paid getOrCreateCustomer round
// trip entirely and go straight to subscription creation.
const hasCachedHelcimIds = Boolean(
memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
)
// Step 3: Show payment modal and get payment result
const paymentResult = await verifyPayment()
console.log('Payment result:', paymentResult)
let existing = null
let probedExistingCard = false
let cardToken = null
if (!paymentResult.success) {
throw new Error('Payment verification failed')
if (hasCachedHelcimIds) {
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
return null
})
probedExistingCard = true
if (existing?.cardToken) {
customerId.value = memberData.value.helcimCustomerId
customerCode.value = memberData.value.helcimCustomerCode
cardToken = existing.cardToken
}
}
// Step 4: Verify payment on backend
const verifyResult = await $fetch('/api/helcim/verify-payment', {
method: 'POST',
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
})
if (!cardToken) {
// Skip HelcimPay verify if a card's already on file — Helcim refuses
// to re-save it, breaking retries after a partial-failed signup.
const [, existingFromFull] = await Promise.all([
getOrCreateCustomer(),
probedExistingCard
? Promise.resolve(existing)
: $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
return null
}),
])
if (!verifyResult.success) {
throw new Error('Payment verification failed on backend')
cardToken = existingFromFull?.cardToken || null
}
if (!cardToken) {
await initializeHelcimPay(
customerId.value,
customerCode.value,
0,
)
const paymentResult = await verifyPayment()
if (!paymentResult.success) {
throw new Error('Payment verification failed')
}
const verifyResult = await $fetch('/api/helcim/verify-payment', {
method: 'POST',
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
})
if (!verifyResult.success) {
throw new Error('Payment verification failed on backend')
}
cardToken = paymentResult.cardToken
}
// Step 5: Create subscription with proper contribution tier
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
method: 'POST',
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionTier: memberData.value?.contributionTier || '5',
cardToken: paymentResult.cardToken,
contributionAmount: memberData.value?.contributionAmount ?? 5,
cardToken,
},
})
@ -71,7 +107,6 @@ export const useMemberPayment = () => {
throw new Error('Subscription creation failed')
}
// Step 6: Payment successful - refresh member data
paymentSuccess.value = true
await checkMemberStatus()

View file

@ -4,137 +4,146 @@
*/
export const MEMBER_STATUSES = {
PENDING_PAYMENT: 'pending_payment',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
}
PENDING_PAYMENT: "pending_payment",
ACTIVE: "active",
SUSPENDED: "suspended",
CANCELLED: "cancelled",
};
export const MEMBER_STATUS_CONFIG = {
pending_payment: {
label: 'Payment Pending',
color: 'orange',
bgColor: 'bg-orange-500/10',
borderColor: 'border-orange-500/30',
textColor: 'text-orange-300',
icon: 'heroicons:exclamation-triangle',
severity: 'warning',
canRSVP: false,
label: "Setting up payment",
color: "orange",
bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/30",
textColor: "text-orange-300",
icon: "heroicons:exclamation-triangle",
severity: "warning",
canRSVP: true,
canAccessMembers: true,
canPeerSupport: false,
canPeerSupport: true,
},
active: {
label: 'Active Member',
color: 'green',
bgColor: 'bg-green-500/10',
borderColor: 'border-green-500/30',
textColor: 'text-green-300',
icon: 'heroicons:check-circle',
severity: 'success',
label: "Active Member",
color: "green",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/30",
textColor: "text-green-300",
icon: "heroicons:check-circle",
severity: "success",
canRSVP: true,
canAccessMembers: true,
canPeerSupport: true,
},
suspended: {
label: 'Membership Suspended',
color: 'red',
bgColor: 'bg-red-500/10',
borderColor: 'border-red-500/30',
textColor: 'text-red-300',
icon: 'heroicons:no-symbol',
severity: 'error',
label: "Membership Suspended",
color: "red",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/30",
textColor: "text-red-300",
icon: "heroicons:no-symbol",
severity: "error",
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
cancelled: {
label: 'Membership Cancelled',
color: 'gray',
bgColor: 'bg-gray-500/10',
borderColor: 'border-gray-500/30',
textColor: 'text-gray-300',
icon: 'heroicons:x-circle',
severity: 'error',
label: "Membership Cancelled",
color: "gray",
bgColor: "bg-gray-500/10",
borderColor: "border-gray-500/30",
textColor: "text-gray-300",
icon: "heroicons:x-circle",
severity: "error",
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
}
};
export const useMemberStatus = () => {
const { memberData } = useAuth()
const { memberData } = useAuth();
// Get current member status
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT)
const status = computed(
() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT,
);
// Get status configuration
const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment)
const statusConfig = computed(
() =>
MEMBER_STATUS_CONFIG[status.value] ||
MEMBER_STATUS_CONFIG.pending_payment,
);
// Helper methods
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
const isInactive = computed(() => !isActive.value)
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
const isPendingPayment = computed(
() => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
);
const isSuspended = computed(
() => status.value === MEMBER_STATUSES.SUSPENDED,
);
const isCancelled = computed(
() => status.value === MEMBER_STATUSES.CANCELLED,
);
const isInactive = computed(() => !isActive.value);
// Check if member can perform action
const canRSVP = computed(() => statusConfig.value.canRSVP)
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
const canRSVP = computed(() => statusConfig.value.canRSVP);
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
// Get action button text and link based on status
const getNextAction = () => {
if (isPendingPayment.value) {
return {
label: 'Complete Payment',
link: '/member/profile#account',
icon: 'heroicons:credit-card',
color: 'orange',
}
label: "Complete Payment",
link: "/member/account",
icon: "heroicons:credit-card",
color: "orange",
};
}
if (isCancelled.value) {
return {
label: 'Reactivate Membership',
link: '/member/profile#account',
icon: 'heroicons:arrow-path',
color: 'blue',
}
label: "Reactivate Membership",
link: "/member/account",
icon: "heroicons:arrow-path",
color: "blue",
};
}
if (isSuspended.value) {
return {
label: 'Contact Support',
link: 'mailto:support@ghostguild.org',
icon: 'heroicons:envelope',
color: 'gray',
}
label: "Contact Support",
link: "mailto:support@ghostguild.org",
icon: "heroicons:envelope",
color: "gray",
};
}
return null
}
return null;
};
// Get banner message based on status
const getBannerMessage = () => {
if (isPendingPayment.value) {
return 'Your 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) {
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) {
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
const getRSVPMessage = () => {
if (isPendingPayment.value) {
return 'Complete your payment to register for events'
}
if (isSuspended.value || isCancelled.value) {
return 'Your membership status prevents RSVP. Please reactivate your account.'
return "Your account isn't active right now. Reach out if you have questions.";
}
return null
}
return null;
};
return {
status,
@ -151,5 +160,5 @@ export const useMemberStatus = () => {
getBannerMessage,
getRSVPMessage,
MEMBER_STATUSES,
}
}
};
};

View 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),
}
}

View file

@ -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 };
};

View 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 })
}
}

View file

@ -21,7 +21,7 @@ export const CIRCLES = {
shortDescription: "Building your studio",
description: "For those actively establishing or growing their coop",
features: [
"Teams working toward applying for the Peer Accelerator",
"Teams working toward applying for Cooperative Foundations",
"Early-stage coop studios",
"Studios transitioning to coop model",
],
@ -33,7 +33,7 @@ export const CIRCLES = {
value: "practitioner",
label: "Practitioners",
shortDescription: "Leading and mentoring",
description: "For Peer Accelerator alumni and experienced studio founders",
description: "For alumni and experienced studio founders",
features: [
"Those implementing cooperative models",
"Industry mentors and advisors",

View file

@ -1,82 +1,22 @@
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
export const CONTRIBUTION_TIERS = {
FREE: {
value: "0",
amount: 0,
label: "$0 - I need support right now",
tier: "free",
helcimPlanId: null, // No Helcim plan needed for free tier
},
SUPPORTER: {
value: "5",
amount: 5,
label: "$5 - I can contribute",
tier: "supporter",
helcimPlanId: "supporter-monthly-5",
},
MEMBER: {
value: "15",
amount: 15,
label: "$15 - I can sustain the community",
tier: "member",
helcimPlanId: "member-monthly-15",
},
ADVOCATE: {
value: "30",
amount: 30,
label: "$30 - I can support others too",
tier: "advocate",
helcimPlanId: "advocate-monthly-30",
},
CHAMPION: {
value: "50",
amount: 50,
label: "$50 - I want to sponsor multiple members",
tier: "champion",
helcimPlanId: "champion-monthly-50",
},
};
// Guidance presets for the contribution amount input.
// These are NOT tiers — just suggested amounts with matching guidance copy.
export const CONTRIBUTION_PRESETS = [
{ amount: 0, label: "I need support right now" },
{ amount: 5, label: "I can contribute" },
{ amount: 15, label: "I can sustain the community" },
{ amount: 30, label: "I can support others too" },
{ amount: 50, label: "I want to sponsor multiple members" },
]
// Get all contribution options as an array (useful for forms)
export const getContributionOptions = () => {
return Object.values(CONTRIBUTION_TIERS);
};
export const requiresPayment = (amount) => amount > 0
// Get valid contribution values for validation
export const getValidContributionValues = () => {
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
};
export const isValidContributionAmount = (amount) =>
Number.isInteger(amount) && amount >= 0
// Get contribution tier by value
export const getContributionTierByValue = (value) => {
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
};
// Get Helcim plan ID for a contribution tier
export const getHelcimPlanId = (contributionValue) => {
const tier = getContributionTierByValue(contributionValue);
return tier?.helcimPlanId || null;
};
// Check if a contribution tier requires payment
export const requiresPayment = (contributionValue) => {
const tier = getContributionTierByValue(contributionValue);
return tier?.amount > 0;
};
// Check if a contribution value is valid
export const isValidContributionValue = (value) => {
return getValidContributionValues().includes(value);
};
// Get contribution tier by Helcim plan ID
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
return Object.values(CONTRIBUTION_TIERS).find(
(tier) => tier.helcimPlanId === helcimPlanId,
);
};
// Get paid tiers only (excluding free tier)
export const getPaidContributionTiers = () => {
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
};
export const getGuidanceLabel = (amount) => {
if (amount === null || amount === undefined) return null
const n = Number(amount)
if (!Number.isFinite(n) || n < 0) return null
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
return match?.label ?? null
}

21
app/config/eventTypes.js Normal file
View 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 || "";
}

View 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
View 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. Johns', value: 'America/St_Johns' },
{ label: 'Brazil — São Paulo', value: 'America/Sao_Paulo' },
{ label: 'Argentina — Buenos Aires', value: 'America/Argentina/Buenos_Aires' },
// Europe / Africa
{ label: 'UTC', value: 'UTC' },
{ label: 'UK — London', value: 'Europe/London' },
{ label: 'Ireland — Dublin', value: 'Europe/Dublin' },
{ label: 'Central Europe — Berlin', value: 'Europe/Berlin' },
{ label: 'Central Europe — Paris', value: 'Europe/Paris' },
{ label: 'Central Europe — Madrid', value: 'Europe/Madrid' },
{ label: 'Eastern Europe — Helsinki', value: 'Europe/Helsinki' },
{ label: 'Africa — Lagos', value: 'Africa/Lagos' },
{ label: 'Africa — Johannesburg', value: 'Africa/Johannesburg' },
// Asia / Oceania
{ label: 'Middle East — Dubai', value: 'Asia/Dubai' },
{ label: 'India — Kolkata', value: 'Asia/Kolkata' },
{ label: 'Southeast Asia — Bangkok', value: 'Asia/Bangkok' },
{ label: 'China — Shanghai', value: 'Asia/Shanghai' },
{ label: 'Japan — Tokyo', value: 'Asia/Tokyo' },
{ label: 'Korea — Seoul', value: 'Asia/Seoul' },
{ label: 'Australia — Sydney', value: 'Australia/Sydney' },
{ label: 'Australia — Perth', value: 'Australia/Perth' },
{ label: 'New Zealand — Auckland', value: 'Pacific/Auckland' },
];

View file

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

View file

@ -1,5 +1,12 @@
<template>
<div class="min-h-screen bg-guild-900">
<div class="coming-soon-layout">
<slot />
</div>
</template>
<style scoped>
.coming-soon-layout {
min-height: 100vh;
background: var(--bg);
}
</style>

View file

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

View file

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

View file

@ -12,11 +12,24 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (
to.path === "/coming-soon" ||
to.path === "/auth/wiki-login" ||
to.path === "/auth/oidc-error" ||
to.path === "/auth/logout-confirm" ||
to.path === "/auth/logout-success" ||
to.path === "/verify" ||
to.path.startsWith("/admin")
) {
return;
}
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
try {
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
const member = await $fetch("/api/auth/member", { headers });
if (member?.role === "admin") return;
} catch {
// Not authenticated — fall through to redirect
}
// Redirect all other routes to coming-soon
return navigateTo("/coming-soon");
});

View file

@ -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");
}
}
});

View file

@ -1,95 +1,118 @@
<template>
<div class="about-page">
<PageShell>
<!-- ABOUT HERO (side by side) -->
<div class="about-hero">
<div class="about-hero-left">
<h1>About Ghost Guild</h1>
<p>A membership community for game developers exploring cooperative business models.</p>
<p>
A membership community for game developers exploring cooperative
models.
</p>
</div>
<div class="about-hero-right">
<div class="section-label">Our Story</div>
<p>Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been supporting indie game developers since 2018. We noticed a gap: game developers interested in cooperative models had nowhere to learn, practice, and connect with others doing the same work.</p>
<p>Ghost Guild is the response &mdash; a membership program where developers at every stage of cooperative practice can find resources, events, mentorship, and community.</p>
<p>We don't prescribe a single model. We're a place to explore the options, learn from people who've tried them, and build something that works for your team.</p>
<p>
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
advancing cooperative and worker-centric models in the game industry
since 2023.
</p>
<p>
Developers interested in co-op practice had few places to learn,
connect, and figure things out alongside others doing the same work.
Ghost Guild is that place: a membership community for developers at
every stage of cooperative practice, with resources, events, and peers
to learn from.
</p>
<p>
We don't prescribe a single model. We're here to explore the options,
learn from people who've tried them, and build something that works
for your team.
</p>
</div>
</div>
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
<div class="content-area">
<div class="content-main">
<ColumnsLayout cols="events-sidebar" :limit="3">
<!-- THE CIRCLES -->
<div class="about-section" id="circles">
<div class="section-label">The Circles</div>
<div class="circles-grid">
<div id="community" class="circle-cell">
<h2 style="color: var(--c-community)">Community</h2>
<!-- THE CIRCLES -->
<div class="about-section" id="circles">
<div class="section-label">The Circles</div>
<div class="circles-grid">
<div id="community" class="circle-cell">
<h3 style="color: var(--c-community);">Community</h3>
<div class="circle-subtitle">"The open hall"</div>
<p>For anyone exploring cooperative models. Wiki access, public events, Slack community, monthly meetings.</p>
</div>
<div id="founder" class="circle-cell">
<h3 style="color: var(--c-founder);">Founder</h3>
<div class="circle-subtitle">"The workshop"</div>
<p>For people actively building cooperatives. Peer accelerator, mentorship, governance templates.</p>
</div>
<div id="practitioner" class="circle-cell">
<h3 style="color: var(--c-practitioner);">Practitioner</h3>
<div class="circle-subtitle">"The alcove"</div>
<p>For experienced practitioners. Mentoring, teaching, shaping the program direction.</p>
</div>
<p>For anyone exploring cooperative models.</p>
</div>
<div id="founder" class="circle-cell">
<h2 style="color: var(--c-founder)">Founder</h2>
<p>For people actively building cooperatives.</p>
</div>
<div id="practitioner" class="circle-cell">
<h2 style="color: var(--c-practitioner)">Practitioner</h2>
<p>For experienced practitioners sharing what they know.</p>
</div>
</div>
<!-- HOW CONTRIBUTION WORKS -->
<div class="about-section">
<div class="section-label">How Contribution Works</div>
<p>Membership is $0&ndash;50/month, pay what you can. Nobody is excluded for lack of funds. Your contribution supports infrastructure, events, and community resources.</p>
<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 &mdash; growing as members contribute. Events range from workshops to social hangs to deep-dive series.</p>
<NuxtLink to="/join" class="cta">Join the Guild &rarr;</NuxtLink>
</div>
<!-- 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 &rarr;</a></p>
</div>
</div>
<!-- EVENTS MINI SIDEBAR -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</div>
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
<div class="two-col-row">
<div class="about-section">
<div class="section-label">How Contribution Works</div>
<p>
Membership is $0&ndash;50/month, pay what you can. Nobody is
excluded for lack of funds. Your contribution supports
infrastructure, events, and community resources.
</p>
<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 &mdash; growing as
members contribute. Events range from workshops to social hangs to
deep-dive series.
</p>
<NuxtLink to="/join" class="cta">Join the Guild &rarr;</NuxtLink>
</div>
</div>
<!-- ABOUT BABY GHOSTS -->
<div class="about-section">
<div class="section-label">About Baby Ghosts</div>
<p>
Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing
cooperative models in game development.
</p>
<p>
<a href="https://babyghosts.org" target="_blank"
>babyghosts.org &rarr;</a
>
</p>
</div>
</ColumnsLayout>
</PageShell>
</template>
<script setup>
const { data: upcomingEvents } = await useFetch('/api/events', {
query: { limit: 3, upcoming: true },
default: () => [],
useSiteMeta({
title: 'About',
description:
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
})
</script>
<style scoped>
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
.about-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- ABOUT HERO ---- */
.about-hero {
display: grid;
@ -104,7 +127,7 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
align-self: stretch;
}
.about-hero-left h1 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
@ -128,24 +151,6 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
margin-bottom: 10px;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
padding: 0;
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- SECTIONS ---- */
.about-section {
padding: 28px 32px;
@ -176,9 +181,11 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
padding: 20px;
border-right: 1px dashed var(--border);
}
.circle-cell:last-child { border-right: none; }
.circle-cell:last-child {
border-right: none;
}
.circle-cell h3 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
line-height: 1.2;
@ -196,6 +203,19 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
line-height: 1.65;
}
/* ---- TWO-COL ROW ---- */
.two-col-row {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px dashed var(--border);
}
.two-col-row .about-section {
border-bottom: none;
}
.two-col-row .about-section:first-child {
border-right: 1px dashed var(--border);
}
/* ---- TIER LIST ---- */
.tier-list {
list-style: none;
@ -209,7 +229,9 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
display: flex;
gap: 12px;
}
.tier-list li:last-child { border-bottom: none; }
.tier-list li:last-child {
border-bottom: none;
}
.tier-amt {
color: var(--text-bright);
font-weight: 600;
@ -218,13 +240,23 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area { grid-template-columns: 1fr; }
.circles-grid { grid-template-columns: 1fr; }
.circles-grid {
grid-template-columns: 1fr;
}
.circle-cell {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.circle-cell:last-child { border-bottom: none; }
.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) {
.about-hero {

728
app/pages/accept-invite.vue Normal file
View 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 &times; 12)</span>.
</p>
<p class="billing-summary-line">
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width">
<label class="checkbox-label">
<input
v-model="form.agreedToGuidelines"
type="checkbox"
>
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank">Community Guidelines</NuxtLink>.
</span>
</label>
</div>
<div class="form-group">
<button
class="form-submit"
type="submit"
:disabled="!isFormValid || isSubmitting"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else-if="needsPayment">Continue to Payment</span>
<span v-else>Accept Invitation</span>
</button>
</div>
</div>
</form>
</div>
<!-- Flow overlay: covers the page through payment + redirect. -->
<SignupFlowOverlay
:state="flowState"
:summary="flowSummary"
:error-message="errorMessage"
dashboard-href="/member/dashboard?welcome=1"
@close="closeFlowOverlay"
/>
</div>
</template>
<script setup>
import {
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
definePageMeta({ layout: false });
useSiteMeta({ title: "Accept Invitation", noindex: true });
const { checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
const step = ref("verifying");
const errorMessage = ref("");
const isSubmitting = ref(false);
const preRegId = ref(null);
const preRegEmail = ref("");
const token = ref("");
const cadence = ref("annual"); // 'monthly' | 'annual'
// Flow overlay state drives the post-submit full-viewport UI.
const flowState = ref("idle");
const form = reactive({
name: "",
pronouns: "",
location: "",
circle: "community",
motivation: "",
contributionAmount: 15,
agreedToGuidelines: false,
});
const isFormValid = computed(() => {
return (
form.name &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
});
const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount);
});
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
const firstCharge = computed(() => {
const amount = form.contributionAmount || 0;
return cadence.value === "annual" ? amount * 12 : amount;
});
const formatContributionAmount = (amount) => {
if (!amount || amount === 0) return "$0";
const display = cadence.value === "annual" ? amount * 12 : amount;
const suffix = cadence.value === "annual" ? "/yr" : "/mo";
return `$${display}${suffix}`;
};
const flowSummary = computed(() => ({
name: form.name,
email: preRegEmail.value,
circle: form.circle,
contribution: formatContributionAmount(form.contributionAmount),
}));
const closeFlowOverlay = () => {
flowState.value = "idle";
errorMessage.value = "";
};
// On mount: extract token from fragment, verify
onMounted(async () => {
const hash = window.location.hash?.slice(1);
if (!hash) {
step.value = "error";
errorMessage.value = "No invitation token found. Please check your email link.";
return;
}
token.value = hash;
try {
const result = await $fetch("/api/invite/verify", {
method: "POST",
body: { token: hash },
});
preRegId.value = result.preRegistrationId;
preRegEmail.value = result.email;
form.name = result.name || "";
form.location = result.city || "";
step.value = "form";
} catch (err) {
step.value = "error";
errorMessage.value =
err.data?.statusMessage || "This invitation link is invalid or has expired.";
}
});
const handleAccept = async () => {
if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true;
errorMessage.value = "";
flowState.value = "creating-customer";
try {
const accepted = await $fetch("/api/invite/accept", {
method: "POST",
body: {
preRegistrationId: preRegId.value,
name: form.name,
pronouns: form.pronouns || undefined,
location: form.location || undefined,
circle: form.circle,
motivation: form.motivation || undefined,
contributionAmount: form.contributionAmount,
agreedToGuidelines: form.agreedToGuidelines,
token: token.value,
},
});
if (!accepted.requiresPayment) {
// Free tier session cookie already set by accept endpoint
await checkMemberStatus();
flowState.value = "success";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
return;
}
// Paid tier: initialize HelcimPay session, auto-open modal
flowState.value = "opening-payment";
await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0);
const paymentResult = await verifyPayment();
if (!paymentResult?.success) {
throw new Error("Payment was not completed.");
}
flowState.value = "processing-payment";
await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: accepted.customerId,
},
});
flowState.value = "creating-subscription";
await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: accepted.customerId,
customerCode: accepted.customerCode,
contributionAmount: form.contributionAmount,
cadence: cadence.value,
cardToken: paymentResult.cardToken,
},
});
await checkMemberStatus();
flowState.value = "success";
setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500);
} catch (err) {
errorMessage.value =
err.data?.statusMessage ||
err.message ||
"Failed to accept invitation. Please try again.";
flowState.value = "error";
} finally {
isSubmitting.value = false;
}
};
</script>
<style scoped>
.accept-invite {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Commit Mono", monospace;
}
.center-box {
max-width: 480px;
margin: 0 auto;
padding: 80px 24px;
text-align: center;
}
.center-box h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 12px;
}
.center-box p {
font-size: 13px;
color: var(--text-dim);
}
.form-container {
max-width: 560px;
margin: 0 auto;
padding: 48px 24px 80px;
}
.form-container h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.form-intro {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 28px;
line-height: 1.5;
}
.error-box {
padding: 12px 16px;
border: 1px dashed var(--ember);
color: var(--ember);
font-size: 12px;
margin-bottom: 20px;
}
/* ---- FORM ---- */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 4px;
}
.form-input,
.form-select {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 8px 10px;
}
.form-input:focus,
.form-select:focus {
border-color: var(--candle);
outline: none;
}
.form-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
textarea.form-input {
resize: vertical;
}
.field-note {
font-size: 10px;
color: var(--text-faint);
margin-top: 4px;
line-height: 1.4;
}
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
.contribution-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.contribution-currency {
font-weight: 600;
}
.contribution-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
outline: none;
border-color: var(--candle);
}
.contribution-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.contribution-preset-chip {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
.contribution-preset-chip:hover {
border-style: solid;
border-color: var(--candle);
}
.contribution-guidance {
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--ink-soft, currentColor);
}
/* ---- BILLING SUMMARY ---- */
.billing-summary {
padding: 12px 16px;
border: 1px dashed var(--border);
background: var(--surface);
}
.billing-summary-line {
font-size: 13px;
color: var(--text);
line-height: 1.5;
margin: 0;
}
.billing-summary-line + .billing-summary-line {
margin-top: 4px;
}
.billing-summary-line strong {
color: var(--text-bright);
font-weight: 600;
}
/* ---- CIRCLE RADIOS ---- */
.circle-radios {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.circle-radio {
position: relative;
}
.circle-radio input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.circle-radio label {
display: block;
padding: 12px;
border: 1px dashed var(--border);
cursor: pointer;
text-align: center;
transition: border-color 0.15s;
}
.circle-radio input:checked + label {
border-color: var(--candle);
border-style: solid;
background: var(--surface);
}
.circle-label-name {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.circle-label-desc {
display: block;
font-size: 10px;
color: var(--text-faint);
}
/* ---- CHECKBOX ---- */
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
line-height: 1.5;
}
.checkbox-label input {
margin-top: 3px;
flex-shrink: 0;
}
.checkbox-label a {
color: var(--candle);
}
/* ---- SUBMIT BUTTON ---- */
.form-submit {
display: inline-block;
padding: 10px 24px;
background: var(--candle);
color: var(--bg);
border: none;
font-family: "Commit Mono", monospace;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.form-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-row {
display: flex;
gap: 12px;
align-items: center;
}
.payment-instruction {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
/* ---- SPINNER ---- */
.spinner {
width: 24px;
height: 24px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---- RESPONSIVE ---- */
@media (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
}
.circle-radios {
grid-template-columns: 1fr;
}
.cadence-radios {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -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">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="formData.name" type="text" placeholder="e.g., coop-formation" />
<p v-if="!editingId" class="help-text">A new Slack channel will be created with this name. Lowercase, letters/numbers/dashes only.</p>
</div>
<div v-if="editingId" class="field">
<label>Slack Channel ID</label>
<input v-model="formData.slackChannelId" type="text" placeholder="C0123456789" />
<p class="help-text">The Slack channel ID (starts with C).</p>
</div>
<div class="field">
<label>Mapped Tags</label>
<p class="help-text">Cooperative tags that route posts to this channel.</p>
<div class="pill-grid">
<button
v-for="tag in cooperativeTags"
:key="tag.slug"
type="button"
class="pill"
:class="{
selected: formData.tagSlugs.includes(tag.slug),
disabled: tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug),
}"
:disabled="!!(tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug))"
:title="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)
? `Already mapped to ${tagOwner(tag.slug)}`
: ''"
@click="toggleTag(tag.slug)"
>{{ tag.label }}<span
v-if="tagOwner(tag.slug) && !formData.tagSlugs.includes(tag.slug)"
class="pill-owner"
> · {{ tagOwner(tag.slug) }}</span></button>
<p v-if="!cooperativeTags.length" class="help-text">No cooperative tags available.</p>
</div>
<p class="help-text">Each tag can only be mapped to one channel.</p>
</div>
</div>
<div class="modal-actions">
<button class="btn" @click="closeModal">Cancel</button>
<button class="btn btn-primary" :disabled="saving" @click="saveChannel">
{{ saving ? 'Saving...' : (editingId ? 'Save Changes' : 'Create Channel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const toast = useToast()
const { channels, fetchChannels } = useBoardChannels()
const { data: tagsData } = await useFetch('/api/tags')
const cooperativeTags = computed(() =>
(tagsData.value?.tags || []).filter((t) => t.pool === 'cooperative'),
)
const tagLabelMap = computed(() => {
const map = {}
for (const tag of tagsData.value?.tags || []) {
map[tag.slug] = tag.label
}
return map
})
const tagLabel = (slug) => tagLabelMap.value[slug] || slug
const mappedSlugs = computed(() => {
const set = new Set()
for (const ch of channels.value) {
for (const slug of ch.tagSlugs || []) set.add(slug)
}
return set
})
// Map of slug -> channel name, EXCLUDING the channel currently being edited.
const otherChannelTagMap = computed(() => {
const map = {}
for (const ch of channels.value) {
if (editingId.value && String(ch._id) === String(editingId.value)) continue
for (const slug of ch.tagSlugs || []) map[slug] = ch.name
}
return map
})
const tagOwner = (slug) => otherChannelTagMap.value[slug] || ''
const unmappedTags = computed(() =>
cooperativeTags.value.filter((t) => !mappedSlugs.value.has(t.slug)),
)
// ---- Modal State ----
const showModal = ref(false)
const editingId = ref(null)
const saving = ref(false)
const formData = reactive({
name: '',
slackChannelId: '',
tagSlugs: [],
})
const resetForm = () => {
formData.name = ''
formData.slackChannelId = ''
formData.tagSlugs = []
}
const openCreateModal = () => {
editingId.value = null
resetForm()
showModal.value = true
}
const openEditModal = (channel) => {
editingId.value = channel._id
formData.name = channel.name || ''
formData.slackChannelId = channel.slackChannelId || ''
formData.tagSlugs = [...(channel.tagSlugs || [])]
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingId.value = null
resetForm()
}
const toggleTag = (slug) => {
const idx = formData.tagSlugs.indexOf(slug)
if (idx === -1) formData.tagSlugs.push(slug)
else formData.tagSlugs.splice(idx, 1)
}
const saveChannel = async () => {
if (!formData.name.trim()) {
toast.add({
title: 'Missing fields',
description: 'Name is required.',
color: 'red',
})
return
}
if (editingId.value && !formData.slackChannelId.trim()) {
toast.add({
title: 'Missing fields',
description: 'Slack channel ID is required.',
color: 'red',
})
return
}
saving.value = true
try {
const body = {
name: formData.name.trim(),
tagSlugs: formData.tagSlugs,
}
if (formData.slackChannelId.trim()) {
body.slackChannelId = formData.slackChannelId.trim()
}
if (editingId.value) {
await $fetch(`/api/admin/board-channels/${editingId.value}`, {
method: 'PATCH',
body,
})
toast.add({ title: 'Channel updated', color: 'green' })
} else {
await $fetch('/api/admin/board-channels', {
method: 'POST',
body,
})
toast.add({ title: 'Channel created', color: 'green' })
}
await fetchChannels()
closeModal()
} catch (err) {
toast.add({
title: 'Save failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
saving.value = false
}
}
const deleteChannel = async (channel) => {
if (!window.confirm(`Delete channel "${channel.name}"? This cannot be undone.`)) return
try {
await $fetch(`/api/admin/board-channels/${channel._id}`, { method: 'DELETE' })
toast.add({ title: 'Channel deleted', color: 'green' })
await fetchChannels()
} catch (err) {
toast.add({
title: 'Delete failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
onMounted(() => {
fetchChannels()
})
</script>
<style scoped>
.admin-board-channels {
padding: 24px;
max-width: 1100px;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--border);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
margin-bottom: 4px;
}
.page-header p {
color: var(--text-dim);
font-size: 13px;
}
.header-actions {
display: flex;
gap: 8px;
}
/* ---- Unmapped Indicator ---- */
.unmapped-block {
border: 1px dashed var(--border);
padding: 16px;
margin-bottom: 24px;
background: var(--surface);
}
.unmapped-hint {
font-size: 12px;
color: var(--text-dim);
margin: 4px 0 12px;
}
.section-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
/* ---- Tag Pills ---- */
.tag-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
display: inline-block;
padding: 2px 9px;
font-size: 11px;
font-family: "Commit Mono", monospace;
background: transparent;
border: 1px dashed var(--border);
color: var(--text-dim);
}
.tag-pill-warning {
border-color: var(--ember);
color: var(--ember);
}
.tag-empty {
color: var(--text-faint);
font-size: 12px;
}
/* ---- Table ---- */
.channels-list {
border: 1px dashed var(--border);
background: var(--bg);
}
.channels-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.channels-table th,
.channels-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px dashed var(--border);
vertical-align: top;
}
.channels-table thead th {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: normal;
background: var(--surface);
}
.channels-table tbody tr:last-child td {
border-bottom: none;
}
.channel-name {
font-weight: 600;
}
.channel-id {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.actions-col {
width: 160px;
}
.actions-cell {
white-space: nowrap;
}
.link-btn {
background: none;
border: none;
color: var(--candle-dim);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
text-decoration: underline;
}
.link-btn:hover {
color: var(--candle);
}
.link-btn-danger {
color: var(--ember);
}
.link-btn-danger:hover {
color: var(--ember);
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-dim);
}
.empty-hint {
font-size: 12px;
color: var(--text-faint);
margin-top: 4px;
}
/* ---- Modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal {
background: var(--bg);
border: 1px dashed var(--border);
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px dashed var(--border);
}
.modal-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 20px;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-dim);
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px dashed var(--border);
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 6px;
}
.field input {
width: 100%;
padding: 8px 10px;
background: var(--input-bg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus {
outline: none;
border-color: var(--candle);
}
.help-text {
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 240px;
overflow-y: auto;
}
.pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-faint);
font-size: 11px;
font-family: "Commit Mono", monospace;
cursor: pointer;
user-select: none;
transition: all 0.12s;
white-space: nowrap;
}
.pill:hover {
color: var(--text-dim);
border-color: var(--border-d);
}
.pill.selected {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
}
.pill.disabled,
.pill:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pill.disabled:hover {
color: var(--text-faint);
border-color: var(--border);
}
.pill-owner {
font-size: 10px;
color: var(--text-faint);
margin-left: 2px;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -16,23 +16,12 @@
<!-- Filters -->
<div class="filter-bar">
<div class="field" style="margin-bottom: 0; flex: 1;">
<input v-model="searchQuery" placeholder="Search events..." />
<input v-model="searchQuery" placeholder="Search events..." >
</div>
<div class="field" style="margin-bottom: 0;">
<select v-model="typeFilter">
<option value="all">All Types</option>
<option value="community">Community</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select>
</div>
<div class="field" style="margin-bottom: 0;">
<select v-model="statusFilter">
<option value="all">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
<option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div class="field" style="margin-bottom: 0;">
@ -44,124 +33,214 @@
</div>
</div>
<!-- Events Table -->
<div class="table-wrap">
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading events...</span>
</div>
<div v-else-if="error" class="error-state">
Error loading events: {{ error }}
</div>
<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>
<!-- Loading / Error -->
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading events...</span>
</div>
<div v-else-if="error" class="error-state">
Error loading events: {{ error }}
</div>
<template v-else>
<!-- Upcoming Events -->
<div class="section-divider">
<span class="section-label">Upcoming Events</span>
<span class="event-count">{{ upcomingFiltered.length }}</span>
</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 -->
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
<div class="modal">
@ -185,6 +264,8 @@
</template>
<script setup>
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
definePageMeta({
layout: 'admin',
middleware: 'admin',
@ -199,33 +280,12 @@ const {
const searchQuery = ref('')
const typeFilter = ref('all')
const statusFilter = ref('all')
const seriesFilter = ref('all')
const filteredEvents = computed(() => {
if (!events.value) return []
return events.value.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesType =
typeFilter.value === 'all' || event.eventType === typeFilter.value
const eventStatus = getEventStatus(event)
const matchesStatus =
statusFilter.value === 'all' || eventStatus.toLowerCase() === statusFilter.value
const matchesSeries =
seriesFilter.value === 'all' ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesStatus && matchesSeries
})
})
const upcomingPage = ref(1)
const pastPage = ref(1)
const UPCOMING_PAGE_SIZE = 10
const PAST_PAGE_SIZE = 5
const getEventStatus = (event) => {
const now = new Date()
@ -237,19 +297,74 @@ const getEventStatus = (event) => {
return 'Past'
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
const applyBaseFilters = (list) => {
if (!list) return []
return list.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesType =
typeFilter.value === 'all' || event.eventType === typeFilter.value
const matchesSeries =
seriesFilter.value === 'all' ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesSeries
})
}
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString('en-US', {
const upcomingFiltered = computed(() => {
return applyBaseFilters(events.value)
.filter((e) => getEventStatus(e) !== 'Past')
.sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
})
const pastFiltered = computed(() => {
return applyBaseFilters(events.value)
.filter((e) => getEventStatus(e) === 'Past')
.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
})
const upcomingPageCount = computed(() => Math.max(1, Math.ceil(upcomingFiltered.value.length / UPCOMING_PAGE_SIZE)))
const pastPageCount = computed(() => Math.max(1, Math.ceil(pastFiltered.value.length / PAST_PAGE_SIZE)))
const upcomingPaged = computed(() => {
const start = (upcomingPage.value - 1) * UPCOMING_PAGE_SIZE
return upcomingFiltered.value.slice(start, start + UPCOMING_PAGE_SIZE)
})
const pastPaged = computed(() => {
const start = (pastPage.value - 1) * PAST_PAGE_SIZE
return pastFiltered.value.slice(start, start + PAST_PAGE_SIZE)
})
// Reset pagination when filters change
watch([searchQuery, typeFilter, seriesFilter], () => {
upcomingPage.value = 1
pastPage.value = 1
})
const formatDate = (event) => {
if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleDateString('en-US', {
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',
minute: '2-digit',
hour12: true,
timeZone: event.displayTimezone || 'America/Toronto',
})
}
@ -309,10 +424,7 @@ const editEvent = (event) => {
</script>
<style scoped>
.admin-events {
max-width: 1100px;
margin: 0 auto;
}
.admin-events {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -350,9 +462,34 @@ const editEvent = (event) => {
flex-wrap: wrap;
}
/* ---- SECTION DIVIDER ---- */
.section-divider {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 28px 0;
}
.section-divider .section-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: normal;
}
.event-count {
font-size: 10px;
color: var(--text-faint);
background: var(--surface);
border: 1px dashed var(--border);
padding: 1px 7px;
letter-spacing: 0.04em;
}
/* ---- TABLE ---- */
.table-wrap {
padding: 0 28px 28px;
padding: 12px 28px 24px;
}
table {
@ -436,7 +573,7 @@ tbody td {
letter-spacing: 0.04em;
text-transform: uppercase;
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;
}
@ -449,7 +586,7 @@ tbody td {
font-size: 10px;
font-weight: 600;
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%;
}
@ -498,12 +635,12 @@ tbody td {
.status-upcoming {
color: var(--candle);
border-color: rgba(122, 90, 16, 0.3);
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
}
.status-ongoing {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
.status-past {
@ -513,7 +650,7 @@ tbody td {
.status-cancelled {
color: var(--ember);
border-color: rgba(138, 68, 32, 0.3);
border-color: color-mix(in srgb, var(--ember) 30%, transparent);
margin-top: 4px;
}
@ -580,6 +717,41 @@ tbody td {
color: var(--ember);
}
/* ---- PAGINATION ---- */
.pagination {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0 0;
}
.page-btn {
background: none;
border: 1px dashed var(--border);
color: var(--candle);
cursor: pointer;
font-family: 'Commit Mono', monospace;
font-size: 12px;
padding: 3px 10px;
transition: border-color 0.1s;
}
.page-btn:disabled {
color: var(--text-faint);
border-color: var(--border);
cursor: default;
}
.page-btn:not(:disabled):hover {
border-color: var(--candle);
}
.page-info {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
}
/* ---- STATES ---- */
.loading-state {
text-align: center;
@ -597,7 +769,7 @@ tbody td {
.empty-state {
text-align: center;
padding: 48px 24px;
padding: 32px 24px;
color: var(--text-faint);
font-size: 12px;
}
@ -699,11 +871,15 @@ tbody td {
.filter-bar {
flex-direction: column;
padding: 12px 20px;
padding: 16px 20px;
}
.section-divider {
padding: 16px 20px 0;
}
.table-wrap {
padding: 0 12px 20px;
padding: 12px 20px 20px;
overflow-x: auto;
}

View file

@ -1,104 +1,113 @@
<template>
<div class="admin-dash">
<!-- Page Header -->
<div class="page-header">
<h1>Admin Dashboard</h1>
<p>Members, events, and community operations</p>
</div>
<PageShell
title="Admin Dashboard"
subtitle="Members, events, and community operations"
>
<AdminAlertsPanel />
<!-- Stats + Quick Actions row -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Overview</div>
<div class="stat-row">
<span class="stat-key">Total Members</span>
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
<ColumnsLayout cols="2" collapse="768" class="admin-row">
<template #left>
<div class="admin-block">
<div class="section-label">Overview</div>
<div class="stat-row">
<span class="stat-key">Total Members</span>
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Active Events</span>
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Monthly Revenue</span>
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Pending Slack Invites</span>
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
</div>
</div>
<div class="stat-row">
<span class="stat-key">Active Events</span>
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Monthly Revenue</span>
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Pending Slack Invites</span>
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
</div>
</div>
</template>
<div class="content-block">
<div class="section-label">Quick Actions</div>
<NuxtLink to="/admin/members" class="action-link">
Manage Members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events" class="action-link">
Manage Events<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events/create" class="action-link">
Create Event<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/series/create" class="action-link">
Create Series<span class="arrow">&rarr;</span>
</NuxtLink>
</div>
</div>
<template #right>
<div class="admin-block">
<div class="section-label">Quick Actions</div>
<NuxtLink to="/admin/members" class="action-link">
Manage Members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events" class="action-link">
Manage Events<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events/create" class="action-link">
Create Event<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/series/create" class="action-link">
Create Series<span class="arrow">&rarr;</span>
</NuxtLink>
</div>
</template>
</ColumnsLayout>
<!-- Recent Activity row -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Recent Members</div>
<ColumnsLayout cols="2" collapse="768" class="admin-row">
<template #left>
<div class="admin-block">
<div class="section-label">Recent Members</div>
<div v-if="pending" class="loading-inline">
<div class="spinner spinner-sm" />
</div>
<div v-if="pending" class="loading-inline">
<div class="spinner spinner-sm" />
</div>
<div v-else-if="recentMembers.length" class="item-list">
<div v-for="member in recentMembers" :key="member._id" class="item-row">
<div>
<span class="item-name">{{ member.name }}</span>
<span class="item-sub">{{ member.email }}</span>
</div>
<div class="item-meta">
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
<div v-else-if="recentMembers.length" class="item-list">
<div v-for="member in recentMembers" :key="member._id" class="item-row">
<div>
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
<span class="item-sub">{{ member.email }}</span>
</div>
<div class="item-meta">
<CircleBadge :circle="member.circle" />
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
</div>
</div>
</div>
<div v-else class="empty-state">No recent members</div>
<NuxtLink to="/admin/members" class="section-link">View all members &rarr;</NuxtLink>
</div>
<div v-else class="empty-state">No recent members</div>
</template>
<NuxtLink to="/admin/members" class="section-link">View all members &rarr;</NuxtLink>
</div>
<template #right>
<div class="admin-block">
<div class="section-label">Upcoming Events</div>
<div class="content-block">
<div class="section-label">Upcoming Events</div>
<div v-if="pending" class="loading-inline">
<div class="spinner spinner-sm" />
</div>
<div v-if="pending" class="loading-inline">
<div class="spinner spinner-sm" />
</div>
<div v-else-if="upcomingEvents.length" class="item-list">
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
<div>
<span class="item-name">{{ event.title }}</span>
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
</div>
<div class="item-meta">
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
<span class="item-date">{{ event.location || 'Online' }}</span>
<div v-else-if="upcomingEvents.length" class="item-list">
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
<div>
<span class="item-name">{{ event.title }}</span>
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
</div>
<div class="item-meta">
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
<span class="item-date">{{ event.location || 'Online' }}</span>
</div>
</div>
</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 &rarr;</NuxtLink>
</div>
</div>
</div>
<NuxtLink to="/admin/events" class="section-link">View all events &rarr;</NuxtLink>
</div>
</template>
</ColumnsLayout>
</PageShell>
</template>
<script setup>
import { eventTypeLabel } from '~/config/eventTypes'
definePageMeta({
layout: 'admin',
middleware: 'admin',
@ -125,48 +134,16 @@ const formatDateTime = (dateString) => {
</script>
<style scoped>
.admin-dash {
max-width: 960px;
margin: 0 auto;
}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
/* ---- ROWS ---- */
.admin-row {
border-bottom: 1px dashed var(--border);
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 4px;
}
.page-header p {
font-size: 12px;
color: var(--text-dim);
}
/* ---- CONTENT GRID ---- */
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px dashed var(--border);
}
.content-block {
.admin-block {
padding: 24px 28px;
border-right: 1px dashed var(--border);
min-width: 0;
}
.content-block:last-child {
border-right: none;
}
/* ---- STATS ---- */
.stat-row {
display: flex;
@ -241,6 +218,12 @@ const formatDateTime = (dateString) => {
display: block;
color: var(--text);
font-size: 13px;
text-decoration: none;
}
a.item-name:hover {
color: var(--candle);
text-decoration: underline;
}
.item-sub {
@ -308,24 +291,7 @@ const formatDateTime = (dateString) => {
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.content-row {
grid-template-columns: 1fr;
}
.content-block {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child {
border-bottom: none;
}
.page-header {
padding: 24px 20px 16px;
}
.content-block {
.admin-block {
padding: 20px;
}
}

File diff suppressed because it is too large Load diff

View 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>

File diff suppressed because it is too large Load diff

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

View file

@ -64,7 +64,7 @@
<div class="series-title-row">
<div>
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
<h3>{{ series.title }}</h3>
<h2>{{ series.title }}</h2>
<p class="series-desc">{{ series.description }}</p>
</div>
<div class="series-meta">
@ -112,7 +112,6 @@
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
<button @click="editSeries(series)" class="link-btn">Edit</button>
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
<button @click="duplicateSeries(series)" class="link-btn">Duplicate</button>
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
</div>
</div>
@ -171,15 +170,7 @@
</div>
<div class="modal-body">
<div class="section-label">Series Management Tools</div>
<button @click="reorderAllSeries" class="bulk-action">
<strong>Auto-Reorder Series</strong>
<span>Fix position numbers based on event dates</span>
</button>
<button @click="validateAllSeries" class="bulk-action">
<strong>Validate Series Data</strong>
<span>Check for consistency issues</span>
</button>
<button @click="exportSeriesData" class="bulk-action">
<button @click="exportSeriesData" class="btn bulk-action">
<strong>Export Series Data</strong>
<span>Download series information as JSON</span>
</button>
@ -575,10 +566,6 @@ const addEventToSeries = (series) => {
navigateTo('/admin/events/create?series=true')
}
const duplicateSeries = () => {
// TODO: Implement
}
const editSeries = (series) => {
editingSeriesId.value = series.id
editingSeriesData.value = {
@ -696,9 +683,6 @@ const saveTicketsEdit = async () => {
}
}
const reorderAllSeries = () => { /* TODO */ }
const validateAllSeries = () => { /* TODO */ }
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
@ -714,10 +698,7 @@ const exportSeriesData = () => {
</script>
<style scoped>
.admin-series {
max-width: 1100px;
margin: 0 auto;
}
.admin-series {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -813,7 +794,7 @@ const exportSeriesData = () => {
gap: 16px;
}
.series-header h3 {
.series-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
@ -869,7 +850,7 @@ const exportSeriesData = () => {
font-size: 11px;
font-weight: 600;
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%;
flex-shrink: 0;
}
@ -950,12 +931,12 @@ const exportSeriesData = () => {
.status-active {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
.status-upcoming {
color: var(--candle);
border-color: rgba(122, 90, 16, 0.3);
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
}
.status-completed {
@ -965,7 +946,7 @@ const exportSeriesData = () => {
.status-ongoing {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
/* ---- LINK BUTTONS ---- */
@ -1217,7 +1198,7 @@ const exportSeriesData = () => {
}
.series-list {
padding: 16px 12px;
padding: 20px 20px;
}
.series-header,

View file

@ -251,7 +251,6 @@ const createAndAddEvent = async () => {
<style scoped>
.create-form {
max-width: 800px;
margin: 0 auto;
}
.page-header {
@ -271,13 +270,14 @@ const createAndAddEvent = async () => {
.page-header p { font-size: 12px; color: var(--text-dim); }
.back-link {
font-size: 12px;
color: var(--candle);
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
margin-bottom: 8px;
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; }

View 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

File diff suppressed because it is too large Load diff

View 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>

View 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>

View 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>

View file

@ -2,12 +2,14 @@
definePageMeta({
layout: false,
});
useSiteMeta({ title: "Wiki Sign In", noindex: true });
const route = useRoute();
const uid = route.query.uid as string;
const email = ref("");
const sent = ref(false);
const notRegistered = ref(false);
const loading = ref(false);
const error = ref("");
@ -15,53 +17,85 @@ async function sendMagicLink() {
if (!email.value || !uid) return;
loading.value = true;
error.value = "";
notRegistered.value = false;
try {
await $fetch("/oidc/interaction/login", {
method: "POST",
body: { email: email.value, uid },
});
sent.value = true;
const response = await $fetch<{ success: boolean; registered: boolean }>(
"/oidc/interaction/login",
{
method: "POST",
body: { email: email.value, uid },
}
);
if (response.registered === false) {
notRegistered.value = true;
} else {
sent.value = true;
}
} catch (e: any) {
error.value = e?.data?.statusMessage || "Something went wrong. Please try again.";
error.value =
e?.data?.statusMessage || "Something went wrong. Please try again.";
} finally {
loading.value = false;
}
}
function resetForm() {
sent.value = false;
notRegistered.value = false;
email.value = "";
}
</script>
<template>
<div class="wiki-login">
<div class="wiki-login-card">
<div class="wiki-login-header">
<span class="wiki-login-overline">Ghost Guild</span>
<main class="wiki-login">
<div class="dashed-box wiki-login-box">
<header class="wiki-login-header">
<p class="section-label">Ghost Guild</p>
<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">
<form v-if="!sent" key="form" @submit.prevent="sendMagicLink" class="wiki-login-form">
<label for="email" class="wiki-login-label">Email address</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="you@example.com"
class="wiki-login-input"
:disabled="loading"
/>
<form
v-if="!sent && !notRegistered"
key="form"
class="wiki-login-form"
@submit.prevent="sendMagicLink"
>
<div class="field">
<label for="email">Email address</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
placeholder="you@example.com"
:disabled="loading"
>
</div>
<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
type="submit"
class="btn btn-primary wiki-login-submit"
: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" }}
</button>
@ -70,187 +104,130 @@ async function sendMagicLink() {
</p>
</form>
<div v-else key="sent" class="wiki-login-sent">
<p class="wiki-login-sent-heading">Check your inbox</p>
<div
v-else-if="sent"
key="sent"
class="wiki-login-sent"
role="status"
aria-live="polite"
>
<h2 class="wiki-login-sent-heading">Check your inbox</h2>
<p class="wiki-login-sent-detail">
A sign-in link was sent to <strong>{{ email }}</strong>
</p>
<button
@click="sent = false; email = '';"
class="wiki-login-link"
>
<button class="wiki-login-reset" @click="resetForm">
Try a different email
</button>
</div>
<div
v-else
key="not-registered"
class="wiki-login-sent"
role="status"
aria-live="polite"
>
<h2 class="wiki-login-sent-heading">Not a member yet</h2>
<p class="wiki-login-sent-detail">
<strong>{{ email }}</strong> isn't registered as a Ghost Guild
member. If you've pre-registered, an admin needs to invite you
before you can sign in.
</p>
<p class="wiki-login-sent-detail">
<a href="https://babyghosts.org/ghost-guild/" class="wiki-login-link"
>Pre-register at Baby Ghosts</a
>
or email
<a href="mailto:hello@babyghosts.org" class="wiki-login-link"
>hello@babyghosts.org</a
>
if you think this is a mistake.
</p>
<button class="wiki-login-reset" @click="resetForm">
Try a different email
</button>
</div>
</Transition>
</div>
</div>
</main>
</template>
<style scoped>
.wiki-login {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse at 30% 70%, rgba(184, 135, 58, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 70% 30%, rgba(178, 104, 64, 0.04) 0%, transparent 60%),
var(--color-guild-900);
min-height: 100vh;
min-height: 100dvh;
padding: var(--page-pad-y) var(--page-pad-x);
}
.dark .wiki-login {
background:
radial-gradient(ellipse at 30% 70%, rgba(224, 184, 110, 0.05) 0%, transparent 60%),
radial-gradient(ellipse at 70% 30%, rgba(218, 154, 114, 0.03) 0%, transparent 60%),
var(--color-guild-900);
}
.wiki-login-card {
.wiki-login-box {
width: 100%;
max-width: 360px;
padding: 2.5rem 2rem 2rem;
background: var(--color-guild-800);
border: 1px solid var(--color-guild-700);
border-radius: 12px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 8px 24px rgba(0, 0, 0, 0.08);
}
.dark .wiki-login-card {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 8px 32px rgba(0, 0, 0, 0.3);
padding: 24px 28px;
}
.wiki-login-header {
text-align: center;
}
.wiki-login-overline {
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-guild-400);
}
.wiki-login-title {
font-family: var(--font-sans);
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
color: var(--color-candlelight-400);
margin-top: 0.25rem;
}
.wiki-login-divider {
height: 1px;
background: linear-gradient(
to right,
transparent,
var(--color-guild-600),
transparent
);
margin: 1.5rem 0;
font-family: var(--font-display);
font-size: 36px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--candle);
margin: 0;
}
.wiki-login-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wiki-login-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-guild-300);
}
.wiki-login-input {
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
color: var(--color-guild-100);
background: var(--color-guild-900);
border: 1px solid var(--color-guild-600);
border-radius: 8px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.wiki-login-input::placeholder {
color: var(--color-guild-500);
}
.wiki-login-input:focus {
border-color: var(--color-candlelight-500);
box-shadow: 0 0 0 3px rgba(184, 135, 58, 0.15);
}
.wiki-login-input:disabled {
opacity: 0.5;
cursor: not-allowed;
gap: 12px;
}
.wiki-login-error {
font-size: 0.8125rem;
color: var(--color-ember-400);
font-size: 13px;
color: var(--ember);
margin: 0;
}
.wiki-login-button {
display: flex;
.wiki-login-submit {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
margin-top: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-guild-50);
background: var(--color-candlelight-500);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
gap: 8px;
margin-top: 4px;
}
.wiki-login-button:hover:not(:disabled) {
background: var(--color-candlelight-400);
}
.wiki-login-button:active:not(:disabled) {
transform: scale(0.98);
}
.wiki-login-button:disabled {
.wiki-login-submit:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.wiki-login-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: white;
display: inline-block;
width: 10px;
height: 10px;
border: 1.5px solid color-mix(in srgb, var(--bg) 35%, transparent);
border-top-color: var(--bg);
border-radius: 50%;
animation: wiki-spin 0.6s linear infinite;
animation: wiki-spin 0.7s linear infinite;
}
@keyframes wiki-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.wiki-login-hint {
font-size: 0.75rem;
color: var(--color-guild-500);
font-size: 11px;
color: var(--text-faint);
text-align: center;
margin: 0;
margin: 4px 0 0;
}
.wiki-login-sent {
@ -258,45 +235,58 @@ async function sendMagicLink() {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
gap: 8px;
}
.wiki-login-sent-heading {
font-family: var(--font-sans);
font-size: 1.25rem;
font-family: var(--font-display);
font-size: 20px;
font-weight: 600;
color: var(--color-guild-100);
color: var(--text-bright);
margin: 0;
}
.wiki-login-sent-detail {
font-size: 0.8125rem;
color: var(--color-guild-400);
font-size: 13px;
color: var(--text-dim);
line-height: 1.5;
margin: 0;
}
.wiki-login-sent-detail strong {
color: var(--color-guild-200);
color: var(--text-bright);
font-weight: 600;
}
.wiki-login-link {
font-size: 0.8125rem;
color: var(--color-candlelight-500);
background: none;
border: none;
cursor: pointer;
padding: 0;
margin-top: 0.5rem;
transition: color 0.15s;
color: var(--candle);
text-decoration: underline;
text-underline-offset: 2px;
}
.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-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;

395
app/pages/board.vue Normal file
View 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>

View file

@ -1,44 +1,30 @@
<template>
<div class="min-h-screen w-full flex flex-col items-center justify-center px-4">
<h1 class="text-display-xl font-bold mb-2 uppercase font-sans!">Ghost Guild</h1>
<p
v-if="!isAuthenticated"
class="text-display-sm text-guild-400 mb-10 uppercase py-4 text-center font-sans!">
Coming Soon
</p>
<div class="coming-soon">
<h1 class="coming-soon-title">Ghost Guild</h1>
<p v-if="!isAuthenticated" class="coming-soon-subtitle">Coming Soon</p>
<!-- 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">
<p class="text-guild-200 font-sans py-4 text-center">
Welcome, <strong class="text-guild-100">{{ memberData.name || memberData.email }}</strong>
<div v-if="isAuthenticated" class="coming-soon-auth">
<p>
Welcome, <strong>{{ memberData.name || memberData.email }}</strong>
</p>
<a
href="https://wiki.ghostguild.org"
class="block w-full py-3 px-6 bg-candlelight-500 hover:bg-candlelight-600 text-guild-900 font-semibold rounded-full uppercase tracking-wide transition-colors font-sans text-center">
<a href="https://wiki.ghostguild.org" class="coming-soon-btn">
Go to Wiki
</a>
<button
class="block w-full text-sm text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide transition-colors"
@click="handleLogout">
<button class="coming-soon-signout" @click="handleLogout">
Sign out
</button>
</div>
<!-- Login form -->
<div v-else class="w-full max-w-sm">
<div v-else class="coming-soon-form">
<!-- Success state -->
<div v-if="loginSuccess" class="text-center py-4">
<div
class="w-16 h-16 bg-candlelight-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<UIcon name="i-heroicons-check-circle" class="w-10 h-10 text-candlelight-400" />
</div>
<h3 class="text-lg font-semibold text-guild-100 mb-2">
Check your email
</h3>
<p class="text-guild-300">
<div v-if="loginSuccess" class="coming-soon-success">
<h3>Check your email</h3>
<p>
We've sent a magic link to
<strong class="text-guild-100">{{ email }}</strong>.
Click the link to sign in.
<strong>{{ email }}</strong
>. Click the link to sign in.
</p>
</div>
@ -50,32 +36,28 @@
type="email"
size="lg"
class="w-full"
placeholder="your.email@example.com" />
placeholder="your.email@example.com"
/>
</UFormField>
<div v-if="loginError" class="mb-4 p-3 bg-ember-500/10 border border-ember-500/30 rounded-lg">
<p class="text-ember-400 text-sm">{{ loginError }}</p>
<div v-if="loginError" class="coming-soon-error">
<p>{{ loginError }}</p>
</div>
<div class="flex justify-center">
<div class="coming-soon-actions">
<UButton
type="submit"
:loading="isLoggingIn"
:disabled="!isFormValid"
size="lg"
class="rounded-full uppercase tracking-wide font-semibold whitespace-nowrap">
class="uppercase tracking-wide font-semibold whitespace-nowrap"
>
Send Magic Link
</UButton>
</div>
<div class="text-center pt-6 border-t border-guild-700 mt-6">
<p class="text-guild-400 text-sm">
<a
href="https://babyghosts.fund/ghost-guild/"
class="text-candlelight-400 hover:text-candlelight-300 font-medium uppercase tracking-wide">
Pre-Register
</a>
</p>
<div class="coming-soon-preregister">
<a href="https://babyghosts.org/ghost-guild/">Pre-Register</a>
</div>
</UForm>
</div>
@ -127,3 +109,138 @@ const handleLogout = async () => {
await logout();
};
</script>
<style scoped>
.coming-soon {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
}
.coming-soon-title {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.coming-soon-subtitle {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 40px;
padding: 16px 0;
}
.coming-soon-auth {
width: 100%;
max-width: 24rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
margin-top: 32px;
color: var(--text-dim);
}
.coming-soon-auth strong {
color: var(--text-bright);
}
.coming-soon-btn {
display: block;
width: 100%;
padding: 12px 24px;
background: var(--parch);
color: var(--parch-text);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
transition: background 0.15s;
}
.coming-soon-btn:hover {
background: var(--parch-hover);
text-decoration: none;
}
.coming-soon-signout {
font-size: 12px;
color: var(--candle);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.15s;
cursor: pointer;
}
.coming-soon-signout:hover {
color: var(--candle-dim);
}
.coming-soon-form {
width: 100%;
max-width: 24rem;
}
.coming-soon-success {
text-align: center;
padding: 16px 0;
}
.coming-soon-success h3 {
font-family: var(--font-display);
font-size: 1.125rem;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.coming-soon-success p {
color: var(--text-dim);
}
.coming-soon-success strong {
color: var(--text-bright);
}
.coming-soon-error {
margin-bottom: 16px;
padding: 12px;
background: var(--ember-bg);
border: 1px dashed var(--ember);
}
.coming-soon-error p {
color: var(--ember);
font-size: 12px;
}
.coming-soon-actions {
display: flex;
justify-content: center;
}
.coming-soon-preregister {
text-align: center;
padding-top: 24px;
border-top: 1px dashed var(--border);
margin-top: 24px;
font-size: 12px;
}
.coming-soon-preregister a {
color: var(--candle);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>

View file

@ -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>
`

View file

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

3
app/pages/ecology.vue Normal file
View file

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

View file

@ -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">&larr; Back to Events</NuxtLink>
</div>
<div v-else>
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
<div class="event-meta-row">
<div class="event-meta-item">
<span class="meta-label">Date</span>
{{ formatDate(event.startDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Time</span>
{{ formatTime(event.startDate, event.endDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Location</span>
{{ event.location }}
</div>
<div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" />
</div>
<div v-if="event.maxAttendees" class="event-meta-item">
<span class="meta-label">Capacity</span>
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</div>
</div>
</div>
<!-- CANCELLED NOTICE -->
<div v-if="event.isCancelled" class="cancelled-notice">
<strong>Event Cancelled</strong>
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
<p v-else>This event has been cancelled. We apologize for any inconvenience.</p>
</div>
<!-- TWO-COLUMN BODY -->
<div class="event-body">
<!-- LEFT: MAIN CONTENT -->
<div class="event-main">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="section">
<div class="series-note">
<span class="section-label">Part of Series</span>
<NuxtLink :to="`/series/${event.series.id}`">{{ event.series.title }}</NuxtLink>
&mdash; Event {{ event.series.position }} of {{ event.series.totalEvents }}
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles?.length" class="section">
<span class="section-label">Recommended for</span>
<div class="circle-badges">
<CircleBadge v-for="circle in event.targetCircles" :key="circle" :circle="circle" />
</div>
</div>
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<p>{{ event.description }}</p>
</div>
<!-- Series Description -->
<div v-if="event.series?.isSeriesEvent && event.series.description" class="section">
<h2>About the {{ event.series.title }} Series</h2>
<p>{{ event.series.description }}</p>
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ol class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">{{ item }}</li>
</ol>
</div>
<!-- Speakers -->
<div v-if="event.speakers?.length" class="section">
<h2>Speakers</h2>
<div v-for="speaker in event.speakers" :key="speaker.name" class="speaker">
<div class="speaker-name">{{ speaker.name }}</div>
<div v-if="speaker.role" class="speaker-role">{{ speaker.role }}</div>
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
</div>
</div>
</div>
<!-- RIGHT: SIDEBAR PANELS -->
<div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System -->
<EventTicketPurchase
v-if="event.tickets?.enabled"
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:user-email="memberData?.email"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Legacy Registration -->
<template v-else>
<!-- Already Registered -->
<div v-if="registrationStatus === 'registered'" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--green);">You're registered!</p>
<p class="reg-price">Confirmation sent to your email</p>
<button class="btn btn-danger" @click="handleCancelRegistration" :disabled="isCancelling">
{{ isCancelling ? 'Cancelling...' : 'Cancel Registration' }}
</button>
</div>
<!-- Member Status Issues -->
<div v-else-if="memberData && !canRSVP" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember);">{{ statusConfig.label }}</p>
<p class="reg-price">{{ getRSVPMessage() }}</p>
<NuxtLink v-if="isPendingPayment" to="#" @click.prevent="completePayment">
<button class="btn btn-primary" :disabled="isProcessingPayment">
{{ isProcessingPayment ? 'Processing...' : 'Complete Payment' }}
</button>
</NuxtLink>
</div>
<!-- Members-Only Gate -->
<div v-else-if="event.membersOnly && memberData && !isMember" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember);">Membership Required</p>
<p class="reg-price">This event is exclusive to members.</p>
<NuxtLink to="/join"><button class="btn btn-primary">Become a Member</button></NuxtLink>
</div>
<!-- Can Register (logged in) -->
<div v-else-if="memberData && (!event.membersOnly || isMember)" class="dashed-box">
<div class="box-title">Registration</div>
<div v-if="event.maxAttendees" class="reg-status">
{{ event.maxAttendees - (event.registeredCount || 0) }} spots remaining
</div>
<div class="reg-price">Free for members</div>
<button class="btn btn-primary" @click="handleRegistration" :disabled="isRegistering">
{{ isRegistering ? 'Registering...' : 'Register for this event' }}
</button>
<a :href="`/api/events/${route.params.id}/calendar`" download class="cal-link">Add to calendar</a>
</div>
<!-- Not Logged In -->
<div v-else class="dashed-box">
<div class="box-title">Registration</div>
<form @submit.prevent="handleRegistration">
<div class="field">
<label>Name</label>
<input v-model="registrationForm.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="registrationForm.email" type="email" required />
</div>
<button type="submit" class="btn btn-primary" :disabled="isRegistering">
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
</button>
</form>
</div>
<!-- Waitlist -->
<div v-if="event.tickets?.waitlist?.enabled && isEventFull" class="dashed-box">
<div class="box-title">Waitlist</div>
<div v-if="isOnWaitlist">
<p class="reg-status">You're on the waitlist (#{{ waitlistPosition }})</p>
<button class="btn" @click="handleLeaveWaitlist" :disabled="isJoiningWaitlist">Leave Waitlist</button>
</div>
<div v-else>
<p class="reg-status" style="color: var(--ember);">This event is full</p>
<form @submit.prevent="handleJoinWaitlist">
<div v-if="!memberData" class="field">
<label>Email</label>
<input v-model="waitlistForm.email" type="email" required />
</div>
<button type="submit" class="btn" :disabled="isJoiningWaitlist">
{{ isJoiningWaitlist ? 'Joining...' : 'Join Waitlist' }}
</button>
</form>
</div>
</div>
</template>
<!-- Event Details Box -->
<div class="dashed-box">
<div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span>
<span class="detail-val">{{ event.eventType }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
<span class="detail-val">Yes</span>
</div>
</div>
<!-- Questions -->
<div class="dashed-box">
<div class="box-title">Questions?</div>
<p style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Drop us a line.</p>
<a href="mailto:events@ghostguild.org" style="font-size: 12px;">events@ghostguild.org</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute()
const toast = useToast()
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`)
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: 'Event not found' })
}
const { isMember, memberData, checkMemberStatus } = useAuth()
const { isPendingPayment, isSuspended, isCancelled, canRSVP, statusConfig, getRSVPMessage } = useMemberStatus()
const { completePayment, isProcessingPayment } = useMemberPayment()
onMounted(async () => {
await checkMemberStatus()
if (memberData.value) {
registrationForm.value.name = memberData.value.name
registrationForm.value.email = memberData.value.email
registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member'
await checkRegistrationStatus()
checkWaitlistStatus()
}
})
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return
try {
const response = await $fetch(`/api/events/${route.params.id}/check-registration`, {
method: 'POST',
body: { email: memberData.value.email },
})
if (response.isRegistered) registrationStatus.value = 'registered'
} catch (err) {
console.error('Failed to check registration status:', err)
}
}
const registrationForm = ref({ name: '', email: '', membershipLevel: 'non-member' })
const isRegistering = ref(false)
const isCancelling = ref(false)
const registrationStatus = ref('not-registered')
const isJoiningWaitlist = ref(false)
const isOnWaitlist = ref(false)
const waitlistPosition = ref(0)
const waitlistForm = ref({ email: '' })
const isEventFull = computed(() => {
if (!event.value?.maxAttendees) return false
return (event.value.registeredCount || 0) >= event.value.maxAttendees
})
const checkWaitlistStatus = () => {
const email = memberData.value?.email || waitlistForm.value.email
if (!email || !event.value?.tickets?.waitlist?.enabled) return
const entries = event.value.tickets.waitlist.entries || []
const idx = entries.findIndex((e) => e.email.toLowerCase() === email.toLowerCase())
if (idx !== -1) { isOnWaitlist.value = true; waitlistPosition.value = idx + 1 }
}
const handleJoinWaitlist = async () => {
isJoiningWaitlist.value = true
try {
const email = memberData.value?.email || waitlistForm.value.email
const name = memberData.value?.name || 'Guest'
const response = await $fetch(`/api/events/${route.params.id}/waitlist`, { method: 'POST', body: { email, name } })
isOnWaitlist.value = true
waitlistPosition.value = response.position
toast.add({ title: 'Added to Waitlist', description: `You're #${response.position} on the waitlist.`, color: 'orange' })
} catch (err) {
toast.add({ title: "Couldn't Join Waitlist", description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { isJoiningWaitlist.value = false }
}
const handleLeaveWaitlist = async () => {
isJoiningWaitlist.value = true
try {
const email = memberData.value?.email || waitlistForm.value.email
await $fetch(`/api/events/${route.params.id}/waitlist`, { method: 'DELETE', body: { email } })
isOnWaitlist.value = false
waitlistPosition.value = 0
toast.add({ title: 'Removed from Waitlist', color: 'blue' })
} catch (err) {
toast.add({ title: 'Error', description: 'Failed to leave waitlist.', color: 'red' })
} finally { isJoiningWaitlist.value = false }
}
const formatDate = (dateStr) => {
const d = new Date(dateStr)
return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(d)
}
const formatTime = (start, end) => {
const fmt = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`
}
const handleRegistration = async () => {
isRegistering.value = true
try {
await $fetch(`/api/events/${route.params.id}/register`, { method: 'POST', body: registrationForm.value })
registrationStatus.value = 'registered'
toast.add({ title: 'Registered!', description: `You're registered for ${event.value.title}.`, color: 'green' })
if (event.value.registeredCount !== undefined) event.value.registeredCount++
} catch (err) {
toast.add({ title: 'Registration Failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { isRegistering.value = false }
}
const handleCancelRegistration = async () => {
isCancelling.value = true
try {
await $fetch(`/api/events/${route.params.id}/cancel-registration`, {
method: 'POST',
body: { email: registrationForm.value.email || memberData.value?.email },
})
registrationStatus.value = 'not-registered'
toast.add({ title: 'Registration Cancelled', color: 'blue' })
if (event.value.registeredCount !== undefined) event.value.registeredCount--
} catch (err) {
toast.add({ title: 'Cancellation Failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { isCancelling.value = false }
}
const handleTicketSuccess = () => { if (event.value.registeredCount !== undefined) event.value.registeredCount++ }
const handleTicketError = (err) => { console.error('Ticket purchase failed:', err) }
useHead(() => ({
title: event.value ? `${event.value.title} - Ghost Guild Events` : 'Event - Ghost Guild',
meta: [{ name: 'description', content: event.value?.description || 'View event details and register' }],
}))
</script>
<style scoped>
.loading {
padding: 48px 32px;
color: var(--text-dim);
}
.loading h2 {
font-family: 'Brygada 1918', serif;
font-size: 22px;
color: var(--text-bright);
margin-bottom: 8px;
}
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.event-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.event-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 16px;
}
.event-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 12px;
color: var(--text-dim);
}
.meta-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 2px;
}
.cancelled-notice {
padding: 20px 32px;
border-bottom: 1px dashed var(--border);
color: var(--ember);
font-size: 12px;
}
.cancelled-notice strong {
display: block;
margin-bottom: 4px;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
}
.event-main {
min-width: 0;
}
.event-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.event-aside .dashed-box {
margin: 0;
border: none;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
}
.event-aside .dashed-box:hover { border-color: var(--border); }
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section h2 {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.circle-badges {
display: flex;
gap: 6px;
margin-top: 4px;
}
.series-note {
font-size: 12px;
color: var(--text-dim);
}
.agenda-list {
padding-left: 20px;
font-size: 12px;
color: var(--text-dim);
line-height: 2;
}
.speaker {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.speaker:last-child { border-bottom: none; }
.speaker-name { font-size: 13px; color: var(--text-bright); font-weight: 500; }
.speaker-role { font-size: 11px; color: var(--text-dim); }
.speaker-bio { font-size: 11px; color: var(--text-faint); margin-top: 2px; }
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.reg-status { font-size: 13px; color: var(--text); margin-bottom: 4px; }
.reg-price { font-size: 11px; color: var(--text-faint); margin-bottom: 10px; }
.cal-link {
display: block;
margin-top: 8px;
font-size: 11px;
color: var(--candle);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-row:last-child { border-bottom: none; }
.detail-key { color: var(--text-faint); }
.detail-val { color: var(--text); }
@media (max-width: 768px) {
.event-body { grid-template-columns: 1fr; }
.event-aside { border-left: none; border-top: 1px dashed var(--border); }
.event-meta-row { flex-direction: column; gap: 8px; }
}
</style>

520
app/pages/events/[slug].vue Normal file
View 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">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
<div class="event-meta-row">
<div class="event-meta-item">
<span class="meta-label">Date</span>
{{ formatDate(event.startDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Time</span>
{{ formatTime(event.startDate, event.endDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Location</span>
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
Platform TBD
</span>
<template v-else>{{ event.location }}</template>
</div>
<div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" />
</div>
</div>
</div>
<!-- CANCELLED NOTICE -->
<div v-if="event.isCancelled" class="cancelled-notice">
<strong>Event Cancelled</strong>
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
<p v-else>
This event has been cancelled. We apologize for any inconvenience.
</p>
</div>
<!-- FEATURE IMAGE -->
<div v-if="event.featureImage?.url" class="event-feature-image">
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
>
</div>
<!-- TWO-COLUMN BODY -->
<div class="event-body">
<!-- LEFT: MAIN CONTENT -->
<div class="event-main">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="section">
<div class="series-note">
<span class="section-label">Part of Series</span>
<NuxtLink :to="`/series/${event.series.id}`">{{
event.series.title
}}</NuxtLink>
&mdash; Event {{ event.series.position }} of
{{ event.series.totalEvents }}
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles?.length" class="section">
<span class="section-label">Recommended for</span>
<div class="circle-badges">
<CircleBadge
v-for="circle in event.targetCircles"
:key="circle"
:circle="circle"
/>
</div>
</div>
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<div class="prose" v-html="renderMarkdown(event.description)" />
</div>
<!-- Series Description -->
<div
v-if="event.series?.isSeriesEvent && event.series.description"
class="section"
>
<h2>About the {{ event.series.title }} Series</h2>
<div class="prose" v-html="renderMarkdown(event.series.description)" />
</div>
<!-- Additional Information -->
<div v-if="event.content" class="section">
<h2>Additional Information</h2>
<div class="prose" v-html="renderMarkdown(event.content)" />
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ul class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">
{{ item }}
</li>
</ul>
</div>
<!-- Speakers -->
<div v-if="event.speakers?.length" class="section">
<h2>Speakers</h2>
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="speaker"
>
<div class="speaker-name">{{ speaker.name }}</div>
<div v-if="speaker.role" class="speaker-role">
{{ speaker.role }}
</div>
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
</div>
</div>
</div>
<!-- RIGHT: SIDEBAR PANELS -->
<div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System -->
<EventTicketPurchase
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:event-timezone="eventTimeZone"
:user-email="memberData?.email"
:user-name="memberData?.name"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Event Details Box -->
<div class="dashed-box">
<div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span>
<span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
<span class="detail-val">Yes</span>
</div>
</div>
<!-- Questions -->
<div class="dashed-box">
<div class="box-title">Questions?</div>
<p
style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px"
>
Drop us a line.
</p>
<a href="mailto:events@ghostguild.org" style="font-size: 12px"
>events@ghostguild.org</a
>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { eventTypeLabel } from "~/config/eventTypes";
const route = useRoute();
const toast = useToast();
const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.slug}`);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
pageBreadcrumbTitle.value = event.value?.title || "";
onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: "Event not found" });
}
const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding();
const { render: renderMarkdown } = useMarkdown();
onMounted(async () => {
await checkMemberStatus();
if (memberData.value && !isComplete.value) {
trackGoal('eventPageVisited');
}
});
const eventTimeZone = computed(
() => event.value?.displayTimezone || "America/Toronto",
);
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: eventTimeZone.value,
}).format(d);
};
const formatTime = (start, end) => {
if (!start || !end) return "";
const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: eventTimeZone.value,
});
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`;
};
const handleTicketSuccess = () => {
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
};
const handleTicketError = (err) => {
console.error("Ticket purchase failed:", err);
};
useSiteMeta(() => ({
title: event.value ? `${event.value.title} · Events` : "Event",
description:
event.value?.description || "View event details and register.",
type: "article",
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
}));
</script>
<style scoped>
.loading {
padding: 48px 32px;
color: var(--text-dim);
}
.loading h2 {
font-family: "Brygada 1918", serif;
font-size: 22px;
color: var(--text-bright);
margin-bottom: 8px;
}
.event-feature-image {
border-bottom: 1px dashed var(--border);
}
.event-feature-image img {
display: block;
width: 100%;
max-height: 400px;
object-fit: cover;
}
.event-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.event-header h1 {
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 16px;
}
.event-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 12px;
color: var(--text-dim);
}
.meta-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 2px;
}
.cancelled-notice {
padding: 20px 32px;
border-bottom: 1px dashed var(--border);
color: var(--ember);
font-size: 12px;
}
.cancelled-notice strong {
display: block;
margin-bottom: 4px;
}
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
flex: 1;
}
.event-main {
min-width: 0;
}
.event-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.event-aside .dashed-box {
margin: 0;
border: none;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
}
.event-aside .dashed-box:hover {
border-color: var(--border);
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.section p {
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.prose {
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.prose :deep(p) {
margin-bottom: 12px;
}
.prose :deep(p:last-child) {
margin-bottom: 0;
}
.prose :deep(a) {
color: var(--ember);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :deep(strong) {
color: var(--text-bright);
}
.prose :deep(ul),
.prose :deep(ol) {
list-style: none;
padding: 0;
margin: 8px 0 12px;
}
.prose :deep(ul li),
.prose :deep(ol li) {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.prose :deep(ul li::before) {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.prose :deep(ol) {
counter-reset: prose-item;
}
.prose :deep(ol li) {
counter-increment: prose-item;
padding-left: 28px;
}
.prose :deep(ol li::before) {
content: counter(prose-item) ".";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
}
.prose :deep(blockquote) {
border-left: 2px solid var(--candle-faint);
padding-left: 12px;
margin: 12px 0;
color: var(--text-faint);
}
.prose :deep(code) {
font-family: "Commit Mono", monospace;
background: var(--input-bg);
padding: 0 4px;
}
.circle-badges {
display: flex;
gap: 6px;
margin-top: 4px;
}
.series-note {
font-size: 12px;
color: var(--text-dim);
}
.agenda-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.agenda-list li {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.agenda-list li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.speaker {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.speaker:last-child {
border-bottom: none;
}
.speaker-name {
font-size: 13px;
color: var(--text-bright);
font-weight: 500;
}
.speaker-role {
font-size: 11px;
color: var(--text-dim);
}
.speaker-bio {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-key {
color: var(--text-faint);
}
.detail-val {
color: var(--text);
}
@media (max-width: 768px) {
.event-body {
grid-template-columns: 1fr;
}
.event-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
.event-meta-row {
flex-direction: column;
gap: 8px;
}
}
</style>

View file

@ -3,14 +3,26 @@
<!-- HERO (compact) -->
<div class="hero">
<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>
<!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions">
<label class="filter-toggle">
<input type="checkbox" v-model="includePastEvents"> Show past events
</label>
<button
type="button"
class="past-toggle"
:class="{ active: includePastEvents }"
:aria-pressed="includePastEvents"
@click="includePastEvents = !includePastEvents"
>
<span class="past-toggle-box" aria-hidden="true">
<span v-if="includePastEvents" class="past-toggle-check">×</span>
</span>
Show past events
</button>
</FilterBar>
<!-- EVENT LIST -->
@ -19,24 +31,40 @@
v-for="event in filteredEvents"
:key="event._id"
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-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 v-if="event.eventType" class="event-type">{{ event.eventType }}</div>
</div>
<span class="event-capacity">
<template v-if="event.maxAttendees">
<span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span>
</template>
<template v-else>Open</template>
</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
<span v-else class="badge all">All</span>
<div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
<span v-else class="badge all">Public</span>
</div>
</div>
<div v-if="!filteredEvents?.length" class="empty">No events found</div>
</div>
@ -47,84 +75,114 @@
<div class="series-grid">
<NuxtLink
v-for="series in activeSeries"
:key="series._id"
:to="`/series/${series._id}`"
:key="series.id"
:to="`/series/${series.id}`"
class="series-box"
>
<h3>{{ series.title }}</h3>
<h2>{{ series.title }}</h2>
<p class="series-desc">{{ series.description }}</p>
<div class="series-meta">
<span>{{ series.eventCount || series.events?.length || 0 }} sessions</span>
<span v-if="series.startDate">{{ formatDate(series.startDate) }} &ndash; {{ formatDate(series.endDate) }}</span>
<span
>{{
series.eventCount || series.events?.length || 0
}}
sessions</span
>
<span v-if="series.startDate"
>{{ formatDate(series.startDate) }} &ndash;
{{ formatDate(series.endDate) }}</span
>
</div>
</NuxtLink>
<div
v-if="activeSeries.length % 2"
class="series-box series-box-filler"
aria-hidden="true"
/>
</div>
</div>
<!-- PROPOSE AN EVENT -->
<div class="full-section">
<div class="section-label">Have an idea?</div>
<DashedBox>
<h3>Propose an Event</h3>
<p>Members can propose events for any circle. Workshops, social hangs, talks, or anything else that serves the community.</p>
<NuxtLink to="/events" class="cta">Propose an event &rarr;</NuxtLink>
</DashedBox>
</div>
</div>
</template>
<script setup>
const activeFilter = ref('all')
const includePastEvents = ref(false)
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
useSiteMeta({
title: "Events",
description:
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
});
const activeFilter = ref("all");
const includePastEvents = ref(false);
const filterOptions = [
{ label: 'All', value: 'all' },
{ label: 'Workshops', value: 'workshop' },
{ label: 'Community', value: 'community' },
{ label: 'Social', value: 'social' },
{ label: 'Showcase', value: 'showcase' },
]
{ label: "All", value: "all" },
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
];
const { data: eventsData } = await useFetch('/api/events')
const { data: seriesData } = await useFetch('/api/series')
const now = new Date()
const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series");
const filteredEvents = computed(() => {
if (!eventsData.value) return []
const now = new Date();
if (!eventsData.value) return [];
return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now) return false
if (activeFilter.value !== 'all' && event.eventType !== activeFilter.value) return false
return true
})
})
if (!includePastEvents.value && new Date(event.startDate) < now)
return false;
if (activeFilter.value !== "all" && event.eventType !== activeFilter.value)
return false;
return true;
});
});
const activeSeries = computed(() => {
if (!seriesData.value) return []
if (!seriesData.value) return [];
return seriesData.value.filter(
(s) => s.status === 'active' || s.isOngoing || s.isUpcoming,
)
})
(s) => s.status === "active" || s.isOngoing || s.isUpcoming,
);
});
const formatDate = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const formatDate = (event) => {
if (!event?.startDate) return "";
const tz = event.displayTimezone || "America/Toronto";
const d = new Date(event.startDate);
const opts = { month: "short", day: "numeric", timeZone: tz };
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
if (dYear !== nowYear) opts.year = "numeric";
return d.toLocaleDateString("en-US", opts);
};
const formatTime = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: event.displayTimezone || "America/Toronto",
});
};
const formatLocation = (event) => {
if (event.isOnline) return "Online";
if (!event.location) return "";
if (event.location.startsWith("#")) return event.location;
// Treat any URL as an online link
if (event.location.startsWith("http")) return "Online";
return event.location;
};
const isAlmostFull = (event) => {
if (!event.maxAttendees) return false
return (event.registeredCount || 0) / event.maxAttendees > 0.8
}
</script>
<style scoped>
.hero {
padding: 32px 32px 24px;
padding: 32px 28px 24px;
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: 'Brygada 1918', serif;
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
@ -140,33 +198,142 @@ const isAlmostFull = (event) => {
/* ---- EVENT LIST ---- */
.event-list-full {
padding: 0 32px;
padding: 0 28px;
border-bottom: 1px dashed var(--border);
}
.event-row {
display: grid;
grid-template-columns: 80px 1fr auto auto;
grid-template-columns: 90px 1fr auto;
gap: 16px;
align-items: baseline;
padding: 12px 0;
align-items: start;
padding: 14px 0;
border-bottom: 1px dashed var(--border);
transition: padding-left 0.2s;
}
.event-row:first-child { padding-top: 16px; }
.event-row:last-child { border-bottom: none; padding-bottom: 16px; }
.event-row:hover { padding-left: 4px; }
.event-date { color: var(--text-faint); font-size: 12px; white-space: nowrap; }
.event-info { min-width: 0; }
.event-title { color: var(--text); font-size: 13px; }
.event-title a { color: var(--text); text-decoration: none; }
.event-title a:hover { color: var(--candle); }
.event-type { font-size: 10px; color: var(--text-faint); margin-top: 1px; }
.event-capacity { font-size: 11px; color: var(--text-faint); white-space: nowrap; }
.seats-warn { color: var(--ember); }
.event-row:first-child {
padding-top: 18px;
}
.event-row:last-child {
border-bottom: none;
padding-bottom: 18px;
}
.event-row:hover {
padding-left: 4px;
}
.event-row.is-cancelled .event-title a {
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
}
.event-date-col {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 1px;
}
.event-date {
color: var(--text-faint);
font-size: 12px;
white-space: nowrap;
}
.event-time {
color: var(--text-faint);
font-size: 11px;
white-space: nowrap;
}
.event-info {
min-width: 0;
}
.event-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text);
}
.event-title a {
color: var(--text);
text-decoration: none;
}
.event-title a:hover {
color: var(--candle);
}
.cancelled-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--ember);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.registered-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--candle);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.event-tagline {
font-size: 11px;
color: var(--text-dim);
line-height: 1.55;
margin-top: 3px;
}
.event-sub {
display: flex;
align-items: center;
gap: 5px;
margin-top: 3px;
}
.event-type-tag {
font-size: 10px;
color: var(--text-faint);
text-transform: capitalize;
}
.sep {
font-size: 10px;
color: var(--text-faint);
}
.event-location {
font-size: 10px;
color: var(--text-faint);
}
.event-badges {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.members-badge {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 5px;
white-space: nowrap;
line-height: 1.5;
}
/* ---- FULL SECTION ---- */
.full-section {
padding: 32px;
padding: 32px 28px;
border-bottom: 1px dashed var(--border);
}
@ -178,15 +345,26 @@ const isAlmostFull = (event) => {
border: 1px dashed var(--border);
}
.series-box {
padding: 20px;
border-right: 1px dashed var(--border);
padding: 20px 24px;
text-decoration: none;
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:hover { background: var(--surface-hover); }
.series-box h3 {
font-family: 'Brygada 1918', serif;
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: none;
}
.series-box-filler {
pointer-events: none;
}
.series-box:not(.series-box-filler):hover {
background: var(--surface-hover);
}
.series-box h2 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
@ -206,38 +384,48 @@ const isAlmostFull = (event) => {
align-items: center;
}
/* ---- PROPOSE ---- */
.full-section h3 {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 4px;
.past-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-family: "Commit Mono", monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: transparent;
border: 1px dashed var(--border);
padding: 4px 10px;
cursor: pointer;
transition: all 0.15s;
}
.full-section p {
font-size: 12px;
.past-toggle:hover {
border-color: var(--candle-faint);
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.cta {
display: inline-block;
margin-top: 8px;
font-size: 12px;
.past-toggle:focus-visible {
outline: 2px dashed var(--candle);
outline-offset: 3px;
}
.past-toggle.active {
border-color: var(--candle);
border-style: solid;
color: var(--candle);
}
.filter-toggle {
display: flex;
.past-toggle-box {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 11px;
color: var(--text-faint);
cursor: pointer;
justify-content: center;
width: 12px;
height: 12px;
border: 1px solid currentColor;
flex-shrink: 0;
}
.filter-toggle input {
accent-color: var(--candle-dim);
.past-toggle-check {
font-size: 12px;
line-height: 1;
color: var(--candle);
}
.empty {
@ -247,13 +435,37 @@ const isAlmostFull = (event) => {
}
@media (max-width: 768px) {
.hero,
.event-list-full,
.full-section {
padding-left: 20px;
padding-right: 20px;
}
.event-row {
grid-template-columns: 60px 1fr;
grid-template-columns: 70px 1fr;
gap: 8px;
}
.event-capacity { display: none; }
.series-grid { grid-template-columns: 1fr; }
.series-box { border-right: none; border-bottom: 1px dashed var(--border); }
.series-box:last-child { border-bottom: none; }
.event-badges {
display: none;
}
.series-grid {
grid-template-columns: 1fr;
}
.series-box {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.series-box:nth-child(2n) {
border-right: none;
}
.series-box:nth-last-child(-n + 2) {
border-bottom: 1px dashed var(--border);
}
.series-box:last-child {
border-bottom: none;
}
.series-box-filler {
display: none;
}
}
</style>

View file

@ -2,25 +2,34 @@
<div>
<!-- HERO -->
<div class="hero">
<h1>Ghost Guild is where game developers practice cooperative business models.</h1>
<p>Resources, events, and a community of people figuring it out. Three circles, no hierarchy. $050/mo, pay what you can.</p>
<h1>Ghost Guild is where game developers explore cooperative models.</h1>
<p>
Resources, events, and a community of people figuring it out. Three
circles, pay what you can.
</p>
<div class="hero-links">
<NuxtLink to="/join" class="hero-link primary">Become a member</NuxtLink>
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
<NuxtLink to="/join" class="hero-link primary"
>Become a member</NuxtLink
>
<a href="https://wiki.ghostguild.org" class="hero-link"
>Read the wiki</a
>
<NuxtLink to="/about" class="hero-link">About the Guild</NuxtLink>
</div>
</div>
<!-- THREE CIRCLES -->
<div class="content-row">
<div v-for="circle in circleData" :key="circle.value" class="content-block">
<div class="label" :style="{ color: `var(--c-${circle.value})` }">{{ circle.label }}</div>
<div
v-for="circle in circleData"
:key="circle.value"
class="content-block"
>
<div class="label" :style="{ color: `var(--c-${circle.value})` }">
{{ circle.label }}
</div>
<h2>{{ circle.metaphor }}</h2>
<p>{{ circle.blurb }}</p>
<details>
<summary>What's included?</summary>
<p>{{ circle.included }}</p>
</details>
</div>
</div>
@ -33,9 +42,11 @@
<div v-if="events?.length" class="event-list">
<div v-for="event in events" :key="event._id" class="event-item">
<div class="block-inset event-item-inner">
<span class="event-date">{{ formatDate(event.date) }}</span>
<span class="event-date">{{ formatDate(event) }}</span>
<span class="event-title">
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title
}}</NuxtLink>
</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
</div>
@ -49,38 +60,54 @@
<div class="block-inset">
<div class="label">Recently in the Wiki</div>
</div>
<div class="wiki-list">
<div class="wiki-item">
<div v-if="wikiArticles?.length" class="wiki-list">
<div
v-for="article in wikiArticles"
:key="article._id"
class="wiki-item"
>
<div class="block-inset wiki-item-inner">
<a href="/wiki">Revenue sharing models</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">What is a cooperative studio?</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">Governance structures</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">Legal incorporation guide</a>
<a :href="article.url" target="_blank">{{ article.title }}</a>
</div>
</div>
</div>
<div v-else class="block-inset">
<p class="empty">
<a href="https://wiki.ghostguild.org">Browse the wiki &rarr;</a>
</p>
</div>
</div>
</div>
<!-- PARCHMENT INSET -->
<ParchmentInset>
<div class="label" style="color: var(--candle-faint); opacity: 0.6; margin-bottom: 12px;">From the Wiki</div>
<h2>What is a cooperative studio?</h2>
<p>A cooperative studio is a game development company owned and governed by the people who work there. Decisions are made collectively. Profits are shared according to contribution, not ownership stake.</p>
<p>The games industry is full of stories about crunch, layoffs, and studios that extract value from workers. Cooperatives are one alternative not the only one, but one worth <a href="/wiki">practicing together</a>.</p>
<p><a href="/wiki">Read more in the wiki &rarr;</a></p>
<div
class="label"
style="color: var(--candle-faint); margin-bottom: 12px"
>
From the Wiki
</div>
<template v-if="hasCustomWikiFeature">
<h2>{{ wikiFeature.title || DEFAULT_WIKI_FEATURE_TITLE }}</h2>
<p v-for="(para, i) in customWikiParagraphs" :key="i">{{ para }}</p>
</template>
<template v-else>
<h2>What is a cooperative studio?</h2>
<p>
A cooperative studio is a game development company owned and governed
by the people who work there. Decisions are made collectively. Profits
are shared according to contribution, not ownership stake.
</p>
<p>
The games industry is full of stories about crunch, layoffs, and
studios that extract value from workers. Cooperatives are one
alternative not the only one, but one worth
<a href="https://wiki.ghostguild.org">practicing together</a>.
</p>
</template>
<p>
<a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a>
</p>
</ParchmentInset>
</div>
</template>
@ -88,42 +115,94 @@
<script setup>
definePageMeta({
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 },
default: () => [],
})
});
const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
query: { limit: 4 },
default: () => [],
});
const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
const { data: wikiFeature } = await useFetch(
"/api/site-content/homepage.wiki_feature",
{ default: () => ({ title: "", body: "" }) },
);
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
const customWikiParagraphs = computed(() => {
const body = wikiFeature.value?.body?.trim() || "";
return body
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
});
const circleData = [
{
value: 'community',
label: 'Community',
metaphor: 'The open hall',
blurb: 'Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.',
included: 'Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.',
value: "community",
label: "Community",
metaphor: "The open hall",
blurb:
"For anyone exploring cooperative models in game development. Solo devs, researchers, students, people who just heard about this and want to know more.",
},
{
value: 'founder',
label: 'Founder',
metaphor: 'The workshop',
blurb: 'For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.',
included: 'Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.',
value: "founder",
label: "Founder",
metaphor: "The workshop",
blurb:
"For people actively building cooperative studios. You're working through governance, legal structure, revenue sharing, and all the hard parts.",
},
{
value: 'practitioner',
label: 'Practitioner',
metaphor: 'The alcove',
blurb: 'Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.',
included: 'Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.',
value: "practitioner",
label: "Practitioner",
metaphor: "The alcove",
blurb:
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
},
]
];
const formatDate = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const formatDate = (event) => {
if (!event?.startDate) return "";
return new Date(event.startDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
};
</script>
<style scoped>
@ -133,7 +212,7 @@ const formatDate = (dateStr) => {
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 36px;
font-weight: 600;
color: var(--text-bright);
@ -200,9 +279,11 @@ const formatDate = (dateStr) => {
padding-left: 28px;
padding-right: 28px;
}
.content-block:last-child { border-right: none; }
.content-block:last-child {
border-right: none;
}
.content-block h2 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
@ -221,26 +302,6 @@ const formatDate = (dateStr) => {
margin-bottom: 8px;
}
/* ---- DETAILS ---- */
details {
margin-top: 12px;
}
details summary {
font-size: 12px;
color: var(--candle-dim);
cursor: pointer;
list-style: none;
}
details summary::before {
content: '+ ';
}
details[open] summary::before {
content: ' ';
}
details p {
margin-top: 8px;
}
/* ---- EVENT LIST ---- */
.event-item {
border-bottom: 1px dashed var(--border);
@ -250,7 +311,7 @@ details p {
}
.event-item-inner {
display: grid;
grid-template-columns: 80px 1fr auto;
grid-template-columns: 60px 1fr auto;
gap: 16px;
align-items: baseline;
padding-top: 10px;
@ -260,10 +321,21 @@ details p {
.content-row.two-col .event-item:hover .event-item-inner {
padding-left: calc(28px + 4px);
}
.event-date { color: var(--text-faint); font-size: 12px; }
.event-title { color: var(--text); font-size: 13px; }
.event-title a { color: var(--text); text-decoration: none; }
.event-title a:hover { color: var(--candle); }
.event-date {
color: var(--text-faint);
font-size: 12px;
}
.event-title {
color: var(--text);
font-size: 13px;
}
.event-title a {
color: var(--text);
text-decoration: none;
}
.event-title a:hover {
color: var(--candle);
}
/* ---- WIKI LIST ---- */
.wiki-item {
@ -277,8 +349,13 @@ details p {
padding-top: 8px;
padding-bottom: 8px;
}
.wiki-item a { color: var(--text); text-decoration: none; }
.wiki-item a:hover { color: var(--candle); }
.wiki-item a {
color: var(--text);
text-decoration: none;
}
.wiki-item a:hover {
color: var(--candle);
}
.empty {
color: var(--text-faint);
@ -295,7 +372,9 @@ details p {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child { border-bottom: none; }
.content-block:last-child {
border-bottom: none;
}
.hero-links {
flex-direction: column;
gap: 8px;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,172 +1,251 @@
<template>
<div class="dashboard">
<PageShell>
<ClientOnly>
<!-- 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 &rarr;</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">&times;</button>
</div>
<ul>
<li><strong>Google Calendar:</strong> Click "+" then "From URL" then paste the link</li>
<li><strong>Apple Calendar:</strong> File then New Calendar Subscription then paste the link</li>
<li><strong>Outlook:</strong> Add Calendar then Subscribe from web then paste the link</li>
</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">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile" class="quick-action">
Update your profile<span class="arrow">&rarr;</span>
</NuxtLink>
<a href="https://wiki.ghostguild.org" target="_blank" class="quick-action">
Browse the wiki<span class="arrow">&rarr;</span>
</a>
<NuxtLink to="/members" class="quick-action">
Browse members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile#account" class="quick-action">
Manage account<span class="arrow">&rarr;</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 &rarr;
</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 &rarr;
</NuxtLink>
</DashedBox>
</div>
</div>
</div>
</template>
<template #fallback>
<div class="loading-state">
<!-- Loading State -->
<div v-if="authPending" class="loading-state">
<div class="spinner" />
<p>Loading your dashboard...</p>
</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 &mdash; 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 &rarr;</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"
>
&times;
</button>
</div>
<ul>
<li>
<strong>Google Calendar:</strong> Click "+" then "From URL"
then paste the link
</li>
<li>
<strong>Apple Calendar:</strong> File then New Calendar
Subscription then paste the link
</li>
<li>
<strong>Outlook:</strong> Add Calendar then Subscribe from
web then paste the link
</li>
</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">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile" class="quick-action">
Update your profile<span class="arrow">&rarr;</span>
</NuxtLink>
<a
href="https://wiki.ghostguild.org"
target="_blank"
class="quick-action"
@click="handleWikiClick"
>
Browse the wiki<span class="arrow">&rarr;</span>
</a>
<NuxtLink to="/members" class="quick-action">
Browse members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/account" class="quick-action">
Manage account<span class="arrow">&rarr;</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 &rarr;
</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 &rarr;
</NuxtLink>
</DashedBox>
</div>
</div>
</ColumnsLayout>
</template>
<template #fallback>
<div class="loading-state">
<div class="spinner" />
<p>Loading your dashboard...</p>
</div>
</template>
</ClientOnly>
</div>
</PageShell>
</template>
<script setup>
useSiteMeta({ title: 'Dashboard', noindex: true });
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 { trackGoal, isComplete: onboardingComplete } = useOnboarding();
const handleWikiClick = () => {
if (!onboardingComplete.value) {
trackGoal("wikiClicked");
}
};
const registeredEvents = ref([]);
const loadingEvents = ref(false);
@ -201,22 +280,21 @@ const copyCalendarLink = async () => {
const { openLoginModal } = useLoginModal();
// Handle authentication check on page load
// server: false ensures this always runs on the client, even on a hard page load.
// The auth middleware only fires for client-side navigations in Nuxt 4, so we
// can't rely on it to open the modal when the user lands directly on this URL.
const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth",
async () => {
// Only check authentication on client side
if (process.server) return null;
// If no member data, try to authenticate
if (!memberData.value) {
const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) {
// Show login modal instead of redirecting
openLoginModal({
title: "Sign in to your dashboard",
description: "Enter your email to access your member dashboard",
title: "Sign in to continue",
description: "You need to be signed in to access this page",
dismissible: true,
redirectTo: "/member/dashboard",
});
return null;
}
@ -224,6 +302,7 @@ const { pending: authPending } = await useLazyAsyncData(
return memberData.value;
},
{ server: false },
);
// Load registered events
@ -286,20 +365,22 @@ const getEventImageUrl = (featureImage) => {
return "";
};
const formatEventDate = (dateString) => {
const date = new Date(dateString);
const formatEventDate = (event) => {
if (!event?.startDate) return "";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(date);
timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
};
const formatEventTime = (dateString) => {
const date = new Date(dateString);
const formatEventTime = (event) => {
if (!event?.startDate) return "";
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(date);
timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
};
const formatMemberSince = (dateString) => {
@ -321,15 +402,6 @@ useHead({
</script>
<style scoped>
/* ---- DASHBOARD LAYOUT ---- */
.dashboard {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
/* ---- LOADING / UNAUTH STATES ---- */
.loading-state {
flex: 1;
@ -358,7 +430,9 @@ useHead({
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.loading-inline {
@ -381,7 +455,7 @@ useHead({
}
.unauth-state h2 {
font-family: 'Brygada 1918', serif;
font-family: var(--font-display);
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
@ -394,39 +468,21 @@ useHead({
margin-bottom: 20px;
}
/* ---- WELCOME HEADER ---- */
.welcome {
padding: 28px 28px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.welcome h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
}
.welcome .meta {
/* ---- WELCOME HEADER META ---- */
.dashboard-meta {
display: flex;
align-items: baseline;
gap: 12px;
font-size: 12px;
color: var(--text-dim);
margin-top: 8px;
}
/* ---- CONTENT GRID ---- */
.dashboard-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
.slack-coming-note {
margin-top: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
}
.content-row {
@ -490,7 +546,7 @@ useHead({
/* ---- CALENDAR BUTTON ---- */
.calendar-btn {
font-family: 'Commit Mono', monospace;
font-family: inherit;
font-size: 11px;
color: var(--candle-dim);
background: none;
@ -574,7 +630,7 @@ useHead({
/* ---- QUICK ACTIONS ---- */
.quick-action {
border: 1px dashed var(--border);
padding: 14px 20px;
padding: 12px 20px;
margin-bottom: 8px;
transition: border-color 0.2s;
display: flex;
@ -636,7 +692,7 @@ useHead({
}
.status-active::before {
content: '';
content: "";
display: inline-block;
width: 6px;
height: 6px;
@ -652,6 +708,7 @@ useHead({
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.content-row {
grid-template-columns: 1fr;
@ -666,12 +723,8 @@ useHead({
border-bottom: none;
}
.welcome {
padding: 24px 20px;
}
.content-block {
padding: 20px;
padding: 20px 24px;
}
.event-item {

View file

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

View file

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

View file

@ -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