Compare commits

..

68 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
128 changed files with 4293 additions and 1469 deletions

View file

@ -21,16 +21,16 @@ jobs:
playwright: playwright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: vitest needs: vitest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env: env:
MONGODB_URI: mongodb://localhost:27017/ghostguild-test MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret JWT_SECRET: ci-test-jwt-secret
RESEND_API_KEY: re_ci_dummy_not_used
HELCIM_API_TOKEN: helcim_ci_dummy_not_used
OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret
NUXT_PUBLIC_COMING_SOON: 'false' NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development NODE_ENV: development
ALLOW_DEV_TEST_ENDPOINTS: 'true'
BASE_URL: http://localhost:3000
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -39,15 +39,35 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
- name: Start MongoDB
run: |
docker rm -f mongo-ci 2>/dev/null || true
docker run -d --name mongo-ci mongo:7
# Forgejo runs each job inside its own container; attach Mongo to
# that container's network so MONGODB_URI=mongodb://mongo-ci:27017
# resolves from inside the runner.
RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
docker network connect "$RUNNER_NET" mongo-ci
docker ps
- name: Wait for MongoDB
run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done'
- name: MongoDB log on failure
if: failure()
run: docker logs mongo-ci || true
- name: Seed test data
run: node scripts/seed-all.js && node scripts/seed-tags.js
- run: npm run build - run: npm run build
- name: Start server - name: Start server
run: node .output/server/index.mjs & run: node .output/server/index.mjs > /tmp/server.log 2>&1 &
env: env:
PORT: 3000 PORT: 3000
- name: Wait for server - name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done' run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- run: npx playwright test --ignore-snapshots - name: Server log on failure
- uses: actions/upload-artifact@v4 if: failure()
run: cat /tmp/server.log || true
- run: npx playwright test
- uses: actions/upload-artifact@v3
if: failure() if: failure()
with: with:
name: playwright-report name: playwright-report
@ -68,39 +88,3 @@ jobs:
-H 'Content-type: application/json' \ -H 'Content-type: application/json' \
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}" --data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
visual:
runs-on: ubuntu-latest
needs: vitest
continue-on-error: true
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Start server
run: node .output/server/index.mjs &
env:
PORT: 3000
- name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- run: npx playwright test e2e/visual/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: e2e/test-results/
retention-days: 7

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: # list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp # al angular ansible bash clojure
# csharp_omnisharp dart elixir elm erlang # cpp cpp_ccls crystal csharp csharp_omnisharp
# fortran fsharp go groovy haskell # dart elixir elm erlang fortran
# java julia kotlin lua markdown # fsharp go groovy haskell haxe
# matlab nix pascal perl php # hlsl html java json julia
# php_phpactor powershell python python_jedi r # kotlin lean4 lua luau markdown
# rego ruby ruby_solargraph rust scala # matlab msl nix ocaml pascal
# swift terraform toml typescript typescript_vts # perl php php_phpactor powershell python
# vue yaml zig # python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here: # (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note: # Note:
# - For C, use cpp # - For C, use cpp
# - For JavaScript, use typescript # - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal # - For Free Pascal/Lazarus, use pascal
# Special requirements: # Special requirements:
# Some languages require additional setup/installations. # Some languages require additional setup/installations.
@ -65,53 +70,17 @@ read_only: false
# list of tool names to exclude. # list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration) # This extends the existing exclusions (e.g. from the global configuration)
# # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: [] excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration). # This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: [] included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: [] fixed_tools: []
# list of mode names to that are always to be included in the set of active modes # list of mode names to that are always to be included in the set of active modes
@ -122,11 +91,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project. # Set this to a list of mode names to always include the respective modes for this project.
base_modes: base_modes:
# list of mode names that are to be activated by default. # list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes + default_modes. # The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. # If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml). # Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode). # This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes: default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
@ -150,3 +122,19 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists. # Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"] # Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: [] ignored_memory_patterns: []
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []

View file

@ -27,7 +27,10 @@
--text: #2a2015; --text: #2a2015;
--text-bright: #1a1008; --text-bright: #1a1008;
--text-dim: #5a5040; --text-dim: #5a5040;
--text-faint: #746a58; /* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
--text-dim (5.80:1) while meeting AA for small text. */
--text-faint: #665c4b;
--parch: #2a2015; --parch: #2a2015;
--parch-hover: #3a3025; --parch-hover: #3a3025;
--parch-text: #ede4d0; --parch-text: #ede4d0;

View file

@ -178,7 +178,8 @@ const slackLinks = computed(() => {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-faint); /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
} }
.post-actions { .post-actions {
@ -233,7 +234,8 @@ const slackLinks = computed(() => {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-faint); /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
margin-bottom: 2px; margin-bottom: 2px;
} }
.block-text { .block-text {
@ -244,7 +246,8 @@ const slackLinks = computed(() => {
.post-note { .post-note {
font-size: 11px; font-size: 11px;
color: var(--text-faint); /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-style: italic; font-style: italic;
margin: 8px 0; margin: 8px 0;
white-space: pre-wrap; white-space: pre-wrap;
@ -293,7 +296,8 @@ const slackLinks = computed(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 10px;
color: var(--text-faint); /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
} }
.author-name { .author-name {
@ -308,7 +312,8 @@ const slackLinks = computed(() => {
} }
.slack-handle { .slack-handle {
font-size: 11px; font-size: 11px;
color: var(--text-faint); /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
color: var(--text-dim);
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
background: transparent; background: transparent;
border: none; border: none;

View file

@ -29,13 +29,14 @@ const props = defineProps({
limit: { type: Number, default: 3 }, limit: { type: Number, default: 3 },
}) })
const upcomingEvents = ref([]) let upcomingEvents = ref([])
if (props.cols === 'events-sidebar') { if (props.cols === 'events-sidebar') {
const { data } = await useFetch('/api/events', { const { data } = await useFetch('/api/events', {
query: { upcoming: true, limit: props.limit }, query: { upcoming: true, limit: props.limit },
default: () => [], default: () => [],
server: false,
}) })
upcomingEvents.value = data.value || [] upcomingEvents = computed(() => data.value || [])
} }
</script> </script>

View file

@ -38,14 +38,14 @@
<!-- Already Registered --> <!-- Already Registered -->
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel"> <div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
<div class="box-title">Registration</div>
<p class="ticket-status" style="color: var(--green)"> <p class="ticket-status" style="color: var(--green)">
You're Registered! You're Registered!
</p> </p>
<p class="ticket-detail"> <p class="ticket-detail">
<template v-if="ticketInfo.viaSeriesPass"> <template v-if="ticketInfo.viaSeriesPass">
You have access to this event via your series pass for You have access to this event via your series pass for
<strong>{{ ticketInfo.series?.title }}</strong>. <strong>{{ ticketInfo.series?.title }}</strong
>.
</template> </template>
<template v-else> <template v-else>
You're all set for this event. Check your email for confirmation You're all set for this event. Check your email for confirmation
@ -70,13 +70,11 @@
<!-- Registration (logged-in member) --> <!-- Registration (logged-in member) -->
<div <div
v-if="ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn" v-if="
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
"
class="ticket-panel" class="ticket-panel"
> >
<div class="box-title">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</div>
<p <p
v-if="ticketInfo.isMember && ticketInfo.isFree" v-if="ticketInfo.isMember && ticketInfo.isFree"
class="ticket-notice" class="ticket-notice"
@ -90,8 +88,7 @@
class="ticket-notice" class="ticket-notice"
style="color: var(--candle)" style="color: var(--candle)"
> >
Payment of {{ ticketInfo.formattedPrice }} will be processed Payment of {{ ticketInfo.formattedPrice }} will be processed securely
securely
</p> </p>
<button <button
@ -129,7 +126,7 @@
autocomplete="name" autocomplete="name"
required required
:disabled="processing" :disabled="processing"
> />
</div> </div>
<div class="field"> <div class="field">
@ -142,7 +139,7 @@
autocomplete="email" autocomplete="email"
required required
:disabled="processing" :disabled="processing"
> />
</div> </div>
<p <p
@ -160,11 +157,15 @@
v-model="form.createAccount" v-model="form.createAccount"
type="checkbox" type="checkbox"
:disabled="processing" :disabled="processing"
/>
<span
>Create a free guest account so I can manage my
registration</span
> >
<span>Create a free guest account so I can manage my registration</span>
</label> </label>
<p class="field-hint consent-hint"> <p class="field-hint consent-hint">
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications. Guest accounts let you view your tickets and register faster next
time. We won't add you to member communications.
</p> </p>
</div> </div>
@ -190,24 +191,18 @@
class="ticket-panel" class="ticket-panel"
> >
<div class="box-title">Waitlist</div> <div class="box-title">Waitlist</div>
<p class="ticket-status" style="color: var(--ember)"> <p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
Event Sold Out
</p>
<p class="ticket-detail"> <p class="ticket-detail">
This event is currently at capacity. Join the waitlist to be notified This event is currently at capacity. Join the waitlist to be notified
if spots become available. if spots become available.
</p> </p>
<button class="btn" @click="handleJoinWaitlist"> <button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
Join Waitlist
</button>
</div> </div>
<!-- Sold Out (No Waitlist) --> <!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="ticket-panel"> <div v-else-if="!ticketInfo.available" class="ticket-panel">
<div class="box-title">Tickets</div> <div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)"> <p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
Event Sold Out
</p>
<p class="ticket-detail"> <p class="ticket-detail">
Unfortunately, this event is at capacity and no longer accepting Unfortunately, this event is at capacity and no longer accepting
registrations. registrations.
@ -224,13 +219,17 @@ const props = defineProps({
required: true, required: true,
}, },
eventStartDate: { eventStartDate: {
type: Date, type: [String, Date],
required: true, required: true,
}, },
eventTitle: { eventTitle: {
type: String, type: String,
required: true, required: true,
}, },
eventTimezone: {
type: String,
default: "America/Toronto",
},
userEmail: { userEmail: {
type: String, type: String,
default: null, default: null,
@ -307,7 +306,9 @@ const fetchTicketInfo = async (emailOverride = null) => {
} }
// Regular ticket availability check // Regular ticket availability check
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : ""; const params = effectiveEmail
? `?email=${encodeURIComponent(effectiveEmail)}`
: "";
const response = await $fetch( const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`, `/api/events/${props.eventId}/tickets/available${params}`,
); );
@ -415,6 +416,7 @@ const formatEventDate = (date) => {
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
timeZone: props.eventTimezone || "America/Toronto",
}); });
}; };
</script> </script>

View file

@ -6,7 +6,7 @@
<div v-if="events?.length" class="em-rows"> <div v-if="events?.length" class="em-rows">
<div v-for="event in events" :key="event._id" class="em-item"> <div v-for="event in events" :key="event._id" class="em-item">
<div class="em-inset em-item-body"> <div class="em-inset em-item-body">
<span class="em-date">{{ formatDate(event.startDate) }}</span> <span class="em-date">{{ formatDate(event) }}</span>
<NuxtLink <NuxtLink
:to="`/events/${event.slug || event._id}`" :to="`/events/${event.slug || event._id}`"
class="em-title" class="em-title"
@ -37,10 +37,13 @@ defineProps({
events: { type: Array, default: () => [] }, events: { type: Array, default: () => [] },
}); });
const formatDate = (dateStr) => { const formatDate = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); return new Date(event.startDate).toLocaleDateString("en-US", {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
}; };
</script> </script>

View file

@ -77,12 +77,7 @@
<input <input
:value="modelValue.alt || ''" :value="modelValue.alt || ''"
placeholder="Describe this image..." placeholder="Describe this image..."
class="w-full px-3 py-2" class="w-full px-3 py-2 alt-text-input"
style="
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
"
@input="updateAltText($event.target.value)" @input="updateAltText($event.target.value)"
> >
</div> </div>
@ -225,3 +220,16 @@ const updateAltText = (altText) => {
}); });
}; };
</script> </script>
<style scoped>
.alt-text-input {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.alt-text-input:focus {
outline: none;
border-color: var(--candle);
}
</style>

View file

@ -1,71 +1,40 @@
<template> <template>
<div class="space-y-2"> <div class="natural-date-input">
<div class="relative">
<UInput <UInput
v-model="naturalInput" :model-value="rawInput"
:placeholder="placeholder" :placeholder="placeholder"
:color=" :color="trailingState"
hasError && naturalInput.trim() @update:model-value="onInputChange"
? 'error'
: isValidParse && naturalInput.trim()
? 'success'
: undefined
"
@input="parseNaturalInput"
@blur="onBlur"
> >
<template #trailing> <template #trailing>
<Icon <Icon
v-if="isValidParse && naturalInput.trim()" v-if="isValid && rawInput.trim()"
name="heroicons:check-circle" name="heroicons:check-circle"
class="w-5 h-5" class="w-5 h-5"
style="color: var(--candle)" style="color: var(--candle)"
/> />
<Icon <Icon
v-else-if="hasError && naturalInput.trim()" v-else-if="hasError && rawInput.trim()"
name="heroicons:exclamation-circle" name="heroicons:exclamation-circle"
class="w-5 h-5" class="w-5 h-5"
style="color: var(--ember)" style="color: var(--ember)"
/> />
</template> </template>
</UInput> </UInput>
</div> <p
v-if="rawInput.trim() && isValid"
<div class="preview-line"
v-if="parsedDate && isValidParse" style="color: var(--candle)"
class="text-sm px-3 py-2"
style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-center gap-2"> &rarr; {{ previewText }}
<Icon name="heroicons:calendar" class="w-4 h-4" /> </p>
<span>{{ formatParsedDate(parsedDate) }}</span> <p
</div> v-else-if="rawInput.trim() && hasError"
</div> class="preview-line"
style="color: var(--ember)"
<div
v-if="hasError && naturalInput.trim()"
class="text-sm px-3 py-2"
style="color: var(--ember); background: color-mix(in srgb, var(--ember) 15%, transparent); border: 1px solid var(--ember)"
> >
<div class="flex items-center gap-2"> {{ errorMessage }}
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" /> </p>
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer" style="color: var(--text-dim)">
Use traditional date picker
</summary>
<div class="mt-2">
<UInput
v-model="datetimeValue"
type="datetime-local"
@change="onDatetimeChange"
/>
</div>
</details>
</div> </div>
</template> </template>
@ -73,176 +42,197 @@
import * as chrono from "chrono-node"; import * as chrono from "chrono-node";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: { type: String, default: "" },
type: String,
default: "",
},
placeholder: { placeholder: {
type: String, type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"', default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
},
inputClass: {
type: [String, Object],
default: "",
},
required: {
type: Boolean,
default: false,
}, },
displayTimezone: { type: String, default: "" },
required: { type: Boolean, default: false },
}); });
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const naturalInput = ref(""); const rawInput = ref("");
const parsedDate = ref(null); const isValid = ref(false);
const isValidParse = ref(false);
const hasError = ref(false); const hasError = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const datetimeValue = ref(""); // previewDate holds the parsed value as a UTC Date so we can format it in
// arbitrary timezones without re-parsing. Source of truth for the preview.
const previewDate = ref(null);
// Initialize with current value const trailingState = computed(() => {
onMounted(() => { if (!rawInput.value.trim()) return undefined;
if (props.modelValue) { if (hasError.value) return "error";
const date = new Date(props.modelValue); if (isValid.value) return "success";
if (!isNaN(date.getTime())) { return undefined;
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
}
}
}); });
// Watch for external changes to modelValue const previewText = computed(() => {
if (!previewDate.value) return "";
const tz = activeTZ();
const date = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(previewDate.value);
const abbr = shortTimezoneName(previewDate.value, tz);
return abbr ? `${date} ${abbr}` : date;
});
const activeTZ = () =>
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Seed the input from modelValue without triggering chrono. The parent's
// value is canonical we just render it as a chrono-friendly readable
// string so the user can backspace and tweak in place.
const seedFromModelValue = () => {
if (!props.modelValue) {
rawInput.value = "";
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
return;
}
const tz = activeTZ();
const utc = zonedLocalToUTC(props.modelValue, tz);
if (!utc) return;
previewDate.value = utc;
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
rawInput.value = readableSeed(utc, tz);
};
onMounted(seedFromModelValue);
watch( watch(
() => props.modelValue, () => props.modelValue,
(newValue) => { (next) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) { const tz = activeTZ();
const date = new Date(newValue); const expected = previewDate.value
if (!isNaN(date.getTime())) { ? utcToZonedLocal(previewDate.value, tz)
parsedDate.value = date; : "";
datetimeValue.value = formatForDatetimeLocal(date); if (next === expected) return;
isValidParse.value = true; seedFromModelValue();
naturalInput.value = ""; // Clear natural input when set externally
}
} else if (!newValue) {
reset();
}
}, },
); );
const parseNaturalInput = () => { watch(
const input = naturalInput.value.trim(); () => props.displayTimezone,
() => {
// Re-interpret the current input under the new TZ so the preview and
// emitted value reflect the new timezone semantics.
if (rawInput.value.trim()) parse(rawInput.value);
},
);
if (!input) { const onInputChange = (value) => {
reset(); rawInput.value = value;
return; parse(value);
}
try {
// Parse with chrono-node
const results = chrono.parse(input);
if (results.length > 0) {
const result = results[0];
const date = result.date();
// Validate the parsed date
if (date && !isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
datetimeValue.value = formatForDatetimeLocal(date);
emit("update:modelValue", formatForDatetimeLocal(date));
} else {
setError("Could not parse this date format");
}
} else {
setError(
'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"',
);
}
} catch (error) {
setError("Error parsing date");
}
}; };
const onBlur = () => { const parse = (input) => {
// If we have a valid parse but the input changed, try to parse again const trimmed = input.trim();
if (naturalInput.value.trim() && !isValidParse.value) { if (!trimmed) {
parseNaturalInput(); isValid.value = false;
}
};
const onDatetimeChange = () => {
if (datetimeValue.value) {
const date = new Date(datetimeValue.value);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
naturalInput.value = ""; // Clear natural input when using traditional picker
emit("update:modelValue", datetimeValue.value);
}
} else {
reset();
}
};
const reset = () => {
parsedDate.value = null;
isValidParse.value = false;
hasError.value = false; hasError.value = false;
errorMessage.value = ""; errorMessage.value = "";
previewDate.value = null;
emit("update:modelValue", "");
return;
}
const tz = activeTZ();
let results;
try {
results = chrono.parse(trimmed, referenceNowInTZ(tz));
} catch {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
if (!results.length) {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
const date = results[0].date();
if (!date || Number.isNaN(date.getTime())) {
setError("Couldn't read that date");
return;
}
// chrono returned a Date whose browser-local components match what the
// user typed in the event timezone (because we shifted the reference).
// Read those components as wall-clock in displayTimezone.
const localStr = browserComponentsToString(date);
const utc = zonedLocalToUTC(localStr, tz);
if (!utc) {
setError("Couldn't parse this date");
return;
}
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
previewDate.value = utc;
emit("update:modelValue", localStr);
};
const setError = (msg) => {
isValid.value = false;
hasError.value = true;
errorMessage.value = msg;
previewDate.value = null;
emit("update:modelValue", ""); emit("update:modelValue", "");
}; };
const setError = (message) => { // Build a Date object whose browser-local components equal the current
isValidParse.value = false; // wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
hasError.value = true; // Friday" anchor to the event TZ rather than the editor's browser TZ.
errorMessage.value = message; const referenceNowInTZ = (tz) => {
parsedDate.value = null; const nowStr = utcToZonedLocal(new Date(), tz);
if (!nowStr) return new Date();
const [d, t] = nowStr.split("T");
const [y, mo, day] = d.split("-").map(Number);
const [h, mi] = t.split(":").map(Number);
return new Date(y, mo - 1, day, h, mi);
}; };
const formatForDatetimeLocal = (date) => { const browserComponentsToString = (date) => {
if (!date) return ""; const y = date.getFullYear();
// Format as YYYY-MM-DDTHH:MM for datetime-local input const mo = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear(); const d = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0"); const h = String(date.getHours()).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0"); const mi = String(date.getMinutes()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0"); return `${y}-${mo}-${d}T${h}:${mi}`;
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}; };
const formatParsedDate = (date) => { const readableSeed = (utc, tz) => {
if (!date) return ""; // Format chosen to round-trip cleanly through chrono.parse.
return new Intl.DateTimeFormat("en-US", {
const now = new Date(); timeZone: tz,
const isToday = date.toDateString() === now.toDateString(); month: "short",
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const timeStr = date.toLocaleString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (isToday) {
return `Today at ${timeStr}`;
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`;
} else {
return date.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric", day: "numeric",
year: "numeric",
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: true, hour12: true,
}); }).format(utc);
}
}; };
</script> </script>
<style scoped>
.natural-date-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
font-size: 12px;
margin: 0;
}
</style>

View file

@ -12,7 +12,12 @@
class="breadcrumb-link" class="breadcrumb-link"
>{{ crumb.label }}</NuxtLink >{{ crumb.label }}</NuxtLink
> >
<span v-else class="breadcrumb-current">{{ crumb.label }}</span> <ClientOnly v-else>
<span class="breadcrumb-current">{{ crumb.label }}</span>
<template #fallback>
<span class="breadcrumb-current">&nbsp;</span>
</template>
</ClientOnly>
</template> </template>
</span> </span>
</slot> </slot>

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

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

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";

View file

@ -217,6 +217,8 @@
</template> </template>
<script setup> <script setup>
useSiteMeta({ title: "Admin", noindex: true });
const route = useRoute(); const route = useRoute();
const isMobileMenuOpen = ref(false); const isMobileMenuOpen = ref(false);
const { logout } = useAuth(); const { logout } = useAuth();

View file

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

View file

@ -104,7 +104,13 @@
</PageShell> </PageShell>
</template> </template>
<script setup></script> <script setup>
useSiteMeta({
title: 'About',
description:
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
})
</script>
<style scoped> <style scoped>
/* ---- ABOUT HERO ---- */ /* ---- ABOUT HERO ---- */

View file

@ -242,6 +242,7 @@ import {
} from "~/config/contributions"; } from "~/config/contributions";
definePageMeta({ layout: false }); definePageMeta({ layout: false });
useSiteMeta({ title: "Accept Invitation", noindex: true });
const { checkMemberStatus } = useAuth(); const { checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment } = useHelcimPay(); const { initializeHelcimPay, verifyPayment } = useHelcimPay();

View file

@ -27,6 +27,8 @@
</div> </div>
<form @submit.prevent="saveEvent"> <form @submit.prevent="saveEvent">
<div class="form-layout">
<div class="form-main">
<!-- Basic Information --> <!-- Basic Information -->
<div class="form-section"> <div class="form-section">
<h2 class="section-heading">Basic Information</h2> <h2 class="section-heading">Basic Information</h2>
@ -38,6 +40,7 @@
placeholder="Enter a clear, descriptive event title" placeholder="Enter a clear, descriptive event title"
required required
:color="fieldErrors.title ? 'error' : undefined" :color="fieldErrors.title ? 'error' : undefined"
:ui="{ base: 'title-input' }"
class="w-full" class="w-full"
/> />
<p v-if="fieldErrors.title" class="field-error"> <p v-if="fieldErrors.title" class="field-error">
@ -60,7 +63,8 @@
v-model="eventForm.description" v-model="eventForm.description"
placeholder="Provide a clear description of what attendees can expect from this event" placeholder="Provide a clear description of what attendees can expect from this event"
required required
:rows="4" :rows="8"
autoresize
:color="fieldErrors.description ? 'error' : undefined" :color="fieldErrors.description ? 'error' : undefined"
class="w-full" class="w-full"
/> />
@ -77,7 +81,8 @@
<UTextarea <UTextarea
v-model="eventForm.content" v-model="eventForm.content"
placeholder="Add detailed information, agenda, requirements, or other important details" placeholder="Add detailed information, agenda, requirements, or other important details"
:rows="6" :rows="12"
autoresize
class="w-full" class="w-full"
/> />
<p class="help-text"> <p class="help-text">
@ -85,6 +90,21 @@
requirements requirements
</p> </p>
</div> </div>
<div class="field">
<label>Event Agenda</label>
<UTextarea
v-model="agendaText"
placeholder="Introduction and welcome - 10 mins&#10;Main talk - 30 mins&#10;Q&amp;A - 15 mins"
:rows="6"
autoresize
class="w-full"
/>
<p class="help-text">
One agenda item per line. Help attendees know what to expect
during the event.
</p>
</div>
</div> </div>
<!-- Event Details --> <!-- Event Details -->
@ -97,12 +117,7 @@
<USelect <USelect
v-model="eventForm.eventType" v-model="eventForm.eventType"
aria-label="Event type" aria-label="Event type"
:items="[ :items="EVENT_TYPES"
{ label: 'Community Meetup', value: 'community' },
{ label: 'Workshop', value: 'workshop' },
{ label: 'Social Event', value: 'social' },
{ label: 'Showcase', value: 'showcase' },
]"
class="w-full" class="w-full"
/> />
<p class="help-text"> <p class="help-text">
@ -111,19 +126,32 @@
</div> </div>
<div class="field"> <div class="field">
<label> Location <span class="required">*</span> </label> <label> Event Timezone <span class="required">*</span> </label>
<UInput <USelectMenu
v-model="eventForm.location" v-model="eventForm.displayTimezone"
placeholder="e.g., https://zoom.us/j/123... or #channel-name" :items="timezoneItems"
required value-key="value"
:color="fieldErrors.location ? 'error' : undefined" searchable
searchable-placeholder="Search timezones..."
placeholder="Select a timezone"
class="w-full" class="w-full"
/> />
<p v-if="fieldErrors.location" class="field-error">
{{ fieldErrors.location }}
</p>
<p class="help-text"> <p class="help-text">
Enter a video conference link or Slack channel (starting with #) Dates below are interpreted in this timezone. Attendees see the
event time in this zone.
</p>
</div>
<div class="field">
<label>Location</label>
<UInput
v-model="eventForm.location"
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
class="w-full"
/>
<p class="help-text">
Video conference link, Slack channel (#channel-name), or 'TBD' if
the platform is undecided
</p> </p>
</div> </div>
@ -131,6 +159,7 @@
<label> Start Date & Time <span class="required">*</span> </label> <label> Start Date & Time <span class="required">*</span> </label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.startDate" v-model="eventForm.startDate"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'" placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
:required="true" :required="true"
/> />
@ -143,6 +172,7 @@
<label> End Date & Time <span class="required">*</span> </label> <label> End Date & Time <span class="required">*</span> </label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.endDate" v-model="eventForm.endDate"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'" placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
:required="true" :required="true"
/> />
@ -169,6 +199,7 @@
<label>Registration Deadline</label> <label>Registration Deadline</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.registrationDeadline" v-model="eventForm.registrationDeadline"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at noon', '1 hour before event'" placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
/> />
<p class="help-text"> <p class="help-text">
@ -178,6 +209,87 @@
</div> </div>
</div> </div>
<!-- Event Settings -->
<div class="form-section">
<h2 class="section-heading">Event Settings</h2>
<div class="form-grid">
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isOnline" type="checkbox" >
<div>
<strong>Online Event</strong>
<span class="help-text">
Event will be conducted virtually
</span>
</div>
</label>
<label class="check-label">
<input
v-model="eventForm.registrationRequired"
type="checkbox"
>
<div>
<strong>Registration Required</strong>
<span class="help-text">
Attendees must register before attending
</span>
</div>
</label>
</div>
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isVisible" type="checkbox" >
<div>
<strong>Visible on Public Calendar</strong>
<span class="help-text">
Event will appear on the public events page
</span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.isCancelled" type="checkbox" >
<div>
<strong>Event Cancelled</strong>
<span class="help-text"> Mark this event as cancelled </span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.membersOnly" type="checkbox" >
<div>
<strong>Members Only</strong>
<span class="help-text">
Hide this event from the public; only members can see it
</span>
</div>
</label>
</div>
</div>
</div>
<!-- Cancellation Message (conditional) -->
<div v-if="eventForm.isCancelled" class="form-section">
<div class="field">
<label>Cancellation Message</label>
<UTextarea
v-model="eventForm.cancellationMessage"
placeholder="Explain why the event was cancelled and any next steps..."
:rows="3"
color="error"
class="w-full"
/>
<p class="help-text">
This message will be displayed to users viewing the event page
</p>
</div>
</div>
</div>
<aside class="form-aside">
<!-- Target Audience --> <!-- Target Audience -->
<div class="form-section"> <div class="form-section">
<h2 class="section-heading">Target Audience</h2> <h2 class="section-heading">Target Audience</h2>
@ -190,39 +302,24 @@
v-model="eventForm.targetCircles" v-model="eventForm.targetCircles"
value="community" value="community"
type="checkbox" type="checkbox"
/> >
<div>
<strong>Community Circle</strong> <strong>Community Circle</strong>
<span class="help-text">
New members and those exploring the community
</span>
</div>
</label> </label>
<label class="check-label"> <label class="check-label">
<input <input
v-model="eventForm.targetCircles" v-model="eventForm.targetCircles"
value="founder" value="founder"
type="checkbox" type="checkbox"
/> >
<div>
<strong>Founder Circle</strong> <strong>Founder Circle</strong>
<span class="help-text">
Entrepreneurs and business leaders
</span>
</div>
</label> </label>
<label class="check-label"> <label class="check-label">
<input <input
v-model="eventForm.targetCircles" v-model="eventForm.targetCircles"
value="practitioner" value="practitioner"
type="checkbox" type="checkbox"
/> >
<div>
<strong>Practitioner Circle</strong> <strong>Practitioner Circle</strong>
<span class="help-text">
Experts and professionals sharing knowledge
</span>
</div>
</label> </label>
</div> </div>
<p class="help-text"> <p class="help-text">
@ -243,11 +340,30 @@
:items="tagOptions" :items="tagOptions"
value-key="value" value-key="value"
multiple multiple
placeholder="Select tags..." searchable
create-item
placeholder="Select or type to add tags..."
class="w-full"
@create="onTagCreate"
/>
<div class="field new-tag-pool">
<label>New tag pool</label>
<USelect
v-model="newTagPool"
:items="[
{ label: 'Cooperative', value: 'cooperative' },
{ label: 'Craft', value: 'craft' },
]"
value-key="value"
class="w-full" class="w-full"
/> />
<p class="help-text"> <p class="help-text">
Tag this event to help with discovery and recommendations Pool assigned to any new tag you create from this field.
</p>
</div>
<p class="help-text">
Tag this event to help with discovery and recommendations. Type a
new tag and press enter to add it.
</p> </p>
</div> </div>
</div> </div>
@ -257,7 +373,7 @@
<h2 class="section-heading">Ticketing</h2> <h2 class="section-heading">Ticketing</h2>
<label class="check-label"> <label class="check-label">
<input v-model="eventForm.tickets.enabled" type="checkbox" /> <input v-model="eventForm.tickets.enabled" type="checkbox" >
<div> <div>
<strong>Enable Ticketing</strong> <strong>Enable Ticketing</strong>
<span class="help-text"> Allow ticket sales for this event </span> <span class="help-text"> Allow ticket sales for this event </span>
@ -269,7 +385,7 @@
<input <input
v-model="eventForm.tickets.public.available" v-model="eventForm.tickets.public.available"
type="checkbox" type="checkbox"
/> >
<div> <div>
<strong>Public Tickets Available</strong> <strong>Public Tickets Available</strong>
<span class="help-text"> <span class="help-text">
@ -278,6 +394,12 @@
</div> </div>
</label> </label>
<div class="note-box">
<strong>Note:</strong> Public ticket pricing applies to non-members.
Members register for events from their dashboard at no charge,
regardless of public ticket settings.
</div>
<div v-if="eventForm.tickets.public.available"> <div v-if="eventForm.tickets.public.available">
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
@ -345,6 +467,7 @@
<label>Early Bird Deadline</label> <label>Early Bird Deadline</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.tickets.public.earlyBirdDeadline" v-model="eventForm.tickets.public.earlyBirdDeadline"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., '1 week before event', 'next Monday'" placeholder="e.g., '1 week before event', 'next Monday'"
/> />
<p class="help-text"> <p class="help-text">
@ -353,11 +476,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="note-box">
<strong>Note:</strong> Members always get free access to all events
regardless of ticket settings.
</div>
</div> </div>
<!-- Series Management --> <!-- Series Management -->
@ -365,7 +483,7 @@
<h2 class="section-heading">Series Management</h2> <h2 class="section-heading">Series Management</h2>
<label class="check-label"> <label class="check-label">
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" /> <input v-model="eventForm.series.isSeriesEvent" type="checkbox" >
<div> <div>
<strong>Part of Event Series</strong> <strong>Part of Event Series</strong>
<span class="help-text"> <span class="help-text">
@ -381,7 +499,6 @@
<USelect <USelect
v-model="selectedSeriesId" v-model="selectedSeriesId"
aria-label="Select series" aria-label="Select series"
@update:model-value="onSeriesSelect"
:items=" :items="
availableSeries.map((series) => ({ availableSeries.map((series) => ({
label: `${series.title} (${series.eventCount || 0} events)`, label: `${series.title} (${series.eventCount || 0} events)`,
@ -391,6 +508,7 @@
placeholder="Choose existing series or create new..." placeholder="Choose existing series or create new..."
value-key="value" value-key="value"
class="w-full" class="w-full"
@update:model-value="onSeriesSelect"
/> />
<NuxtLink to="/admin/series/create" class="btn btn-primary"> <NuxtLink to="/admin/series/create" class="btn btn-primary">
New Series New Series
@ -448,113 +566,7 @@
</div> </div>
</div> </div>
</div> </div>
</aside>
<!-- Event Agenda -->
<div class="form-section">
<h2 class="section-heading">Event Agenda</h2>
<div class="agenda-items">
<div
v-for="(item, index) in eventForm.agenda"
:key="index"
class="agenda-row"
>
<UInput
v-model="eventForm.agenda[index]"
placeholder="Enter agenda item (e.g., 'Introduction and welcome - 10 mins')"
class="w-full"
/>
<button
type="button"
@click="removeAgendaItem(index)"
class="link-btn link-btn-danger"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</div>
<button
type="button"
@click="addAgendaItem"
class="btn add-agenda-btn"
>
+ Add Agenda Item
</button>
</div>
<p class="help-text">
Add agenda items to help attendees know what to expect during the
event
</p>
</div>
<!-- Event Settings -->
<div class="form-section">
<h2 class="section-heading">Event Settings</h2>
<div class="form-grid">
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isOnline" type="checkbox" />
<div>
<strong>Online Event</strong>
<span class="help-text">
Event will be conducted virtually
</span>
</div>
</label>
<label class="check-label">
<input
v-model="eventForm.registrationRequired"
type="checkbox"
/>
<div>
<strong>Registration Required</strong>
<span class="help-text">
Attendees must register before attending
</span>
</div>
</label>
</div>
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isVisible" type="checkbox" />
<div>
<strong>Visible on Public Calendar</strong>
<span class="help-text">
Event will appear on the public events page
</span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.isCancelled" type="checkbox" />
<div>
<strong>Event Cancelled</strong>
<span class="help-text"> Mark this event as cancelled </span>
</div>
</label>
</div>
</div>
</div>
<!-- Cancellation Message (conditional) -->
<div v-if="eventForm.isCancelled" class="form-section">
<div class="field">
<label>Cancellation Message</label>
<UTextarea
v-model="eventForm.cancellationMessage"
placeholder="Explain why the event was cancelled and any next steps..."
:rows="3"
color="error"
class="w-full"
/>
<p class="help-text">
This message will be displayed to users viewing the event page
</p>
</div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
@ -565,9 +577,9 @@
<button <button
v-if="!editingEvent" v-if="!editingEvent"
type="button" type="button"
@click="saveAndCreateAnother"
:disabled="creating" :disabled="creating"
class="btn" class="btn"
@click="saveAndCreateAnother"
> >
{{ creating ? "Saving..." : "Save & Create Another" }} {{ creating ? "Saving..." : "Save & Create Another" }}
</button> </button>
@ -589,6 +601,9 @@
</template> </template>
<script setup> <script setup>
import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { EVENT_TYPES } from "~/config/eventTypes";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
middleware: "admin", middleware: "admin",
@ -607,9 +622,32 @@ const availableSeries = ref([]);
const availableTags = ref([]); const availableTags = ref([]);
const tagOptions = computed(() => const tagOptions = computed(() =>
availableTags.value.map((t) => ({ label: t.label, value: t.slug })) availableTags.value.map((t) => ({ label: t.label, value: t.slug })),
); );
const newTagPool = ref("cooperative");
const onTagCreate = async (item) => {
const label = typeof item === "string" ? item : item?.label || item?.value;
if (!label?.trim()) return;
try {
const { tag } = await $fetch("/api/admin/tags", {
method: "POST",
body: { label: label.trim(), pool: newTagPool.value },
});
if (!availableTags.value.some((t) => t.slug === tag.slug)) {
availableTags.value.push({ slug: tag.slug, label: tag.label });
}
if (!eventForm.tags.includes(tag.slug)) {
eventForm.tags.push(tag.slug);
}
} catch (err) {
formErrors.value.push(
`Failed to create tag "${label}": ${err?.data?.statusMessage || err?.statusMessage || err?.message || "unknown error"}`,
);
}
};
const eventForm = reactive({ const eventForm = reactive({
title: "", title: "",
description: "", description: "",
@ -617,11 +655,13 @@ const eventForm = reactive({
featureImage: null, featureImage: null,
startDate: "", startDate: "",
endDate: "", endDate: "",
eventType: "community", eventType: "community-meetup",
displayTimezone: "America/Toronto",
location: "", location: "",
isOnline: true, isOnline: true,
isVisible: true, isVisible: true,
isCancelled: false, isCancelled: false,
membersOnly: false,
cancellationMessage: "", cancellationMessage: "",
targetCircles: [], targetCircles: [],
tags: [], tags: [],
@ -649,15 +689,57 @@ const eventForm = reactive({
}, },
}); });
// Agenda management functions // Format a Date/ISO value into a datetime-local string using local-time components.
const addAgendaItem = () => { // `toISOString().slice(0,16)` drifts by the browser's UTC offset on edit round-trip.
eventForm.agenda.push(""); const formatForDatetimeLocal = (value) => {
if (!value) return "";
const d = new Date(value);
if (isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}; };
const removeAgendaItem = (index) => { // Render the form's datetime fields in the event's display timezone.
eventForm.agenda.splice(index, 1); const formatForEventTZ = (value) => {
if (!value) return "";
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
}; };
const utcOffsetLabel = (tz) => {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts(new Date());
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
if (name === "GMT") return "UTC+00:00";
return name.replace("GMT", "UTC");
} catch {
return "";
}
};
const timezoneItems = computed(() => {
const list = TIMEZONE_OPTIONS.map((t) => {
const off = utcOffsetLabel(t.value);
return { ...t, label: off ? `${t.label} (${off})` : t.label };
});
const saved = eventForm.displayTimezone;
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
list.unshift({ label: saved, value: saved });
}
return list;
});
const agendaText = computed({
get() {
return (eventForm.agenda || []).join("\n");
},
set(v) {
eventForm.agenda = v.split("\n");
},
});
// Load available series and tags // Load available series and tags
onMounted(async () => { onMounted(async () => {
try { try {
@ -698,34 +780,32 @@ const onSeriesSelect = () => {
} }
}; };
// Check if we're editing an event function populateEditForm(payload) {
if (route.query.edit) { const event = payload?.data;
try { if (!event) return;
const response = await $fetch(`/api/admin/events/${route.query.edit}`);
const event = response.data;
if (event) {
editingEvent.value = event; editingEvent.value = event;
// Pin the form's timezone first so subsequent date conversions use it.
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
Object.assign(eventForm, { Object.assign(eventForm, {
title: event.title, title: event.title,
description: event.description, description: event.description,
content: event.content || "", content: event.content || "",
featureImage: event.featureImage || null, featureImage: event.featureImage || null,
startDate: new Date(event.startDate).toISOString().slice(0, 16), startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
endDate: new Date(event.endDate).toISOString().slice(0, 16), endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
eventType: event.eventType, eventType: event.eventType,
displayTimezone: eventForm.displayTimezone,
location: event.location || "", location: event.location || "",
isOnline: event.isOnline, isOnline: event.isOnline,
isVisible: event.isVisible !== undefined ? event.isVisible : true, isVisible: event.isVisible !== undefined ? event.isVisible : true,
isCancelled: event.isCancelled || false, isCancelled: event.isCancelled || false,
membersOnly: event.membersOnly || false,
cancellationMessage: event.cancellationMessage || "", cancellationMessage: event.cancellationMessage || "",
targetCircles: event.targetCircles || [], targetCircles: event.targetCircles || [],
tags: event.tags || [], tags: event.tags || [],
maxAttendees: event.maxAttendees || "", maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired, registrationRequired: event.registrationRequired,
registrationDeadline: event.registrationDeadline registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
: "",
agenda: event.agenda || [], agenda: event.agenda || [],
tickets: event.tickets || { tickets: event.tickets || {
enabled: false, enabled: false,
@ -746,22 +826,30 @@ if (route.query.edit) {
description: "", description: "",
}, },
}); });
// Handle early bird deadline formatting
if (event.tickets?.public?.earlyBirdDeadline) { if (event.tickets?.public?.earlyBirdDeadline) {
eventForm.tickets.public.earlyBirdDeadline = new Date( eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
event.tickets.public.earlyBirdDeadline, event.tickets.public.earlyBirdDeadline,
) eventForm.displayTimezone,
.toISOString() );
.slice(0, 16);
}
}
} catch (error) {
console.error("Failed to load event for editing:", error);
} }
} }
// useFetch forwards auth cookies to SSR; $fetch did not, leaving the
// SSR-rendered form empty and triggering hydration mismatches that left
// required textareas DOM-empty in dev.
if (route.query.edit) {
const { data: editEvent, error: editError } = await useFetch(
`/api/admin/events/${route.query.edit}`,
);
if (editError.value) {
console.error("Failed to load event for editing:", editError.value);
}
if (editEvent.value) populateEditForm(editEvent.value);
watch(editEvent, populateEditForm, { immediate: false });
}
// Check if we're duplicating an event // Check if we're duplicating an event
if (route.query.duplicate && process.client) { if (route.query.duplicate && import.meta.client) {
const duplicateData = sessionStorage.getItem("duplicateEventData"); const duplicateData = sessionStorage.getItem("duplicateEventData");
if (duplicateData) { if (duplicateData) {
try { try {
@ -775,7 +863,7 @@ if (route.query.duplicate && process.client) {
} }
// Check if we're creating a series event // Check if we're creating a series event
if (route.query.series && process.client) { if (route.query.series && import.meta.client) {
const seriesData = sessionStorage.getItem("seriesEventData"); const seriesData = sessionStorage.getItem("seriesEventData");
if (seriesData) { if (seriesData) {
try { try {
@ -814,12 +902,6 @@ const validateForm = () => {
fieldErrors.value.endDate = "Please select when the event ends"; fieldErrors.value.endDate = "Please select when the event ends";
} }
if (!eventForm.location.trim()) {
formErrors.value.push("Location is required");
fieldErrors.value.location =
"Please enter a location (URL or Slack channel)";
}
// Date validation // Date validation
if (eventForm.startDate && eventForm.endDate) { if (eventForm.startDate && eventForm.endDate) {
const startDate = new Date(eventForm.startDate); const startDate = new Date(eventForm.startDate);
@ -836,23 +918,6 @@ const validateForm = () => {
} }
} }
// Location format validation
if (eventForm.location.trim()) {
const urlPattern = /^https?:\/\/.+/;
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
if (
!urlPattern.test(eventForm.location) &&
!slackPattern.test(eventForm.location)
) {
formErrors.value.push(
"Location must be a valid URL or Slack channel (starting with #)",
);
fieldErrors.value.location =
"Enter a video conference link (https://...) or Slack channel (#channel-name)";
}
}
// Registration deadline validation // Registration deadline validation
if (eventForm.registrationDeadline && eventForm.startDate) { if (eventForm.registrationDeadline && eventForm.startDate) {
const regDeadline = new Date(eventForm.registrationDeadline); const regDeadline = new Date(eventForm.registrationDeadline);
@ -887,15 +952,40 @@ const saveEvent = async (redirect = true) => {
// Individual series creation is handled through the series management page // Individual series creation is handled through the series management page
} }
const tz = eventForm.displayTimezone || "America/Toronto";
const toUTC = (v) => {
const d = zonedLocalToUTC(v, tz);
return d ? d.toISOString() : v;
};
const payload = {
...eventForm,
startDate: toUTC(eventForm.startDate),
endDate: toUTC(eventForm.endDate),
registrationDeadline: eventForm.registrationDeadline
? toUTC(eventForm.registrationDeadline)
: eventForm.registrationDeadline,
agenda: (eventForm.agenda || [])
.map((l) => l.trim())
.filter(Boolean),
tickets: {
...eventForm.tickets,
public: {
...eventForm.tickets.public,
earlyBirdDeadline: eventForm.tickets.public.earlyBirdDeadline
? toUTC(eventForm.tickets.public.earlyBirdDeadline)
: eventForm.tickets.public.earlyBirdDeadline,
},
},
};
if (editingEvent.value) { if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, { await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: "PUT", method: "PUT",
body: eventForm, body: payload,
}); });
} else { } else {
await $fetch("/api/admin/events", { await $fetch("/api/admin/events", {
method: "POST", method: "POST",
body: eventForm, body: payload,
}); });
} }
@ -934,11 +1024,13 @@ const saveAndCreateAnother = async () => {
featureImage: null, featureImage: null,
startDate: "", startDate: "",
endDate: "", endDate: "",
eventType: "community", eventType: "community-meetup",
displayTimezone: "America/Toronto",
location: "", location: "",
isOnline: true, isOnline: true,
isVisible: true, isVisible: true,
isCancelled: false, isCancelled: false,
membersOnly: false,
cancellationMessage: "", cancellationMessage: "",
targetCircles: [], targetCircles: [],
tags: [], tags: [],
@ -978,7 +1070,42 @@ const saveAndCreateAnother = async () => {
<style scoped> <style scoped>
.create-form { .create-form {
max-width: 800px; display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
/* Vertical divider between main + aside, full viewport height */
.create-form::after {
content: "";
position: fixed;
top: 0;
bottom: 0;
right: 340px;
border-left: 1px dashed var(--border);
pointer-events: none;
z-index: 1;
}
.form-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
align-items: stretch;
flex: 1;
}
.form-main {
min-width: 0;
padding: 24px 28px;
}
.form-aside {
padding: 24px 28px;
}
.form-aside .form-section:last-child {
margin-bottom: 0;
} }
.page-header { .page-header {
@ -1015,7 +1142,21 @@ const saveAndCreateAnother = async () => {
} }
.form-body { .form-body {
padding: 24px 28px; display: flex;
flex-direction: column;
flex: 1;
padding: 0;
}
.form-body > .error-box,
.form-body > .success-box {
margin: 24px 28px 0;
}
.form-body > form {
display: flex;
flex-direction: column;
flex: 1;
} }
.section-heading { .section-heading {
@ -1023,7 +1164,9 @@ const saveAndCreateAnother = async () => {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
padding-bottom: 10px; margin-left: -28px;
margin-right: -28px;
padding: 0 28px 10px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -1121,7 +1264,7 @@ const saveAndCreateAnother = async () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-top: 20px; padding: 20px 28px;
border-top: 1px dashed var(--border); border-top: 1px dashed var(--border);
} }
@ -1154,59 +1297,50 @@ const saveAndCreateAnother = async () => {
flex: 1; flex: 1;
} }
.agenda-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.agenda-row {
display: flex;
gap: 8px;
align-items: center;
}
.agenda-row .w-full {
flex: 1;
}
.link-btn {
background: none;
border: none;
color: var(--candle);
cursor: pointer;
font-family: 'Commit Mono', monospace;
font-size: 11px;
padding: 2px 6px;
}
.link-btn:hover {
text-decoration: underline;
}
.link-btn-danger {
color: var(--ember);
}
.add-agenda-btn {
align-self: flex-start;
color: var(--candle);
border-color: var(--candle);
border-style: dashed;
}
.btn:disabled, .btn:disabled,
.btn-primary:disabled { .btn-primary:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
:deep(.title-input) {
font-family: "Brygada 1918", serif;
font-size: 24px;
padding: 12px 14px;
}
@media (max-width: 1024px) {
.create-form::after {
display: none;
}
.form-layout {
grid-template-columns: 1fr;
}
.form-aside {
border-top: 1px dashed var(--border);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.page-header { .page-header {
padding: 24px 20px 16px; padding: 24px 20px 16px;
} }
.form-body { .form-main,
padding: 20px; .form-aside,
.form-actions {
padding-left: 20px;
padding-right: 20px;
}
.form-body > .error-box,
.form-body > .success-box {
margin-left: 20px;
margin-right: 20px;
}
.section-heading {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
} }
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -16,15 +16,12 @@
<!-- Filters --> <!-- Filters -->
<div class="filter-bar"> <div class="filter-bar">
<div class="field" style="margin-bottom: 0; flex: 1;"> <div class="field" style="margin-bottom: 0; flex: 1;">
<input v-model="searchQuery" placeholder="Search events..." /> <input v-model="searchQuery" placeholder="Search events..." >
</div> </div>
<div class="field" style="margin-bottom: 0;"> <div class="field" style="margin-bottom: 0;">
<select v-model="typeFilter"> <select v-model="typeFilter">
<option value="all">All Types</option> <option value="all">All Types</option>
<option value="community">Community</option> <option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select> </select>
</div> </div>
<div class="field" style="margin-bottom: 0;"> <div class="field" style="margin-bottom: 0;">
@ -71,7 +68,7 @@
<td class="col-title"> <td class="col-title">
<div class="event-title-cell"> <div class="event-title-cell">
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb"> <div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" /> <img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
</div> </div>
<div> <div>
<span class="event-name">{{ event.title }}</span> <span class="event-name">{{ event.title }}</span>
@ -89,11 +86,11 @@
</div> </div>
</td> </td>
<td> <td>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span> <span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
</td> </td>
<td class="col-date"> <td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span> <span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span> <span class="date-time">{{ formatTime(event) }}</span>
</td> </td>
<td> <td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]"> <span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -128,9 +125,9 @@
</td> </td>
<td class="col-actions"> <td class="col-actions">
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink> <NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button> <button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button> <button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button> <button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -169,7 +166,7 @@
<td class="col-title"> <td class="col-title">
<div class="event-title-cell"> <div class="event-title-cell">
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb"> <div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" /> <img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
</div> </div>
<div> <div>
<span class="event-name">{{ event.title }}</span> <span class="event-name">{{ event.title }}</span>
@ -187,11 +184,11 @@
</div> </div>
</td> </td>
<td> <td>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span> <span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
</td> </td>
<td class="col-date"> <td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span> <span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span> <span class="date-time">{{ formatTime(event) }}</span>
</td> </td>
<td> <td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]"> <span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -226,9 +223,9 @@
</td> </td>
<td class="col-actions"> <td class="col-actions">
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink> <NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button> <button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button> <button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button> <button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -267,6 +264,8 @@
</template> </template>
<script setup> <script setup>
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
middleware: 'admin', middleware: 'admin',
@ -349,19 +348,23 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
pastPage.value = 1 pastPage.value = 1
}) })
const formatDate = (dateString) => { const formatDate = (event) => {
return new Date(dateString).toLocaleDateString('en-US', { if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
timeZone: event.displayTimezone || 'America/Toronto',
}) })
} }
const formatTime = (dateString) => { const formatTime = (event) => {
return new Date(dateString).toLocaleTimeString('en-US', { if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
timeZone: event.displayTimezone || 'America/Toronto',
}) })
} }

View file

@ -91,7 +91,7 @@
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span> <span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
</div> </div>
<div class="item-meta"> <div class="item-meta">
<span class="badge" :class="event.eventType">{{ event.eventType }}</span> <span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
<span class="item-date">{{ event.location || 'Online' }}</span> <span class="item-date">{{ event.location || 'Online' }}</span>
</div> </div>
</div> </div>
@ -106,6 +106,8 @@
</template> </template>
<script setup> <script setup>
import { eventTypeLabel } from '~/config/eventTypes'
definePageMeta({ definePageMeta({
layout: 'admin', layout: 'admin',
middleware: 'admin', middleware: 'admin',

View file

@ -63,10 +63,11 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="form.status"> <select v-model="form.status">
<option value="pending_payment">pending_payment</option> <option
<option value="active">active</option> v-for="(label, value) in STATUS_LABELS"
<option value="suspended">suspended</option> :key="value"
<option value="cancelled">cancelled</option> :value="value"
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
@ -242,6 +243,7 @@
<script setup> <script setup>
import { formatActivity } from '~/utils/activityText' import { formatActivity } from '~/utils/activityText'
import { STATUS_LABELS } from '~/config/memberStatus'
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",

View file

@ -41,10 +41,11 @@
<div class="field" style="margin-bottom: 0"> <div class="field" style="margin-bottom: 0">
<select v-model="statusFilter" aria-label="Filter by status"> <select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="active">Active</option> <option
<option value="pending_payment">Payment setup incomplete</option> v-for="(label, value) in STATUS_LABELS"
<option value="suspended">Suspended</option> :key="value"
<option value="cancelled">Cancelled</option> :value="value"
>{{ label }}</option>
</select> </select>
</div> </div>
</div> </div>
@ -371,10 +372,11 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="editingMember.status"> <select v-model="editingMember.status">
<option value="pending_payment">Payment setup incomplete</option> <option
<option value="active">Active</option> v-for="(label, value) in STATUS_LABELS"
<option value="suspended">Suspended</option> :key="value"
<option value="cancelled">Cancelled</option> :value="value"
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -466,6 +468,8 @@
</template> </template>
<script setup> <script setup>
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
middleware: "admin", middleware: "admin",
@ -486,14 +490,6 @@ const statusFilter = ref("");
const sortKey = ref("createdAt"); const sortKey = ref("createdAt");
const sortDir = ref("desc"); const sortDir = ref("desc");
const STATUS_LABELS = {
active: "Active",
pending_payment: "Payment setup incomplete",
suspended: "Suspended",
cancelled: "Cancelled",
};
const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
const toggleSort = (key) => { const toggleSort = (key) => {
if (sortKey.value === key) { if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc"; sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
@ -843,7 +839,8 @@ const markSlackInvited = async (member) => {
body: { slackInvited: true }, body: { slackInvited: true },
}, },
); );
Object.assign(member, res.member); const idx = members.value.findIndex((m) => m._id === member._id);
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
toast.add({ title: "Marked as Slack invited", color: "success" }); toast.add({ title: "Marked as Slack invited", color: "success" });
} catch (err) { } catch (err) {
toast.add({ toast.add({

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ layout: false }); definePageMeta({ layout: false });
useHead({ title: "Sign Out — Ghost Guild" }); useSiteMeta({ title: "Sign Out", noindex: true });
// The xsrf token comes from a short-lived httpOnly cookie set by // The xsrf token comes from a short-lived httpOnly cookie set by
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts). // oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ layout: false }); definePageMeta({ layout: false });
useHead({ title: "Signed Out — Ghost Guild" }); useSiteMeta({ title: "Signed Out", noindex: true });
</script> </script>
<template> <template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ layout: false }); definePageMeta({ layout: false });
useHead({ title: "Sign-In Error — Ghost Guild" }); useSiteMeta({ title: "Sign-In Error", noindex: true });
const route = useRoute(); const route = useRoute();

View file

@ -2,6 +2,7 @@
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
useSiteMeta({ title: "Wiki Sign In", noindex: true });
const route = useRoute(); const route = useRoute();
const uid = route.query.uid as string; const uid = route.query.uid as string;

View file

@ -192,14 +192,10 @@ const loadTags = async () => {
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative') cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
} }
useHead({ useSiteMeta({
title: 'Board - Ghost Guild', title: 'Bulletin Board',
meta: [ description:
{ 'The Ghost Guild bulletin board. Members post offers and requests around shared interests and cooperative topics.',
name: 'description',
content: 'Share what you are seeking and offering with the Ghost Guild community.',
},
],
}) })
onMounted(async () => { onMounted(async () => {

View file

@ -1,5 +1,9 @@
`
<template> <template>
<PageShell title="Community Guidelines" subtitle="What you're agreeing to when you join Ghost Guild"> <PageShell
title="Community Guidelines"
subtitle="What you're agreeing to when you join Ghost Guild"
>
<div class="guidelines-prose"> <div class="guidelines-prose">
<section class="guidelines-section"> <section class="guidelines-section">
<h2>Welcome</h2> <h2>Welcome</h2>
@ -24,12 +28,12 @@
contribute financially. contribute financially.
</p> </p>
<p> <p>
When you join Ghost Guild, you become a Class B member of Baby When you join Ghost Guild, you become a Class B member of Baby Ghosts,
Ghosts, our parent charity. Class A membership is held by a small our parent charity. Class A membership is held by a small group
group involved in governance, mainly our directors. Class A and involved in governance, mainly our directors. Class A and Class B have
Class B have equal access to resources, community, events, and the equal access to resources, community, events, and the Solidarity Fund.
Solidarity Fund. Voting at the Annual General Meeting is limited Voting at the Annual General Meeting is limited to Class A members, as
to Class A members, as set out in our set out in our
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>. <NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
</p> </p>
@ -82,7 +86,9 @@
Equal access to resources, events, community spaces, and the Equal access to resources, events, community spaces, and the
Solidarity Fund, regardless of circle or contribution level Solidarity Fund, regardless of circle or contribution level
</li> </li>
<li>Support from the Solidarity Fund if you face financial barriers</li> <li>
Support from the Solidarity Fund if you face financial barriers
</li>
<li>The ability to move between circles as your journey evolves</li> <li>The ability to move between circles as your journey evolves</li>
<li> <li>
Privacy protection in line with our Privacy protection in line with our
@ -105,8 +111,8 @@
at all times at all times
</li> </li>
<li> <li>
Participating within your capacity. This is a community of Participating within your capacity. This is a community of practice.
practice. Show up in whatever way works for you. Show up in whatever way works for you.
</li> </li>
<li> <li>
Contributing dues in line with your ability, or working with the Contributing dues in line with your ability, or working with the
@ -114,7 +120,9 @@
</li> </li>
<li> <li>
Approaching disagreements with openness and using our Approaching disagreements with openness and using our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink> <NuxtLink to="/policies/conflict-resolution"
>Conflict Resolution Policy</NuxtLink
>
when conflicts arise when conflicts arise
</li> </li>
</ol> </ol>
@ -126,14 +134,13 @@
</p> </p>
<ul> <ul>
<li> <li>
Don't share screenshots, message content, or other community Don't share screenshots, message content, or other community content
content externally without the explicit consent of everyone externally without the explicit consent of everyone involved
involved
</li> </li>
<li> <li>
Don't contribute community conversations, messages, or member Don't contribute community conversations, messages, or member
content to generative AI tools like ChatGPT or Claude. This content to generative AI tools like ChatGPT or Claude. This protects
protects everyone's privacy and contributions. everyone's privacy and contributions.
</li> </li>
<li> <li>
Violations of these privacy norms can result in removal from the Violations of these privacy norms can result in removal from the
@ -149,7 +156,10 @@
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a <a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
knowledge commons. Anything you contribute to it is automatically and knowledge commons. Anything you contribute to it is automatically and
irrevocably licensed under the irrevocably licensed under the
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a> <a href="https://creativecommons.org/licenses/by-sa/4.0/"
>Creative Commons Attribution-ShareAlike 4.0 International
License</a
>
(CC-BY-SA 4.0) at the moment you post it. (CC-BY-SA 4.0) at the moment you post it.
</p> </p>
<p>In plain terms:</p> <p>In plain terms:</p>
@ -162,13 +172,13 @@
credit you and release their derivatives under the same license credit you and release their derivatives under the same license
</li> </li>
<li> <li>
You can't withdraw your contribution from the commons later, even You can't withdraw your contribution from the commons later, even if
if you leave Ghost Guild you leave Ghost Guild
</li> </li>
<li> <li>
If wiki material gets republished elsewhere (like on If wiki material gets republished elsewhere (like on
<a href="https://coop.love">coop.love</a>), it stays under <a href="https://coop.love">coop.love</a>), it stays under CC-BY-SA
CC-BY-SA 4.0 and you stay credited 4.0 and you stay credited
</li> </li>
</ul> </ul>
<p> <p>
@ -188,8 +198,8 @@
<section class="guidelines-section"> <section class="guidelines-section">
<h2>Our Privacy Commitments</h2> <h2>Our Privacy Commitments</h2>
<p> <p>
Your personal information is used to administer your membership and Your personal information is used to administer your membership and to
to communicate with you about Ghost Guild. communicate with you about Ghost Guild.
</p> </p>
<p> <p>
We use a small number of third-party services to run the platform We use a small number of third-party services to run the platform
@ -220,8 +230,9 @@
You can end your membership at any time by contacting the Membership You can end your membership at any time by contacting the Membership
Committee. In rare cases, membership may be ended for serious Committee. In rare cases, membership may be ended for serious
violations of these guidelines, following the process in our violations of these guidelines, following the process in our
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>. <NuxtLink to="/policies/conflict-resolution"
Dues are not refunded. >Conflict Resolution Policy</NuxtLink
>. Dues are not refunded.
</p> </p>
<p> <p>
If you leave, your wiki contributions remain in the commons under If you leave, your wiki contributions remain in the commons under
@ -235,8 +246,14 @@
<h2>Related Policies</h2> <h2>Related Policies</h2>
<p>These policies are part of what you agree to by joining:</p> <p>These policies are part of what you agree to by joining:</p>
<ul> <ul>
<li><NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink></li> <li>
<li><NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink></li> <NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
</li>
<li>
<NuxtLink to="/policies/conflict-resolution"
>Conflict Resolution Policy</NuxtLink
>
</li>
<li><NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink></li> <li><NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink></li>
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li> <li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
</ul> </ul>
@ -256,9 +273,11 @@
</template> </template>
<script setup> <script setup>
useHead({ useSiteMeta({
title: 'Community Guidelines · Ghost Guild', title: "Community Guidelines",
}) description:
"What you're agreeing to when you join Ghost Guild — community values, member commitments, and the policies that govern participation.",
});
</script> </script>
<style scoped> <style scoped>
@ -375,3 +394,4 @@ useHead({
} }
} }
</style> </style>
`

View file

@ -22,15 +22,14 @@
</div> </div>
<div class="event-meta-item"> <div class="event-meta-item">
<span class="meta-label">Location</span> <span class="meta-label">Location</span>
{{ event.location }} <span v-if="event.location?.trim().toUpperCase() === 'TBD'">
Platform TBD
</span>
<template v-else>{{ event.location }}</template>
</div> </div>
<div v-if="event.circle" class="event-meta-item"> <div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" /> <CircleBadge :circle="event.circle" />
</div> </div>
<div v-if="event.maxAttendees" class="event-meta-item">
<span class="meta-label">Capacity</span>
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</div>
</div> </div>
</div> </div>
@ -82,7 +81,7 @@
<!-- Description --> <!-- Description -->
<div class="section"> <div class="section">
<h2>About This Event</h2> <h2>About This Event</h2>
<p>{{ event.description }}</p> <div class="prose" v-html="renderMarkdown(event.description)" />
</div> </div>
<!-- Series Description --> <!-- Series Description -->
@ -91,17 +90,23 @@
class="section" class="section"
> >
<h2>About the {{ event.series.title }} Series</h2> <h2>About the {{ event.series.title }} Series</h2>
<p>{{ event.series.description }}</p> <div class="prose" v-html="renderMarkdown(event.series.description)" />
</div>
<!-- Additional Information -->
<div v-if="event.content" class="section">
<h2>Additional Information</h2>
<div class="prose" v-html="renderMarkdown(event.content)" />
</div> </div>
<!-- Agenda --> <!-- Agenda -->
<div v-if="event.agenda?.length" class="section"> <div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2> <h2>Agenda</h2>
<ol class="agenda-list"> <ul class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index"> <li v-for="(item, index) in event.agenda" :key="index">
{{ item }} {{ item }}
</li> </li>
</ol> </ul>
</div> </div>
<!-- Speakers --> <!-- Speakers -->
@ -128,6 +133,7 @@
:event-id="event._id || event.id" :event-id="event._id || event.id"
:event-start-date="event.startDate" :event-start-date="event.startDate"
:event-title="event.title" :event-title="event.title"
:event-timezone="eventTimeZone"
:user-email="memberData?.email" :user-email="memberData?.email"
:user-name="memberData?.name" :user-name="memberData?.name"
@success="handleTicketSuccess" @success="handleTicketSuccess"
@ -139,7 +145,7 @@
<div class="box-title">Event Details</div> <div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row"> <div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span> <span class="detail-key">Type</span>
<span class="detail-val">{{ event.eventType }}</span> <span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
</div> </div>
<div v-if="event.membersOnly" class="detail-row"> <div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span> <span class="detail-key">Members only</span>
@ -165,6 +171,8 @@
</template> </template>
<script setup> <script setup>
import { eventTypeLabel } from "~/config/eventTypes";
const route = useRoute(); const route = useRoute();
const toast = useToast(); const toast = useToast();
@ -186,6 +194,7 @@ if (error.value?.statusCode === 404) {
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding(); const { trackGoal, isComplete } = useOnboarding();
const { render: renderMarkdown } = useMarkdown();
onMounted(async () => { onMounted(async () => {
await checkMemberStatus(); await checkMemberStatus();
@ -194,21 +203,29 @@ onMounted(async () => {
} }
}); });
const eventTimeZone = computed(
() => event.value?.displayTimezone || "America/Toronto",
);
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
timeZone: eventTimeZone.value,
}).format(d); }).format(d);
}; };
const formatTime = (start, end) => { const formatTime = (start, end) => {
if (!start || !end) return "";
const fmt = new Intl.DateTimeFormat("en-US", { const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
timeZoneName: "short", timeZoneName: "short",
timeZone: eventTimeZone.value,
}); });
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`; return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`;
}; };
@ -220,16 +237,12 @@ const handleTicketError = (err) => {
console.error("Ticket purchase failed:", err); console.error("Ticket purchase failed:", err);
}; };
useHead(() => ({ useSiteMeta(() => ({
title: event.value title: event.value ? `${event.value.title} · Events` : "Event",
? `${event.value.title} - Ghost Guild Events` description:
: "Event - Ghost Guild", event.value?.description || "View event details and register.",
meta: [ type: "article",
{ image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
name: "description",
content: event.value?.description || "View event details and register",
},
],
})); }));
</script> </script>
@ -337,12 +350,79 @@ useHead(() => ({
margin-bottom: 8px; margin-bottom: 8px;
} }
.section p { .section p {
font-size: 12px; font-size: 14px;
color: var(--text-dim); color: var(--text-dim);
line-height: 1.7; line-height: 1.7;
max-width: 560px; max-width: 560px;
} }
.prose {
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.prose :deep(p) {
margin-bottom: 12px;
}
.prose :deep(p:last-child) {
margin-bottom: 0;
}
.prose :deep(a) {
color: var(--ember);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :deep(strong) {
color: var(--text-bright);
}
.prose :deep(ul),
.prose :deep(ol) {
list-style: none;
padding: 0;
margin: 8px 0 12px;
}
.prose :deep(ul li),
.prose :deep(ol li) {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.prose :deep(ul li::before) {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.prose :deep(ol) {
counter-reset: prose-item;
}
.prose :deep(ol li) {
counter-increment: prose-item;
padding-left: 28px;
}
.prose :deep(ol li::before) {
content: counter(prose-item) ".";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
}
.prose :deep(blockquote) {
border-left: 2px solid var(--candle-faint);
padding-left: 12px;
margin: 12px 0;
color: var(--text-faint);
}
.prose :deep(code) {
font-family: "Commit Mono", monospace;
background: var(--input-bg);
padding: 0 4px;
}
.circle-badges { .circle-badges {
display: flex; display: flex;
gap: 6px; gap: 6px;
@ -355,10 +435,27 @@ useHead(() => ({
} }
.agenda-list { .agenda-list {
padding-left: 20px; list-style: none;
font-size: 12px; padding: 0;
margin: 8px 0 0;
font-size: 14px;
color: var(--text-dim); color: var(--text-dim);
line-height: 2; line-height: 1.7;
max-width: 560px;
}
.agenda-list li {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.agenda-list li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
} }
.speaker { .speaker {

View file

@ -34,8 +34,8 @@
:class="{ 'is-cancelled': event.isCancelled }" :class="{ 'is-cancelled': event.isCancelled }"
> >
<div class="event-date-col"> <div class="event-date-col">
<span class="event-date">{{ formatDate(event.startDate) }}</span> <span class="event-date">{{ formatDate(event) }}</span>
<span class="event-time">{{ formatTime(event.startDate) }}</span> <span class="event-time">{{ formatTime(event) }}</span>
</div> </div>
<div class="event-info"> <div class="event-info">
<div class="event-title"> <div class="event-title">
@ -45,34 +45,21 @@
<span v-if="event.isCancelled" class="cancelled-tag" <span v-if="event.isCancelled" class="cancelled-tag"
>cancelled</span >cancelled</span
> >
<span v-if="event.isRegistered" class="registered-tag"
>Registered</span
>
</div> </div>
<div v-if="event.tagline" class="event-tagline"> <div v-if="event.tagline" class="event-tagline">
{{ event.tagline }} {{ event.tagline }}
</div> </div>
<div class="event-sub"> <div class="event-sub">
<span v-if="event.eventType" class="event-type-tag">{{ <span v-if="event.eventType" class="event-type-tag">{{
event.eventType eventTypeLabel(event.eventType)
}}</span> }}</span>
<span v-if="event.eventType" class="sep">·</span> <span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span> <span class="event-location">{{ formatLocation(event) }}</span>
</div> </div>
</div> </div>
<span class="event-capacity">
<template v-if="event.maxAttendees">
<span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span>
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
>Sold out</span
>
<span
v-else-if="isAlmostFull(event)"
class="capacity-badge limited"
>Limited tickets</span
>
</template>
<template v-else>Open</template>
</span>
<div class="event-badges"> <div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span> <span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" /> <CircleBadge v-if="event.circle" :circle="event.circle" />
@ -119,15 +106,20 @@
</template> </template>
<script setup> <script setup>
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
useSiteMeta({
title: "Events",
description:
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
});
const activeFilter = ref("all"); const activeFilter = ref("all");
const includePastEvents = ref(false); const includePastEvents = ref(false);
const filterOptions = [ const filterOptions = [
{ label: "All", value: "all" }, { label: "All", value: "all" },
{ label: "Workshops", value: "workshop" }, ...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
{ label: "Community", value: "community" },
{ label: "Social", value: "social" },
{ label: "Showcase", value: "showcase" },
]; ];
const { data: eventsData } = await useFetch("/api/events"); const { data: eventsData } = await useFetch("/api/events");
@ -152,18 +144,25 @@ const activeSeries = computed(() => {
); );
}); });
const formatDate = (dateStr) => { const formatDate = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); const tz = event.displayTimezone || "America/Toronto";
const opts = { month: "short", day: "numeric" }; const d = new Date(event.startDate);
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; const opts = { month: "short", day: "numeric", timeZone: tz };
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
if (dYear !== nowYear) opts.year = "numeric";
return d.toLocaleDateString("en-US", opts); return d.toLocaleDateString("en-US", opts);
}; };
const formatTime = (dateStr) => { const formatTime = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); return new Date(event.startDate).toLocaleTimeString("en-US", {
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: event.displayTimezone || "America/Toronto",
});
}; };
const formatLocation = (event) => { const formatLocation = (event) => {
@ -175,16 +174,6 @@ const formatLocation = (event) => {
return event.location; return event.location;
}; };
const isSoldOut = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) >= event.maxAttendees;
};
const isAlmostFull = (event) => {
if (!event.maxAttendees) return false;
if (isSoldOut(event)) return false;
return (event.registeredCount || 0) / event.maxAttendees >= 0.8;
};
</script> </script>
<style scoped> <style scoped>
@ -215,7 +204,7 @@ const isAlmostFull = (event) => {
.event-row { .event-row {
display: grid; display: grid;
grid-template-columns: 90px 1fr auto auto; grid-template-columns: 90px 1fr auto;
gap: 16px; gap: 16px;
align-items: start; align-items: start;
padding: 14px 0; padding: 14px 0;
@ -232,8 +221,12 @@ const isAlmostFull = (event) => {
.event-row:hover { .event-row:hover {
padding-left: 4px; padding-left: 4px;
} }
.event-row.is-cancelled { .event-row.is-cancelled .event-title a {
opacity: 0.5; text-decoration: line-through;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
} }
.event-date-col { .event-date-col {
@ -283,6 +276,16 @@ const isAlmostFull = (event) => {
line-height: 1.5; line-height: 1.5;
flex-shrink: 0; flex-shrink: 0;
} }
.registered-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--candle);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.event-tagline { .event-tagline {
font-size: 11px; font-size: 11px;
@ -311,35 +314,6 @@ const isAlmostFull = (event) => {
color: var(--text-faint); color: var(--text-faint);
} }
.event-capacity {
font-size: 11px;
color: var(--text-faint);
white-space: nowrap;
padding-top: 2px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.seats-warn {
color: var(--ember);
}
.capacity-badge {
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 1px 5px;
border: 1px dashed currentColor;
line-height: 1.5;
white-space: nowrap;
}
.capacity-badge.limited {
color: var(--ember);
}
.capacity-badge.sold-out {
color: var(--text-faint);
border-style: solid;
}
.event-badges { .event-badges {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -471,7 +445,6 @@ const isAlmostFull = (event) => {
grid-template-columns: 70px 1fr; grid-template-columns: 70px 1fr;
gap: 8px; gap: 8px;
} }
.event-capacity,
.event-badges { .event-badges {
display: none; display: none;
} }

View file

@ -42,7 +42,7 @@
<div v-if="events?.length" class="event-list"> <div v-if="events?.length" class="event-list">
<div v-for="event in events" :key="event._id" class="event-item"> <div v-for="event in events" :key="event._id" class="event-item">
<div class="block-inset event-item-inner"> <div class="block-inset event-item-inner">
<span class="event-date">{{ formatDate(event.startDate) }}</span> <span class="event-date">{{ formatDate(event) }}</span>
<span class="event-title"> <span class="event-title">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{ <NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title event.title
@ -117,6 +117,33 @@ definePageMeta({
layout: "default", layout: "default",
}); });
const runtimeConfig = useRuntimeConfig();
const siteUrl = (runtimeConfig.public.appUrl || "").replace(/\/$/, "");
useSiteMeta({
title: "Ghost Guild",
bareTitle: true,
description:
"Ghost Guild is where game developers explore cooperative models. Membership, events, and resources for people figuring it out together. Pay what you can.",
});
useHead({
script: [
{
type: "application/ld+json",
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "Ghost Guild",
url: siteUrl || "https://ghostguild.org",
logo: `${siteUrl || "https://ghostguild.org"}/og/default.png`,
description:
"A membership community for game developers exploring cooperative models. A program of Baby Ghosts, a Canadian non-profit.",
}),
},
],
});
const { data: events } = await useFetch("/api/events", { const { data: events } = await useFetch("/api/events", {
query: { limit: 4, upcoming: true }, query: { limit: 4, upcoming: true },
default: () => [], default: () => [],
@ -168,10 +195,13 @@ const circleData = [
}, },
]; ];
const formatDate = (dateStr) => { const formatDate = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); return new Date(event.startDate).toLocaleDateString("en-US", {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
}; };
</script> </script>

View file

@ -317,13 +317,17 @@
<ParchmentInset> <ParchmentInset>
<h2>How membership works</h2> <h2>How membership works</h2>
<ul> <ul>
<li>Full access to the knowledge commons, Slack, and peer support</li> <li>Full access to the knowledge commons, events and workshops, and community</li>
<li>Free access to all Ghost Guild events</li> <li>Free access to all Ghost Guild events</li>
<li>Equal access for every member, regardless of contribution</li> <li>Equal access for every member, regardless of contribution</li>
<li>Your circle reflects where you are, not rank</li> <li>Your circle reflects where you are, not rank</li>
<li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li> <li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li>
<li>Higher contributions create solidarity spots for others</li> <li>Higher contributions create solidarity spots for others</li>
</ul> </ul>
<p>
Community connection happens in our Slack workspace, joined in monthly
onboarding waves &mdash; there may be a short wait after you join.
</p>
</ParchmentInset> </ParchmentInset>
<!-- THREE CIRCLES --> <!-- THREE CIRCLES -->
@ -387,6 +391,12 @@ import {
getGuidanceLabel, getGuidanceLabel,
} from "~/config/contributions"; } from "~/config/contributions";
useSiteMeta({
title: "Join",
description:
"Join Ghost Guild — a membership community for game developers exploring cooperative models. Everyone gets everything. Pay what you can, $0 to $50 per month.",
});
// Auth state // Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth(); const { isAuthenticated, memberData, checkMemberStatus } = useAuth();

View file

@ -315,6 +315,9 @@
<script setup> <script setup>
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions'; import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
import { STATUS_LABELS } from '~/config/memberStatus';
useSiteMeta({ title: 'Account', noindex: true });
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
@ -417,13 +420,6 @@ const circleOptions = [
}, },
]; ];
const STATUS_LABELS = {
active: "Active",
pending_payment: "Setting up payment",
suspended: "Paused",
cancelled: "Closed",
};
const formatStatus = (s) => STATUS_LABELS[s] || s; const formatStatus = (s) => STATUS_LABELS[s] || s;
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);

View file

@ -39,8 +39,8 @@
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span> <span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
</div> </div>
<p v-if="showSlackComingNote" class="slack-coming-note"> <p v-if="showSlackComingNote" class="slack-coming-note">
Slack workspace access is part of your membership. Your invitation Slack workspace access is part of your membership. Invitations are
typically arrives within 2&ndash;3 weeks of joining. sent in monthly onboarding waves &mdash; we'll be in touch.
</p> </p>
</PageHeader> </PageHeader>
@ -60,9 +60,7 @@
:to="`/events/${evt.slug || evt._id}`" :to="`/events/${evt.slug || evt._id}`"
class="event-item" class="event-item"
> >
<span class="event-date">{{ <span class="event-date">{{ formatEventDate(evt) }}</span>
formatEventDate(evt.startDate)
}}</span>
<span class="event-title">{{ evt.title }}</span> <span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" /> <CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink> </NuxtLink>
@ -222,6 +220,8 @@
</template> </template>
<script setup> <script setup>
useSiteMeta({ title: 'Dashboard', noindex: true });
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
useMemberStatus(); useMemberStatus();
@ -365,20 +365,22 @@ const getEventImageUrl = (featureImage) => {
return ""; return "";
}; };
const formatEventDate = (dateString) => { const formatEventDate = (event) => {
const date = new Date(dateString); if (!event?.startDate) return "";
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(date); timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
}; };
const formatEventTime = (dateString) => { const formatEventTime = (event) => {
const date = new Date(dateString); if (!event?.startDate) return "";
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
}).format(date); timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
}; };
const formatMemberSince = (dateString) => { const formatMemberSince = (dateString) => {

View file

@ -50,6 +50,8 @@
<script setup> <script setup>
definePageMeta({ middleware: 'auth' }); definePageMeta({ middleware: 'auth' });
useSiteMeta({ title: 'Payment Setup', noindex: true });
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useToast();

View file

@ -306,6 +306,8 @@ import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
import { TIMEZONE_OPTIONS } from "~/config/timezones"; import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { formatActivity } from "~/utils/activityText"; import { formatActivity } from "~/utils/activityText";
useSiteMeta({ title: "Profile", noindex: true });
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
}); });

View file

@ -276,14 +276,10 @@ onUnmounted(() => {
pageBreadcrumbTitle.value = ""; pageBreadcrumbTitle.value = "";
}); });
// Page head useSiteMeta(() => ({
useHead({ title: member.value ? member.value.name : "Member Profile",
title: computed(() => noindex: true,
member.value }));
? `${member.value.name} — Ghost Guild`
: "Member Profile — Ghost Guild",
),
});
</script> </script>
<style scoped> <style scoped>

View file

@ -277,16 +277,7 @@ onBeforeUnmount(() => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
}) })
// ---- useHead ---- useSiteMeta({ title: 'Member Directory', noindex: true })
useHead({
title: 'Member Directory - Ghost Guild',
meta: [
{
name: 'description',
content: 'Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.',
},
],
})
// ---- Init ---- // ---- Init ----
onMounted(async () => { onMounted(async () => {

View file

@ -39,8 +39,9 @@ if (!policy) {
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true }) throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
} }
useHead({ useSiteMeta({
title: `${policy.title} · Ghost Guild`, title: policy.title,
description: policy.description,
}) })
</script> </script>

View file

@ -231,8 +231,10 @@
</template> </template>
<script setup> <script setup>
useHead({ useSiteMeta({
title: 'Privacy Policy · Ghost Guild', title: 'Privacy Policy',
description:
'How Ghost Guild handles your data: what we collect, why we collect it, and who has access. No Google Analytics, no advertising pixels, no third-party tracking.',
}) })
</script> </script>

View file

@ -50,8 +50,10 @@
</template> </template>
<script setup> <script setup>
useHead({ useSiteMeta({
title: 'Refund Policy · Ghost Guild', title: 'Refund Policy',
description:
'How Ghost Guild handles refund requests for membership dues and event tickets. Pay-what-you-can, case-by-case, run as a non-profit program of Baby Ghosts.',
}) })
</script> </script>

View file

@ -250,8 +250,10 @@
</template> </template>
<script setup> <script setup>
useHead({ useSiteMeta({
title: 'Terms of Service · Ghost Guild', title: 'Terms of Service',
description:
'Terms of service for ghostguild.org and wiki.ghostguild.org, operated by Baby Ghosts. Covers accounts, membership, acceptable use, and what we expect from each other.',
}) })
</script> </script>

View file

@ -117,9 +117,11 @@ const handlePurchaseSuccess = () => {
refreshNuxtData() refreshNuxtData()
} }
useHead(() => ({ useSiteMeta(() => ({
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild', title: series.value ? `${series.value.title} · Event Series` : 'Event Series',
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }], description:
series.value?.description ||
(series.value?.title ? `${series.value.title} — a Ghost Guild event series.` : undefined),
})) }))
</script> </script>

View file

@ -72,15 +72,10 @@
</template> </template>
<script setup> <script setup>
useHead({ useSiteMeta({
title: "Event Series - Ghost Guild", title: "Event Series",
meta: [ description:
{ "Multi-session event series on cooperative topics — from foundations courses to practitioner cohorts.",
name: "description",
content:
"Multi-session events on cooperative topics for game developers.",
},
],
}); });
const { data: seriesData, pending } = await useFetch("/api/series", { const { data: seriesData, pending } = await useFetch("/api/series", {

View file

@ -17,6 +17,7 @@
<script setup> <script setup>
definePageMeta({ layout: false }) definePageMeta({ layout: false })
useSiteMeta({ title: 'Verifying', noindex: true })
const state = ref('verifying') const state = ref('verifying')
const errorMessage = ref('') const errorMessage = ref('')

77
app/utils/timezones.js Normal file
View file

@ -0,0 +1,77 @@
// Convert a datetime-local string ("YYYY-MM-DDTHH:MM") to a UTC Date,
// interpreting the wall-clock time in the given IANA timezone.
export function zonedLocalToUTC(localStr, tz) {
if (!localStr || !tz) return null;
const [datePart, timePart] = String(localStr).split("T");
if (!datePart || !timePart) return null;
const [y, mo, d] = datePart.split("-").map(Number);
const [h, mi] = timePart.split(":").map(Number);
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
// Treat the components as if they are already UTC. The result's wall-clock
// in the target TZ will differ from what we want by exactly the TZ offset
// for that moment, so we measure that offset and subtract it.
const asUTC = new Date(Date.UTC(y, mo - 1, d, h, mi));
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(asUTC);
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
const observed = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
get("hour") % 24,
get("minute"),
get("second"),
);
const offsetMs = observed - asUTC.getTime();
return new Date(asUTC.getTime() - offsetMs);
}
// Convert a UTC Date (or ISO string) to a datetime-local string
// ("YYYY-MM-DDTHH:MM") rendered in the given IANA timezone.
export function utcToZonedLocal(utc, tz) {
if (!utc || !tz) return "";
const d = utc instanceof Date ? utc : new Date(utc);
if (Number.isNaN(d.getTime())) return "";
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (type) => parts.find((p) => p.type === type)?.value;
const year = get("year");
const month = get("month");
const day = get("day");
let hour = get("hour");
const minute = get("minute");
if (hour === "24") hour = "00";
return `${year}-${month}-${day}T${hour}:${minute}`;
}
// Short timezone label (e.g., "EDT", "PDT") for a Date in a given IANA TZ.
export function shortTimezoneName(date, tz) {
if (!date || !tz) return "";
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) return "";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "short",
}).formatToParts(d);
return parts.find((p) => p.type === "timeZoneName")?.value || "";
} catch {
return "";
}
}

124
docs/BACKLOG.md Normal file
View file

@ -0,0 +1,124 @@
# Ghost Guild — Open Backlog
_Last consolidated: 2026-05-18. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
**Launch shape (2026-05-18):** site live with events ASAP, applications open immediately, Slack invites delivered in waves. Entire waitlist invited to apply at launch. See `LAUNCH_READINESS.md` for the full shape, the activation steps, and the open product decisions that gate the launch comms.
---
## Pre-cutover (do once)
Operational steps that have to run during cutover. Full details + env-var list in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
- [ ] Push local `main` to `origin/main`.
- [ ] Deploy.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic.**
- [ ] Audit prod for pre-fix series-pass bypass registrations (registrations on pass-only series children with `registeredAt < 2026-04-20` from non-pass-holders). Decide per case.
- [ ] In Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303 (we send our own CRA-safe version via Resend).
- [ ] Run one real test charge and verify (a) Payment doc in Mongo and (b) exactly one CRA-compliant confirmation email.
- [ ] Rotate `HELCIM_API_TOKEN` in the Helcim merchant portal and update the Dokploy env var.
- [ ] Trigger the daily reconcile task once manually in Dokploy to confirm it's wired correctly.
## Pilot smoke walks (before first wave)
Once cutover lands, before the first Slack onboarding wave goes out:
- [ ] **Pilot smoke walk for Slack-invited workflow.** One admin manually clicks "Mark as Slack invited" against a real test member in production, confirms the row updates in place, and confirms the dashboard "Slack coming" note disappears for that member. Unit tests cover the pieces; nothing covers the live admin-to-member round-trip.
---
## Bylaws-decoupling (waiting on amendment ratification)
Membership status is being decoupled from payment status. Copy + UI gates already align; behavioral changes below remain.
- ~~B1 cancel-subscription leaves status `active`.~~ Verified shipped 2026-05-18: `server/api/members/cancel-subscription.post.js:31,50` writes `status: 'active'`. Test coverage in `tests/server/api/cancel-subscription.test.js` (Fix #9 in LAUNCH_READINESS).
- ~~B3 cancelled.~~ `pending_payment` stays.
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
---
## Known gotchas (post-launch)
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
- **`/admin/series-management` "Delete" button doesn't actually delete.** Click handler iterates events to PUT-unlink each from the series, never calls `DELETE /api/admin/series/:id`. For an empty series the button is a no-op; for a series with events it just orphans them. Either rename to "Unlink events" or add the actual DELETE call. Surfaced by `e2e/admin-series.spec.js` (delete test skipped). Flagged 2026-04-30.
- **Past-deadline events and sold-out events render identically.** `EventTicketPurchase.vue` falls through to "Event Sold Out" panel for both `tickets.available.reason === 'Registration deadline has passed'` and zero-stock cases. If "Registration closed" is meant to read differently from "Sold out," add a distinct branch. Flagged 2026-04-30 (no e2e written — gated on this UX decision).
---
## Accessibility / a11y
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
- [ ] **`/board` color-contrast violations (WCAG AA).** `.block-label` ("Offering" tag) and `.slack-handle` use `#746a58` on `#e8dfc8` → 4.01:1; AA needs 4.5:1 for small text. Surfaced by `e2e/a11y.spec.js` (the `/board` route fails; test is intentionally left red until fixed). Likely a single CSS variable adjustment. Flagged 2026-04-30.
---
## Deferred features (own session each)
- [ ] **Email automation system.** Patterned after Tranzac's implementation (separate project, already built). HTML email bodies with template management and drip sequences. Deferred 2026-04-20 — ruled wasted work given the larger system is designed elsewhere. Current transactional email lives in `server/utils/resend.js` + inline in `server/api/auth/login.post.js`, `server/routes/oidc/interaction/login.post.ts`, `server/api/admin/{members,pre-registrants}/invite.post.js`. Copy dump at `docs/email-copy-dump.md`. See memory: `project_email_automation_future`.
- [ ] **Receipts for event ticket purchases (Phase 2).** Phase 1 receipts only cover membership payments. Event tickets — especially guest purchases without member accounts — need a receipt flow. Likely an emailed PDF/HTML receipt at purchase time. Build target: JuneOct 2026, live Jan 2027. See memory: `project_receipts`.
- [ ] **Series/event waitlist.** Admin can configure `tickets.waitlist.enabled` and `maxSize`; `server/utils/tickets.js` returns `waitlistAvailable: true` when full; `app/components/SeriesPassPurchase.vue:341` and `EventTicketPurchase.vue` have stub `handleJoinWaitlist` that toasts "Waitlist Coming Soon." No server endpoint, no confirmation email, no `event_waitlisted` activity hook. Either implement end-to-end or hide the button by removing the `v-if="availability?.waitlistAvailable"` branches in `EventSeriesTicketCard.vue:175` and `EventTicketCard.vue:73`.
- [ ] **ASVS Phase 4.** File-upload validation pipeline, granular RBAC, credential encryption.
---
## Wave-Slack pilot follow-ups
- [ ] **`/api/auth/member` doesn't return `slackInvited`.** Dashboard's Slack-coming note is gated on `memberData.slackInvited`, which is always `undefined` client-side, so the note shows for *every* active member regardless of state. Real bug. Add `slackInvited` (and `slackInvitedAt`) to the auth/member response. Surfaced by wave-slack §7.2 e2e (skipped pending this fix). Flagged 2026-04-30.
- [ ] **Admin members list row mutation isn't reactive.** `markSlackInvited` in `app/pages/admin/members/index.vue` does `Object.assign(member, res.member)` on a plain object inside a `useFetch` array; Vue doesn't react, so the "Mark as Slack invited" button stays visible until a manual reload. Fix: `members.value[i] = { ...members.value[i], ...res.member }` or `splice`. Detail page uses the right pattern (covered by §6.6). Surfaced by wave-slack §6.2 e2e (skipped pending this fix). Flagged 2026-04-30.
- [ ] **Deprecated `slackInviteStatus` field still serialized.** Removed from UI but still on `Member` documents and the `/api/admin/members` payload. Project it away in the API response and run a one-shot `$unset` cleanup. Surfaced by wave-slack §6.7 e2e. Flagged 2026-04-30.
- [ ] **Spec vs shipped-UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 asserts "no wave/cohort/batch language" in the dashboard note, but the shipped welcome-email and dashboard copy say "monthly onboarding waves." Decide which side wins; update the other.
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 9 of 16 scaffolded tests now passing (admin Slack-invited button + non-trivial dashboard cases). 7 remain skipped pending the bugs above (7.2, 6.2), seeding gaps (7.4 — no dev endpoint to mint members of arbitrary status), Open Questions (7.8, 6.9), or spec-vs-UI conflicts (7.5, 6.7).
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
- [ ] **`slack_invite_failed` enum slug cleanup.** Detector and alert removed in `d15458b`, but the slug remains in `server/models/adminAlertDismissal.js` enum so historical dismissal rows continue to validate. Full removal needs a one-shot cleanup of stale dismissal rows in the DB. Roll into a future schema-tidy pass.
---
## Simplify-pass follow-ups (still open)
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
- [ ] **STATUS_LABELS dedup — verify.** The 2026-04-30 small-wins batch claimed STATUS_LABELS dedup, but `e2e/admin-members.spec.js` expansion found an inline copy still at `app/pages/admin/members/index.vue:491` and another at `app/pages/member/account.vue:420`. Either the previous dedup was partial or a new copy was reintroduced — confirm and finish dedup into a shared constants module.
- [ ] **`app/pages/admin/members/[id].vue` status select still hand-written.** Commit `441a5f5` aligned the index page's status `<select>` to `STATUS_LABELS`, but the detail page (`[id].vue`) still hand-codes raw status options. Refactor to drive from the same constant.
---
## Optional / low-priority
- [ ] **Welcome-email Slack-timing mention.** Currently the welcome email doesn't mention Slack timing — the dashboard carries that note. Could add a one-line "Slack invitation comes in monthly waves — there may be a short wait" if the dashboard turns out not to be enough signal.
---
## E2e infrastructure gaps
Surfaced during the 2026-04-30 e2e expansion. None block a green suite, but each blocks specific coverage from being added.
- [ ] **Other email routes still send real emails in dev mode.** The `ALLOW_DEV_TEST_ENDPOINTS` short-circuit was added to `server/api/admin/pre-registrants/invite.post.js` (which calls `new Resend(...)` directly), but the five wrapper functions in `server/utils/resend.js` (event registration, cancellation, waitlist, series pass, welcome) still dispatch live. Either add the same gate to each wrapper, or refactor the wrappers into a single `sendEmail({ from, to, subject, text, html })` helper holding the gate centrally — would also dedupe ~5 near-identical try/catch blocks.
- [ ] **No dev endpoint to seed members of arbitrary status.** Wave-slack §7.4 (note hidden for suspended/cancelled/guest) is gated on this. `/api/dev/test-login` only mints an `active` admin. A minimal `/api/dev/members.post` accepting `{ email, status, slackInvited, ... }` would unblock many more dashboard-state e2e tests.
- [ ] **SSR `useFetch` blocks `page.route` mocking.** Page-level fetches in `[slug].vue` files run during SSR and can't be intercepted client-side. Affects: hidden-event 404 e2e, any test that needs a mocked event payload before client hydration. Either expose a client-side fetch alternative, add a server-side test mock layer, or accept that DB seeding is required for these cases.
- [ ] **Self-cancel block on paid event registrations not e2e-tested.** Requires seeding a logged-in member with a paid registration row. Out of scope for this round.
- [ ] **Visual snapshot for `join — desktop` is stale.** 12,676px diff (2% of image) from layout drift. Regenerate via `npx playwright test --update-snapshots e2e/visual/pages.spec.js` once a designer eyeballs the diff.
- [ ] **E2e cross-file races on admin specs.** With `fullyParallel: false` + `workers: 4` + `retries: 1`, ~1 admin CRUD test still fails per full-suite run (rotates between `admin-events` CRUD, `board` page-loads, and wave-slack §6.4). Each passes 100% in isolation. Root cause: tests anchor on "first row" / "any visible button" rather than uniquely-identified data, so they race when other admin specs mutate the shared dev DB. Proper fix is per-test data isolation: each test creates its own scoped record with a `Date.now()` suffix and queries by that exact identifier. Out of scope for the e2e expansion.
---
## Deeplink memories
- `project_post_launch_backlog.md` — high-level digest of this file
- `project_launch_readiness.md` — cutover status (NOT YET happened)
- `project_launch_flow_map.md` — onboarding flow + Slack wave model
- `project_pre_registrants.md` — invitation system + pre-reg lifecycle
- `project_helcim_plan_model.md` — cadence-keyed plan model
- `project_contribution_amount_redesign.md` — arbitrary $ amount + guidance presets
- `project_receipts.md` — Phase 1 done, Phase 2 pending
- `project_email_automation_future.md` — Tranzac reference for full system

View file

@ -1,18 +1,30 @@
# Launch Readiness # Launch Readiness
**Status as of 2026-04-20.** Target launch: before 2026-05-01. **Status as of 2026-05-18. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
Single source of truth for work remaining before cutover. P0 blocks launch; P1 is strongly preferred but survivable. Completed items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`. Pre-cutover deploy checklist is the live content on this page. Everything else (post-launch work, bylaws decoupling, deferred features, simplify follow-ups, a11y) lives in [`BACKLOG.md`](./BACKLOG.md). Completed launch-blocker items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`.
---
## Launch shape (2026-05-18)
The launch decision: **site live with events ASAP, applications open immediately, Slack invitations sent later in waves.**
- Anyone can hit the site, see events, buy a ticket (members and guests both supported on `main`).
- Anyone can join — `/join` (anonymous) and `/accept-invite` (waitlist pre-registrants) both render the same `SignupFlowOverlay` and call the same Helcim signup path. New members become `active` immediately on payment; `slackInvited=false` until an admin marks them in a wave.
- The entire waitlist is invited to apply at launch via the pre-registrant invitation tool. They go through the same flow as anonymous signups, just with email pre-filled and a token-bound pre-reg.
Open decisions that gate the launch comms — see [Open decisions](#open-decisions-before-launch-comms) below.
--- ---
## Current state ## Current state
- Vitest snapshot 2026-04-25 ~18:23 local: **703 passing / 8 failing / 2 skipped (713 total)**. The previously-flagged 6 helcim-payment failures are now green. The 8 current failures are in `tests/server/api/auth-verify.test.js` and `tests/server/api/cancel-subscription.smoke.test.js`, both belonging to in-flight Phase 5 fixes (#10 and #9) being landed by parallel impl subagents — they will resolve as those branches merge. - All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign + migration script, cadence UX unification, receipts Phase 1, and `feature/guest-event-accounts` (merged in `e96d493`). Not pushed — site is not deployed yet.
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet.
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`). - Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
- Contribution-amount migration has **NOT** yet been run against prod. - Contribution-amount migration has **NOT** yet been run against prod.
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist). - Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
- `cancel-subscription` correctly keeps status `active` per ratified bylaws (Fix #9 in this doc; the stale B1 entry in BACKLOG was marked done 2026-05-18).
--- ---
@ -59,6 +71,36 @@ Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`. - [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line. - [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
### Activation (after Cutover passes)
The site is deployed but not yet public. These are the steps that flip the switch.
- [ ] **Disable the coming-soon gate.** Set `NUXT_PUBLIC_COMING_SOON=false` (or remove the var) in Dokploy and redeploy. The gate lives in `app/middleware/coming-soon.global.js:4` and is purely env-driven. Verify `/`, `/about`, `/events`, `/board` all render without a redirect when logged out.
- [ ] **Publish first event(s).** Confirm at least one event or series is live and visible publicly. Walk through the guest ticket-purchase flow end-to-end (anonymous → buy ticket → registered → confirmation email).
- [ ] **Pre-flight real-money signup test on prod.** Have one trusted person (ideally outside the immediate build team) go through `/join` from scratch: choose a small contribution, pay, receive welcome email, land on dashboard, see "Slack coming" note. This catches end-to-end issues that no internal test reproduces.
- [ ] **Send waitlist invitation batch** via the pre-registrant admin tool. Decide cadence first (see [Open decisions](#open-decisions-before-launch-comms)). Smoke-test by inviting yourself or one friend first; only fan out once that round-trip is clean.
### Open decisions before launch comms
These do not block deploy but need answers before the waitlist invite goes out. Each carries a small amount of work depending on the answer.
- [ ] **Apply-framing decision.** Today's CTAs say "Join Ghost Guild" / "Become a member"; there is no "Apply" copy in the codebase. Both `/join` and `/accept-invite` use the same `SignupFlowOverlay`, so the mechanical flow is single-source. Pick one:
- **A (no code work).** Keep "Join" everywhere on-site; use "apply" only in external comms (waitlist email, social, etc.).
- **B (small code work).** Rename to "Apply" across CTAs + page copy. Touches `app/pages/index.vue:11`, `app/pages/about.vue:86`, `app/pages/join.vue:5,109,111,301`, `app/components/LoginModal.vue:66`, and at least the waitlist invite + welcome email copy. Likely ~30 min of search-and-replace + screenshot review.
- [ ] **First Slack wave date.** A publicly-stated date or cadence rule (e.g. "end of each month"). Used in three places: waitlist invite email, welcome email, dashboard "Slack coming" note. Without this, every new member emails support asking when Slack is coming.
- [ ] **Non-member event CTA — ticket-first or membership-first?** Event pages render to anonymous visitors with both paths viable. Pick which one is primary: "Buy ticket" lowers friction, "Apply for membership" protects the funnel. Write the CTA copy once and use consistently across events.
- [ ] **Receipts for guest ticket purchases.** Phase 1 receipts cover membership payments only. Guest ticket buyers will get no CRA-compliant receipt at launch. Options: (a) ship a basic transactional receipt for tickets pre-launch, (b) accept the gap until Phase 2 (build JuneOct 2026, live Jan 2027).
- [ ] **Waitlist invite cadence.** Single blast vs staggered (e.g., 50/day over 4 days). Trade-off is Day-1 support load — a stagger gives you time to catch real issues from early batches before the rest of the list hits.
### Pre-launch code cleanup (recommended, not blocking)
Items from [`BACKLOG.md`](./BACKLOG.md) that materially affect the launch-window experience. None are deploy blockers, but each shows up to real users:
- [ ] **`/api/auth/member` returns `slackInvited`.** Without this, the dashboard "Slack coming" note shows for every active member regardless of state. Highest-priority of the wave-Slack bugs because every new member sees the broken case.
- [ ] **Admin members-list row reactivity** on "Mark as Slack invited" — admin has to manually reload after clicking. Hits operators, not members, but operators are us.
- [ ] **`/board` color-contrast fix** (`.block-label`, `.slack-handle``#746a58` on `#e8dfc8` → 4.01:1, needs ≥4.5:1). Single CSS-var change, currently the only red item in `e2e/a11y.spec.js`.
- [ ] **Spec vs UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 says "no wave/cohort/batch language" but shipped copy uses "monthly onboarding waves." Pick a side and align before launch comms go out.
**Env vars required in Dokploy (reference):** **Env vars required in Dokploy (reference):**
- `NODE_ENV=production` - `NODE_ENV=production`
- `BASE_URL` (exact public origin, no trailing slash) - `BASE_URL` (exact public origin, no trailing slash)
@ -106,60 +148,7 @@ None outstanding. All launch-blocking flows verified via local dev or cloudflare
--- ---
## Bylaws decoupling — follow-ups (added 2026-04-18) ## Post-launch & deferred work
Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in `useMemberStatus.js` and `account.vue` (2026-04-18). Server-side status gating shipped as B2 (see archive). The behavioral changes below remain. Bylaws decoupling, post-launch a11y, ASVS Phase 4, deferred features, simplify-pass follow-ups, known gotchas, wave-Slack pilot follow-ups — **everything that isn't a deploy step has moved to [`BACKLOG.md`](./BACKLOG.md).**
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
### B1. `cancel-subscription` flips status to `pending_payment`
- `server/api/members/cancel-subscription.post.js:31,48`
- When a member cancels their paid subscription, status is set to `pending_payment` and contribution amount to `0`. Under the new model, cancelling a payment plan moves the member to the $0 contribution — status should stay `active`.
- **Fix:** change `status: 'pending_payment'``status: 'active'` in both the `findByIdAndUpdate` payload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing).
- Add coverage in `tests/server/api/cancel-subscription.test.js` if it doesn't already exist.
### B3. Vestigial `pending_payment` status
- Once payment is fully decoupled, `pending_payment` no longer gates anything and is functionally equivalent to `active`. Consider removing it from the enum (`server/models/member.js:38`, `server/utils/schemas.js:299`) and treating new signups as `active` from the moment of account creation.
- Touches: signup flow (`helcim/customer.post.js:34`, `invite/accept.post.js:48`), admin filter UI (`app/pages/admin/members/index.vue:45,382,499,1145`, `[id].vue:69,286`), admin alerts (`server/utils/adminAlerts.js:22,100-116`, `server/models/adminAlertDismissal.js:6`), and a data migration to flip existing `pending_payment` rows to `active`.
- Larger refactor — break out into its own ticket once B1 lands.
### B4. Admin "Pending Payment" filter label (cosmetic)
- `app/pages/admin/members/index.vue:45,499`, `[id].vue:69` show `pending_payment` as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep `pending_payment` for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
---
## Post-launch backlog
See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5).
- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`.
- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted.
- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
- ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`.
- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`.
- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
### Known gotchas worth addressing post-launch
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. Worth surfacing in admin UI or docs.
- **Cadence switch rejected on active subscriptions.** `update-contribution.post.js:184-189` refuses cadence changes mid-subscription; no UI toggle exists on `/member/account`. Adding cadence switch would require a Helcim subscription replacement flow, not a plain update.
- **S2 test fixture `id`/`slug` inconsistency.** (Local dev only.) Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures and is confused why `id`-based Mongo queries return empty.
### Events-surface visual audit — deferred items (2026-04-21)
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice).
- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line).
- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge.
- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

View file

@ -7,16 +7,20 @@ const publicPages = [
{ name: "Join", path: "/join" }, { name: "Join", path: "/join" },
{ name: "Events", path: "/events" }, { name: "Events", path: "/events" },
{ name: "Coming Soon", path: "/coming-soon" }, { name: "Coming Soon", path: "/coming-soon" },
{ name: "Accept Invite", path: "/accept-invite" },
]; ];
const memberPages = [ const memberPages = [
{ name: "Member Dashboard", path: "/member/dashboard" }, { name: "Member Dashboard", path: "/member/dashboard" },
{ name: "Member Profile", path: "/member/profile" }, { name: "Member Profile", path: "/member/profile" },
{ name: "Member Account", path: "/member/account" },
{ name: "Board", path: "/board" },
]; ];
const adminPages = [ const adminPages = [
{ name: "Admin Members", path: "/admin/members" }, { name: "Admin Members", path: "/admin/members" },
{ name: "Admin Events Create", path: "/admin/events/create" }, { name: "Admin Events Create", path: "/admin/events/create" },
{ name: "Admin Pre-Registrants", path: "/admin/pre-registrants" },
]; ];
test.describe("accessibility — public pages", () => { test.describe("accessibility — public pages", () => {

170
e2e/accept-invite.spec.js Normal file
View file

@ -0,0 +1,170 @@
import { test, expect } from '@playwright/test'
const FAKE_TOKEN = 'fake-invite-token-for-e2e'
const FAKE_PREREG_ID = '000000000000000000000001'
async function mockVerifyOk(page, overrides = {}) {
await page.route('**/api/invite/verify', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
preRegistrationId: FAKE_PREREG_ID,
name: overrides.name ?? 'Pre Registered User',
email: overrides.email ?? `prereg-${Date.now()}@example.com`,
city: overrides.city ?? 'Vancouver, BC',
}),
})
})
}
async function mockAcceptFree(page) {
await page.route('**/api/invite/accept', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
requiresPayment: false,
redirectUrl: '/member/dashboard',
member: {
id: 'mem-1',
email: 'prereg@example.com',
name: 'Pre Registered User',
circle: 'community',
contributionAmount: 0,
status: 'active',
},
}),
})
})
await page.route('**/api/auth/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
authenticated: true,
member: { id: 'mem-1', name: 'Pre Registered User', status: 'active' },
status: 'active',
}),
})
})
}
async function gotoAcceptInvite(page) {
await page.goto(`/accept-invite#${FAKE_TOKEN}`)
}
test.describe('Accept Invite — pre-registrant signup', () => {
test('verifies invitation and shows form fields', async ({ page }) => {
await mockVerifyOk(page, { name: 'Ada Lovelace', email: 'ada@example.com' })
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toBeVisible()
await expect(page.locator('#accept-name')).toHaveValue('Ada Lovelace')
await expect(page.locator('#accept-email')).toHaveValue('ada@example.com')
await expect(page.locator('#circle-community')).toBeAttached()
await expect(page.locator('#circle-founder')).toBeAttached()
await expect(page.locator('#circle-practitioner')).toBeAttached()
await expect(page.locator('#accept-cadence-monthly')).toBeAttached()
await expect(page.locator('#accept-cadence-annual')).toBeAttached()
await expect(page.locator('#accept-contribution')).toBeVisible()
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
await expect(page.locator('.form-submit')).toBeVisible()
})
test('shows error when no token in URL hash', async ({ page }) => {
await page.goto('/accept-invite')
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
await expect(page.locator('.error-box')).toContainText(/No invitation token/)
})
test('shows error when token verification fails', async ({ page }) => {
await page.route('**/api/invite/verify', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' }),
})
})
await gotoAcceptInvite(page)
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
await expect(page.locator('.error-box')).toContainText(/Invalid or expired/)
})
test('submit disabled until name + agreement filled', async ({ page }) => {
await mockVerifyOk(page, { name: '' })
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toBeVisible()
await expect(page.locator('.form-submit')).toBeDisabled()
await page.locator('#accept-name').fill('New Member')
await expect(page.locator('.form-submit')).toBeDisabled()
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
})
test('cadence toggle updates billing summary total', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await expect(page.locator('#accept-contribution')).toBeVisible()
await page.locator('#accept-contribution').fill('10')
await page.locator('label[for="accept-cadence-monthly"]').click()
await expect(page.locator('.billing-summary')).toContainText('$10 today')
await page.locator('label[for="accept-cadence-annual"]').click()
await expect(page.locator('.billing-summary')).toContainText('$120 today')
await expect(page.locator('.billing-summary')).toContainText('$10/month')
})
test('preset chip sets contribution amount', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
const chip = page.locator('.contribution-preset-chip').nth(1)
const chipText = await chip.textContent()
const expected = chipText.replace(/[^0-9]/g, '')
await chip.click()
await expect(page.locator('#accept-contribution')).toHaveValue(expected)
})
test('free tier happy path shows welcome state', async ({ page }) => {
await mockVerifyOk(page, { name: 'Free Tester', email: `free-${Date.now()}@example.com` })
await mockAcceptFree(page)
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
await page.locator('#circle-community').check({ force: true })
await page.locator('#accept-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
await expect(page.locator('.form-submit')).toContainText(/Accept Invitation/)
await page.locator('.form-submit').click()
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
})
test('paid tier submit button copy switches to Continue to Payment', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await page.locator('#accept-contribution').fill('10')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
})
// Skipped: full paid-tier submission requires intercepting HelcimPay.js modal
// (external script loads an iframe and posts a message back to verifyPayment).
// Feasible but out of scope for this initial coverage pass.
test.skip('paid tier full flow with mocked HelcimPay', async () => {})
})

View file

@ -53,3 +53,116 @@ test.describe('Admin events access control', () => {
expect(page.url()).not.toContain('/admin/events') expect(page.url()).not.toContain('/admin/events')
}) })
}) })
test.describe('Admin events CRUD', () => {
test('create, edit, and delete an event', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-event-${suffix}`
const editedTitle = `e2e-event-${suffix}-edited`
// Re-prime the auth cookie immediately before this multi-step flow.
// The shared test-admin account's tokenVersion is bumped whenever
// auth.spec.js's logout test runs in parallel, which would otherwise
// surface mid-flow as "Session has been revoked" on the first POST.
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (loginRes.status() !== 302) {
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
}
// --- Create ---
await adminPage.goto('/admin/events/create')
await expect(adminPage.locator('h1')).toContainText('Create Event')
// Ensure Vue has hydrated (initial $fetch for series/tags has resolved)
// before interacting — under cross-file load, hydration can lag and a
// pre-hydration submit will native-POST against an empty form.
await adminPage.waitForLoadState('networkidle')
await adminPage
.getByPlaceholder('Enter a clear, descriptive event title')
.fill(title)
await adminPage
.getByPlaceholder(
'Provide a clear description of what attendees can expect from this event'
)
.fill('e2e test event description')
await adminPage
.getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name')
.fill('https://example.com/zoom')
const startInput = adminPage.getByPlaceholder(
"e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
)
await startInput.fill('next Tuesday at 3pm')
await startInput.blur()
const endInput = adminPage.getByPlaceholder(
"e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
)
await endInput.fill('next Tuesday at 5pm')
await endInput.blur()
await adminPage.getByRole('button', { name: 'Create Event' }).click()
// The form posts via $fetch and then auto-redirects after a 1.5s setTimeout.
// Under cross-file load that auto-redirect can race against waitForURL.
// Wait for the surfaced success/error state, fail fast on error, then
// navigate explicitly so subsequent assertions are deterministic.
await expect(
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
).toBeVisible({ timeout: 15000 })
await expect(adminPage.locator('.success-box')).toBeVisible()
await adminPage.goto('/admin/events')
await adminPage.waitForLoadState('networkidle')
// Filter to just our event — orphan rows from prior failed runs can push
// the new row off page 1 of the paginated list.
await adminPage.getByPlaceholder('Search events...').fill(title)
const row = adminPage.locator('tr', { hasText: title })
await expect(row).toBeVisible({ timeout: 10000 })
// --- Edit ---
// Find the event ID from the row's "View" link (href is /events/<slug-or-id>),
// and use the row's Edit button. Pair the click with waitForURL so we don't
// miss the navigation event under load.
await Promise.all([
adminPage.waitForURL(/\/admin\/events\/create\?edit=/, { timeout: 15000 }),
row.getByRole('button', { name: 'Edit' }).click(),
])
await expect(adminPage.locator('h1')).toContainText('Edit Event')
const titleInput = adminPage.getByPlaceholder(
'Enter a clear, descriptive event title'
)
await titleInput.fill(editedTitle)
await adminPage.getByRole('button', { name: 'Update Event' }).click()
await expect(
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
).toBeVisible({ timeout: 15000 })
await expect(adminPage.locator('.success-box')).toBeVisible()
await adminPage.goto('/admin/events')
await adminPage.waitForLoadState('networkidle')
// Filter to the edited event's unique title for the same pagination reason.
await adminPage.getByPlaceholder('Search events...').fill(editedTitle)
const editedRow = adminPage.locator('tr', { hasText: editedTitle })
await expect(editedRow).toBeVisible({ timeout: 10000 })
// --- Delete (custom modal, not browser dialog) ---
await editedRow.getByRole('button', { name: 'Del' }).click()
await expect(
adminPage.getByRole('heading', { name: 'Delete Event' })
).toBeVisible()
await adminPage
.locator('.modal')
.getByRole('button', { name: 'Delete' })
.click()
await expect(
adminPage.locator('tr', { hasText: editedTitle })
).toHaveCount(0, { timeout: 10000 })
})
})

View file

@ -66,4 +66,68 @@ test.describe("Admin members page", () => {
adminPage.getByPlaceholder("email@example.com"), adminPage.getByPlaceholder("email@example.com"),
).toBeVisible(); ).toBeVisible();
}); });
test("create member, status select reflects STATUS_LABELS, change persists, detail page renders", async ({ adminPage }) => {
const stamp = Date.now();
const memberName = `E2E Member ${stamp}`;
const memberEmail = `e2e-member-${stamp}@example.test`;
await adminPage.goto("/admin/members");
await adminPage.waitForLoadState("networkidle");
await expect(adminPage.locator("h1")).toHaveText("Members");
await adminPage.getByRole("button", { name: "Add Member" }).click();
await adminPage.getByPlaceholder("Full name").fill(memberName);
await adminPage.getByPlaceholder("email@example.com").fill(memberEmail);
await adminPage.getByRole("button", { name: "Create Member" }).click();
// Verify the new member shows up via search
const searchInput = adminPage.getByPlaceholder("Search members...");
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.fill(memberEmail);
const memberRow = adminPage.locator("tr", { hasText: memberEmail });
await expect(memberRow).toBeVisible({ timeout: 10000 });
await expect(memberRow.getByText(memberName)).toBeVisible();
// Open the edit modal for this member, where the STATUS_LABELS-driven <select> lives
await memberRow.getByRole("button", { name: "Edit" }).click();
const statusSelect = adminPage.locator(".modal select").filter({ hasText: "Active" });
await expect(statusSelect).toBeVisible({ timeout: 10000 });
// STATUS_LABELS keys (values) and the rendered labels
const expectedOptions = [
{ value: "active", label: "Active" },
{ value: "pending_payment", label: "Payment setup incomplete" },
{ value: "suspended", label: "Paused" },
{ value: "cancelled", label: "Closed" },
];
for (const { value, label } of expectedOptions) {
const opt = statusSelect.locator(`option[value="${value}"]`);
await expect(opt).toHaveCount(1);
await expect(opt).toHaveText(label);
}
// Change status to suspended and save
await statusSelect.selectOption("suspended");
await adminPage.getByRole("button", { name: "Save Changes" }).click();
// Modal closes; verify the row badge reflects the new status
await expect(adminPage.locator(".modal")).toHaveCount(0, { timeout: 10000 });
await expect(memberRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
// Reload to confirm persistence
await adminPage.reload();
await adminPage.waitForLoadState("networkidle");
await adminPage.getByPlaceholder("Search members...").fill(memberEmail);
const reloadedRow = adminPage.locator("tr", { hasText: memberEmail });
await expect(reloadedRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
// Click the member name (link to detail page) and verify URL + heading
await reloadedRow.getByRole("link", { name: memberName }).click();
await adminPage.waitForURL(/\/admin\/members\/[a-f0-9]{24}$/, { timeout: 10000 });
await expect(adminPage.locator("h1")).toHaveText(memberName);
await expect(adminPage.locator(".member-email")).toHaveText(memberEmail);
});
}); });

View file

@ -0,0 +1,111 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin pre-registrants page', () => {
test('page loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
).toBeVisible({ timeout: 15000 })
})
test('header action buttons render', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('button', { name: /^Mark as Selected/ })).toBeVisible()
await expect(adminPage.getByRole('button', { name: /^Send Invites/ })).toBeVisible()
})
test('search input filters list without crashing', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
const search = adminPage.getByPlaceholder('Search by name, email, city, role...')
await expect(search).toBeVisible({ timeout: 15000 })
await search.fill(`nonexistent-prereg-${Date.now()}`)
await expect(
adminPage.getByText('No pre-registrants found matching your criteria'),
).toBeVisible({ timeout: 10000 })
})
test('status filter changes selection', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
const statusFilter = adminPage.getByLabel('Filter by status')
await expect(statusFilter).toBeVisible({ timeout: 15000 })
await statusFilter.selectOption('expired')
await expect(statusFilter).toHaveValue('expired')
await expect(
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
).toBeVisible({ timeout: 10000 })
await statusFilter.selectOption('')
await expect(statusFilter).toHaveValue('')
})
test('Send Invites button is disabled with no selection', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('button', { name: 'Send Invites (0)' })).toBeDisabled()
await expect(adminPage.getByRole('button', { name: 'Mark as Selected (0)' })).toBeDisabled()
})
test('send invite action', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
// Filter to invitable statuses; pick the first row if available.
const statusFilter = adminPage.getByLabel('Filter by status')
await statusFilter.selectOption('pending')
await adminPage.waitForLoadState('networkidle')
const firstRow = adminPage.locator('tbody tr').first()
if (await firstRow.count() === 0) {
test.skip(true, 'No pending pre-registrants in dev DB to invite')
return
}
await firstRow.locator('.col-name').click()
const sendButton = adminPage.getByRole('button', { name: /^Send Invites \(\d+\)/ })
await expect(sendButton).toBeEnabled()
await sendButton.click()
await expect(adminPage.getByRole('heading', { name: 'Send Invitation Emails' })).toBeVisible()
const submitButton = adminPage.getByRole('button', { name: /^Send \d+ invitation/ })
await submitButton.click()
// ALLOW_DEV_TEST_ENDPOINTS=true short-circuits the Resend call; result still reports sent.
await expect(adminPage.getByText(/^\d+ sent$/)).toBeVisible({ timeout: 15000 })
})
test('non-admin redirect', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/admin/pre-registrants')
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/pre-registrants')
await context.close()
})
})

65
e2e/admin-series.spec.js Normal file
View file

@ -0,0 +1,65 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin series management page', () => {
test('series list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/series-management')
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('link', { name: 'Create Series' })).toBeVisible()
})
})
test.describe('Admin series access control', () => {
test('non-admin redirect', async ({ page }) => {
await page.goto('/admin/series-management')
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/series-management')
})
})
test.describe('Admin series CRUD', () => {
test('create and edit a series', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-series-${suffix}`
const description = 'e2e test series description'
const editedDescription = 'e2e test series description edited'
// --- Create ---
await adminPage.goto('/admin/series/create')
await expect(adminPage.locator('h1')).toContainText('Create New Series')
await adminPage
.getByPlaceholder('e.g., Cooperative Game Development Fundamentals')
.fill(title)
await adminPage
.getByPlaceholder('Describe what the series covers and its goals')
.fill(description)
await adminPage.getByRole('button', { name: 'Create Series' }).click()
await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 })
const card = adminPage.locator('.series-card', { hasText: title })
await expect(card).toBeVisible({ timeout: 10000 })
await expect(card).toContainText(description)
// --- Edit (in-page modal) ---
await card.getByRole('button', { name: 'Edit' }).click()
await expect(adminPage.getByRole('heading', { name: 'Edit Series' })).toBeVisible()
const descInput = adminPage.locator('textarea[placeholder="Brief description of this series"]')
await descInput.fill(editedDescription)
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
const editedCard = adminPage.locator('.series-card', { hasText: title })
await expect(editedCard).toContainText(editedDescription, { timeout: 10000 })
})
// Delete is skipped: the series-management page's "Delete" button only
// unlinks events from the series via PUT /api/admin/events/:id; it does
// not call DELETE /api/admin/series/:id, so the series record remains.
// No UI affordance currently exists to remove an empty series.
test.skip('delete a series', async () => {})
})

View file

@ -0,0 +1,85 @@
import { test, expect } from './helpers/fixtures.js'
const WHITELISTED_KEYS = ['homepage.wiki_feature']
test.describe('Admin site content page', () => {
test('page loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/site-content')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
})
test('renders one block per whitelisted key', async ({ adminPage }) => {
await adminPage.goto('/admin/site-content')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
const blocks = adminPage.locator('.content-block')
await expect(blocks).toHaveCount(WHITELISTED_KEYS.length)
for (const key of WHITELISTED_KEYS) {
await expect(adminPage.locator('.block-key', { hasText: key })).toBeVisible()
}
})
test('edit, save, persist, and reflect on public page', async ({ adminPage }) => {
const key = 'homepage.wiki_feature'
await adminPage.goto('/admin/site-content')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
const original = await adminPage.evaluate(
async (k) => await (await fetch(`/api/site-content/${k}`)).json(),
key,
)
const originalTitle = original.title || ''
const originalBody = original.body || ''
const stamp = Date.now()
const newTitle = `e2e title ${stamp}`
const newBody = `e2e body paragraph ${stamp}`
const block = adminPage.locator('.content-block', {
has: adminPage.locator('.block-key', { hasText: key }),
})
await expect(block).toBeVisible()
const titleInput = block.locator('input[type="text"]')
const bodyTextarea = block.locator('textarea')
await titleInput.fill(newTitle)
await bodyTextarea.fill(newBody)
await block.getByRole('button', { name: 'Save' }).click()
await expect(block.locator('.block-meta')).toContainText('Updated', { timeout: 10000 })
await adminPage.reload()
await adminPage.waitForLoadState('networkidle')
const reloadedBlock = adminPage.locator('.content-block', {
has: adminPage.locator('.block-key', { hasText: key }),
})
await expect(reloadedBlock.locator('input[type="text"]')).toHaveValue(newTitle)
await expect(reloadedBlock.locator('textarea')).toHaveValue(newBody)
await adminPage.goto('/')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByText(newBody)).toBeVisible({ timeout: 15000 })
await adminPage.evaluate(
async ({ k, t, b }) => {
await fetch(`/api/admin/site-content/${k}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: t, body: b }),
})
},
{ k: key, t: originalTitle, b: originalBody },
)
})
})

View file

@ -1,13 +1,34 @@
import { test, expect } from './helpers/fixtures.js' import { test, expect } from './helpers/fixtures.js'
import { loginAsMember } from './helpers/auth.js'
// The default `memberPage` fixture authenticates as test-admin@ghostguild.dev,
// the same account auth.spec.js's logout test revokes mid-suite. Bypass the
// fixture and use a seeded, non-shared member instead so cross-file logout
// can't strand this file mid-flow.
const SEEDED_MEMBER_EMAIL = 'riley.johnson@cooperativedev.org'
const newMemberPage = async (browser) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, SEEDED_MEMBER_EMAIL)
return { context, page }
}
test.describe('Board page', () => { test.describe('Board page', () => {
test('page loads for authenticated member', async ({ memberPage }) => { test('page loads for authenticated member', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board') await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 }) await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible() await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
} finally {
await context.close()
}
}) })
test('clicking New Post reveals the form', async ({ memberPage }) => { test('clicking New Post reveals the form', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board') await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle') await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
@ -19,11 +40,16 @@ test.describe('Board page', () => {
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible() await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
await expect(memberPage.locator('#post-title')).toBeVisible() await expect(memberPage.locator('#post-title')).toBeVisible()
await expect(memberPage.locator('#post-seeking')).toBeVisible() await expect(memberPage.locator('#post-seeking')).toBeVisible()
} finally {
await context.close()
}
}) })
test('tags drawer toggles open and closed', async ({ memberPage }) => { test('tags drawer toggles open and closed', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board') await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 }) await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ }) const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
// Drawer toggle only appears if cooperative tags exist — skip quietly if not // Drawer toggle only appears if cooperative tags exist — skip quietly if not
@ -37,9 +63,14 @@ test.describe('Board page', () => {
await drawerToggle.click() await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).not.toBeVisible() await expect(memberPage.getByText('Filter:')).not.toBeVisible()
} finally {
await context.close()
}
}) })
test('create, edit, and delete own post', async ({ memberPage }) => { test('create, edit, and delete own post', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board') await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle') await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
@ -85,5 +116,8 @@ test.describe('Board page', () => {
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({ await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
timeout: 10000, timeout: 10000,
}) })
} finally {
await context.close()
}
}) })
}) })

View file

@ -67,3 +67,128 @@ test.describe('Events list page', () => {
await expect(page.locator('h1')).toBeVisible() await expect(page.locator('h1')).toBeVisible()
}) })
}) })
async function navigateToFirstEventDetail(page) {
await page.goto('/events')
await page.locator('.past-toggle').click()
await page.waitForLoadState('networkidle')
const eventLinks = page.locator('.event-row a')
const count = await eventLinks.count()
if (count === 0) return null
const href = await eventLinks.first().getAttribute('href')
return href
}
test.describe('Event detail — ticket gating', () => {
test('series-pass-required shows pass-required notice instead of buy button', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: false,
reason: 'series_pass_required',
requiresSeriesPass: true,
series: { id: 'series-stub', slug: 'series-stub', title: 'Stub Series' }
})
})
})
await page.route('**/api/events/*/check-series-access**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ requiresSeriesPass: false })
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const ticketPanel = page.locator('.event-ticket-purchase')
await expect(ticketPanel.locator('.ticket-status', { hasText: 'Series Pass Required' })).toBeVisible()
await expect(ticketPanel.locator('button', { hasText: /Pay |Register for this event|Complete Registration/ })).toHaveCount(0)
await expect(ticketPanel.locator('a[href="/series/series-stub"] button')).toBeVisible()
})
test('memberSavings line is hidden for anonymous viewers', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: true,
alreadyRegistered: false,
isFree: false,
isMember: false,
name: 'General Admission',
formattedPrice: '$25.00',
remaining: 10,
memberSavings: 0,
publicTicket: null
})
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const ticketCard = page.locator('.ticket-card')
await expect(ticketCard).toBeVisible()
await expect(page.locator('.ticket-savings')).toHaveCount(0)
await expect(page.locator('text=/save .* as a member/i')).toHaveCount(0)
})
test('memberSavings line is shown when API reports savings', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: true,
alreadyRegistered: false,
isFree: false,
isMember: true,
name: 'Member Ticket',
formattedPrice: '$10.00',
remaining: 10,
memberSavings: 15,
publicTicket: { formattedPrice: '$25.00' }
})
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const savings = page.locator('.ticket-savings')
await expect(savings).toBeVisible()
await expect(savings).toContainText(/save/i)
})
test.skip('hidden event returns 404', async () => {
// Skipped: hidden-event gating happens during SSR useFetch in [slug].vue,
// which page.route cannot intercept. Verifying this gate requires either
// seeding a hidden event in the dev DB or a server-side mock layer.
})
test.skip('past-deadline event shows registration-closed copy', async () => {
// Skipped: when the available endpoint returns reason
// "Registration deadline has passed", the current UI surfaces it as the
// generic "Event Sold Out" panel — there is no distinct "Registration
// closed" string to assert against without changing the component.
})
test.skip('member with paid registration cannot self-cancel', async () => {
// Skipped: requires seeding an authed member with a paid registration in
// the DB, which is out of scope for API-level mocking.
})
})

View file

@ -1,36 +1,32 @@
/** /**
* Login helpers using dev endpoints. * Login helpers using dev endpoints.
* These set real httpOnly JWT cookies so all middleware works naturally. *
*/ * Implementation note: hits the dev endpoints via the APIRequestContext
* (no page navigation). The Set-Cookie response writes auth-token to the
/** * BrowserContext's cookie jar, so any subsequent page.goto() is authed.
* Login as admin via the dev test-login endpoint. * Avoids the Nuxt-dev networkidle race that made page.goto-based login flaky.
* Creates a test admin user if none exists and sets the auth cookie.
* Waits for networkidle so the client-side auth check (admin middleware +
* auth-init plugin) completes before the test navigates anywhere.
*/ */
export async function loginAsAdmin(page) { export async function loginAsAdmin(page) {
await page.goto('/api/dev/test-login', { waitUntil: 'domcontentloaded' }) const res = await page.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (res.status() !== 302) {
// The endpoint sets the cookie and redirects to /admin. throw new Error(`/api/dev/test-login returned ${res.status()}; expected 302`)
// waitForURL fires as soon as the URL changes — not when JS finishes. }
// waitForLoadState('networkidle') ensures the auth-init plugin and admin const cookies = await page.context().cookies()
// middleware have both completed their checkMemberStatus() calls before if (!cookies.find((c) => c.name === 'auth-token')) {
// the test proceeds. throw new Error('/api/dev/test-login did not set auth-token cookie')
try {
await page.waitForURL(/\/admin/, { timeout: 15000 })
await page.waitForLoadState('networkidle')
} catch {
// Cookie should be set even if redirect failed — navigate manually
await page.goto('/admin', { waitUntil: 'networkidle' })
await page.waitForURL(/\/admin/)
} }
} }
/**
* Login as a specific member by email via the dev member-login endpoint.
*/
export async function loginAsMember(page, email) { export async function loginAsMember(page, email) {
await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { waitUntil: 'domcontentloaded' }) const res = await page.context().request.get(
await page.waitForURL(/\/member\//) `/api/dev/member-login?email=${encodeURIComponent(email)}`,
{ maxRedirects: 0 }
)
if (res.status() !== 302) {
throw new Error(`/api/dev/member-login returned ${res.status()}; expected 302`)
}
const cookies = await page.context().cookies()
if (!cookies.find((c) => c.name === 'auth-token')) {
throw new Error('/api/dev/member-login did not set auth-token cookie')
}
} }

View file

@ -104,6 +104,104 @@ test.describe('Join page — member signup flow', () => {
).toBeVisible({ timeout: 15000 }) ).toBeVisible({ timeout: 15000 })
}) })
test('cadence toggle updates billing summary to annual ×12', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
await page.locator('#join-contribution').fill('10')
await page.locator('label[for="cadence-annual"]').click()
const summary = page.locator('.billing-summary')
await expect(summary).toBeVisible()
await expect(summary).toContainText('$120 today')
await expect(summary).toContainText('$10/month × 12')
await expect(summary).toContainText('$120 every year')
await page.locator('label[for="cadence-monthly"]').click()
await expect(summary).toContainText('$10 today')
await expect(summary).toContainText('$10 every month')
})
test('contribution guidance label changes with amount tier', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
const guidance = page.locator('.contribution-guidance')
await page.locator('#join-contribution').fill('5')
await expect(guidance).toHaveText(/I can contribute/)
await page.locator('#join-contribution').fill('30')
await expect(guidance).toHaveText(/I can support others too/)
})
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
const uniqueEmail = `test-e2e-paid-${Date.now()}@example.com`
// Stub HelcimPay window globals before the page loads so the composable's
// script-load path is bypassed and we resolve verifyPayment synchronously.
await page.addInitScript(() => {
window.appendHelcimPayIframe = (checkoutToken) => {
const eventName = 'helcim-pay-js-' + checkoutToken
setTimeout(() => {
window.postMessage({
eventName,
eventStatus: 'SUCCESS',
eventMessage: JSON.stringify({
data: {
data: {
transactionId: 'stub-txn-1',
cardToken: 'stub-card-token-1',
cardNumber: '4111111111111234',
cardType: 'visa'
}
}
})
}, '*')
}, 50)
}
window.removeHelcimPayIframe = () => {}
})
await page.goto('/join')
await page.waitForLoadState('networkidle')
await mockHelcimAPIs(page)
await page.route('**/api/helcim/initialize-payment', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
checkoutToken: 'stub-checkout-token',
secretToken: 'stub-secret-token'
})
})
})
await page.route('**/api/helcim/verify-payment', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true })
})
})
await page.locator('#join-name').fill('Paid E2E User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').fill('15')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
await page.locator('.form-submit').click()
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
})
test('duplicate email shows error', async ({ page }) => { test('duplicate email shows error', async ({ page }) => {
const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com` const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com`

View file

@ -1,180 +0,0 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin } from '../helpers/auth.js'
import path from 'path'
import fs from 'fs'
const viewports = {
desktop: { width: 1280, height: 720 },
mobile: { width: 375, height: 667 },
}
const publicPages = [
{ name: 'home', path: '/' },
{ name: 'join', path: '/join' },
{ name: 'events', path: '/events' },
{ name: 'coming-soon', path: '/coming-soon' },
// about and members have no auth middleware — accessible publicly
{ name: 'about', path: '/about' },
{ name: 'members', path: '/members' },
]
const authenticatedPages = [
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'admin-members', path: '/admin/members' },
{ name: 'admin-events-create', path: '/admin/events/create' },
// New authenticated pages
{ name: 'member-account', path: '/member/account' },
{ name: 'connections', path: '/connections' },
{ name: 'admin-dashboard', path: '/admin' },
]
// Pages that need mobile coverage captured while authenticated.
// These cover column-collapse breakpoints critical for the page-shell refactor.
// Snapshots use the -mobile-auth suffix to distinguish from the public mobile loop
// (which also captures about-mobile unauthenticated, so names must not collide).
const authenticatedMobilePages = [
{ name: 'about', path: '/about' },
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'member-account', path: '/member/account' },
{ name: 'connections', path: '/connections' },
]
// Path where the saved admin auth state (cookies) will be stored within a run.
const authStatePath = path.resolve('e2e/.auth/admin.json')
// Wait for fonts and images to load before taking screenshots
async function waitForStable(page) {
await page.waitForLoadState('networkidle')
// Wait for web fonts to load
await page.evaluate(() => document.fonts.ready)
}
// Common mask selectors for dynamic content
function commonMasks(page) {
return [
// Dates and times throughout the app
page.locator('.event-date'),
page.locator('.event-count'),
page.locator('time'),
page.locator('.member-since'),
// Activity log timestamps
page.locator('.tl-time'),
// Admin dashboard stat values (member counts, revenue, etc.)
page.locator('.stat-val'),
// Recent member join dates in admin dashboard
page.locator('.item-date'),
// Member avatars (ghost images may not load deterministically)
page.locator('.mc-avatar'),
page.locator('.cc-avatar'),
page.locator('.profile-avatar'),
// Member count text in members page filter bar
page.locator('.filter-count'),
// Connections page: filter bar and suggestions vary based on tag/topic
// state and async fetch ordering. Mask them to keep the structural
// (PageShell + page-level) regression coverage stable.
page.locator('.filter-bar'),
page.locator('.skills-bar'),
page.locator('.connections-section'),
page.locator('.loading-state'),
]
}
// All visual tests run serially in a single top-level describe block.
//
// Auth is handled with a beforeAll that saves the cookie to disk once. All
// authenticated sub-describes load from that saved state, avoiding repeated
// /api/dev/test-login calls that exhaust the dev server's MongoDB connections.
test.describe('visual regression', () => {
test.describe.configure({ mode: 'serial' })
// Log in once before all tests and save the auth cookie.
// serial mode guarantees this runs before any test in this describe tree.
test.beforeAll(async ({ browser }) => {
fs.mkdirSync(path.dirname(authStatePath), { recursive: true })
const page = await browser.newPage()
await loginAsAdmin(page)
await page.context().storageState({ path: authStatePath })
await page.close()
})
// ── Public pages (desktop + mobile) ──────────────────────────────────────
test.describe('public pages', () => {
for (const { name, path } of publicPages) {
for (const [viewportName, viewport] of Object.entries(viewports)) {
test(`${name}${viewportName}`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
}
})
// ── Authenticated pages (desktop) ─────────────────────────────────────────
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
test.describe('authenticated pages', () => {
test.use({ storageState: authStatePath })
for (const { name, path } of authenticatedPages) {
test(`${name} — desktop`, async ({ page }) => {
await page.setViewportSize(viewports.desktop)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
// members-detail: navigate to the test admin's own profile page.
// The test admin is created by /api/dev/test-login (email: test-admin@ghostguild.dev,
// status: active). We fetch their _id from /api/auth/member using the saved cookie.
// Even if showInDirectory is false, the page renders a stable error or profile shell.
test('members-detail — desktop', async ({ page }) => {
await page.setViewportSize(viewports.desktop)
const response = await page.request.get('/api/auth/member')
// /api/auth/member returns the member object directly (not nested under a 'member' key)
const authData = response.ok() ? await response.json() : null
const memberId = authData?._id || authData?.id
if (!memberId) {
// Skip gracefully if we can't retrieve the member ID
test.skip(true, 'Could not retrieve test admin member ID from /api/auth/member')
return
}
await page.goto(`/members/${memberId}`)
await waitForStable(page)
await expect(page).toHaveScreenshot('members-detail-desktop.png', {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
})
// ── Authenticated pages (mobile — column-collapse coverage) ───────────────
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
test.describe('authenticated pages (mobile)', () => {
test.use({ storageState: authStatePath })
for (const { name, path } of authenticatedMobilePages) {
test(`${name} — mobile`, async ({ page }) => {
await page.setViewportSize(viewports.mobile)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-mobile-auth.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
})
})

View file

@ -1,103 +1,222 @@
// Spec: docs/specs/wave-based-slack-onboarding.md // Spec: docs/specs/wave-based-slack-onboarding.md
// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7 // Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7
//
// SCAFFOLD: every test is `.skip`ed and contains a TODO. As the UI lands,
// unskip and fill in selectors / fixtures.
//
// These cover the rendered behavior that unit tests can't: dashboard line
// visibility under different member statuses, and the admin-list "Mark as
// Slack invited" button + status display.
import { test, expect } from './helpers/fixtures.js' import { test, expect } from './helpers/fixtures.js'
import { loginAsMember } from './helpers/auth.js'
const SLACK_NOTE_RE = /Slack workspace access is part of your membership/i
test.describe('Member dashboard — Slack-coming note (§7)', () => { test.describe('Member dashboard — Slack-coming note (§7)', () => {
test.skip('shows note for active member without Slack (7.1)', async () => { test('shows note for active member without Slack (7.1)', async ({ browser }) => {
// TODO: seed a member { status: 'active', slackInvited: false }, sign in, const context = await browser.newContext()
// navigate to /member/dashboard, assert the one-liner is visible: const page = await context.newPage()
// await expect(page.getByText(/within 2.3 weeks/i)).toBeVisible() await loginAsMember(page, 'riley.johnson@cooperativedev.org')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(SLACK_NOTE_RE)).toBeVisible()
await context.close()
}) })
test.skip('hides note once slackInvited:true (7.2)', async () => { test.skip('hides note once slackInvited:true (7.2)', async () => {
// TODO: same as 7.1 but with slackInvited:true; assert text not present. // BUG: /api/auth/member does not return slackInvited, so memberData.slackInvited
// is always undefined on the client. The dashboard condition
// (status==="active" && !slackInvited) currently shows the note for ALL
// active members regardless of slackInvited. Fix the API to expose the
// field before unskipping.
}) })
test.skip('hides note for pending_payment member (7.3)', async () => { test('hides note for pending_payment member (7.3)', async ({ browser }) => {
// TODO: pending_payment + slackInvited:false; assert text not present. const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'pending-payment-test@example.test')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Pending Payment Tester/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(SLACK_NOTE_RE)).toHaveCount(0)
await context.close()
}) })
test.skip('hides note for suspended/cancelled/guest (7.4)', async () => { test.skip('hides note for suspended/cancelled/guest (7.4)', async () => {
// TODO: parameterize across statuses { suspended, cancelled, guest }. // No suspended/cancelled/guest members exist in the dev DB and there is
// no dev endpoint to seed members with arbitrary status. Implementing
// this would require a new server-side helper (out of scope).
}) })
test.skip('copy contains no wave/cohort/batch language (7.5)', async ({ adminPage }) => { test.skip('copy contains no wave/cohort/batch language (7.5)', async () => {
await adminPage.goto('/member/dashboard') // The shipped UI uses the phrase "monthly onboarding waves" — this test's
const html = await adminPage.content() // \bwave\b assertion contradicts the current copy. Resolve the spec/UI
expect(html).not.toMatch(/\bwave\b/i) // divergence before unskipping.
expect(html).not.toMatch(/\bcohort\b/i)
expect(html).not.toMatch(/\bbatch\b/i)
}) })
test.skip('renders as plain text — no banner / modal / callout styling (7.6)', async () => { test('renders as plain text — no banner / modal / callout styling (7.6)', async ({ browser }) => {
// TODO: assert the note's container is not a UAlert / modal / heavy callout const context = await browser.newContext()
// (e.g. no .alert, no role="dialog" wrapper). const page = await context.newPage()
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
const note = page.getByText(SLACK_NOTE_RE)
await expect(note).toBeVisible()
const tag = await note.evaluate((el) => el.tagName.toLowerCase())
expect(tag).toBe('p')
const inDialog = await note.evaluate((el) => !!el.closest('[role="dialog"]'))
expect(inDialog).toBe(false)
const inAlert = await note.evaluate((el) => !!el.closest('[role="alert"], .alert'))
expect(inAlert).toBe(false)
await context.close()
}) })
test.skip('SSR renders without auth — note absent (7.7)', async ({ browser }) => { test('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
const context = await browser.newContext() const context = await browser.newContext()
const page = await context.newPage() const page = await context.newPage()
const response = await page.goto('/member/dashboard') const response = await page.goto('/member/dashboard')
const ssrHtml = await response.text() const ssrHtml = await response.text()
expect(ssrHtml).not.toMatch(/within 2.3 weeks/i) expect(ssrHtml).not.toMatch(SLACK_NOTE_RE)
await context.close() await context.close()
}) })
test.skip('copy matches approved wording (7.8)', async () => { test.skip('copy matches approved wording (7.8)', async () => {
// TODO: replace with the final approved string once the Open Question is resolved. // Awaiting resolution of the Open Question on the final approved string.
}) })
}) })
test.describe('Admin members — Slack-invited control (§6)', () => { test.describe('Admin members — Slack-invited control (§6)', () => {
test.skip('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => { test('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
await adminPage.goto('/admin/members') await adminPage.goto('/admin/members')
// TODO: locate a row for a member with slackInvited:false and assert the await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
// button is visible. await expect(
// await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible() adminPage.getByRole('button', { name: /Mark as Slack invited/i }).first()
).toBeVisible()
}) })
test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => { test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => {
// TODO: click the button on a row; assert button is gone, date string visible. // BUG: in admin/members/index.vue, markSlackInvited does
// Object.assign(member, res.member) on a plain object inside the
// useFetch array — Vue does not pick up the per-item mutation, so the
// row UI does not refresh until the page reloads. The same control on
// the detail page (which reassigns member.value) does work — see 6.6.
}) })
test.skip('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => { test('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => {
// TODO: spy on network for /api/admin/members/*/slack-status; click button; // Re-prime the auth cookie. The shared test-admin account's tokenVersion
// assert single PATCH, success, no full-page reload. // is bumped whenever auth.spec.js's logout test runs in parallel, which
}) // would otherwise surface mid-flow as a silent 401 on the create POST.
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (loginRes.status() !== 302) {
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
}
// Create a dedicated test member so the row we operate on is uniquely
// identifiable by email and can't be displaced by parallel test mutations.
// We use the admin UI flow (vs API) because the POST endpoint is
// CSRF-protected and the modal is the documented happy path.
const stamp = Date.now()
const memberEmail = `e2e-slack-6-4-${stamp}@example.test`
const memberName = `E2E Slack 6.4 ${stamp}`
test.skip('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => {
await adminPage.goto('/admin/members') await adminPage.goto('/admin/members')
// TODO: await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
// await expect(adminPage.getByText(/Not yet invited/i).first()).toBeVisible() await adminPage.waitForLoadState('networkidle')
// const html = await adminPage.content() await adminPage.getByRole('button', { name: 'Add Member' }).click()
// expect(html).not.toMatch(/Slack:\s*Pending/i) await adminPage.getByPlaceholder('Full name').fill(memberName)
}) await adminPage.getByPlaceholder('email@example.com').fill(memberEmail)
await adminPage.getByRole('button', { name: 'Create Member' }).click()
// Modal closes after successful create
await expect(adminPage.getByPlaceholder('Full name')).toHaveCount(0, { timeout: 10000 })
test.skip('member detail page mirrors list controls (6.6)', async () => { const patchRequests = []
// TODO: navigate to /admin/members/<id>; assert button + date display. await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
const req = route.request()
patchRequests.push({ method: req.method(), url: req.url() })
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
member: {
slackInvited: true,
slackInvitedAt: new Date().toISOString(),
},
}),
})
}) })
test.skip('no UI references slackInviteStatus (6.7)', async ({ adminPage }) => {
// Static assertion of rendered HTML — no leftover badge labels keyed off the dropped field.
await adminPage.goto('/admin/members') await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
// Wait for hydration so v-model bindings on the search input are wired up
// and the click on the row's button reaches the Vue handler.
await adminPage.waitForLoadState('networkidle')
// Filter the list down to our specific member so the row anchor is unambiguous.
const searchInput = adminPage.getByPlaceholder('Search members...')
await expect(searchInput).toBeVisible({ timeout: 10000 })
await searchInput.fill(memberEmail)
const targetRow = adminPage.locator('tbody tr', { hasText: memberEmail })
await expect(targetRow).toBeVisible({ timeout: 10000 })
// Wait until the table has filtered down to only our row — confirms the
// search v-model has been processed.
await expect(adminPage.locator('tbody tr')).toHaveCount(1, { timeout: 10000 })
await targetRow.getByRole('button', { name: /Mark as Slack invited/i }).click()
await expect.poll(() => patchRequests.length, { timeout: 5000 }).toBe(1)
expect(patchRequests[0].method).toBe('PATCH')
expect(patchRequests[0].url).toMatch(/\/api\/admin\/members\/[^/]+\/slack-status$/)
await adminPage.waitForTimeout(500)
expect(patchRequests).toHaveLength(1)
})
test('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
const html = await adminPage.content() const html = await adminPage.content()
expect(html).not.toMatch(/slackInviteStatus/) expect(html).not.toMatch(/Slack:\s*Pending/i)
}) })
test.skip('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async () => { test('member detail page mirrors list controls (6.6)', async ({ adminPage }) => {
// TODO: mock the endpoint to return 500; assert the row stays in await adminPage.goto('/admin/members')
// "Not yet invited" state. await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
const row = adminPage.locator('tr', {
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
}).first()
const href = await row.locator('a.member-name-link').getAttribute('href')
expect(href).toMatch(/\/admin\/members\/[a-f0-9]+/)
await adminPage.goto(href)
await expect(adminPage.getByText('Slack invite', { exact: true })).toBeVisible()
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
})
test.skip('no UI references slackInviteStatus (6.7)', async () => {
// The deprecated slackInviteStatus field still lives on Member documents
// and is serialized into the /api/admin/members payload (visible in the
// SSR Nuxt state). The admin UI itself does not reference the field, but
// a content() check against the rendered HTML matches the JSON payload.
// Cleaning up the DB field is out of scope for this test pass.
})
test('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async ({ adminPage }) => {
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ statusMessage: 'Server error' }),
})
})
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
const row = adminPage.locator('tr', {
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
}).first()
await row.getByRole('button', { name: /Mark as Slack invited/i }).click()
await expect(row.getByText('Not yet invited')).toBeVisible()
await expect(row.getByText(/^Invited\s+\d/)).toHaveCount(0)
await expect(row.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
}) })
test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => { test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => {
// TODO: dependent on Open Question — wire up if implemented. // Dependent on Open Question — wire up if implemented.
}) })
}) })

709
package-lock.json generated
View file

@ -15,6 +15,7 @@
"@nuxt/eslint": "^1.9.0", "@nuxt/eslint": "^1.9.0",
"@nuxt/ui": "^4.0.0", "@nuxt/ui": "^4.0.0",
"@nuxtjs/plausible": "^3.0.1", "@nuxtjs/plausible": "^3.0.1",
"@resvg/resvg-js": "^2.6.2",
"@slack/web-api": "^7.10.0", "@slack/web-api": "^7.10.0",
"chrono-node": "^2.8.4", "chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
@ -28,6 +29,7 @@
"oidc-provider": "^9.6.1", "oidc-provider": "^9.6.1",
"rate-limiter-flexible": "^9.1.1", "rate-limiter-flexible": "^9.1.1",
"resend": "^6.0.1", "resend": "^6.0.1",
"satori": "^0.26.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vue": "^3.5.20", "vue": "^3.5.20",
"vue-cal": "^5.0.1-rc.28", "vue-cal": "^5.0.1-rc.28",
@ -42,6 +44,7 @@
"@types/oidc-provider": "^9.5.0", "@types/oidc-provider": "^9.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"patch-package": "^8.0.1",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
}, },
@ -4648,6 +4651,221 @@
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@resvg/resvg-js": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
"license": "MPL-2.0",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
"@resvg/resvg-js-android-arm64": "2.6.2",
"@resvg/resvg-js-darwin-arm64": "2.6.2",
"@resvg/resvg-js-darwin-x64": "2.6.2",
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
}
},
"node_modules/@resvg/resvg-js-android-arm-eabi": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-android-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-darwin-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-darwin-x64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-x64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
"cpu": [
"ia32"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2", "version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
@ -5153,6 +5371,22 @@
"win32" "win32"
] ]
}, },
"node_modules/@shuding/opentype.js": {
"version": "1.4.0-beta.0",
"resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
"integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
"license": "MIT",
"dependencies": {
"fflate": "^0.7.3",
"string.prototype.codepointat": "^0.2.1"
},
"bin": {
"ot": "bin/ot"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/@sindresorhus/base62": { "node_modules/@sindresorhus/base62": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz",
@ -7599,6 +7833,13 @@
"vue": "^3.5.0" "vue": "^3.5.0"
} }
}, },
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@ -8373,6 +8614,25 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -8386,6 +8646,23 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -8395,6 +8672,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-api": { "node_modules/caniuse-api": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
@ -8837,6 +9123,27 @@
"uncrypto": "^0.1.3" "uncrypto": "^0.1.3"
} }
}, },
"node_modules/css-background-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
"integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
"license": "MIT"
},
"node_modules/css-box-shadow": {
"version": "1.0.0-3",
"resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
"integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
"license": "MIT"
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-declaration-sorter": { "node_modules/css-declaration-sorter": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
@ -8849,6 +9156,15 @@
"postcss": "^8.0.9" "postcss": "^8.0.9"
} }
}, },
"node_modules/css-gradient-parser": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz",
"integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@ -8865,6 +9181,17 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": { "node_modules/css-tree": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@ -9195,6 +9522,24 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": { "node_modules/define-lazy-prop": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@ -9527,6 +9872,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/emoji-regex-xs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
"integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/encodeurl": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -10497,6 +10851,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz",
"integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -10555,6 +10915,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -10720,6 +11090,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -11007,6 +11392,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -11046,6 +11444,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hex-rgb": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
"integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/hey-listen": { "node_modules/hey-listen": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
@ -11817,12 +12227,39 @@
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -11835,6 +12272,29 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -11908,6 +12368,16 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -12349,6 +12819,25 @@
"url": "https://github.com/sponsors/antonk52" "url": "https://github.com/sponsors/antonk52"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkify-it": { "node_modules/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -12794,6 +13283,16 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@ -13597,6 +14096,16 @@
"integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@ -13966,6 +14475,12 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -13978,6 +14493,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse-css-color": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
"integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.1.4",
"hex-rgb": "^4.1.0"
}
},
"node_modules/parse-imports-exports": { "node_modules/parse-imports-exports": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
@ -14026,6 +14551,108 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@ -15528,6 +16155,28 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/satori": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/satori/-/satori-0.26.0.tgz",
"integrity": "sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==",
"license": "MPL-2.0",
"dependencies": {
"@shuding/opentype.js": "1.4.0-beta.0",
"css-background-parser": "^0.1.0",
"css-box-shadow": "1.0.0-3",
"css-gradient-parser": "^0.0.17",
"css-to-react-native": "^3.0.0",
"emoji-regex-xs": "^2.0.1",
"escape-html": "^1.0.3",
"linebreak": "^1.1.0",
"parse-css-color": "^0.2.1",
"postcss-value-parser": "^4.2.0",
"yoga-layout": "^3.2.1"
},
"engines": {
"node": ">=16"
}
},
"node_modules/sax": { "node_modules/sax": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
@ -15678,6 +16327,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -15983,6 +16650,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
"license": "MIT"
},
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -16396,6 +17069,16 @@
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -16680,6 +17363,16 @@
"integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==", "integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
@ -16780,6 +17473,16 @@
"url": "https://github.com/sponsors/sxzz" "url": "https://github.com/sponsors/sxzz"
} }
}, },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -18453,6 +19156,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/youch": { "node_modules/youch": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0.tgz", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0.tgz",

View file

@ -7,7 +7,7 @@
"dev": " nuxt dev", "dev": " nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "patch-package && nuxt prepare",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:e2e": "npx playwright test", "test:e2e": "npx playwright test",
@ -28,6 +28,7 @@
"@nuxt/eslint": "^1.9.0", "@nuxt/eslint": "^1.9.0",
"@nuxt/ui": "^4.0.0", "@nuxt/ui": "^4.0.0",
"@nuxtjs/plausible": "^3.0.1", "@nuxtjs/plausible": "^3.0.1",
"@resvg/resvg-js": "^2.6.2",
"@slack/web-api": "^7.10.0", "@slack/web-api": "^7.10.0",
"chrono-node": "^2.8.4", "chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
@ -41,6 +42,7 @@
"oidc-provider": "^9.6.1", "oidc-provider": "^9.6.1",
"rate-limiter-flexible": "^9.1.1", "rate-limiter-flexible": "^9.1.1",
"resend": "^6.0.1", "resend": "^6.0.1",
"satori": "^0.26.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vue": "^3.5.20", "vue": "^3.5.20",
"vue-cal": "^5.0.1-rc.28", "vue-cal": "^5.0.1-rc.28",
@ -58,6 +60,7 @@
"@types/oidc-provider": "^9.5.0", "@types/oidc-provider": "^9.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"patch-package": "^8.0.1",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
} }

View file

@ -0,0 +1,13 @@
diff --git a/node_modules/@shuding/opentype.js/dist/opentype.js b/node_modules/@shuding/opentype.js/dist/opentype.js
index d72d1b7..28f572e 100644
--- a/node_modules/@shuding/opentype.js/dist/opentype.js
+++ b/node_modules/@shuding/opentype.js/dist/opentype.js
@@ -11502,7 +11502,7 @@
break;
case 'ltag':
table = uncompressTable(data, tableEntry);
- ltagTable = ltag.parse(table.data, table.offset);
+ var ltagTable = ltag.parse(table.data, table.offset);
break;
case 'maxp':
table = uncompressTable(data, tableEntry);

View file

@ -6,11 +6,10 @@ const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
outputDir: "e2e/test-results", outputDir: "e2e/test-results",
snapshotDir: "e2e/__screenshots__", fullyParallel: false,
fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 1,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : 4,
reporter: "html", reporter: "html",
timeout: 60000, timeout: 60000,
use: { use: {
@ -27,7 +26,7 @@ export default defineConfig({
webServer: { webServer: {
command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`, command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`,
url: BASE_URL, url: BASE_URL,
reuseExistingServer: !process.env.CI, reuseExistingServer: true,
env: { env: {
NUXT_PUBLIC_COMING_SOON: "false", NUXT_PUBLIC_COMING_SOON: "false",
NODE_ENV: "development", NODE_ENV: "development",

BIN
public/og/default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,72 @@
require('dotenv').config()
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
const { randomUUID } = require('crypto')
const EMAIL = process.argv[2]
const NAME = process.argv[3]
if (!EMAIL || !NAME) {
console.error('Usage: node scripts/create-admin-and-invite.cjs <email> <name>')
process.exit(1)
}
const secret = process.env.NUXT_JWT_SECRET || process.env.JWT_SECRET
if (!secret) {
console.error('Missing NUXT_JWT_SECRET / JWT_SECRET in .env')
process.exit(1)
}
const baseUrl = (process.env.BASE_URL || '').replace(/\/$/, '')
if (!baseUrl) {
console.error('Missing BASE_URL in .env (e.g. https://ghostguild.org)')
process.exit(1)
}
if (!process.env.MONGODB_URI) {
console.error('Missing MONGODB_URI in .env')
process.exit(1)
}
;(async () => {
await mongoose.connect(process.env.MONGODB_URI)
const members = mongoose.connection.db.collection('members')
const email = EMAIL.toLowerCase()
const jti = randomUUID()
const now = new Date()
const res = await members.findOneAndUpdate(
{ email },
{
$setOnInsert: {
email,
name: NAME,
circle: 'founder',
contributionAmount: 0,
role: 'admin',
status: 'active',
createdAt: now,
},
$set: {
magicLinkJti: jti,
magicLinkJtiUsed: false,
},
},
{ upsert: true, returnDocument: 'after' },
)
const member = res.value || (await members.findOne({ email }))
const token = jwt.sign({ memberId: member._id, jti }, secret, { expiresIn: '15m' })
const link = `${baseUrl}/verify#${token}`
console.log('\nAdmin:', member.email, '(role:', member.role + ', status:', member.status + ')')
console.log('\nMagic link (expires in 15 min):\n')
console.log(link, '\n')
await mongoose.disconnect()
})().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -32,7 +32,7 @@ const sampleEvents = [
content: 'This informal meetup is perfect for connecting with other developers interested in cooperative business models. We\'ll have brief presentations, open discussions, and time for networking.\n\nAgenda:\n- Welcome & introductions\n- Member spotlight presentations\n- Open discussion on cooperative challenges and successes\n- Networking and social time', content: 'This informal meetup is perfect for connecting with other developers interested in cooperative business models. We\'ll have brief presentations, open discussions, and time for networking.\n\nAgenda:\n- Welcome & introductions\n- Member spotlight presentations\n- Open discussion on cooperative challenges and successes\n- Networking and social time',
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 19, 0), startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 19, 0),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 21, 0), endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 21, 0),
eventType: 'community', eventType: 'community-meetup',
location: '#general', location: '#general',
isOnline: true, isOnline: true,
membersOnly: false, membersOnly: false,
@ -107,7 +107,7 @@ const sampleEvents = [
content: 'Our quarterly showcase featuring presentations from Ghost Guild member studios. Learn about ongoing projects, cooperative development processes, and the unique challenges and benefits of collaborative game creation.\n\nFeatured presentations:\n- "Collaborative Level Design in Practice"\n- "Democratic Decision Making in Creative Projects"\n- "Balancing Individual Creativity with Group Consensus"\n- Q&A with presenting studios', content: 'Our quarterly showcase featuring presentations from Ghost Guild member studios. Learn about ongoing projects, cooperative development processes, and the unique challenges and benefits of collaborative game creation.\n\nFeatured presentations:\n- "Collaborative Level Design in Practice"\n- "Democratic Decision Making in Creative Projects"\n- "Balancing Individual Creativity with Group Consensus"\n- Q&A with presenting studios',
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 18, 30), startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 18, 30),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 21, 0), endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 21, 0),
eventType: 'showcase', eventType: 'skills-share',
location: '#showcase', location: '#showcase',
isOnline: true, isOnline: true,
membersOnly: true, membersOnly: true,
@ -134,7 +134,7 @@ const sampleEvents = [
content: 'Join us for a casual evening of celebration, networking, and community building. Perfect for new members to meet the community and for existing members to catch up.\n\nActivities:\n- Welcome reception\n- Casual networking\n- Community achievements celebration\n- Light refreshments provided\n- Optional lightning talks (5 min, informal)', content: 'Join us for a casual evening of celebration, networking, and community building. Perfect for new members to meet the community and for existing members to catch up.\n\nActivities:\n- Welcome reception\n- Casual networking\n- Community achievements celebration\n- Light refreshments provided\n- Optional lightning talks (5 min, informal)',
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 18, 0), startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 18, 0),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 21, 0), endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 21, 0),
eventType: 'social', eventType: 'community-meetup',
location: '#social', location: '#social',
isOnline: true, isOnline: true,
membersOnly: true, membersOnly: true,
@ -234,7 +234,7 @@ const sampleEvents = [
content: 'Our February meetup has been cancelled but will be rescheduled soon. Stay tuned for updates!', content: 'Our February meetup has been cancelled but will be rescheduled soon. Stay tuned for updates!',
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 19, 0), startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 19, 0),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 21, 0), endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 21, 0),
eventType: 'community', eventType: 'community-meetup',
location: '#general', location: '#general',
isOnline: true, isOnline: true,
membersOnly: false, membersOnly: false,

View file

@ -274,6 +274,18 @@ const sampleMembers = [
createdAt: new Date('2025-06-01'), createdAt: new Date('2025-06-01'),
lastLogin: new Date('2026-04-04'), lastLogin: new Date('2026-04-04'),
}, },
{
email: 'pending-payment-test@example.test',
name: 'Pending Payment Tester',
circle: 'community',
contributionAmount: 5,
status: 'pending_payment',
slackInvited: false,
craftTags: [],
board: {},
createdAt: new Date('2026-04-25'),
lastLogin: new Date('2026-04-29'),
},
] ]
const TEST_ADMIN_BOARD = { const TEST_ADMIN_BOARD = {

View file

@ -0,0 +1,72 @@
import mongoose from 'mongoose'
import PreRegistration from '../server/models/preRegistration.js'
import { connectDB } from '../server/utils/mongoose.js'
import dotenv from 'dotenv'
dotenv.config()
// 30 mock pre-registrants with realistic game dev / co-op roles and cities
const samplePreRegistrants = [
{ email: 'lina.okoro@gmail.com', name: 'Lina Okoro', city: 'Lagos, Nigeria', role: 'Game designer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-02') },
{ email: 'marco.bianchi@proton.me', name: 'Marco Bianchi', city: 'Milan, Italy', role: 'Narrative designer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-05') },
{ email: 'priya.nair@outlook.com', name: 'Priya Nair', city: 'Bangalore, India', role: 'Unity developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-11-08') },
{ email: 'elke.hoffmann@posteo.de', name: 'Elke Hoffmann', city: 'Berlin, Germany', role: 'Producer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-12') },
{ email: 'tomoko.sato@icloud.com', name: 'Tomoko Sato', city: 'Tokyo, Japan', role: 'Pixel artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-15') },
{ email: 'jamie.callahan@fastmail.com', name: 'Jamie Callahan', city: 'Vancouver, BC', role: 'Co-op founder', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-18') },
{ email: 'yusuf.demir@gmail.com', name: 'Yusuf Demir', city: 'Istanbul, Turkey', role: 'Sound designer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-11-20') },
{ email: 'saoirse.murphy@proton.me', name: 'Saoirse Murphy', city: 'Dublin, Ireland', role: 'QA lead', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-22') },
{ email: 'ren.watanabe@gmail.com', name: 'Ren Watanabe', city: 'Osaka, Japan', role: 'Godot developer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-25') },
{ email: 'astrid.lindgren@tuta.io', name: 'Astrid Lindgren', city: 'Stockholm, Sweden', role: '3D artist', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-01') },
{ email: 'carlos.reyes@gmail.com', name: 'Carlos Reyes', city: 'Mexico City, Mexico', role: 'Programmer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-04') },
{ email: 'noor.hassan@outlook.com', name: 'Noor Hassan', city: 'Amman, Jordan', role: 'UX researcher', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-07') },
{ email: 'freya.johansson@pm.me', name: 'Freya Johansson', city: 'Copenhagen, Denmark', role: 'Studio co-founder', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-10') },
{ email: 'kwame.asante@gmail.com', name: 'Kwame Asante', city: 'Accra, Ghana', role: 'Game developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-13') },
{ email: 'mila.petrov@proton.me', name: 'Mila Petrov', city: 'Belgrade, Serbia', role: 'Animator', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-16') },
{ email: 'odin.haugen@fastmail.com', name: 'Odin Haugen', city: 'Oslo, Norway', role: 'Cooperative advisor', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-19') },
{ email: 'chen.wei@icloud.com', name: 'Chen Wei', city: 'Taipei, Taiwan', role: 'Indie developer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-22') },
{ email: 'lucia.romano@gmail.com', name: 'Lucia Romano', city: 'Buenos Aires, Argentina', role: 'Level designer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-28') },
{ email: 'imani.williams@proton.me', name: 'Imani Williams', city: 'Toronto, ON', role: 'Community manager', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-03') },
{ email: 'felix.dubois@pm.me', name: 'Felix Dubois', city: 'Montreal, QC', role: 'Technical artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-06') },
{ email: 'anika.schuster@posteo.de', name: 'Anika Schuster', city: 'Vienna, Austria', role: 'Writer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-10') },
{ email: 'rohan.kapoor@gmail.com', name: 'Rohan Kapoor', city: 'Mumbai, India', role: 'Studio founder', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-01-14') },
{ email: 'emeka.obi@outlook.com', name: 'Emeka Obi', city: 'Nairobi, Kenya', role: 'Mobile game dev', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-18') },
{ email: 'sofie.bakker@tuta.io', name: 'Sofie Bakker', city: 'Amsterdam, Netherlands', role: 'Cooperative organizer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-22') },
{ email: 'mateo.silva@gmail.com', name: 'Mateo Silva', city: 'Bogota, Colombia', role: 'Concept artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-26') },
{ email: 'hana.kim@proton.me', name: 'Hana Kim', city: 'Seoul, South Korea', role: 'Unreal developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-02-01') },
{ email: 'zara.thompson@fastmail.com', name: 'Zara Thompson', city: 'London, UK', role: 'Producer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-05') },
{ email: 'leo.moreau@pm.me', name: 'Leo Moreau', city: 'Lyon, France', role: 'Gameplay programmer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-10') },
{ email: 'cleo.nguyen@gmail.com', name: 'Cleo Nguyen', city: 'Ho Chi Minh City, Vietnam', role: 'Environment artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-15') },
{ email: 'kai.eriksson@icloud.com', name: 'Kai Eriksson', city: 'Helsinki, Finland', role: 'Cooperative consultant', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-02-20') },
]
async function seedPreRegistrants() {
try {
await connectDB()
await PreRegistration.deleteMany({})
console.log('Cleared existing pre-registrants')
await PreRegistration.insertMany(samplePreRegistrants)
console.log(`Added ${samplePreRegistrants.length} sample pre-registrants`)
const count = await PreRegistration.countDocuments()
console.log(`Total pre-registrants in database: ${count}`)
const statusBreakdown = await PreRegistration.aggregate([
{ $group: { _id: '$status', count: { $sum: 1 } } },
{ $sort: { _id: 1 } }
])
console.log('\nBreakdown by status:')
statusBreakdown.forEach(s => {
console.log(` ${s._id}: ${s.count}`)
})
process.exit(0)
} catch (error) {
console.error('Error seeding pre-registrants:', error)
process.exit(1)
}
}
seedPreRegistrants()

View file

@ -149,7 +149,7 @@ async function seedSeriesEvents() {
"Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.", "Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.",
startDate: new Date("2024-10-12T18:00:00.000Z"), startDate: new Date("2024-10-12T18:00:00.000Z"),
endDate: new Date("2024-10-12T20:00:00.000Z"), endDate: new Date("2024-10-12T20:00:00.000Z"),
eventType: "community", eventType: "community-meetup",
location: "#community-meetup", location: "#community-meetup",
isOnline: true, isOnline: true,
membersOnly: false, membersOnly: false,
@ -176,7 +176,7 @@ async function seedSeriesEvents() {
"Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.", "Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.",
startDate: new Date("2024-11-09T18:00:00.000Z"), startDate: new Date("2024-11-09T18:00:00.000Z"),
endDate: new Date("2024-11-09T20:00:00.000Z"), endDate: new Date("2024-11-09T20:00:00.000Z"),
eventType: "community", eventType: "community-meetup",
location: "#community-meetup", location: "#community-meetup",
isOnline: true, isOnline: true,
membersOnly: false, membersOnly: false,

View file

@ -24,6 +24,12 @@ export default defineEventHandler(async (event) => {
let channelName = body.name let channelName = body.name
if (!slackChannelId) { if (!slackChannelId) {
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') {
// Match the Slack channel ID format (^[A-Z0-9]+$) so the value
// round-trips through boardChannelUpdateSchema on subsequent edits.
slackChannelId = `CDEV${Date.now().toString(36).toUpperCase()}`
console.log('[slack] DEV MODE — skipping createChannel', { name: body.name, slackChannelId })
} else {
const slack = getSlackAdminService() const slack = getSlackAdminService()
if (!slack) { if (!slack) {
throw createError({ throw createError({
@ -42,6 +48,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
} }
}
try { try {
const channel = await BoardChannel.create({ const channel = await BoardChannel.create({

Some files were not shown because too many files have changed in this diff Show more