Skip to content

Extract email composer into reusable component for payment reminders#34

Open
fordprefect480 wants to merge 5 commits into
mainfrom
claude/compassionate-ride-cz5v2z
Open

Extract email composer into reusable component for payment reminders#34
fordprefect480 wants to merge 5 commits into
mainfrom
claude/compassionate-ride-cz5v2z

Conversation

@fordprefect480

Copy link
Copy Markdown
Owner

Summary

Refactors the email composition logic into a reusable, host-agnostic component to support both the full-page email composer and a new in-modal payment reminder feature. Adds a membership payment reminder modal to the Members page that allows admins to send renewal emails to selected members.

Key Changes

  • New emailComposer.tsx module: Extracts shared email composition logic including:

    • HtmlEditor component for contentEditable HTML editing
    • ComposeForm component with subject/body fields and send state machine
    • useEmailTemplate() hook for loading and caching Resend templates
    • Template splitting utilities (splitTemplate, trimFollowing, trimPreceding)
    • Helper functions (isBodyEmpty, memberLabel, pluralize)
  • New PaymentReminderModal.tsx: Modal composer for membership renewal emails with:

    • Pre-filled reminder draft generated from garden/membership config
    • Recipient selection UI with ability to deselect members
    • Reuses ComposeForm for editing and sending
    • Portal-based overlay with keyboard escape handling
  • New paymentReminder.ts: Generates starter reminder email content with:

    • Customizable subject and body based on garden name, FY label, and pricing
    • Conditional payment link based on paymentsEnabled flag
  • Updated EmailCompose.tsx: Refactored to use extracted components:

    • Removes duplicated template/compose logic
    • Uses ComposeForm for subject/body/send UI
    • Maintains recipient selection UI (all subscribers, specific members, custom emails)
  • Updated Members.tsx: Adds payment reminder workflow:

    • Fetches renewalTargetUtc to determine membership payment status
    • Adds member selection checkboxes to table
    • Opens PaymentReminderModal when "Email selected" is clicked
    • Updates membership status logic to check against renewal boundary instead of current time
  • New backend endpoint: GetMembershipRenewalEndpoint exposes the renewal target date (next 1 July boundary with late-join carry-over) so the admin UI can accurately determine which members need to renew

  • Updated MemberDetail.tsx: Uses new fetchMembershipRenewalTarget() for consistent membership status determination

  • CSS updates: Adds checkbox column styling for admin tables

Implementation Details

  • The ComposeForm component is completely host-agnostic: it accepts buildRecipients() callback and onSent() handler, allowing different hosts (full-page composer vs. modal) to provide their own recipient logic and handle results differently
  • Template splitting preserves the email chrome (header/footer) around the editable body section
  • Membership status now consistently checks against the renewal boundary across all pages, enabling the modal to correctly identify members who haven't paid for the upcoming year
  • Modal uses React portals for proper z-index layering and keyboard event handling

https://claude.ai/code/session_012FZmX5BKggFcrFTKk7BfEv

claude added 5 commits June 27, 2026 13:08
Admins can now remind members who haven't paid their membership directly
from the Members page. A "Send payment reminder" action lets them pick one
of two cohorts — members not yet paid for the upcoming financial year, or
members whose membership has fully lapsed — and jumps to the email composer
with those recipients and a pre-filled, editable draft.

- Server: GET /api/admin/members/membership-renewal exposes the renewal
  target date (next 1 July boundary, with the existing late-join carry-over,
  computed in Adelaide local time) so the browser can identify who hasn't
  paid for the upcoming year without re-deriving the boundary client-side.
- Members page: cohort selection panel with live counts, skipping members
  without an email address.
- Email composer: accepts pre-selected recipients and a draft via router
  state; surfaces a note so the admin reviews the list before sending.
  Reuses the existing newsletter "specific members" send pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FZmX5BKggFcrFTKk7BfEv
Replace the cohort-button reminder flow with a more direct, transparent UX
on the Members page: select rows with checkboxes, narrow with the filter
chips, then email the selection from a modal.

- Extract the composer into a shared component (emailComposer.tsx):
  HtmlEditor, template split/load (useEmailTemplate), isBodyEmpty, and
  ComposeForm (subject + body + send/confirm/error pipeline). Both the
  full-page composer and the new modal use it, so behaviour stays identical
  with no duplication. EmailCompose keeps its three recipient modes and now
  renders ComposeForm.
- PaymentReminderModal: pre-filled renewal draft, removable recipient chips,
  skips selected members without an email, sends via the existing newsletter
  "specific members" path, and lands on the sent-email detail for delivery
  feedback.
- Members page: checkbox column with a select-all-(filtered) header checkbox
  (indeterminate for partial); selection tracked by id so it survives filter
  changes; checkbox clicks don't trigger row navigation. A selection toolbar
  exposes "Email selected".
- Retarget "Paid"/"Not Yet Paid" to the upcoming renewal boundary
  (ComputePaidThrough) instead of "now", so the chip singles out members who
  haven't paid for the year ahead. For consistency the member detail pill and
  the Dashboard's member-stats count use the same definition.
- Simplify paymentReminder.ts to a single buildReminderDraft helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FZmX5BKggFcrFTKk7BfEv
Fix membership status so a member covered through to the end of the upcoming
financial year is shown as paid for it. The check compared paid-through
against the year's END boundary (1 July) with >=, so a member covered until
30 June read as "not yet paid" for that very year - the detail card then
contradicted itself ("covered until 30 June 2027" yet "not paid for 2026/27").
Now coverage is tested against the START of the renewal year (shared
isPaidForRenewalYear helper), which is robust to the off-by-hours boundary and
still flags members covered only for the prior year. Applied consistently to
the Members list chips, the member-detail pill, and the Dashboard stat.

Generalise the selected-members modal: it's no longer payment-specific.
Renamed PaymentReminderModal -> MemberEmailModal, titled "Email selected
members", and it starts blank so the admin can send anything. A one-click
"Insert membership-reminder template" button keeps the renewal-reminder case
easy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FZmX5BKggFcrFTKk7BfEv
Modal: drop the "Insert reminder template" button and seed the body with a
plain "Hi," greeting starter instead, so it's a general email-to-selected-
members tool. Removes the now-unused paymentReminder.ts draft helper and the
config props/wiring it needed.

Tests:
- Extract the membership "paid for the renewal year" rule into pure,
  testable MembershipPeriod.RenewalYearStart / IsPaidForRenewalYear methods
  (GetMemberStatsEndpoint now uses RenewalYearStart for its threshold), and
  add xunit coverage for the boundary cases - including the regression where a
  member covered to the last day before the 1 July boundary must read as paid.
- Add a frontend test runner (vitest, `npm test`) and cover the frontend side
  of the same logic: isPaidForRenewalYear, financialYearLabel, and
  membershipPaidThroughFyLabel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FZmX5BKggFcrFTKk7BfEv
Stand up a WebApplicationFactory-based integration harness for the API:
- TestApiFactory boots the real app under a new "Testing" environment,
  swaps the SQL Server context for an isolated EF in-memory store, and
  authenticates every request as an admin via a test scheme (no cookie
  login needed). Program.cs skips its startup migrate/seed/warmup under
  "Testing" so the in-memory provider can boot.
- Tests cover the membership/payment endpoints end-to-end: recording a
  manual payment advances paid-through to ComputePaidThrough and stores a
  Paid payment row; member stats count paid vs unpaid by the renewal year
  (including the just-before-boundary case); the membership-renewal
  endpoint returns the compute-paid-through target.

Adds Microsoft.AspNetCore.Mvc.Testing and EFCore.InMemory to the test
project. Note: the .NET SDK isn't available in this environment, so these
were written but not compiled/run here - CI is the first execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FZmX5BKggFcrFTKk7BfEv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants