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.
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.
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.
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.
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.
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.
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.
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.
- 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
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.
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.
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.
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.
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.
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.
- 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).
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().
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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/*.
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.
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.
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.