Commit graph

515 commits

Author SHA1 Message Date
53f81b3605 refactor(css): extract .tint-candle / .tint-ember utility classes
The candle tint (color-mix accent fill + matching solid border) was inlined
as style="" in five spots across SeriesPassPurchase and EventSeriesTicketCard.
Promote to .tint-candle / .tint-ember utility classes in main.css and replace
the inline styles with the class.
2026-05-24 22:18:26 +01:00
dac423afcd fix(email): gate resend wrappers behind ALLOW_DEV_TEST_ENDPOINTS
All five resend.js send wrappers (registration, cancellation, waitlist,
series pass, welcome) dispatched live in dev. Add a skipEmailInDev guard
mirroring the gate in pre-registrants/invite.post.js so dev runs and e2e
don't fire real Resend sends. Also add the monthly-onboarding Slack-timing
line to the welcome email. Unit-tested.
2026-05-24 22:17:24 +01:00
a9312c423b fix(admin): series Delete button actually deletes the series
The /admin/series Delete handler only PUT-unlinked each event and never
called the DELETE /api/admin/series/[id] endpoint, so the series document
persisted (a no-op for empty series). Replace the redundant per-event loop
with a single DELETE call — the endpoint already unlinks events server-side.
Unskip the e2e delete test.
2026-05-24 22:17:19 +01:00
eb6449de43 fix(account): format contribution label via shared formatContribution helper
All checks were successful
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Successful in 16m3s
Test / Notify on failure (push) Has been skipped
Renders $15/yr instead of $15 / year — uses the shared helper instead of inline cadence formatting.
2026-05-24 17:33:35 +01:00
426f233ccd fix(join): persist billingCadence on /join signup
All checks were successful
Test / vitest (push) Successful in 11m8s
Test / playwright (push) Successful in 16m14s
Test / Notify on failure (push) Has been skipped
Mirror of the invite-accept fix (c3b1c59) for the self-serve /join path. A joiner who picked annual but abandoned before /api/helcim/subscription ran was left at billingCadence:'monthly' (the model default) with a cadence-unit contributionAmount, rendering $180/mo in admin views.

join.vue now sends cadence to /api/helcim/customer; helcimCustomerSchema accepts cadence (defaults 'monthly'); customer.post.js persists billingCadence in both the new-member create branch and the guest-upgrade $set branch, forced to 'monthly' for $0 signups.
2026-05-24 15:30:31 +01:00
c3b1c59779 fix(invite): persist billingCadence at invite-accept time
Annual-choosing invitees who abandoned between accept and payment were left at billingCadence:'monthly' (the model default) while contributionAmount held an annual-unit value, rendering $180/mo in admin views. Persist the chosen cadence at Member.create time.

accept-invite.vue now sends cadence in the accept POST body; inviteAcceptSchema accepts cadence (defaults 'monthly'); accept.post.js sets billingCadence on create, forced to 'monthly' for $0 members since a free member has no billing relationship.
2026-05-24 15:21:34 +01:00
10a28ac5ef feat(helcim): accept signup-bridge cookie in verify-payment
All checks were successful
Test / vitest (push) Successful in 13m42s
Test / playwright (push) Successful in 19m35s
Test / Notify on failure (push) Has been skipped
Membership signup verifies the card before email verification, so the
signup-bridge cookie set by /api/helcim/customer now satisfies auth in
verify-payment when no session exists. Adds a cloudflared tunnel script
for testing the Helcim flow locally against a production build.
2026-05-24 14:01:02 +01:00
151481f1ec feat(admin): rename series route and add tags review page
Rename /admin/series-management to /admin/series so it follows the
/admin/<section> convention; the breadcrumb's auto-derived parent link
is now a real route (was a dead /admin/series link).

Add an /admin/tags page to review pending TagSuggestions — list,
approve (creates the Tag), reject — backed by new admin endpoints and a
tagSuggestionReviewSchema. Resolves the dead /admin/tags alert link.
2026-05-24 00:44:14 +01:00
7beb86b430 fix(activity): allow board_post_created in ActivityLog enum
The model enum array had drifted from the canonical type list in
utils/activityLog.js, so logging board-post activity failed validation.
2026-05-24 00:44:01 +01:00
039a6802e3 fix(e2e): repair failing suite — a11y fixes and stale assertions
Three product a11y defects: drop role="radiogroup" from the /join PWYC
<ul> (it stripped the list role; native radios already group), use
--parch-text on the active contribution chip (was --text-bright, 1.17:1
on --parch), and label the New tag pool USelect on event create.

Three stale tests: real event-type filter labels, updated location
placeholder, and click the label instead of the hidden 0×0 radio.
2026-05-24 00:43:54 +01:00
fee5959818 feat(join): redesign /join page with split hero and unified contribution list
Restructures /join.vue per docs/specs/join-form-redesign.md:

- Split hero (1fr 1fr) matching about.vue rhythm: H1 + tagline on left,
  "What you get" benefits on right (including "Pick your circle anytime
  after signup")
- Form section (1fr 1fr): form on left, "About the money" trust copy on
  right
- Inline PWYC editorial list with visually-hidden radio inputs for
  keyboard accessibility; cadence-aware preset amounts ($0/$5/$15/$30/$50
  monthly, ×12 annual); $15 row tagged "Suggested"
- Custom-amount row commits on blur and snaps to matching preset
- Cadence toggle (Monthly · Annual) in the section header; switching
  multiplies/floor-divides both form.contributionAmount and the custom
  amount display
- Removed: circle radio picker (defers to post-signup), ParchmentInset
  "How membership works", bottom three-circle row
- Submit row: bare agreement checkbox + auto-width button, wraps at 480px
- form.circle stays initialized to "community" and submits unchanged

Tests updated for new markup (radio ids #pwyc-N, .submit-btn class).
Cadence/billing-summary and contribution-guidance tests retired with
the ContributionAmountField usage on this page.
2026-05-23 18:57:11 +01:00
c85b2ae3d9 feat(api): default helcim customer circle to community when omitted
Lets the join form omit circle from the signup payload (circle picker
moves to a post-signup prompt per docs/specs/join-form-redesign.md).
Mongoose Member.circle is required with no default, so the Zod
default fills it in before reaching the model.
2026-05-23 18:49:55 +01:00
5d4321612f fix(forms): use expression form for conditional fieldError reset
Vue template attribute expressions don't allow if-statements; the
short-circuit form (`x && (x = '')`) is the idiomatic equivalent and
runs without a template compiler warning.
2026-05-23 18:49:22 +01:00
e5f1e9f95e fix(activity): cadence-aware suffixes on contribution log entries 2026-05-23 16:18:32 +01:00
10f8cab6e3 feat(forms): add inline blur validation for name and email 2026-05-23 16:09:36 +01:00
1079e8212f feat(forms): tighten labels and add Circle helper text
- join.vue: "Full Name" -> "Name", "Email Address" -> "Email", drop redundant "Your name" placeholder
- join.vue + accept-invite.vue: unify Circle helper copy to "Where you are in your co-op journey. You can change this anytime."
- join.vue: add .field-note style rule for the new helper paragraph
2026-05-23 16:05:15 +01:00
ad63a37a05 feat(contribution): port account.vue to ContributionAmountField
Replace the inline contribution UI (label, input row, presets, guidance)
with the shared ContributionAmountField component, locking cadence and
suppressing its built-in summary (account.vue has its own change hint).

Fix three computeds that double-applied the cadence conversion now that
Member.contributionAmount is stored in cadence-unit (post-Task 7):
contributionChangeHint, currentContributionLabel, and nextChargeAmount
no longer multiply annual amounts by 12.

Convert form.contributionAmount to a monthly-equivalent before the
payment-setup redirect — that page is monthly-only and would otherwise
attempt an annual-sized monthly charge for annual members.

Drop the now-unused guidanceLabel computed, the CONTRIBUTION_PRESETS and
getGuidanceLabel imports, and the dead contribution-* CSS rules.
2026-05-23 15:58:53 +01:00
aa6a176fb9 feat(migration): convert annual contributionAmount to cadence-unit
One-time script to convert existing annual Member records from the legacy
monthly-equivalent interpretation to cadence-unit. Multiplies
contributionAmount by 12 for billingCadence='annual' members.

Companion to commit 5023fb1 which dropped the server's ×12 on annual
recurringAmount. Must run after deploy, before annual members renew.

Idempotent via a transient `contributionAmountConverted: true` marker
field on each migrated doc — re-runs are safe. Dry-run by default;
`--apply` to write. Skips null/undefined contributionAmount, logs $0
amounts as no-ops.
2026-05-23 15:54:51 +01:00
f848773887 fix(admin): round monthlyRevenue and drop dead cadence ternaries 2026-05-23 15:52:26 +01:00
0dd68ff1aa fix(display): cadence-aware contribution suffix across UI + admin dashboard
Add formatContribution helper in app/config/contributions.js and route
all member-facing and admin contribution displays through cadence-aware
expressions so annual members see /yr instead of /mo. Normalize annual
amounts to monthly equivalents in the admin dashboard revenue
aggregate now that contributionAmount is stored in cadence units.
2026-05-23 15:47:10 +01:00
5023fb14ad fix(server): treat contributionAmount as cadence-unit (drop ×12)
ContributionAmountField now emits cadence-unit values (180 for $180/yr,
15 for $15/mo). Server endpoints were still multiplying annual by 12,
which would have charged $2160/yr instead of $180/yr after the form
ports in Tasks 2–3.

- helcim/subscription.post.js: recurringAmount = contributionAmount
  (no more × 12 for annual)
- members/update-contribution.post.js: same drop in both Case 1
  (free→paid) and Case 3 (paid→paid)
- slack.ts notifyNewMember: new positional `cadence` param so the
  Slack notification suffix renders /yr or /mo instead of hardcoded
  /month; all three call sites updated to pass member.billingCadence
- tests updated to match the new contract:
  - helcim-subscription.test.js: annual tests now send the cadence-
    unit amount (180, 600) and expect the same recurringAmount
  - update-contribution.test.js: annual Case 1 and Case 3 tests
    updated likewise
2026-05-23 15:37:52 +01:00
e0e7da5cca feat(contribution): port accept-invite.vue to ContributionAmountField
Replace cadence radios, contribution input, preset chips, guidance label,
and billing summary block with a single ContributionAmountField usage.
Default contribution updated to 180 to preserve the previous $15/mo
suggested annual default (cadence-unit value now). Updated flowSummary to
format cadence-unit directly. Updated e2e selectors to use the data-testids
the component exposes and new summary copy.
2026-05-23 15:14:33 +01:00
3126ddb8ea fix(join): format flowSummary contribution in cadence units 2026-05-23 15:10:48 +01:00
2f229cbfa0 test(join): align e2e with new ContributionAmountField
Add data-testid hooks for the contribution amount input and cadence
toggle labels so playwright can target them through useId-generated
ids. Update join-flow.spec.js to use the new selectors and to assert
the new billing-summary copy ('at each annual renewal' / 'each month'),
dropping the obsolete '/month x 12' parenthetical.
2026-05-23 15:08:03 +01:00
26ee1ca60d feat(contribution): port join.vue to ContributionAmountField
Replace inline cadence radios, contribution input + presets, guidance
label, and billing summary with the shared ContributionAmountField
component. Removes duplicated state (guidanceLabel, firstCharge),
unused imports (CONTRIBUTION_PRESETS, getGuidanceLabel), and the
matching CSS rules. The parent retains the cadence ref because
formatContributionAmount (left-column tier list) reads it.
2026-05-23 15:01:54 +01:00
f28558a433 fix(contribution): sanitize amount input and a11y polish 2026-05-23 14:58:39 +01:00
81783866d1 feat(contribution): extract ContributionAmountField component 2026-05-23 14:53:47 +01:00
1c3273cee2 Various pre-launch fixes.
Some checks failed
Test / vitest (push) Successful in 14m0s
Test / playwright (push) Failing after 20m2s
Test / Notify on failure (push) Successful in 3s
2026-05-22 18:53:07 +01:00
246f2023bc Add a sweet ghostie favicon 2026-05-22 14:58:57 +01:00
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