Compare commits

...
54 Commits
Author SHA1 Message Date
Joel Brock 4d194176b1 Civi extension: register xml/Menu via hook_civicrm_xmlMenu
The Engagement Report tab on Organization contact pages was loading the
contact-view summary recursively inside its own tab pane. Root cause:
Civi's menu router was not picking up the extension's xml/Menu file, so
`civicrm/contact/view/engagement-report` fell back to the parent
`civicrm/contact/view` route. Adding an explicit hook_civicrm_xmlMenu
implementation forces the menu file to register, after which the route
resolves to CRM_WebformMw_Page_Tab and the iframe renders as intended.

Deploy: replace the extension files on the Civi server, then in
Administer → System Settings → Extensions Disable + re-Enable webform-mw
(or run `cv flush` on the server) so the menu cache is rebuilt.
2026-06-09 13:32:24 -07:00
Joel Brock a9c4a074d3 Form: drop sole required field; broaden currency field visibility
No fields on the survey should block submit — removes required:true
from Preliminary_Market_Assessment so the form is fully optional end
to end.

Surfaces Stage 0 sources-and-uses currency fields (Total_cost_of_project,
Member_equity_raised, Member_loans_raised, Member_preferred_shares_raised,
Bank_debt_raised, Grants_Donations_Raised, Other_sources_raised) starting
at Business Feasibility instead of Stabilize so they can be filled in
earlier in the lifecycle.
2026-06-09 12:09:07 -07:00
Joel Brock 9341f67231 Add PRODUCTION_CUTOVER.md and link from DEPLOYMENT.md
Cross-references the Civi-side cutover checklist from the deploy doc
so anyone deploying knows the CiviCRM prerequisites are tracked in a
single place.
2026-06-09 12:09:00 -07:00
Joel Brock 4ca3c194d7 Staff report: CSP frame-ancestors + frame-mode + WebForm-mw Civi extension
App side:
- Per-route CSP: /staff/report now sets frame-ancestors 'self'
  <CIVI_BASE_URL origin> and drops X-Frame-Options so the CiviCRM
  extension can iframe it. All other routes keep frame-ancestors
  'none' + X-Frame-Options: DENY via a path-negation source.
- Staff page recognises ?frame=1 and renders without SiteHeader/
  SiteFooter so it fills the iframe cleanly.
- StaffReportView posts its scrollHeight to the parent window via
  postMessage when framed; the Civi tab listens and auto-resizes
  the iframe (no nested scrollbar). Anchor strip drops its sticky
  positioning in frame mode since there's no internal scroll.

CiviCRM extension (civi-extension/webform-mw/, key webform-mw):
- info.xml + main hook file (webform_mw.php) implementing
  hook_civicrm_tabset to add an 'Engagement Report' tab to
  Organization contact-view pages.
- CRM/WebformMw/Page/Tab.php + Smarty template render an iframe
  pointing at <WEBFORM_MW_APP_URL>/staff/report?org=<cid>&key=&frame=1,
  with a postMessage listener that validates event.origin against
  the configured app URL before resizing.
- Config via PHP constants in civicrm.settings.php (WEBFORM_MW_APP_URL,
  WEBFORM_MW_STAFF_KEY) or matching env vars. Help banner shown when
  unconfigured.
- README documents install, config, behaviour, security caveats.
2026-06-05 17:42:35 -07:00
Joel Brock b548b6425b Staff report: compact rows, anchor nav, Civi file links, Y1 matrix
UX iteration after first live look:
- Sticky anchor strip below the header with a chip per section (incl.
  Submissions) so staff can jump around a long page.
- Compact one-line rows that show only the latest value; multi-history
  fields get a muted 'N earlier entries' toggle that reveals the rest
  inline. Same affordance for file fields.
- Empty fields collapse under a single 'N empty fields' toggle per
  section instead of taking a row each.
- Stage 5: Y1_Q<n>_<metric> fields render as a read-only matrix table
  (rows: metrics; columns: Q1..Q4) matching the form's matrix layout.

File proxy (/api/staff/file) deleted. APIv4 Attachment isn't exposed
on this Civi instance (per the June upload spike), which is why the
previous proxy returned broken images. Staff are already authenticated
to Civi when they arrive here, so file fields now render as outbound
links to CIVI_BASE_URL/civicrm/file?reset=1&id=<id> and the browser
uses the staff session. No more proxy auth, no more SSRF surface to
harden, no broken images.

CIVI_BASE_URL flows from the staff page (server component) into the
client as a prop. No secret material crosses the boundary.
2026-06-05 17:28:17 -07:00
Joel Brock 586cf14e75 Amplify: wire STAFF_REPORT_KEY into build env + .env.production
Without this, even after setting STAFF_REPORT_KEY in the Amplify Secrets
tab the value never reaches the SSR Lambda — the build script only writes
the listed env vars into .env.production, which is what Next bundles.
2026-06-05 17:08:17 -07:00
Joel Brock d7a1396640 Staff file proxy: harden against SVG XSS and SSRF
- Allowlist inline MIME types (png/jpeg/gif/webp/pdf only); everything
  else, including SVG and HTML, served as application/octet-stream
  with content-disposition: attachment.
- X-Content-Type-Options: nosniff and a restrictive CSP on every response.
- Validate the upstream URL Civi returns: must match CIVI_BASE_URL origin
  before we attach basic-auth creds and follow it. redirect: manual to
  prevent off-host hops.
- Drop SVG from the client's inline-image list (server forces download).
2026-06-05 17:02:42 -07:00
Joel Brock 64076a145b Staff report: drop dead .url file join (client uses proxy URL) 2026-06-05 16:42:36 -07:00
Joel Brock e76ed39091 Docs: document STAFF_REPORT_KEY env var 2026-06-05 16:38:38 -07:00
Joel Brock 36821f42a8 Staff report: app/staff/report page with auth gate 2026-06-05 16:37:15 -07:00
Joel Brock f889212296 Staff report: StaffReportView client component 2026-06-05 16:35:15 -07:00
Joel Brock d83077ba09 Staff report: file proxy with stub PNG and Civi attachment streaming 2026-06-05 16:31:12 -07:00
Joel Brock b05d7c77e3 Staff report: live Civi branch (schema discovery + activity walk) 2026-06-05 16:27:23 -07:00
Joel Brock 5a349a4f1c Staff report: API route with stub payload and key validation 2026-06-05 16:15:30 -07:00
Joel Brock 7cd5cd6cdf Staff report: add shared-secret key validator 2026-06-05 16:09:57 -07:00
Joel Brock 92bd784d7a Staff report: relax CustomFieldRow JSDoc for dotted property names 2026-06-05 16:09:20 -07:00
Joel Brock d31faf2def Staff report: add CustomField → StaffFieldDescriptor mapper with tests 2026-06-05 14:45:20 -07:00
Joel Brock d737950a3d Report: extract Loading/Empty/Error states to components/report/ 2026-06-05 14:43:19 -07:00
Joel Brock 5cc60467a9 Report: extract MembershipChart to components/report/ 2026-06-05 14:21:36 -07:00
Joel Brock cc2a17e7a0 Report: extract DateTimeline to components/report/ 2026-06-05 14:11:25 -07:00
Joel Brock 5f5c1d6a61 Report: trim unused FieldHistory re-exports from ReportView 2026-06-05 13:23:28 -07:00
Joel Brock 89e0c7a7ea Report: extract FieldHistory primitives to components/report/ 2026-06-05 13:22:24 -07:00
Joel Brock e2f7b1e1ab Staff report: drop unused StaffFileMeta type (YAGNI) 2026-06-05 13:10:28 -07:00
Joel Brock ff0deed5fb Staff report: add StaffReportPayload and supporting types 2026-06-05 13:04:27 -07:00
Joel Brock b474cb8004 Copy: route contact prompts to Chris @ FCI; warmer form subtitle 2026-06-05 11:37:43 -07:00
Joel Brock 9702eaa077 Footer: replace privacy blurb with copyright line; tidy thank-you and contact copy 2026-06-05 11:18:32 -07:00
Joel Brock 2400931a04 File upload pipeline: wire end-to-end via APIv4 File.create
Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).

Spike findings (see scripts/spike-file-upload.mjs):
  - APIv4 Attachment is NOT exposed on this Civi
  - APIv4 File + EntityFile ARE exposed; File.create accepts inline
    base64 `content` and returns a usable file id
  - Custom file fields store the file id directly in the custom column,
    so EntityFile linkage is unnecessary for this use case
  - Round-trip via Contact.update + Contact.get .file_name join verified
    on a real org contact

Pipeline:

  Renderer (FileField) picks up onChange  →
    POST /api/upload (multipart) with file + cid + cs + fieldRef  →
      verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap  →
        civi.File.create({ file_name, mime_type, content: base64 })  →
          returns { id, file_name }  →
            renderer stores in RHF state via setValue
  Form submit  →
    POST /api/submit (JSON) with the {id, file_name} value  →
      submit detects the file shape and writes the id as the value of
      the activity/contact custom field

File changes:

  app/api/upload/route.ts
    Replaced the 501 stub with the real File.create call. Comment
    documents that EntityFile linkage is intentionally skipped and that
    orphan cleanup is owned by a CiviCRM scheduled job.

  app/api/submit/route.ts
    For type:"file" values shaped as {id, file_name}, write the id as
    the custom field value (activity or contact, depending on the
    civiField / civiContactField the field declares).

  components/fields/FieldRenderer.tsx
    Replaced the bare <input type=file> register() with FileField, an
    upload-on-pick subcomponent. The native input is NOT register()'d:
    its FileList value was the original bug. FileField owns its
    uploading + error state and writes {id, file_name} via setValue on
    success. Submit is blocked upstream while uploads are in flight.

  components/StageSection.tsx, components/EngagementForm.tsx
    Thread setValue, cid, cs, and an onUploadStateChange callback
    through to FieldRenderer. EngagementForm tracks uploads-in-flight
    count; onSubmit refuses to submit while the count is > 0.

  config/form.ts
    Promotes Certificate_of_Incorporation from readonly to a real
    file field now that the pipeline works.

  app/api/data/route.ts
    Drops the readonly carveout that was only needed while the
    certificate was readonly.

  scripts/list-civi-entities.mjs (new)
    APIv4 entity probe + APIv3 attachment-API probe. Used to determine
    that File (not Attachment) was the right entity on this Civi.

  scripts/spike-file-upload.mjs (new)
    The actual end-to-end test that proved out the pipeline before
    wiring. Safe to re-run on any Civi instance during future audits.

Not in this change:
  - Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
  - Per-field MIME allowlists (single global list for v1)
  - S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
    today to stay under Amplify Lambda's 6 MB sync payload limit)
2026-06-05 07:48:57 -07:00
Joel Brock 8159b87074 File upload pipeline: spike + endpoint skeleton (Phase 1, in progress)
Lays groundwork for closing the file-upload gap discovered while wiring
the org-contact custom fields. Currently no file fields in the form
actually persist to CiviCRM -- the renderer FileList drops at the
onSubmit JSON.stringify, and there is no /api/upload route or
Attachment.create call anywhere.

This commit adds:

1. scripts/spike-attachment-upload.mjs

   One-off spike to answer the open question that gates the rest of the
   work: does APIv4 Attachment.create accept an unbound upload, or must
   we attach to an entity at create time? If unbound works we can use
   the planned two-step pattern (upload returns a file id; submit
   references it). If not, activity-bound file fields need a different
   flow because the activity does not exist yet at upload time.

   The spike also exercises the Contact.update + .file_name read-back
   path against the Certificate_of_Incorporation field on a real org
   contact, then cleans up after itself.

   Usage:
     node --env-file=.env.local scripts/spike-attachment-upload.mjs \
       --org-id=<id> [--keep]

2. app/api/upload/route.ts

   Structural pieces that do not depend on the spike outcome:
     - multipart parsing via Request.formData()
     - 5 MB hard cap (under Amplify Lambda 6 MB sync payload limit)
     - MIME allowlist (PDF, DOC/DOCX, XLS/XLSX, JPEG/PNG/GIF/WEBP)
     - magic-byte sniff to cross-check the client-reported MIME
     - filename sanitization (path traversal scrub, length cap)
     - checksum verification, rate limiting, field-ref allowlist
     - STUB-mode short-circuit for local dev without live Civi
     - explicit 501 where the Civi Attachment.create wiring goes,
       with a comment pointing at the spike that resolves it

   Result: endpoint compiles, registers as a Next route, returns 501
   with a clear message; build passes; nothing wired into the frontend
   yet so the existing form is unaffected.

Phase 2 (renderer upload-on-pick), Phase 3 (submit reshape), Phase 4
(promote Certificate_of_Incorporation to editable) follow once the
spike output picks the Attachment.create variant.
2026-06-05 07:11:18 -07:00
Joel Brock 8ecf64c79b Stage 0: org-contact custom fields (Food_Co_op_Organizing)
Adds four fields from the Organization Contact's Food_Co_op_Organizing
custom group to the Stage 0 (always-visible) section:

  Date Incorporated                (date, editable)
  Name on Incorporation Certificate (text, editable)
  Certificate of Incorporation     (readonly; see note)
  Equity share                     (currency, editable)

These live on the Organization Contact record, not on the Check-in
activity, so they read/write through a different code path:

  - FieldConfig gains civiContactField, mutually exclusive with civiField
  - /api/data extends the org Contact.get select to include them and
    merges values into the prefill payload keyed by form-side name
  - /api/submit splits incoming values: contact-bound fields go through
    Contact.update (run first), activity-bound fields stay in the
    Activity.create call (run second)
  - FieldRenderer readonly branch now detects file-shaped values
    ({id, file_name}) and displays the filename rather than [object Object]

Certificate_of_Incorporation is wired readonly only: the form's
file-upload pipeline is not actually wired end-to-end (FileList drops
at JSON.stringify in onSubmit; no /api/upload endpoint exists). A
follow-up will close that gap.

Also adds scripts/inspect-org-custom-fields.mjs, a one-off introspection
script for dumping CustomField metadata when wiring a new group.
2026-06-04 17:37:36 -07:00
Joel Brock b120075ca2 Rebrand: Co-op Check-in -> Co-op Survey in user-facing copy
Updates the tool product name across the app UI (header, page title,
section labels, form buttons, success/error states, report stat labels),
README, deployment docs, and the CiviCRM email template guidance.
Custom domain references move from check-in.fci.coop to survey.fci.coop
(DNS update still required).

The underlying CiviCRM "Check-in (organizing)" activity type, custom
group machine names (Check_in_data__organizing_), health-check ids,
and the internal org_engagement_check_in form id are unchanged --
those are CiviCRM contract surfaces, not product copy.
2026-06-04 17:12:03 -07:00
Joel Brock 9bc4bbb732 Field groups: tighter style + apply to report
Form: dropped the boxed card treatment for FieldGroupCard in favor
of a leaf-tinted left rule + small uppercase mini-label. Eats only
~14px of horizontal space (border + pl-3/sm:pl-4) instead of
~32-40px for the previous bg-tinted card with px-4/sm:px-5 on both
sides, so the inner 2-col grid keeps more breathing room for the
fields themselves.

Report: same field-group concept now applies to ReportSection.
Grouped FieldHistoryRows render together inside a leaf-tinted left
rule with a small label above. Walk preserves the declared field
order — a group is emitted at its first member's position; the
other members are skipped when the loop later reaches them. Mixes
cleanly with the existing MembershipChart inline insertion and the
divide-y rhythm of standalone rows.
2026-05-21 16:51:00 -07:00
Joel Brock 8766de5ed0 Copy: "member(s)" → "member-owner(s)" in user-facing strings
Sweeps every active label / help / intro / visible paragraph in the
app to use 'member-owner' terminology consistently. Stub option
labels for the Capital Stack (Member equity / Member loans) updated
on the label side; option `value:` strings stay as the CRM-side
stored values. Sentinel comparisons in ReportView's chart legend
updated to track the new field labels (otherwise the `(custom
label)` parenthetical would print spuriously even at default).

Untouched on purpose:
  - Civi machine names (`Members__current_`, `Member_*`,
    `*_Member*`) — wire-level identifiers, must match Civi.
  - Option-group `value:` strings — CRM-stored values, must match.
  - NCG_Member / INFRA_Member option labels — these refer to a
    co-op's membership in distributor networks, not member-owners.
  - Commented-out fields and technical comments referencing Civi
    field names.
2026-05-21 14:08:06 -07:00
Joel Brock 5e7774795b Style anchors with underline + leaf color for visibility
Body-copy mailto links ("Chris @ FCI") and the chrome nav link
were rendering as plain text, hard to spot. Added an @layer base
rule that gives every `a[href]` a 1px underline at 2px offset and
the FCI Seed Grant green, with a subtle hover thicken. Tailwind
utilities still win on a per-element basis (the nav link keeps its
`text-ink-soft` color, the brand-image wrapper opts out via
`no-underline`).
2026-05-21 13:46:51 -07:00
Joel Brock 42b25289f8 Repair two multi-line fields the sync script left without commas
Member_equity_raised and Member_loans_raised had their civiField
lines stripped of trailing commas when the script inserted help
text. Re-added the comma; build passes.
2026-05-21 13:21:54 -07:00
Joel Brock d2d733a1d6 sync-help: ensure trailing comma before inserting new help line
Multi-line field whose last property had no trailing comma got
corrupted into invalid JS:

  civiField: `${G0}.X`
  help: "...",

(JS requires the comma between properties.) The insertion path now
checks the last non-whitespace, non-comma char of the property line
just above the closing brace; if it isn't already a comma, one is
inserted before the new help line is spliced in.
2026-05-21 13:21:54 -07:00
Joel Brock d4a6709149 Stage 1/3: add date+upload field groups; drop Stage 2 group labels
More pair-clustering across stages: Stage 1 gets four (Preliminary
Market Assessment, Preliminary Sources & Uses, Vision, Business
Concept), Stage 3 gets one (Site Letter of Intent). Stage 2 groups
drop their labels — the pair structure reads on its own and the
extra heading was visual noise.
2026-05-21 12:51:01 -07:00
Joel Brock de53fde1f7 Submit bar: animated stage-progress pills mirroring the header
Bar's secondary line is now a horizontal cluster of the same six
pills used in the page header, followed by the viewed stage label.
Active pill expands and gains a slow halo (rail-pulse keyframe) when
in the bar; same component runs without the pulse in the static
header. Width transitions smoothly between ranks as the user scrolls,
so the indicator visibly tracks progress through the form.

StageProgress now takes an optional `pulse` prop and tightens its
transition timing for nicer scroll-driven animation.
2026-05-21 12:41:14 -07:00
Joel Brock 7e5b1e1197 Sticky submit bar: show org name + currently viewed stage
Repurpose the previously-empty left side of the floating submit bar.
Top line is the org name; secondary line updates as the user scrolls
so the currently viewed stage is always visible even after the
top-of-form header has scrolled out of sight.

IntersectionObserver with a top-biased rootMargin tracks which
section is in view; topmost intersecting section wins ties.
Submit-state feedback (error / in-flight) still takes priority over
the viewing/draft text when active.
2026-05-21 12:30:54 -07:00
Joel Brock 2ca2d378a4 Add sync-help-from-civi script + field-group rendering
Two additions, both touching the form-config story:

1. scripts/sync-help-from-civi.mjs

   Diffs per-field help text in config/form.ts against CustomField rows
   in CiviCRM and (with --write) updates the file in place. Reads env
   from .env.local via Node's --env-file flag. Run as `npm run sync-help`
   or `npm run sync-help -- --write`. A --debug mode prints the parser's
   field list without calling Civi.

   Rationale: this form is low-traffic and help text doesn't change
   often once in production. A manual one-off sync is leaner than
   coupling every page load (or every build) to a Civi API call.

2. fieldGroups: visual clustering of related fields within a section

   New optional FieldGroupConfig overlay on StageSectionConfig — pure
   presentation, names existing fields by name so submit/visibility
   logic walks them unchanged. StageSection.tsx pulls grouped fields
   out of the standalone per-field grid and renders each group as its
   own bordered card with an optional heading. Stage 2 now clusters
   Market Study, Pro Forma, Business Plan, and Board Self Assessment
   (each a date + upload pair) into their own cards.
2026-05-21 12:27:22 -07:00
Joel Brock 9460e8320f WIP: form copy and field visibility adjustments 2026-05-21 12:11:23 -07:00
Joel Brock 709d9bfd8b WIP: form copy and field visibility adjustments 2026-05-21 11:17:06 -07:00
Joel Brock 0545dc3bc4 Pin Turbopack root to app dir to stop dev recompile loop
Two package-lock.json files exist (parent civi-webform/ + this app);
Next 16 silently picked the outer one, so Turbopack watched the parent
node_modules/, .claude-flow/, .swarm/, ruvector.db. Background writes
in those trees triggered a recompile loop that thrashed .next/dev and
leaked memory until the dev server crashed. Setting turbopack.root
keeps the watcher scoped to WebForm-mw/.
2026-05-21 11:15:24 -07:00
Joel Brock f0530ed337 FCI theming refresh, contact identity fields, Amplify secrets fix
- Remap globals.css tokens to FCI brand palette (Eggplant #801d7f,
  Spring Pea #96bc33, Seed Grant #679038, Squash #c9ad2d, FCI gray
  #4b5657). Existing leaf-* / clay-* class names preserved.
- Switch body font to Open Sans (FCI's free fallback for Museo Sans).
  Headings keep Fraunces.
- Add contact identity (first name, last name, email) as readonly
  fields at the top of Stage 0. /api/data fetches via APIv4
  Contact.get with email_primary.email join; values flow through
  FormDataPayload.contact and into the form's evalState so the
  readonly renderer displays them. Draft restore re-applies them so
  a stale local draft can't override.
- amplify.yml: fetch Amplify Secrets from SSM Parameter Store when
  they don't arrive as build-shell env vars (the common failure mode
  behind "Refusing to run in production without CIVI_*"). Adds a
  length-only diagnostic echo and a hard-fail guard so a missing
  required var stops the build with a clear message instead of
  bundling empty strings and crashing the SSR Lambda at runtime.
2026-05-20 16:46:44 -07:00
Joel Brock 8caa851bcc Comment out three Stage 0 fields (Peer Group, Internal Startup Assessment x2) 2026-05-20 16:04:21 -07:00
Joel Brock fbf668800b Amplify: write env vars + secrets to .env.production at build time
Amplify Gen 2 exposes Environment Variables and Secrets in the build shell
but does not inject them into the SSR Lambda runtime. Writing them to
.env.production during preBuild lets Next.js bundle them into the server
output so process.env reads work at request time.
2026-05-20 16:03:33 -07:00
Joel Brock 97222afe58 AMPLIFY_DEPLOY: document Gen 2 Environment variables vs Secrets split
Amplify Gen 2's console has two separate pages for runtime config:
Environment variables (plaintext) and Secrets (SSM Parameter Store
SecureString). The earlier 'mark as Secret with eye icon' wording was
Gen 1; in Gen 2 you choose by which page you add the value on.

Step 2 rewritten:
- Brief explanation of both pages and how they're injected (both end
  up as plain env vars in the app, same name).
- Combined variable table with a Page column showing where each value
  lives.
- Rule-of-thumb: anything that would let someone impersonate the app
  to CiviCRM or bypass a gate is a Secret; hostnames/usernames are
  fine in Environment variables.
- Callout reminding not to duplicate names across both pages
  (precedence undefined in Gen 2).
2026-05-19 17:28:24 -07:00
Joel Brock e90d007513 Amplify: install devDependencies during build (Tailwind/PostCSS need them)
@tailwindcss/postcss lives in devDependencies (along with the rest of
the PostCSS toolchain). When NODE_ENV=production is set in the Amplify
build environment, npm ci skips devDependencies — and next build then
fails resolving @tailwindcss/postcss while compiling globals.css.

amplify.yml now passes --include=dev to npm ci so the build always
installs everything regardless of NODE_ENV. AMPLIFY_DEPLOY.md updated
to warn against setting NODE_ENV=production in the Amplify env vars
panel — it's redundant (Next sets it correctly) and an easy footgun.
2026-05-19 17:13:13 -07:00
Joel Brock 6e7df382c9 AWS Amplify Hosting deploy prep
- amplify.yml: build spec (preBuild npm ci with offline cache, build
  next build, artifacts at .next/**, cache node_modules/.next-cache/.npm).
- .nvmrc: pin Node 20 so Amplify uses the same runtime as local.
- AMPLIFY_DEPLOY.md: first-time walkthrough covering AWS-side setup
  (create app, connect GitHub via OAuth/App, branch/auto-detect),
  environment variable table with secret-flag guidance, smoke-test via
  /healthz and /api/health, optional custom domain + per-PR previews,
  cost estimate, and operational notes (cold starts, no static
  egress IPs, CloudWatch logs, secret rotation).
- README deploy section: now points at both AMPLIFY_DEPLOY.md and the
  existing DEPLOYMENT.md (Render).
2026-05-19 16:48:38 -07:00
Joel Brock d88f1229ae Report: stair-step actual line, drop sparklines, move chart into Stage 0
- Actual member line now uses the same carry-forward step pattern as
  the goal line — between measurements the chart holds the prior value
  instead of interpolating diagonally, and the final value extends flat
  to the right edge. Eliminates the apparent dips that arose when
  diagonal interpolation crossed missing periods or low intermediate
  values.
- Per-field Sparkline (and its isNumericField / formatScalarText
  helpers) removed entirely. Expanding 'earlier entries' now just shows
  the chronological list. Curated multi-metric charts (like the
  Membership chart) are the path forward for trend visualization.
- Membership chart relocated from the top-of-report band into the
  Stage 0 ('Check-in (organizing)') section, rendered inline after
  whichever of Members__current_ / Member_Goal_for_current_Stage
  appears last in the section's fields-with-history list. Naturally
  scopes the chart to wherever those questions live (no double-render
  if config later moves them). Chart props refactored to take field
  + history pairs directly instead of walking the full sections array.
2026-05-19 16:45:53 -07:00
Joel Brock ae4e47025c Report: dedicated Membership chart (goal vs actual over time)
New MembershipChart card sits between the DateTimeline and the section
accordions, rendering whenever Members__current_ or
Member_Goal_for_current_Stage has any historical data.

- Actual member count: smooth leaf-700 polyline with a faint leaf-500
  area fill underneath, dots at every measurement, an emphasized dot
  on the most recent point with the value labeled inline.
- Goal: dashed clay-600 step line — each goal value is treated as a
  target that holds until the next update, then extends flat to the
  right edge of the chart. Dots at each update; the most-recent goal
  value labeled at the right.
- Y-axis: niceYTicks picks 3–5 round-number ticks (snapped to
  1/2/2.5/5/10 × 10^N) spanning [min(0, dataMin), dataMax]; faint
  gridlines + tabular-num labels on the left. Anchoring at 0 keeps
  growth-from-small-base readable.
- X-axis: reuses generateAxisTicks for adaptive month/year stepping,
  matching the timeline above. Today gets a dashed clay vertical
  guide when in range.
- Header: title + subtitle + an inline 'NNN of MMM target · X to go'
  callout in tabular-nums, color-coded (clay-700 if behind goal,
  leaf-700 if above).
- Legend at the bottom with line+dot chips for both series.
2026-05-19 16:37:01 -07:00
Joel Brock 00e10a992f Timeline: hover/focus tooltips, stage 0 lane, adaptive date axis
Three improvements to the report's DateTimeline:

1. Tooltips on every dot. Each dot is now a focusable span (tabIndex,
   role=img, full aria-label). A small ink-tinted card appears above
   the dot on mouse hover or keyboard focus, showing field label,
   formatted date, stage rank, and an 'Opened' marker for Date_Opened.
   Anchor flips to left/center/right based on the dot's position so
   tooltips don't overflow the row at the edges.

2. Plot every date field, including stage 0. The previous version
   skipped Stage 0 dates (Internal_Startup_Assessment_Date,
   Date_Closed_Folded). Now there are six swim lanes (0-5) instead
   of five. Stage 0 gets bg-leaf-200 so the gradient extends one
   step lighter.

3. Adaptive month/year x-axis under the lanes. generateAxisTicks
   picks a 'nice' interval based on the visible span: 1mo / 2mo /
   3mo / 6mo / 1yr / 2yr. January-bordered ticks include the year
   so the reader has anchors. Today gets its own labeled clay tick
   when it falls in range.
2026-05-19 16:32:50 -07:00
Joel Brock 814b560363 Report: sparkline charts on numeric history + stage-grouped date timeline
Two visual additions to the read-only activity report.

Sparkline: when the user expands earlier-entries on a numeric field
(number/currency/percent) with two or more numeric points, the
expansion now leads with a 240x56 inline SVG trend chart — chronological
polyline, faint area fill, small dots at every measurement, a slightly
larger emphasized dot on the most recent point. Min and max captions
sit beneath in tabular-nums, formatted in the field's native style
(currency uses Intl, percent appends %, etc.). Non-numeric fields are
unchanged.

DateTimeline: a new card between the context header and the section
accordions. Walks every date-type field in stage sections 1-5 (Stage 0
omitted as it isn't a stage in the journey sense), pulls each field's
most-recent entered date, and lays the events out in five horizontal
swim lanes — one per stage rank, labeled at the left. Time axis
spans from the earliest event to max(latest event, Date_Opened).
Stage 5's Date_Opened is rendered as a larger clay-700 dot with a
heavier ring so it reads as the journey's anchor at the right end.
A faint clay-300 dashed vertical line marks 'today' if it falls
within the range. Color scale across stages is leaf-300 / leaf-500
/ leaf-600 / leaf-700 / clay-700 — a sprout-to-fruit gradient that
matches the existing palette. Empty stage rows still draw their lane
line at half opacity so the structure stays readable. SR-only event
list provides screen-reader access to all plotted dates with their
labels.

Stub payload enriched with four cross-stage date entries so the
timeline has content in dev preview.
2026-05-19 16:16:57 -07:00
Joel Brock 9209d6dc02 Prefill file fields with their file_name joined from CiviCRM
CiviCRM APIv4 file custom fields return a bare file id by default; an
extra '.file_name' join is required to get the human-readable filename.
Both the form prefill walk (lib/prefill.ts) and the report walk
(app/api/report/route.ts) now request '<civiField>.file_name' for every
file-type field alongside the primary value, and wrap the prefill into
a { id, file_name } object so downstream UI has both. Falls back to
file_name undefined when the join returns null (eg orphaned id).

The form's FilePriorIndicator already accepts the object shape, so it
now shows the filename inline. The report's FormattedValue gets a
matching case: renders the file_name string if present, falls back to
'Attachment #<id>' when only the id came through.
2026-05-19 15:51:35 -07:00
Joel Brock a8ed6073a9 File fields: surface a prior-attachment indicator from RHF prefill
The earlier 'Currently on file' indicator hung off readonlyValue, which
is sourced from evalState (only carries current_stage). For file fields
the prefill lives in RHF state, not in evalState, so the indicator
never fired.

Replaced with a FilePriorIndicator subcomponent that subscribes to the
field's RHF value via useWatch and renders a small leaf-50 banner with
a paperclip glyph when a previous attachment is present. Falls silent
the moment the user picks a new file (RHF value becomes a FileList).
Filename is derived from whatever shape Civi returned — bare string,
object with file_name/name/label/filename, or numeric file id (generic
message in that case).
2026-05-19 15:48:10 -07:00
47 changed files with 5939 additions and 616 deletions
+1
View File
@@ -0,0 +1 @@
20
+193
View File
@@ -0,0 +1,193 @@
# Deploying to AWS Amplify Hosting
First-time walkthrough for shipping this app on AWS Amplify Hosting. Amplify
auto-detects Next.js, runs the build, and routes SSR pages + API routes
through managed Lambda behind a CloudFront CDN. The `amplify.yml` and
`.nvmrc` in this repo are the only pieces Amplify needs from the codebase.
## Before you start
- An AWS account with billing enabled. Amplify Hosting has a free tier
(1,000 build minutes + 15 GB served + 100 GB-hours of SSR Lambda per
month for the first year) — typical traffic for this app stays comfortably
inside it.
- GitHub access to `joelbrock/WebForm-mw`. Amplify connects via OAuth or a
GitHub App; either works.
- The three required CiviCRM env vars in hand: `CIVI_BASE_URL`,
`CIVI_API_KEY`, `CIVI_SITE_KEY`. Plus the optional `CIVI_HTTP_AUTH_*`
pair if CiviCRM sits behind webserver-level Basic Auth.
## Step 1 — Create the Amplify app
1. Open the Amplify console: <https://console.aws.amazon.com/amplify/>
- Pick a region near you / near CiviCRM. `us-east-1` (N. Virginia) is
the default and has the lowest cold-start latency for most setups.
2. Click **Create new app****Host web app**.
3. Choose **GitHub** as the source. Authorize Amplify if prompted.
The first time, AWS will install a GitHub App on your account; grant
it access just to `joelbrock/WebForm-mw` (not "all repos").
4. Pick the repo `joelbrock/WebForm-mw` and the `main` branch.
5. **App name:** something like `coop-checkin` (this becomes the
`*.amplifyapp.com` subdomain).
6. Amplify reads `amplify.yml` automatically; you do not need to edit
the build spec on this screen. Click **Next**.
## Step 2 — Environment variables and Secrets
Amplify Gen 2 splits configuration across **two separate pages** in the
console sidebar (under **Hosting**):
- **Environment variables** — plaintext-at-rest, intended for non-sensitive
config. Readable by anyone with `amplify:GetApp` permission on the AWS
account.
- **Secrets** — values stored in AWS Systems Manager Parameter Store as
SecureString, encrypted at rest. Injected into the build/runtime as
normal environment variables under the same name, so application code
doesn't need to know the difference.
Add each variable to the page indicated below:
| Variable | Page | Notes |
|---|---|---|
| `CIVI_BASE_URL` | Environment variables | e.g. `https://crm.fci.coop` |
| `CIVI_API_KEY` | **Secrets** | Required |
| `CIVI_SITE_KEY` | **Secrets** | Required |
| `CIVI_HTTP_AUTH_USER` | Environment variables | Only if CiviCRM has webserver-level Basic Auth in front of it |
| `CIVI_HTTP_AUTH_PASS` | **Secrets** | Only if above set |
| `HEALTH_TOKEN` | **Secrets** | Optional; gates `/api/health` in production |
| `PREVIEW_ADMIN_TOKEN` | **Secrets** | Optional; gates `/api/preview-link` |
| `STAFF_REPORT_KEY` | **Secrets** | Shared secret for `/staff/report` and `/api/staff/file` |
**`STAFF_REPORT_KEY`** — shared secret guarding the internal staff report
at `/staff/report` and the file proxy at `/api/staff/file`. Store in
Amplify SSM Parameter Store next to the `CIVI_*` secrets. Rotate by
changing the env value; all live links must be updated. Anyone with the
secret can view any organization's full activity history.
Rule of thumb: if leaking the value would let someone impersonate the app
to CiviCRM, or bypass a gate, it goes in **Secrets**. Hostnames and
usernames are fine in plaintext Environment variables.
> **Don't set `NODE_ENV=production` in either page.** Amplify and Next
> already set it correctly at runtime; setting it at build time causes
> `npm ci` to skip devDependencies, which breaks the Tailwind/PostCSS
> step. The `amplify.yml` in this repo guards against this with
> `--include=dev`, but it's cleaner not to set it at all.
> **Don't duplicate names across both pages.** If the same variable
> name appears in both Environment variables and Secrets, Amplify's
> precedence is undefined in Gen 2.
Click through, review, **Save and deploy**.
## Step 3 — First build
The first build takes about 46 minutes (later builds are 23 with cache).
You can watch progress in the **Hosting environments** view. When it
completes, three things are confirmed:
- The build succeeded → `next build` produced `.next/`.
- SSR functions deployed → API routes and dynamic pages have a Lambda
function behind them.
- The app is reachable at `https://main.<random>.amplifyapp.com`.
Smoke-test the deploy:
```
https://main.<random>.amplifyapp.com/healthz
```
Should return `200 OK` (lightweight platform check, doesn't hit CiviCRM).
If you set `HEALTH_TOKEN`, the richer diagnostic at
`/api/health?token=<value>` will probe the CiviCRM connection itself —
useful to confirm env vars are wired correctly.
## Step 4 — Custom domain (optional)
Amplify can attach a custom domain (e.g. `survey.fci.coop`) with
auto-issued TLS in a few minutes:
1. In the app, **Hosting****Custom domains****Add domain**.
2. Enter the apex (`fci.coop`) or a subdomain.
3. Amplify suggests DNS records (CNAME / ALIAS). Add them at your DNS
provider (Cloudflare, Route53, Namecheap, etc.).
4. Wait 515 minutes for validation and certificate issuance.
The `*.amplifyapp.com` URL keeps working alongside the custom domain.
## Step 5 — Per-PR previews (optional, recommended)
Amplify can build a preview environment for every pull request:
- **Hosting** → **Previews** → enable for the `joelbrock/WebForm-mw` repo.
- PRs get their own `https://pr-<num>.<random>.amplifyapp.com` URL,
posted as a comment on the PR.
- Previews inherit the main branch's environment variables unless you
override on the preview branch.
Useful for reviewing form changes against live CiviCRM DEV before merging.
## What it'll cost
Realistic estimate for this app's expected traffic (a few hundred
form/report loads per month, very modest API throughput):
- **Hosting (CDN + static)**: pennies. Free tier covers it.
- **SSR Lambda**: ~$0.05$0.50/month depending on traffic. Each form load
triggers a couple of API calls into CiviCRM via Lambda; each call is a
short-lived invocation.
- **Build minutes**: free tier covers up to ~300 small builds/month.
Expect **<$5/month** total at launch traffic. If usage grows
significantly, the costs scale roughly linearly with Lambda invocations.
## Things to know going in
- **Cold starts.** First request after a quiet period adds 5001500ms of
Lambda init. Subsequent requests reuse the warm container. For an
internal-use form this is fine; users see the loading state during
init.
- **Outbound IPs are not static.** Amplify SSR Lambda functions egress
through AWS-managed IPs that change. If CiviCRM has an IP allowlist
on its API, this won't work out of the box — you'd need a VPC + NAT
Gateway + Elastic IP setup (significantly more complex). Most
CiviCRM auth uses API keys, not IP allowlisting, so this usually
isn't an issue.
- **No Render-style background workers.** Amplify is request/response
only. Anything cron-shaped needs EventBridge + a separate Lambda.
The current app doesn't have background jobs, so this doesn't apply.
- **Logs live in CloudWatch.** Each SSR function has its own log group;
click into the function from the Amplify app view to jump to logs.
Retention defaults to "never expire" — switch to 30 days unless you
need more.
- **Secrets rotate manually.** No auto-rotation. To rotate
`CIVI_API_KEY`, update it in Amplify env vars → trigger a redeploy.
- **`render.yaml` is now informational.** It's still in the repo for the
Render path; Amplify ignores it. If you commit fully to Amplify and
abandon Render, the `render.yaml` and the Render section of
`DEPLOYMENT.md` can be removed.
## When something breaks
- **Build fails on `npm ci`** → usually a Node version mismatch. The
`.nvmrc` pins Node 20; if Amplify's build image doesn't have it, the
`amplify.yml` `nvm install` falls back. Open the build log, search
for "Node version".
- **Build fails on `next build`** → typecheck or lint error. Reproduce
locally with `npm run build`.
- **Site builds but routes 503** → SSR Lambda misconfig. Check the
function's CloudWatch logs for the actual error. Most common cause:
missing required env var (the app refuses to boot in production stub
mode and the `lib/env.ts` validator throws).
- **CiviCRM calls fail with 401** → API key or Basic Auth credentials
wrong. Re-check `CIVI_API_KEY` and `CIVI_HTTP_AUTH_*` in the Amplify
env-vars panel; redeploy after changes.
## After the first successful deploy
- Bookmark the Amplify app URL and the CloudWatch log group.
- Run the email/link smoke test from `EMAIL_DELIVERY.md` against the
Amplify URL to confirm `/api/preview-link` produces working tokenized
URLs end-to-end.
- Update any docs / staff runbooks pointing at the old Render URL.
+9 -2
View File
@@ -3,6 +3,12 @@
This app is built to deploy to **[Render](https://render.com)** as a Web
Service via the included `render.yaml` blueprint.
For the **CiviCRM-side** setup that must happen before this app can
work (extension install, custom-field defaults, relationship types,
option groups, post-deploy verification), see
[`PRODUCTION_CUTOVER.md`](./PRODUCTION_CUTOVER.md). Keep that doc
current — every Civi prerequisite we discover belongs there.
## Pre-flight checklist
Before deploying, confirm:
@@ -28,7 +34,8 @@ Verify all of the above against your live CiviCRM by hitting `/api/health` (in d
| `CIVI_HTTP_AUTH_USER` | only if Civi sits behind webserver-level Basic Auth | HTTP Basic Auth username. |
| `CIVI_HTTP_AUTH_PASS` | only if CIVI_HTTP_AUTH_USER is set | HTTP Basic Auth password. |
| `HEALTH_TOKEN` | recommended | Long random string (e.g. `openssl rand -hex 32`). Required to access `/api/health` in production. If unset, that route returns 404. |
| `PUBLIC_ORIGIN` | optional | e.g. `https://check-in.fci.coop` — used in absolute self-links if needed later. |
| `PUBLIC_ORIGIN` | optional | e.g. `https://survey.fci.coop` — used in absolute self-links if needed later. |
| `STAFF_REPORT_KEY` | recommended | Shared secret guarding the internal staff report at `/staff/report` and the file proxy at `/api/staff/file`. Store alongside the `CIVI_*` secrets. Rotate by changing the env value; all live links must be updated. Anyone with the secret can view any organization's full activity history. |
3. **Trigger the first deploy.** Render will run `npm ci && npm run build` then `npm run start`. The platform health check hits `/healthz` (lightweight, no Civi dependency).
@@ -37,7 +44,7 @@ Verify all of the above against your live CiviCRM by hitting `/api/health` (in d
- `https://<your-render-url>/api/health?token=<HEALTH_TOKEN>` → all checks should be green
- Visit `https://<your-render-url>/?cid=<test-individual>&cs=<their-checksum>` → form loads with prefill
5. **Custom domain (optional)** — Render dashboard → Custom Domain → add `check-in.fci.coop`. Update DNS to the provided CNAME. Render auto-issues a Let's Encrypt cert.
5. **Custom domain (optional)** — Render dashboard → Custom Domain → add `survey.fci.coop`. Update DNS to the provided CNAME. Render auto-issues a Let's Encrypt cert.
## Security defaults
+15 -15
View File
@@ -1,10 +1,10 @@
# Email link delivery
The check-in form is reached via a personalized URL that carries the
The survey form is reached via a personalized URL that carries the
contact's CiviCRM ID + a server-issued checksum:
```
https://check-in.fci.coop/?cid=<contact_id>&cs=<checksum>
https://survey.fci.coop/?cid=<contact_id>&cs=<checksum>
```
This document covers the three pieces needed to actually get those links
@@ -24,10 +24,10 @@ The preview endpoint (#3) is a debugging convenience.
In CiviCRM:
1. Navigate to **Mailings → Message Templates → Add Message Template**.
2. Title: `Co-op Check-in invitation`
2. Title: `Co-op Survey invitation`
3. Subject:
```
Time for your co-op check-in
Time for your co-op survey
```
4. Plain-text body (substitute your real domain):
@@ -38,8 +38,8 @@ In CiviCRM:
month. The form pre-fills your prior responses — you only need to update
what's changed.
Open your check-in:
https://check-in.fci.coop/?cid={contact.contact_id}&cs={contact.checksum}
Open your survey:
https://survey.fci.coop/?cid={contact.contact_id}&cs={contact.checksum}
The link is personalized to you and expires in 14 days. If you no longer
have it, reply to this email and we'll send a fresh one.
@@ -58,11 +58,11 @@ In CiviCRM:
what's changed.</p>
<p style="margin: 24px 0;">
<a href="https://check-in.fci.coop/?cid={contact.contact_id}&amp;cs={contact.checksum}"
<a href="https://survey.fci.coop/?cid={contact.contact_id}&amp;cs={contact.checksum}"
style="display: inline-block; background: #3a5520; color: #fafaf7;
padding: 10px 20px; border-radius: 6px; text-decoration: none;
font-weight: 500;">
Open your check-in
Open your survey
</a>
</p>
@@ -95,14 +95,14 @@ Best for: low volume, one-at-a-time, or you want to review each send.
1. Open the **organization's contact record** in CiviCRM.
2. **Relationships** tab → find the row with **"Primary Contact"** → click the related individual's name.
3. On the individual's contact view: **Actions → Send Email**.
4. **Use Template**: select `Co-op Check-in invitation`. The subject and body populate; tokens render in the preview.
4. **Use Template**: select `Co-op Survey invitation`. The subject and body populate; tokens render in the preview.
5. **Send**. CiviCRM logs the email as an Activity on the contact.
Repeat per contact. Slow but bulletproof.
### Option B — Bulk: CiviMail (campaign-style send)
Best for: send the check-in invitation to every active co-op at once (e.g. monthly).
Best for: send the survey invitation to every active co-op at once (e.g. monthly).
1. Build a Smart Group of contacts whose Primary Contact relationships point at orgs in active stages. Example query:
- **Contacts** → **Advanced Search**
@@ -112,7 +112,7 @@ Best for: send the check-in invitation to every active co-op at once (e.g. month
2. **Mailings → New Mailing**.
3. Recipients: the smart group above.
4. Choose template: `Co-op Check-in invitation`.
4. Choose template: `Co-op Survey invitation`.
5. Schedule send.
Each recipient gets their own checksum embedded in the URL — CiviMail
@@ -125,10 +125,10 @@ Best for: a button-on-the-org-record workflow.
Setup:
1. **Administer → CiviRules → Manage Rules → New Rule**.
2. Title: `Send Co-op Check-in invitation`.
2. Title: `Send Co-op Survey invitation`.
3. **Trigger**: pick a "manually trigger from org row" action if your CiviRules version supports it. Otherwise: trigger on org Stage change (auto-fires when staff advance an org).
4. **Action**: `Send email to contact via message template`.
- Template: `Co-op Check-in invitation`.
- Template: `Co-op Survey invitation`.
- Recipient: **Contact in relationship** → "Primary Contact" → side B (the Individual).
5. Save.
@@ -153,7 +153,7 @@ contact/org context for verification.
**Request:**
```bash
curl "https://check-in.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN"
curl "https://survey.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN"
```
**Response:**
@@ -165,7 +165,7 @@ curl "https://check-in.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN"
"orgId": 9609,
"orgName": "A Sample Food Co-op",
"orgStage": "Organizing",
"url": "https://check-in.fci.coop/?cid=513&cs=8d1f...",
"url": "https://survey.fci.coop/?cid=513&cs=8d1f...",
"checksum": "8d1f...",
"ttlHours": 336
}
+278
View File
@@ -0,0 +1,278 @@
# Production cutover guide
Step-by-step CiviCRM-side checklist to recreate the Co-op Survey
environment on a new CiviCRM (e.g. when moving from
`client.crm.fci.coop` to production `crm.fci.coop`). The Next.js app
deploy is covered separately in [`DEPLOYMENT.md`](./DEPLOYMENT.md) (Render)
and [`AMPLIFY_DEPLOY.md`](./AMPLIFY_DEPLOY.md) (AWS Amplify). This
document captures everything that must exist *inside CiviCRM* for those
deployments to work.
Keep this file current — every time we discover a manual Civi-side
prerequisite, add it here so the next environment cutover is a single
read-through.
---
## 0. Prerequisites on the target CiviCRM
- CiviCRM 5.50+ on PHP 7.4 or 8.x.
- A service-user contact with an **API key** generated (Contact view →
edit → API Key field). The same user's permissions are what the app
runs as.
- The site's `CIVICRM_SITE_KEY` from `civicrm.settings.php`.
- If CiviCRM sits behind webserver-level Basic Auth, credentials for it.
These three (`CIVI_BASE_URL`, `CIVI_API_KEY`, `CIVI_SITE_KEY`) are the
core env vars the app needs.
---
## 1. Activity type and custom field groups
The form writes one activity per submission, of type
**`Check-in (organizing)`** (machine name, exact spelling).
That activity type must exist with the **six** custom field groups
attached, all extending `Activity` filtered to this single activity
type:
| Custom group machine name | Stage |
|----------------------------------|-------|
| `Check_in_data__organizing_` | 0 (always-on survey data + the `Stage` snapshot field) |
| `Stage_1` | 1 — Convene & Prepare |
| `Stage_2` | 2 — Grow & Plan |
| `Stage_3` | 3 — Connect & Gather |
| `Stage_4` | 4 — Excite & Build |
| `Stage_5` | 5 — Fulfill & Stabilize |
Field names *within* those groups are referenced explicitly by
`config/form.ts` (search for `civiField:`). If you renamed any field on
the source CRM, mirror the change here or update the config.
**Sanity check** after import: `Activity → Search` for any existing
`Check-in (organizing)` row and confirm every custom field renders.
---
## 2. The `Stage` custom field default — **must be cleared**
Custom field id 242 (label "Stage", inside the
`Check_in_data__organizing_` group) **must have an empty default
value**. The form is intentionally not the authority on stage; staff
set Stage on their own stage-change check-ins, and `/api/data` derives
the org's current stage from the most recent stage-bearing activity.
If a default is set (the original config had "Unknown" as the default),
every form-submitted activity gets stamped with that value and
contaminates the stage-derivation logic.
The Civi admin UI's dropdown does **not** allow you to select "blank",
so clear it via API or SQL:
**Option A — API (preferred):**
```bash
cv api4 CustomField.update \
where='[["id","=",242]]' \
values='{"default_value":null}'
```
Or in the API Explorer v4 (`Support → Developer → API Explorer v4`):
```
Entity: CustomField
Action: update
where: [["id","=",242]]
values: {"default_value": null}
```
**Option B — direct SQL** (only if the API isn't handy):
```sql
UPDATE civicrm_custom_field SET default_value = NULL WHERE id = 242;
```
Then `cv flush` or `Administer → System Settings → Cleanup Caches`.
**Cleanup of historical bad data** (optional, only if production was
running with the bad default for a while):
```bash
cv api4 Activity.update \
where='[["activity_type_id:name","=","Check-in (organizing)"],
["Check_in_data__organizing_.Stage","=","Unknown"]]' \
values='{"Check_in_data__organizing_.Stage": null}'
```
Verify: pull one activity back and confirm Stage is null:
```bash
cv api4 Activity.get \
where='[["activity_type_id:name","=","Check-in (organizing)"]]' \
select='["id","Check_in_data__organizing_.Stage"]' \
limit=5
```
---
## 3. Framework Stage option group on Organizations
Each Organization contact has a custom field that holds its current
Framework Stage. The option group backing it (referenced by
`STAGE_OPTION_GROUP_ID` in `config/form.ts`, currently `75`) must
contain these six values, spelled exactly:
- `Inquiry`
- `Organizing`
- `Feasibility`
- `Business feasibility`
- `Store Implementation`
- `Stabilize newly opened co-op`
If the option group id differs in the target CRM, update
`STAGE_OPTION_GROUP_ID` in `config/form.ts` and redeploy.
---
## 4. Relationship type: form-filler → organization
The form resolves "what org is this submission about?" by looking up
the form-filler's **`Primary Contact`** relationship to an
Organization. The relationship type must exist with:
- `name_a_b` = `Primary Contact`
- side A = Individual
- side B = Organization
This is the value of `FORM_CONTACT_RELATIONSHIP` in `config/form.ts`.
If you use a different relationship type in the target CRM, update that
constant and redeploy.
**Per-org setup** (this is the recurring operational step): for each
Organization that should receive a survey, create exactly one *active*
`Primary Contact` relationship from the staff/board contact who will
fill out the form to that Organization.
- Zero active relationships → the form returns
`No active "Primary Contact" relationship found for your contact.`
- More than one active relationship → the form refuses with
`multiple active … relationships; staff must resolve before this link
will work.` (Deactivate the older ones.)
---
## 5. Install the `webform-mw` Civi extension
The extension adds an **Engagement Report** tab to Organization
contact pages that embeds the staff report in an iframe.
1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's
`[civicrm.extensionsDir]` (usually
`<civi-root>/sites/default/ext/`). The directory must be named
exactly `webform-mw` (matches `<key>` in `info.xml`).
2. `Administer → System Settings → Extensions → Add new → Refresh`,
then **Install** next to "WebForm-mw".
3. Configure — add to `civicrm.settings.php`:
```php
define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
define('WEBFORM_MW_STAFF_KEY', '...same value as STAFF_REPORT_KEY in the app env...');
```
`WEBFORM_MW_STAFF_KEY` **must** equal the `STAFF_REPORT_KEY` env var
on the Next.js app. Rotate them together.
4. Confirm: open any Organization contact in CiviCRM — there should be
an **Engagement Report** tab. Click it; the staff report should
load with no nested scrollbar (the extension auto-sizes the iframe
via `postMessage`).
Full extension docs:
[`civi-extension/webform-mw/README.md`](./civi-extension/webform-mw/README.md).
---
## 6. CSP / `frame-ancestors` — app side
The Next.js app's `/staff/report` route must allow the CiviCRM origin
in its `frame-ancestors` CSP, or the iframe will refuse to render.
The build reads `CIVI_BASE_URL` and adds its origin to the CSP
automatically — so make sure `CIVI_BASE_URL` on the app deploy points
at the **production** CRM origin (`https://crm.fci.coop`), not
`client.crm.fci.coop`.
Confirm after deploy:
```bash
curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy
```
Should include `frame-ancestors 'self' https://crm.fci.coop` (or
whatever your production CRM origin is).
---
## 7. App env vars (cross-reference)
The full list lives in [`DEPLOYMENT.md`](./DEPLOYMENT.md#first-time-render-setup).
Production-specific reminders:
- `CIVI_BASE_URL` — production CRM, not the client/staging one.
- `STAFF_REPORT_KEY` — long random secret, identical to the
`WEBFORM_MW_STAFF_KEY` in `civicrm.settings.php` (step 5).
- `HEALTH_TOKEN` — set this in production, or `/api/health` returns
404. Use `openssl rand -hex 32`.
- Confirm none of the three core `CIVI_*` vars are missing — the app
refuses to start in `NODE_ENV=production` if any are unset.
---
## 8. Post-deploy verification
Run these against the production deploy:
1. `curl https://survey.fci.coop/healthz`
→ `{"ok":true,"service":"coop-checkin"}`
2. `curl 'https://survey.fci.coop/api/health?token=$HEALTH_TOKEN'`
→ all checks green. This validates Civi connectivity, the
`Primary Contact` relationship type, the `Check-in (organizing)`
activity type, all six custom groups, the Stage option group, and
the option-group ID match.
3. Pick a test Individual contact that has a `Primary Contact`
relationship to a test Organization. Generate a Civi checksum for
that Individual and load:
`https://survey.fci.coop/?cid=<id>&cs=<checksum>` — the form should
load with prefill.
4. Submit a stub answer. Then in Civi, pull the new activity:
```bash
cv api4 Activity.get \
where='[["activity_type_id:name","=","Check-in (organizing)"]]' \
orderBy='{"id":"DESC"}' limit=1 \
select='["id","subject","Check_in_data__organizing_.Stage"]'
```
- `Stage` must be **null** (not "Unknown"). If it's "Unknown",
step 2 above wasn't completed on this CRM.
- `subject` should be `Co-op Survey (form submission)`.
5. Open the target Organization in CiviCRM → **Engagement Report**
tab. The staff report should render the submission you just made.
---
## Change log
When you discover a new manual prerequisite, append a short note here
so the rationale survives.
- **2026-06-08** — Documented the field-242 "Unknown" default issue
after a production submission was stamped `Stage = "Unknown"`.
Cleared via `CustomField.update`; see step 2.
+9 -9
View File
@@ -1,4 +1,4 @@
# Co-op Check-in
# Co-op Survey
A tokenized, mobile-friendly web form that lets external co-op contacts
update their organization's tracking data on CiviCRM, plus a read-only
@@ -11,8 +11,8 @@ Each co-op has a designated **Primary Contact** (the individual) linked
to the **Organization** record in CiviCRM. Staff send that contact a
personalized link generated against the contact's CiviCRM checksum:
- `https://check-in.fci.coop/?cid=<contactId>&cs=<checksum>` — the form
- `https://check-in.fci.coop/report?cid=<contactId>&cs=<checksum>` — the report
- `https://survey.fci.coop/?cid=<contactId>&cs=<checksum>` — the form
- `https://survey.fci.coop/report?cid=<contactId>&cs=<checksum>` — the report
When the link is opened, the app verifies the checksum against CiviCRM,
resolves the organization through the Primary Contact relationship, and
@@ -24,12 +24,12 @@ Build → Fulfill & Stabilize). The org's current stage controls which
sections are editable; past and current stages are open for editing,
future stages render as previews with their fields locked so the co-op
can see the framework ahead. Fields prefill with each value's most
recent non-empty entry from past check-ins. On submit, a new
recent non-empty entry from past surveys. On submit, a new
"Check-in (organizing)" activity is created; nothing is overwritten.
**Stage authority.** The current stage is derived from the most recent
"Check-in (organizing)" activity whose Stage field is set. Staff own
stage transitions by manually setting Stage on a check-in activity they
stage transitions by manually setting Stage on a "Check-in (organizing)" activity they
create; the form itself never writes Stage, so org self-submissions
can't override a staff transition. Orgs with no stage-bearing activity
default to "Inquiry."
@@ -105,11 +105,11 @@ works while in stub mode. The report is at `/report?cid=1&cs=anything`.
## Deploy
The repo ships with `render.yaml` for [Render](https://render.com) and a
detailed `DEPLOYMENT.md` covering Render specifically. The app is a
The repo ships with `amplify.yml` for [AWS Amplify Hosting](https://aws.amazon.com/amplify/)
(see `AMPLIFY_DEPLOY.md` for a first-time walkthrough) and `render.yaml`
for [Render](https://render.com) (see `DEPLOYMENT.md`). The app is a
standard Next.js 16 App Router project and runs anywhere Node 20+ can
run `next start` — Vercel, AWS Amplify Hosting, App Runner, Fly,
self-hosted, etc.
run `next start` — Vercel, App Runner, Fly, self-hosted, etc.
Build + start:
+91
View File
@@ -0,0 +1,91 @@
version: 1
applications:
- frontend:
phases:
preBuild:
commands:
- nvm use $(cat .nvmrc) || nvm install $(cat .nvmrc)
# --include=dev is required because the build needs PostCSS /
# Tailwind plugins which live in devDependencies; without it,
# NODE_ENV=production in the Amplify env causes npm to skip them.
- npm ci --include=dev --cache .npm --prefer-offline
# Amplify exposes Environment Variables in the build shell as
# plain `$VAR` references, but Secrets are SecureString entries
# in SSM Parameter Store at `/amplify/<appId>/<branch>/<name>`
# and are NOT always injected automatically into the build
# shell on older build images. If a Secret is unset as an env
# var, fall back to fetching it from SSM directly.
#
# Once resolved, the values get baked into .env.production so
# Next bundles them into the SSR Lambda. .env* is gitignored.
- |
# Try SSM for any of these that arrive empty (they were set
# via the Amplify Secrets tab, not Environment variables).
# Requires the Amplify build role to have ssm:GetParameter on
# /amplify/$AWS_APP_ID/$AWS_BRANCH/* — granted by default.
fetch_secret() {
local name="$1"
local current="${!name}"
if [ -n "$current" ]; then return 0; fi
local path="/amplify/${AWS_APP_ID}/${AWS_BRANCH}/${name}"
local val
val=$(aws ssm get-parameter --name "$path" --with-decryption \
--query "Parameter.Value" --output text 2>/dev/null || true)
if [ -n "$val" ] && [ "$val" != "None" ]; then
export "$name=$val"
echo "[secrets] $name resolved from SSM ($path)"
fi
}
for v in CIVI_API_KEY CIVI_SITE_KEY CIVI_HTTP_AUTH_PASS HEALTH_TOKEN PREVIEW_ADMIN_TOKEN STAFF_REPORT_KEY; do
fetch_secret "$v"
done
- |
# Length-only diagnostic (no values leaked to the log).
for v in CIVI_BASE_URL CIVI_API_KEY CIVI_SITE_KEY CIVI_HTTP_AUTH_USER CIVI_HTTP_AUTH_PASS HEALTH_TOKEN PREVIEW_ADMIN_TOKEN STAFF_REPORT_KEY; do
val="${!v}"
if [ -n "$val" ]; then
echo "[env check] $v set (${#val} chars)"
else
echo "[env check] $v UNSET"
fi
done
- |
# Required-vars guard. Fails the build now (with a clear
# message) instead of letting an empty .env.production crash
# the SSR Lambda at runtime with "Refusing to run in
# production" from lib/env.ts.
missing=""
for v in CIVI_BASE_URL CIVI_API_KEY CIVI_SITE_KEY; do
if [ -z "${!v}" ]; then missing="$missing $v"; fi
done
if [ -n "$missing" ]; then
echo "::error::Amplify build env missing required vars:$missing"
echo "Confirm: (1) the var is in the Amplify console under either"
echo "Hosting → Environment variables OR Hosting → Secrets;"
echo "(2) it is scoped to branch '${AWS_BRANCH}' (or All branches);"
echo "(3) the Amplify build role can read /amplify/${AWS_APP_ID}/${AWS_BRANCH}/* from SSM."
exit 1
fi
- |
{
echo "CIVI_BASE_URL=$CIVI_BASE_URL"
echo "CIVI_API_KEY=$CIVI_API_KEY"
echo "CIVI_SITE_KEY=$CIVI_SITE_KEY"
echo "CIVI_HTTP_AUTH_USER=$CIVI_HTTP_AUTH_USER"
echo "CIVI_HTTP_AUTH_PASS=$CIVI_HTTP_AUTH_PASS"
echo "HEALTH_TOKEN=$HEALTH_TOKEN"
echo "PREVIEW_ADMIN_TOKEN=$PREVIEW_ADMIN_TOKEN"
echo "STAFF_REPORT_KEY=$STAFF_REPORT_KEY"
} > .env.production
build:
commands:
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- node_modules/**/*
- .next/cache/**/*
- .npm/**/*
+73 -6
View File
@@ -39,6 +39,11 @@ const DEFAULT_STAGE = "Inquiry";
const STUB_PAYLOAD: FormDataPayload = {
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
contact: {
firstName: "Jordan",
lastName: "Sample",
email: "jordan.sample@example.coop",
},
prefill: {
Peer_Group_Participation: "Yes",
Members__current_: 87,
@@ -55,7 +60,7 @@ const STUB_PAYLOAD: FormDataPayload = {
141: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }],
142: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }],
133: [{ value: "Viable", label: "Viable" }, { value: "Marginal", label: "Marginal" }, { value: "Not viable", label: "Not viable" }],
134: [{ value: "Member equity", label: "Member equity" }, { value: "Member loans", label: "Member loans" }, { value: "Bank debt", label: "Bank debt" }, { value: "Grants", label: "Grants" }],
134: [{ value: "Member equity", label: "Member-Owner equity" }, { value: "Member loans", label: "Member-Owner loans" }, { value: "Bank debt", label: "Bank debt" }, { value: "Grants", label: "Grants" }],
139: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }],
135: [{ value: "Co-op", label: "Co-op grocery" }, { value: "Conventional", label: "Conventional grocery" }, { value: "Other", label: "Other" }],
136: [{ value: "Member", label: "Member" }, { value: "Considering", label: "Considering" }, { value: "Not a member", label: "Not a member" }],
@@ -158,13 +163,44 @@ export async function GET(req: NextRequest) {
}
const orgId = orgs[0].contact_id_b;
// Fire org-name lookup, stage-bearing-activity lookup, prefill walk, and
// option-group fetch in parallel — they're independent.
const [orgRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
civi<{ id: number; display_name: string }>("Contact", "get", {
select: ["id", "display_name"],
// Org-contact custom fields configured on form fields. Each
// FieldConfig.civiContactField is `<group_name>.<field_name>` and is
// selected directly off the Organization Contact record.
const orgContactFieldRefs = allFields
.map((f) => f.civiContactField)
.filter((s): s is string => Boolean(s));
// For file-typed org-contact fields, also pull the joined .file_name so
// the prior-attachment indicator shows the filename, not just the file id.
const orgContactFileRefs = allFields
.filter((f) => f.type === "file" && f.civiContactField)
.map((f) => f.civiContactField!)
.filter(Boolean);
const orgContactSelect = [
"id",
"display_name",
...orgContactFieldRefs,
...orgContactFileRefs.map((ref) => `${ref}.file_name`),
];
// Fire org-name lookup, contact-identity lookup, stage-bearing-activity
// lookup, prefill walk, and option-group fetch in parallel — they're
// independent.
const [orgRes, contactRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
civi<{ id: number; display_name: string; [key: string]: unknown }>("Contact", "get", {
select: orgContactSelect,
where: [["id", "=", orgId]],
}),
// Identifying details for the form-filler. APIv4 lets us chain through
// the primary-email join in the same call.
civi<{
id: number;
first_name: string | null;
last_name: string | null;
"email_primary.email": string | null;
}>("Contact", "get", {
select: ["id", "first_name", "last_name", "email_primary.email"],
where: [["id", "=", Number(cid)]],
}),
// Most recent Check-in (organizing) activity whose Stage custom field
// is set. Staff own this field; the form never writes it. Tiebreaker on
// equal activity_date_time is id DESC.
@@ -190,9 +226,40 @@ export async function GET(req: NextRequest) {
const stageRaw = stageActivityRes.values?.[0]?.[ACTIVITY_STAGE_FIELD];
const currentStage = typeof stageRaw === "string" && stageRaw ? stageRaw : DEFAULT_STAGE;
const contactRow = contactRes.values?.[0];
const contact = contactRow
? {
firstName: contactRow.first_name ?? "",
lastName: contactRow.last_name ?? "",
email: contactRow["email_primary.email"] ?? "",
}
: undefined;
// Merge org-contact custom-field values into prefill, keyed by form-side
// field name. Activity-based prefill values take precedence only when an
// explicit non-empty entry exists — but org-contact fields don't appear
// in the activity walk, so collisions can't happen in practice.
for (const f of allFields) {
if (!f.civiContactField) continue;
const raw = org[f.civiContactField];
if (raw === null || raw === undefined || raw === "") continue;
if (f.type === "file" || f.type === "readonly") {
// File or readonly fields: surface as {id, file_name} so the renderer
// can show a filename indicator. Plain readonly text fields fall
// through to the else branch.
const fname = org[`${f.civiContactField}.file_name`];
if (typeof fname === "string" && fname) {
prefill[f.name] = { id: raw, file_name: fname };
continue;
}
}
prefill[f.name] = raw;
}
const payload: FormDataPayload = {
orgName: org.display_name,
currentStage,
contact,
prefill,
options,
};
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* GET /api/preview-link?cid=<contactId>&token=<HEALTH_TOKEN>
*
* Returns a ready-to-send check-in URL for a given contact, plus enough
* Returns a ready-to-send survey URL for a given contact, plus enough
* context for staff to verify it's the right org. Used for:
* - Generating links to paste into ad-hoc emails (or external tools)
* - Testing a contact's setup before sending the real CiviCRM email
+39 -5
View File
@@ -41,15 +41,19 @@ const STUB_PAYLOAD: ReportPayload = (() => {
const today = new Date();
const daysAgo = (n: number) =>
new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString();
const ymd = (n: number) => {
const d = new Date(today.getTime() - n * 24 * 3600 * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
return {
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
activities: [
{ id: 9012, date: daysAgo(3), subject: "Co-op Check-in (form submission)" },
{ id: 9008, date: daysAgo(34), subject: "Co-op Check-in (form submission)" },
{ id: 9012, date: daysAgo(3), subject: "Co-op Survey (form submission)" },
{ id: 9008, date: daysAgo(34), subject: "Co-op Survey (form submission)" },
{ id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" },
{ id: 8995, date: daysAgo(95), subject: "Co-op Check-in (form submission)" },
{ id: 8980, date: daysAgo(180), stage: "Inquiry", subject: "Initial check-in (staff)" },
{ id: 8995, date: daysAgo(95), subject: "Co-op Survey (form submission)" },
{ id: 8980, date: daysAgo(180), stage: "Inquiry", subject: "Initial survey (staff)" },
],
fieldHistory: {
Peer_Group_Participation: [
@@ -69,6 +73,20 @@ const STUB_PAYLOAD: ReportPayload = (() => {
{ activityId: 9012, date: daysAgo(3), value: "Strong" },
{ activityId: 8995, date: daysAgo(95), value: "Moderate" },
],
// Stage-spanning dates so the timeline strip has events across the
// whole journey in dev preview.
Preliminary_Market_Assessment: [
{ activityId: 8995, date: daysAgo(95), value: ymd(180) },
],
Market_Study_Date: [
{ activityId: 9008, date: daysAgo(34), value: ymd(60) },
],
Projected_Opening_Date: [
{ activityId: 9012, date: daysAgo(3), value: ymd(-365) },
],
Date_Opened: [
{ activityId: 9012, date: daysAgo(3), value: ymd(-450) },
],
},
options: {
140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }],
@@ -158,15 +176,26 @@ export async function GET(req: NextRequest) {
const orgId = orgs[0].contact_id_b;
// Org name + activity walk + option groups, in parallel.
// For file-type fields, also request the joined `.file_name` so the
// report can surface a human-readable filename rather than the raw
// file id that APIv4 returns by default.
const civiFieldNames = Array.from(
new Set(allFields.map((f) => f.civiField).filter((f): f is string => !!f)),
);
const fileFieldRefs = Array.from(
new Set(
allFields
.filter((f) => f.type === "file" && f.civiField)
.map((f) => `${f.civiField!}.file_name`),
),
);
const select = [
"id",
"activity_date_time",
"subject",
ACTIVITY_STAGE_FIELD,
...civiFieldNames,
...fileFieldRefs,
];
const [orgRes, activityRes, options] = await Promise.all([
@@ -227,10 +256,15 @@ export async function GET(req: NextRequest) {
for (const row of rows) {
const v = row[f.civiField];
if (v === null || v === undefined || v === "") continue;
let value: unknown = v;
if (f.type === "file") {
const fname = row[`${f.civiField}.file_name`];
value = { id: v, file_name: typeof fname === "string" ? fname : undefined };
}
entries.push({
activityId: row.id,
date: row.activity_date_time,
value: v,
value,
});
}
if (entries.length > 0) fieldHistory[f.name] = entries;
+447
View File
@@ -0,0 +1,447 @@
/**
* GET /api/staff/report?org=<id>&key=<secret>
*
* Returns a comprehensive read-only StaffReportPayload for the given
* organization. Field list is built at request time from
* CustomField.get (no static config).
*
* Auth: STAFF_REPORT_KEY must match the `key` query param.
*
* STUB MODE: if CiviCRM env vars are unset, returns a fabricated payload
* exercising every render kind so the staff page is usable in dev.
*/
import { NextRequest, NextResponse } from "next/server";
import { isStaffKeyValid } from "@/lib/staff-auth";
import { civi } from "@/lib/civicrm";
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
import type {
StaffReportPayload,
StaffFieldDescriptor,
StaffReportSection,
ActivitySummary,
FieldHistoryEntry,
SelectOption,
} from "@/types/form";
// CustomFieldRow comes from the JSDoc typedef in staff-field-mapping.mjs;
// we mirror it locally as a TS interface so the call sites are type-checked.
type CustomFieldRow = Record<string, unknown> & {
name: string;
label: string;
data_type: string;
html_type: string;
option_group_id: number | null | undefined;
weight: number;
"custom_group_id.name": string;
"custom_group_id.title": string;
};
const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
const ACTIVITY_STAGE_FIELD = "Check_in_data__organizing_.Stage";
const STAGE_OPTION_GROUP_ID = 75;
const ACTIVITY_GROUP_NAMES = [
"Check_in_data__organizing_",
"Stage_1",
"Stage_2",
"Stage_3",
"Stage_4",
"Stage_5",
];
const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"];
const ALL_GROUP_NAMES = [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES];
function isCiviStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
function buildStubPayload(orgId: number): StaffReportPayload {
const today = new Date();
const daysAgo = (n: number) =>
new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString();
const ymd = (n: number) => {
const d = new Date(today.getTime() - n * 24 * 3600 * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
return {
orgId,
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
sections: [
{
groupName: "Food_Co_op_Organizing",
groupTitle: "Organization profile",
groupKind: "org",
fields: [
{
descriptor: {
groupName: "Food_Co_op_Organizing",
groupTitle: "Organization profile",
groupKind: "org",
civiField: "Food_Co_op_Organizing.Date_Incorporated",
name: "Date_Incorporated",
label: "Date Incorporated",
render: "date",
},
history: [{ activityId: 0, date: "", value: ymd(900) }],
},
{
descriptor: {
groupName: "Food_Co_op_Organizing",
groupTitle: "Organization profile",
groupKind: "org",
civiField: "Food_Co_op_Organizing.Equity_share",
name: "Equity_share",
label: "Equity share (USD)",
render: "currency",
},
history: [{ activityId: 0, date: "", value: 200 }],
},
],
},
{
groupName: "Check_in_data__organizing_",
groupTitle: "Check-in data (organizing)",
groupKind: "activity",
fields: [
{
descriptor: {
groupName: "Check_in_data__organizing_",
groupTitle: "Check-in data (organizing)",
groupKind: "activity",
civiField: "Check_in_data__organizing_.Members__current_",
name: "Members__current_",
label: "Members (current)",
render: "number",
},
history: [
{ activityId: 9012, date: daysAgo(3), value: 124 },
{ activityId: 9008, date: daysAgo(34), value: 109 },
{ activityId: 8995, date: daysAgo(95), value: 87 },
],
},
{
descriptor: {
groupName: "Check_in_data__organizing_",
groupTitle: "Check-in data (organizing)",
groupKind: "activity",
civiField: "Check_in_data__organizing_.Member_Goal_for_current_Stage",
name: "Member_Goal_for_current_Stage",
label: "Member goal for current stage",
render: "number",
},
history: [{ activityId: 9008, date: daysAgo(34), value: 200 }],
},
{
descriptor: {
groupName: "Check_in_data__organizing_",
groupTitle: "Check-in data (organizing)",
groupKind: "activity",
civiField: "Check_in_data__organizing_.Peer_Group_Participation",
name: "Peer_Group_Participation",
label: "Peer group participation",
render: "select",
optionGroupId: 140,
},
history: [
{ activityId: 9012, date: daysAgo(3), value: "Yes" },
{ activityId: 9008, date: daysAgo(34), value: "Considering" },
],
},
{
descriptor: {
groupName: "Check_in_data__organizing_",
groupTitle: "Check-in data (organizing)",
groupKind: "activity",
civiField: "Check_in_data__organizing_.Internal_Note",
name: "Internal_Note",
label: "Internal note",
render: "longtext",
},
history: [
{
activityId: 9012,
date: daysAgo(3),
value:
"Strong member momentum this quarter. Need a working group lead before next check-in.",
},
],
},
],
},
{
groupName: "Stage_1",
groupTitle: "Stage 1",
groupKind: "activity",
fields: [
{
descriptor: {
groupName: "Stage_1",
groupTitle: "Stage 1",
groupKind: "activity",
civiField: "Stage_1.Vision_Upload",
name: "Vision_Upload",
label: "Vision — Upload",
render: "file",
},
history: [
{
activityId: 9012,
date: daysAgo(3),
value: { id: 4242, file_name: "co-op-vision.pdf" },
},
],
},
],
},
],
activities: [
{ id: 9012, date: daysAgo(3), subject: "Co-op Survey (form submission)", submittedBy: "Jane Doe" },
{ id: 9008, date: daysAgo(34), subject: "Co-op Survey (form submission)", submittedBy: "Jane Doe" },
{ id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" },
{ id: 8995, date: daysAgo(95), subject: "Co-op Survey (form submission)", submittedBy: "John Roe" },
],
options: {
140: [
{ value: "Yes", label: "Yes" },
{ value: "No", label: "No" },
{ value: "Considering", label: "Considering" },
],
75: [
{ value: "Inquiry", label: "Inquiry" },
{ value: "Organizing", label: "Stage 1 — Convene & Prepare" },
{ value: "Feasibility", label: "Stage 2 — Grow & Plan" },
{ value: "Business feasibility", label: "Stage 3 — Connect & Gather" },
{ value: "Store Implementation", label: "Stage 4 — Excite & Build" },
{ value: "Stabilize newly opened co-op", label: "Stage 5 — Fulfill & Stabilize" },
],
},
};
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const key = url.searchParams.get("key");
const orgStr = url.searchParams.get("org");
if (!isStaffKeyValid(key)) {
// Don't leak whether the route exists.
return new NextResponse("Not found", { status: 404 });
}
const orgId = Number(orgStr);
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
}
if (isCiviStubMode()) {
return NextResponse.json(buildStubPayload(orgId));
}
try {
const payload = await buildLivePayload(orgId);
return NextResponse.json(payload);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/report] live fetch failed:", msg);
return NextResponse.json(
{ error: "Couldn't load the report. Check Civi credentials." },
{ status: 502 },
);
}
}
async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
// 1. Discover fields.
const fieldsRes = await civi<CustomFieldRow>("CustomField", "get", {
select: [
"name",
"label",
"data_type",
"html_type",
"option_group_id",
"weight",
"custom_group_id.name",
"custom_group_id.title",
],
where: [
["custom_group_id.name", "IN", ALL_GROUP_NAMES],
["is_active", "=", true],
],
orderBy: { "custom_group_id.weight": "ASC", weight: "ASC" },
limit: 500,
});
const descriptors: StaffFieldDescriptor[] = (fieldsRes.values ?? []).map((row) => {
const d = mapCustomFieldRow(row);
if (d.render === "text" && row.data_type !== "String" && row.data_type !== "Text") {
console.warn(
`[staff/report] field ${d.civiField} mapped to text fallback (data_type=${row.data_type} html_type=${row.html_type})`,
);
}
return d;
});
const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity");
const orgDescriptors = descriptors.filter((d) => d.groupKind === "org");
// 2. Org Contact (display_name + every org-side custom field).
const orgSelect = ["id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField)];
// 3. Activities (every activity-side custom field + file-name/url joins).
const fileFieldRefs = activityDescriptors
.filter((d) => d.render === "file")
.map((d) => `${d.civiField}.file_name`);
const activitySelect = [
"id",
"activity_date_time",
"subject",
"source_contact_id.display_name",
ACTIVITY_STAGE_FIELD,
...activityDescriptors.map((d) => d.civiField),
...fileFieldRefs,
];
// 4. Option groups for every select/multiselect + the stage option group.
const optionGroupIds = Array.from(
new Set([
STAGE_OPTION_GROUP_ID,
...descriptors.map((d) => d.optionGroupId).filter((id): id is number => typeof id === "number"),
]),
);
const [orgRes, activityRes, options] = await Promise.all([
civi<Record<string, unknown> & { id: number; display_name: string; contact_type: string }>(
"Contact",
"get",
{
select: orgSelect,
where: [["id", "=", orgId]],
},
),
civi<Record<string, unknown> & { id: number; activity_date_time: string }>("Activity", "get", {
select: activitySelect,
where: [
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
["target_contact_id", "=", orgId],
],
orderBy: { activity_date_time: "DESC", id: "DESC" },
limit: 500,
}),
fetchOptionGroups(optionGroupIds),
]);
const org = orgRes.values?.[0];
if (!org || org.contact_type !== "Organization") {
throw new Error(`Org ${orgId} not found or not an Organization`);
}
const rows = activityRes.values ?? [];
// Activity summaries.
const activities: ActivitySummary[] = rows.map((r) => ({
id: r.id,
date: r.activity_date_time,
stage: (r[ACTIVITY_STAGE_FIELD] as string | null | undefined) ?? null,
subject: (r.subject as string | null | undefined) ?? null,
submittedBy:
(r["source_contact_id.display_name"] as string | null | undefined) ?? null,
}));
// Current stage.
const stageRow = rows.find((r) => {
const v = r[ACTIVITY_STAGE_FIELD];
return typeof v === "string" && v.length > 0;
});
const currentStage =
typeof stageRow?.[ACTIVITY_STAGE_FIELD] === "string"
? (stageRow![ACTIVITY_STAGE_FIELD] as string)
: null;
// Build sections in order: org first, then activity groups in ALL_GROUP_NAMES order.
const sections: StaffReportSection[] = [];
// Org section (single section since we only have Food_Co_op_Organizing today).
if (orgDescriptors.length > 0) {
const groupName = orgDescriptors[0].groupName;
sections.push({
groupName,
groupTitle: "Organization profile",
groupKind: "org",
fields: orgDescriptors.map((d) => {
const raw = org[d.civiField];
const history: FieldHistoryEntry[] =
raw === null || raw === undefined || raw === ""
? []
: [{ activityId: 0, date: "", value: raw }];
return { descriptor: d, history };
}),
});
}
// Activity sections, in canonical order.
for (const groupName of ACTIVITY_GROUP_NAMES) {
const groupDescriptors = activityDescriptors.filter((d) => d.groupName === groupName);
if (groupDescriptors.length === 0) continue;
const groupTitle = groupDescriptors[0].groupTitle;
sections.push({
groupName,
groupTitle,
groupKind: "activity",
fields: groupDescriptors.map((d) => {
const entries: FieldHistoryEntry[] = [];
for (const row of rows) {
const v = row[d.civiField];
if (v === null || v === undefined || v === "") continue;
let value: unknown = v;
if (d.render === "file") {
const fname = row[`${d.civiField}.file_name`];
value = {
id: v,
file_name: typeof fname === "string" ? fname : undefined,
};
}
entries.push({ activityId: row.id, date: row.activity_date_time, value });
}
return { descriptor: d, history: entries };
}),
});
}
return {
orgId,
orgName: org.display_name,
currentStage,
sections,
activities,
options,
};
}
async function fetchOptionGroups(ids: number[]): Promise<Record<number, SelectOption[]>> {
if (ids.length === 0) return {};
const res = await civi<{
value: string;
label: string;
option_group_id: number;
is_active: boolean;
}>("OptionValue", "get", {
select: ["value", "label", "option_group_id", "is_active"],
where: [
["option_group_id", "IN", ids],
["is_active", "=", true],
],
orderBy: { weight: "ASC" },
limit: 1000,
});
const out: Record<number, SelectOption[]> = {};
for (const row of res.values ?? []) {
if (!out[row.option_group_id]) out[row.option_group_id] = [];
out[row.option_group_id].push({ value: row.value, label: row.label });
}
return out;
}
+37 -6
View File
@@ -70,7 +70,7 @@ export async function POST(req: Request) {
return NextResponse.json(
{
error: appEnv().isProduction
? "Could not save your check-in. Please try again, or contact your engagement coordinator."
? "Could not save your survey. Please try again, or contact <a href=\"mailto:chris@fci.coop\">Chris @ FCI</a>."
: `Save failed: ${redact(msg)}`,
},
{ status: 500 },
@@ -103,20 +103,51 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
}
const orgId = orgs[0].contact_id_b;
// Build the activity record. The Stage custom field is deliberately NOT
// set here — staff own stage transitions on their own activities.
// Split incoming values into:
// - activityRecord: fields with civiField → written to the new activity
// - orgContactValues: fields with civiContactField → written to the org
// contact via Contact.update
// Readonly fields are skipped entirely (display-only).
const activityRecord: Record<string, unknown> = {
"activity_type_id:name": ACTIVITY_TYPE_NAME,
"status_id:name": "Completed",
target_contact_id: orgId,
source_contact_id: Number(cid),
subject: "Co-op Check-in (form submission)",
subject: "Co-op Survey (form submission)",
};
const orgContactValues: Record<string, unknown> = {};
for (const [name, value] of Object.entries(values)) {
const field = FIELD_BY_NAME.get(name);
if (!field || !field.civiField) continue;
if (!field) continue;
if (field.type === "readonly") continue; // never write read-only fields
activityRecord[field.civiField] = value;
// File fields: the renderer uploads to /api/upload on file-pick and
// stores {id, file_name} in form state. Submit only needs the id —
// that's what Civi stores in the custom column. If the user left a
// prior attachment alone, we receive the same prefill shape and
// still write the same id (no-op effectively).
let civiValue: unknown = value;
if (field.type === "file" && value && typeof value === "object" && !Array.isArray(value)) {
const v = value as { id?: unknown };
civiValue = typeof v.id === "number" || typeof v.id === "string" ? v.id : null;
}
if (field.civiContactField) {
orgContactValues[field.civiContactField] = civiValue;
} else if (field.civiField) {
activityRecord[field.civiField] = civiValue;
}
}
// Update the org contact first (if any contact-bound fields changed),
// then create the submission activity. Order matters: if the contact
// update fails we'd rather not have an orphan activity claiming the
// submission succeeded.
if (Object.keys(orgContactValues).length > 0) {
await civi("Contact", "update", {
where: [["id", "=", orgId]],
values: orgContactValues,
});
}
await civi("Activity", "create", { values: activityRecord });
+253
View File
@@ -0,0 +1,253 @@
/**
* POST /api/upload
*
* Multipart endpoint that accepts a single file plus the form's auth pair
* (cid + cs) and the target Civi field reference. Verifies, validates,
* stores in CiviCRM via Attachment.create, returns { id, file_name }.
*
* The form's file renderer calls this on file-pick (not at submit time)
* so submit can stay a simple JSON POST. The returned { id, file_name }
* is what gets put into RHF state and ultimately submitted as the field
* value — the same shape /api/data uses for prefill, so the renderer's
* FilePriorIndicator works unchanged for fresh uploads too.
*
* Request: multipart/form-data with parts:
* - file the binary
* - cid contact id (form auth)
* - cs checksum (form auth)
* - fieldRef the Civi field reference, e.g. "Stage_1.Vision_Upload"
* or "Food_Co_op_Organizing.Certificate_of_Incorporation"
*
* Response: { id: number, file_name: string } OR { error: string }
*
* STUB MODE: if CiviCRM env vars are unset, returns a fake id + the
* uploaded filename so frontend dev works without a live CRM.
*/
import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { allFields } from "@/config/form";
import { rateLimit, clientIp } from "@/lib/rate-limit";
// 5 MB hard cap. Sits under AWS Amplify Lambda's 6 MB sync invocation
// payload limit with headroom for multipart envelope overhead.
const MAX_BYTES = 5 * 1024 * 1024;
// Per the v1 spec: PDF, DOC/DOCX, XLS/XLSX, common image types.
const ALLOWED_MIME = new Set<string>([
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
// Magic-byte sniff for the most common forgeries. Don't trust client-
// reported MIME alone — a renamed .exe shouldn't slip past us on the
// strength of a "Content-Type: application/pdf" header.
function sniffMime(bytes: Uint8Array): string | null {
if (bytes.length < 8) return null;
const b = bytes;
// %PDF
if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) {
return "application/pdf";
}
// PK (ZIP container — docx/xlsx)
if (b[0] === 0x50 && b[1] === 0x4b && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07)) {
return "application/zip"; // accept-with-allowlist handles docx/xlsx
}
// OLE compound (legacy doc/xls)
if (
b[0] === 0xd0 && b[1] === 0xcf && b[2] === 0x11 && b[3] === 0xe0 &&
b[4] === 0xa1 && b[5] === 0xb1 && b[6] === 0x1a && b[7] === 0xe1
) {
return "application/x-ole-storage";
}
// JPEG
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
// PNG
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
// GIF
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return "image/gif";
// WEBP — "RIFF????WEBP"
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
return "image/webp";
}
return null;
}
// Office formats (docx/xlsx) sniff as application/zip via PK header but
// are allowed at the client-reported MIME level. The mime check below
// keeps both axes honest: client mime must be in ALLOWED_MIME, AND the
// magic bytes must be plausible for that mime.
function mimePlausible(clientMime: string, sniffed: string | null): boolean {
if (!sniffed) return false;
if (sniffed === clientMime) return true;
// docx/xlsx are zips under the hood — accept the alias.
const zipAliased = new Set([
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
if (sniffed === "application/zip" && zipAliased.has(clientMime)) return true;
// Legacy doc/xls share the OLE container.
const oleAliased = new Set(["application/msword", "application/vnd.ms-excel"]);
if (sniffed === "application/x-ole-storage" && oleAliased.has(clientMime)) return true;
return false;
}
// Path-traversal scrub + length cap. Civi will store its own normalized
// name internally; this is purely defensive.
function sanitizeFilename(name: string): string {
const base = name.split(/[\\/]/).pop() ?? name;
// Drop control chars and anything that's not letters/digits/dot/dash/underscore/space.
const cleaned = base.replace(/[^\w.\- ]/g, "_").trim();
return cleaned.slice(0, 200) || "upload";
}
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
const FILE_FIELD_REFS = new Set(
allFields
.filter((f) => f.type === "file" && (f.civiField || f.civiContactField))
.map((f) => f.civiField ?? f.civiContactField!),
);
export async function POST(req: Request) {
// Generous-but-not-unlimited: 5 uploads per minute per IP. Captures
// accidental retry loops without throttling legitimate use (a form
// with 4 file fields fills in well under a minute).
const ip = clientIp(req);
const rl = rateLimit(`upload:${ip}`, { capacity: 5, windowMs: 60_000 });
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many uploads. Please wait a moment and try again." },
{ status: 429, headers: { "Retry-After": String(Math.ceil(rl.resetMs / 1000)) } },
);
}
// Parse multipart. Next 16 supports Request.formData() natively.
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: "Expected multipart/form-data." }, { status: 400 });
}
const cid = (form.get("cid") as string | null) ?? "";
const cs = (form.get("cs") as string | null) ?? "";
const fieldRef = (form.get("fieldRef") as string | null) ?? "";
const fileEntry = form.get("file");
if (!cid || !cs) {
return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 });
}
if (!fieldRef || !FILE_FIELD_REFS.has(fieldRef)) {
// Refusing unknown fieldRefs blocks the obvious abuse vector: a
// client posting an upload pointed at an arbitrary Civi field.
return NextResponse.json({ error: "Unknown or non-file field reference." }, { status: 400 });
}
if (!(fileEntry instanceof File)) {
return NextResponse.json({ error: "Missing file part." }, { status: 400 });
}
if (fileEntry.size === 0) {
return NextResponse.json({ error: "Empty file." }, { status: 400 });
}
if (fileEntry.size > MAX_BYTES) {
return NextResponse.json(
{ error: `File too large. Maximum is ${MAX_BYTES / (1024 * 1024)} MB.` },
{ status: 413 },
);
}
const clientMime = fileEntry.type || "application/octet-stream";
if (!ALLOWED_MIME.has(clientMime)) {
return NextResponse.json(
{ error: `File type ${clientMime} is not allowed.` },
{ status: 415 },
);
}
const bytes = new Uint8Array(await fileEntry.arrayBuffer());
const sniffed = sniffMime(bytes);
if (!mimePlausible(clientMime, sniffed)) {
return NextResponse.json(
{ error: "File contents don't match the declared type." },
{ status: 415 },
);
}
const safeName = sanitizeFilename(fileEntry.name);
if (isStubMode()) {
console.warn(
"[upload:STUB] would Attachment.create",
JSON.stringify({ name: safeName, mime: clientMime, bytes: fileEntry.size, fieldRef }),
);
return NextResponse.json({ id: -1, file_name: safeName, stub: true });
}
// Auth: verify the form's checksum before doing anything Civi-side.
const ok = await verifyChecksum(cid, cs);
if (!ok) {
return NextResponse.json(
{ error: "Link is invalid or has expired." },
{ status: 401 },
);
}
// APIv4 File.create with inline base64 content. Spike (June 2026) on
// this Civi instance confirmed:
// - APIv4 Attachment is NOT exposed
// - APIv4 File + EntityFile ARE exposed
// - File.create with file_name + mime_type + content (base64) returns
// a usable file id
// - Custom file fields store the file id directly in the custom
// column, so EntityFile linkage is not needed for our use case
// - Round-trip via Contact.update + Contact.get .file_name join works
//
// We do not create EntityFile rows here. Civi's custom-field renderer
// joins through the custom column to civicrm_file directly, and the
// form-side prefill/read code in /api/data uses the same join.
//
// The returned id is what the frontend stores in RHF state and
// ultimately sends as the field value on /api/submit. /api/submit then
// writes that id to the activity custom field (for stage-N file fields)
// or to the org contact custom field (for Food_Co_op_Organizing.*).
//
// Orphan files: if the user uploads and then abandons the form, the
// File row persists with no entity referencing it. Cleanup is handled
// by a CiviCRM scheduled job (configured separately by the Civi admin)
// that deletes File rows with no inbound references older than ~24h.
let fileId: number;
try {
const res = await civi<{ id: number }>("File", "create", {
values: {
file_name: safeName,
mime_type: clientMime,
content: Buffer.from(bytes).toString("base64"),
},
});
const id = res.values?.[0]?.id;
if (!id) throw new Error("File.create returned no id");
fileId = Number(id);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[upload] File.create failed:", msg);
return NextResponse.json(
{ error: "Could not save the upload. Please try again." },
{ status: 502 },
);
}
return NextResponse.json({ id: fileId, file_name: safeName });
}
+74 -60
View File
@@ -1,62 +1,76 @@
@import "tailwindcss";
/* ─────────────────────────────────────────────────────────────────────────
* Theme — "Field Almanac"
* Theme — FCI Brand
*
* Inspiration: 1970s ecological-movement print, agricultural cooperative
* publications, hand-bound field journals. Warm cream paper, deep
* botanical green ink, sparing terracotta accent for emphasis.
* Aligned with the Food Co-op Initiative brand guidelines (12.2022).
* Primary palette: Eggplant (#801d7f), Spring Pea (#96bc33), Squash
* (#c9ad2d). Secondary neutrals: FCI Dark Gray (#4b5657), FCI Beige
* (#D2C9B0), Seed Grant Green (#679038). Type: Open Sans (FCI's free
* substitute for Museo Sans, per the guidelines).
*
* The palette is built in OKLCH for perceptual uniformity. Every neutral
* is tinted toward the leaf hue (h≈130) so cream and green feel like they
* came from the same press run.
* Semantic token names from the previous theme are preserved so existing
* component class names continue to apply:
* - leaf-* → Spring Pea / Seed Grant green family
* - clay-* → Eggplant purple family (brand primary accent)
* - squash-*→ FCI yellow accent (new)
* - paper/ink → clean neutrals tuned to brand gray
* ─────────────────────────────────────────────────────────────────────── */
@theme {
/* Typography */
/* Typography
* Headings: existing display serif (Fraunces) retained for editorial voice.
* Body: Open Sans — FCI brand's free fallback for Museo Sans. */
--font-display: var(--font-display), ui-serif, Georgia, "Times New Roman", serif;
--font-body: var(--font-body), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-body: var(--font-body), ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
/* Custom utility names */
--color-paper: oklch(97.5% 0.012 95); /* warm cream */
--color-paper-2: oklch(95.5% 0.018 90); /* deeper cream — section bands */
--color-ink: oklch(20% 0.02 130); /* near-black, slight green tint */
--color-ink-soft: oklch(35% 0.018 130);
--color-ink-mute: oklch(55% 0.014 120);
--color-rule: oklch(82% 0.025 100); /* hairline rules — like ink on cream */
--color-rule-soft: oklch(89% 0.02 100);
/* Neutrals — anchored on FCI Dark Gray #4b5657 with FCI Beige #D2C9B0
* peeking through in tinted surfaces. */
--color-paper: #ffffff; /* clean white */
--color-paper-2: #f7f4ec; /* tinted band — FCI beige, very light */
--color-ink: #2d3536; /* near-black, brand-aligned */
--color-ink-soft: #4b5657; /* FCI Dark Gray (exact) */
--color-ink-mute: #7a8384;
--color-rule: #d7d3c7; /* FCI beige, desaturated */
--color-rule-soft: #ebe7dc;
/* Botanical green — primary */
--color-leaf-50: oklch(97% 0.025 135);
--color-leaf-100: oklch(93% 0.05 135);
--color-leaf-200: oklch(87% 0.085 135);
--color-leaf-300: oklch(78% 0.115 135);
--color-leaf-400: oklch(68% 0.13 135);
--color-leaf-500: oklch(57% 0.135 135);
--color-leaf-600: oklch(47% 0.13 135);
--color-leaf-700: oklch(38% 0.115 135);
--color-leaf-800: oklch(30% 0.09 135);
--color-leaf-900: oklch(22% 0.06 135);
/* Leaf — Spring Pea #96bc33 + Seed Grant #679038 (FCI brand greens). */
--color-leaf-50: #f3f8e8;
--color-leaf-100: #e6f2cd;
--color-leaf-200: #d0e5a6;
--color-leaf-300: #b8d97c;
--color-leaf-400: #a4cf57;
--color-leaf-500: #96bc33; /* FCI Spring Pea (exact) */
--color-leaf-600: #7faa2a;
--color-leaf-700: #679038; /* FCI Seed Grant (exact) */
--color-leaf-800: #4f7029;
--color-leaf-900: #344f1a;
/* Terracotta — accent. Used sparingly: current-stage marker, key CTAs. */
--color-clay-100: oklch(94% 0.04 50);
--color-clay-200: oklch(86% 0.075 50);
--color-clay-400: oklch(70% 0.13 45);
--color-clay-500: oklch(63% 0.15 42);
--color-clay-600: oklch(54% 0.155 40);
--color-clay-700: oklch(45% 0.135 38);
/* Clay — repurposed as FCI Eggplant #801d7f (brand primary accent). */
--color-clay-100: #f5e6f4;
--color-clay-200: #e3c1e1;
--color-clay-400: #b34db1;
--color-clay-500: #9f2d9d;
--color-clay-600: #801d7f; /* FCI Eggplant (exact) */
--color-clay-700: #6a1869;
/* Stone — kept as a familiar alias mapping to our warm neutrals so any
* earlier `text-stone-*` / `border-stone-*` references stay readable. */
/* Squash — FCI yellow #c9ad2d. Reserved for warning glyphs and small
* highlights; not currently consumed by components but available. */
--color-squash-100: #faf3d0;
--color-squash-300: #e6d066;
--color-squash-500: #c9ad2d; /* FCI Squash (exact) */
--color-squash-700: #9a8623;
/* Stone alias — mapped to FCI neutrals for any lingering text-stone-* refs. */
--color-stone-50: var(--color-paper);
--color-stone-100: var(--color-paper-2);
--color-stone-200: var(--color-rule-soft);
--color-stone-300: var(--color-rule);
--color-stone-400: oklch(70% 0.018 110);
--color-stone-400: #a8aeae;
--color-stone-500: var(--color-ink-mute);
--color-stone-600: oklch(48% 0.015 120);
--color-stone-600: #5e6868;
--color-stone-700: var(--color-ink-soft);
--color-stone-800: oklch(28% 0.018 130);
--color-stone-800: #353c3d;
--color-stone-900: var(--color-ink);
}
@@ -73,28 +87,10 @@ html {
body {
background-color: var(--color-paper);
color: var(--color-ink);
/* Subtle paper grain — two diagonal repeating linear gradients combined
* with a soft radial overlay. No external image. Survives dark mode if
* we ever add one. */
background-image:
radial-gradient(ellipse 90% 60% at 50% 0%, oklch(99% 0.01 95 / 0.7), transparent 60%),
repeating-linear-gradient(
105deg,
oklch(96% 0.02 95 / 0.4) 0,
oklch(96% 0.02 95 / 0.4) 1px,
transparent 1px,
transparent 6px
),
repeating-linear-gradient(
15deg,
oklch(94% 0.02 95 / 0.25) 0,
oklch(94% 0.02 95 / 0.25) 1px,
transparent 1px,
transparent 9px
);
}
/* Typography defaults */
/* Typography defaults. Display serif keeps optical-size tuning (Fraunces);
* body switches to Open Sans (FCI brand fallback for Museo Sans). */
.font-display { font-family: var(--font-display); font-feature-settings: "ss01", "cv01"; }
.font-body { font-family: var(--font-body); }
@@ -116,6 +112,24 @@ input, select, textarea, button {
border-radius: 4px;
}
/* Anchor defaults. Body links (in copy and chrome) read as interactive
* affordances: FCI leaf color + a 1px underline with a small offset.
* Lives in @layer base so Tailwind utility classes (text-*, no-underline)
* still win on elements that opt out individually. */
@layer base {
a[href] {
color: var(--color-leaf-700);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
transition: color 150ms ease-out, text-decoration-thickness 150ms ease-out;
}
a[href]:hover {
color: var(--color-leaf-800);
text-decoration-thickness: 1.5px;
}
}
/* Date input chevron styled to match */
input[type="date"]::-webkit-calendar-picker-indicator {
opacity: 0.5;
+8 -7
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Fraunces, DM_Sans } from "next/font/google";
import { Fraunces, Open_Sans } from "next/font/google";
import "./globals.css";
/**
@@ -16,20 +16,21 @@ const display = Fraunces({
});
/**
* Body face: DM Sans. A humanist geometric sans with a slightly soft feel —
* legible at small sizes for forms, doesn't read as cold/corporate the way
* Inter or Helvetica does.
* Body face: Open Sans. Per the FCI brand guidelines, Open Sans is the free
* substitute for the brand's primary type family (Museo Sans). It pairs
* cleanly with the Fraunces headings while keeping the form copy on-brand.
*/
const body = DM_Sans({
const body = Open_Sans({
variable: "--font-body",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: "Co-op Check-in · Food Co-op Initiative",
title: "Co-op Survey · Food Co-op Initiative",
description:
"Update your co-op's progress through the FCI organizing framework. A monthly check-in for food co-ops in development.",
"Update your co-op's progress through the FCI organizing Framework. A survey for food co-ops in development.",
robots: { index: false, follow: false }, // Form pages are tokenized; not for crawlers.
};
+2 -2
View File
@@ -40,7 +40,7 @@ function PageIntro() {
return (
<header className="mb-10 max-w-2xl">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Monthly check-in · Co-op organizing
Survey · Co-op organizing
</p>
<h1 className="mt-2 font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
{formConfig.title}
@@ -61,7 +61,7 @@ function MissingLinkParams() {
</h2>
<p className="mt-3 leading-relaxed text-ink-soft">
Open the form using the personalized link from your email. If you no longer have it,
please contact your engagement coordinator and ask for a fresh link.
please contact <a href="mailto:survey@fci.coop">Chris @ FCI</a> and ask for a fresh link.
</p>
</div>
);
+2 -2
View File
@@ -52,7 +52,7 @@ function ReportIntro() {
</h1>
<p className="mt-4 max-w-prose text-[17px] leading-relaxed text-ink-soft">
A read-only summary of every value your co-op has shared through past
check-ins, grouped by stage. The most recent entry sits at the top of
surveys, grouped by stage. The most recent entry sits at the top of
each row; expand a row to see how a number or note has changed over
time.
</p>
@@ -69,7 +69,7 @@ function MissingLinkParams() {
</h2>
<p className="mt-3 leading-relaxed text-ink-soft">
Open the report using the personalized link from your email. If you no
longer have it, please contact your engagement coordinator and ask for
longer have it, please contact <a href="mailto:survey@fci.coop">Chris @ FCI</a> and ask for
a fresh link.
</p>
</div>
+113
View File
@@ -0,0 +1,113 @@
import { Suspense } from "react";
import { StaffReportView } from "@/components/StaffReportView";
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
import { isStaffKeyValid } from "@/lib/staff-auth";
interface PageProps {
searchParams: Promise<{ org?: string; key?: string; frame?: string }>;
}
export const metadata = {
title: "Staff report — Food Co-op Initiative",
robots: { index: false, follow: false },
};
export default async function StaffReportPage({ searchParams }: PageProps) {
const { org, key, frame } = await searchParams;
const isFramed = frame === "1";
// Generic "not found" if the key is missing or wrong — don't confirm
// route existence.
if (!isStaffKeyValid(key)) {
return <NotFound framed={isFramed} />;
}
const orgId = Number(org);
const orgValid = !!org && Number.isFinite(orgId) && orgId > 0;
// CIVI_BASE_URL flows from server config to client only as a base for
// outbound file links. No secret material is exposed.
const civiBaseUrl = process.env.CIVI_BASE_URL ?? "";
// When embedded in CiviCRM (?frame=1), drop the site header/footer so the
// report fills the iframe cleanly. Standalone visits keep full chrome.
const body = (
<main id="main" className="flex-1">
<div
className={
isFramed
? "mx-auto max-w-5xl px-3 py-4"
: "mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14"
}
>
{orgValid ? (
<Suspense fallback={null}>
<StaffReportView
org={orgId}
authKey={key!}
civiBaseUrl={civiBaseUrl}
framed={isFramed}
/>
</Suspense>
) : (
<MissingOrg />
)}
</div>
</main>
);
if (isFramed) return body;
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
>
Skip to content
</a>
<SiteHeader />
{body}
<SiteFooter />
</>
);
}
function NotFound({ framed }: { framed: boolean }) {
const inner = (
<div
className={
framed
? "mx-auto max-w-2xl px-4 py-8 text-center"
: "mx-auto max-w-2xl px-4 py-20 text-center"
}
>
<h1 className="font-display text-3xl text-ink">Not found</h1>
<p className="mt-3 text-ink-soft">
The page you requested doesn&apos;t exist.
</p>
</div>
);
if (framed) return <main className="flex-1">{inner}</main>;
return (
<>
<SiteHeader />
<main className="flex-1">{inner}</main>
<SiteFooter />
</>
);
}
function MissingOrg() {
return (
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
<h2 className="font-display text-xl font-medium text-clay-700">
Missing or invalid org id.
</h2>
<p className="mt-3 leading-relaxed text-ink-soft">
Add <code className="font-mono">?org=&lt;civi-org-id&gt;</code> to the URL. The org
id is the Civi Contact id of the organization (visible in the URL when
viewing the org in CiviCRM).
</p>
</div>
);
}
@@ -0,0 +1,35 @@
<?php
/**
* Engagement Report tab page.
*
* Loaded as an AJAX snippet by the contact-view tabset (snippet=1). The
* template emits a single iframe pointing at the WebForm-mw staff report
* plus a small postMessage listener that auto-sizes the iframe to the
* report's content height.
*/
class CRM_WebformMw_Page_Tab extends CRM_Core_Page {
public function run() {
$cid = CRM_Utils_Request::retrieve('cid', 'Positive', $this, TRUE);
$appUrl = _webform_mw_app_url();
$key = _webform_mw_staff_key();
$configured = ($appUrl !== '' && $key !== '');
if ($configured) {
$src = $appUrl . '/staff/report'
. '?org=' . urlencode((string) $cid)
. '&key=' . urlencode($key)
. '&frame=1';
$this->assign('iframeSrc', $src);
// Expose just the app origin so the parent-side postMessage
// listener can validate event.origin without leaking the secret.
$this->assign('appOrigin', parse_url($appUrl, PHP_URL_SCHEME)
. '://' . parse_url($appUrl, PHP_URL_HOST));
}
$this->assign('configured', $configured);
parent::run();
}
}
+103
View File
@@ -0,0 +1,103 @@
# WebForm-mw — CiviCRM extension
Adds an **Engagement Report** tab to Organization contact-view pages that
embeds the FCI Co-op Survey staff report
(`https://survey.fci.coop/staff/report`) in an iframe. The tab shows the
report for the current organization, scoped by the contact id in the URL.
The embedded app handles its own auth via a shared staff secret. The
extension is otherwise read-only and adds no Civi tables, custom fields,
or scheduled jobs.
## Install
1. Copy the entire `webform-mw/` directory to your CiviCRM extensions
directory (typically `<civi-root>/sites/default/ext/` or wherever
`[civicrm.extensionsDir]` points in your `civicrm.settings.php`).
- The directory name on disk must be `webform-mw` (matching `<key>` in
`info.xml`).
2. In CiviCRM:
`Administer → System Settings → Extensions → Add new → Refresh`,
then click **Install** next to "WebForm-mw".
3. Configure (see next section). The tab will be hidden until both
settings are present.
## Configuration
The extension reads two values, in this order:
1. Constants in `civicrm.settings.php` (preferred):
```php
define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...');
```
2. Or environment variables (`WEBFORM_MW_APP_URL`, `WEBFORM_MW_STAFF_KEY`)
set wherever PHP-FPM / the web server reads its environment from.
`WEBFORM_MW_STAFF_KEY` must match the `STAFF_REPORT_KEY` configured on the
Next.js app (Amplify environment / SSM Parameter Store). The two are the
same shared secret; rotate them together.
`WEBFORM_MW_APP_URL` is the public base URL of the WebForm-mw deployment
(no trailing slash). Production: `https://survey.fci.coop`.
When either value is missing, the tab body shows a help banner with the
exact configuration snippet to paste, so anyone installing the extension
without prior context can self-serve.
## Behaviour
- Tab title: **Engagement Report**.
- Visible only on **Organization** contacts (Individuals and Households
see no tab). The check happens server-side in the tabset hook.
- Tab body is an iframe pointing at
`${WEBFORM_MW_APP_URL}/staff/report?org=<cid>&key=<secret>&frame=1`.
- `frame=1` tells the embedded app to suppress its site header/footer
and emit a `postMessage({type:"webform-mw-height", height})` payload
on render and on resize. The tab's small inline script listens for
this message and auto-sizes the iframe so there's no nested scrollbar.
- The script validates the postMessage `event.origin` against the
configured `WEBFORM_MW_APP_URL` origin before resizing.
## Security notes
- The staff secret travels with each tab render inside the iframe `src`.
Anyone permitted to view the Engagement Report tab (i.e., anyone with
CiviCRM access) can read the URL in their browser's DevTools and reuse
the secret to view any org's report. Acceptable model if "CiviCRM
access" and "should view any staff report" overlap; otherwise consider
upgrading the embedded app to per-user signed tokens and minting them
in the page controller.
- The extension uses `access CiviCRM` as its access argument — anyone
with that permission sees the tab on Organization contacts. Tighten by
changing the `<access_arguments>` value in `xml/Menu/webform_mw.xml`
to a more specific permission (e.g., `view all contacts`) and
reinstalling.
- The embedded app's CSP (`frame-ancestors`) must include the CiviCRM
origin or the iframe will refuse to render. The Next app reads
`CIVI_BASE_URL` at build time and adds its origin to the staff route's
CSP automatically. Confirm with `curl -I <app>/staff/report` after
deploy — you should see `Content-Security-Policy: ... frame-ancestors
'self' https://<your-civi-host> ...`.
## Files
- `info.xml` — extension manifest. Key `webform-mw`, type `module`.
- `webform_mw.php` — `hook_civicrm_tabset` + config readers.
- `CRM/WebformMw/Page/Tab.php` — page controller for the tab snippet.
- `templates/CRM/WebformMw/Page/Tab.tpl` — iframe + height-listener.
- `xml/Menu/webform_mw.xml` — registers the
`civicrm/contact/view/engagement-report` URL.
## Versions tested
- CiviCRM 5.50+
- PHP 7.4+ / 8.x
- Tested against Backdrop-as-host and Drupal-as-host CiviCRM deployments.
## Uninstalling
`Administer → Extensions → Disable`, then `Uninstall`. Removes nothing
from the database; the extension stores no Civi data of its own.
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0"?>
<extension key="webform-mw" type="module">
<file>webform_mw</file>
<name>WebForm-mw</name>
<description>
Adds an "Engagement Report" tab to Organization contact pages that embeds
the FCI Co-op Survey staff report (the WebForm-mw Next.js app) for the
organization. Read-only; the embedded app handles its own auth via a
shared staff secret.
</description>
<license>AGPL-3.0</license>
<maintainer>
<author>Food Co-op Initiative</author>
<email>survey@fci.coop</email>
</maintainer>
<urls>
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
</urls>
<releaseDate>2026-06-05</releaseDate>
<version>0.1.0</version>
<develStage>beta</develStage>
<compatibility>
<ver>5.50</ver>
</compatibility>
<comments>
Configuration is via PHP constants in civicrm.settings.php (or
environment variables on the CiviCRM server). See README.md.
</comments>
<classloader>
<psr0 prefix="CRM_" path=""/>
</classloader>
<civix>
<namespace>CRM/WebformMw</namespace>
<format>22.05.0</format>
</civix>
</extension>
@@ -0,0 +1,40 @@
{* Engagement Report tab content. *}
{if $configured}
<div id="wfmw-engagement-report-wrap" style="margin:-1em -1em 0 -1em;">
<iframe
id="wfmw-engagement-report"
src="{$iframeSrc|escape:'htmlall'}"
title="Engagement Report"
style="width:100%;height:1200px;border:0;display:block;background:transparent;"
referrerpolicy="same-origin"
loading="eager"
></iframe>
</div>
<script>
(function () {
var APP_ORIGIN = {$appOrigin|json_encode};
var frame = document.getElementById('wfmw-engagement-report');
if (!frame) return;
window.addEventListener('message', function (e) {
if (APP_ORIGIN && e.origin !== APP_ORIGIN) return;
var d = e && e.data;
if (!d || d.type !== 'webform-mw-height' || typeof d.height !== 'number') return;
// Add a little headroom so the report's own bottom padding isn't clipped.
var h = Math.max(600, Math.floor(d.height) + 24);
frame.style.height = h + 'px';
}, false);
})();
</script>
{else}
<div class="messages status no-popup">
<p><strong>{ts}Engagement Report is not configured.{/ts}</strong></p>
<p>
{ts}Add the following to your <code>civicrm.settings.php</code> (or set both as environment variables on the CiviCRM server) and reload:{/ts}
</p>
<pre>define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...');</pre>
<p>
{ts}See the extension's README.md for details.{/ts}
</p>
</div>
{/if}
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* WebForm-mw CiviCRM extension.
*
* Adds an "Engagement Report" tab to Organization contact-view pages that
* embeds the WebForm-mw Next.js staff report via an iframe.
*
* Configuration (in civicrm.settings.php or as env vars on the Civi server):
*
* define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
* define('WEBFORM_MW_STAFF_KEY', '<the STAFF_REPORT_KEY shared with the app>');
*
* Both values must be set or the tab renders a help banner explaining what
* to configure. See README.md.
*/
/**
* Resolve the app URL from constant or env. Empty string when unset.
*/
function _webform_mw_app_url(): string {
if (defined('WEBFORM_MW_APP_URL')) {
return rtrim((string) constant('WEBFORM_MW_APP_URL'), '/');
}
$env = getenv('WEBFORM_MW_APP_URL');
return is_string($env) && $env !== '' ? rtrim($env, '/') : '';
}
/**
* Resolve the staff secret from constant or env. Empty string when unset.
*/
function _webform_mw_staff_key(): string {
if (defined('WEBFORM_MW_STAFF_KEY')) {
return (string) constant('WEBFORM_MW_STAFF_KEY');
}
$env = getenv('WEBFORM_MW_STAFF_KEY');
return is_string($env) ? $env : '';
}
/**
* Implements hook_civicrm_xmlMenu().
*
* Registers this extension's menu file. Civi 5.50+ usually auto-discovers
* `xml/Menu/*.xml` from extensions, but some installations only pick the
* file up when an explicit hook returns it. Without this the new path
* `civicrm/contact/view/engagement-report` falls back to the parent
* `civicrm/contact/view` route and the tab pane recursively embeds the
* contact-view summary instead of our iframe.
*/
function webform_mw_civicrm_xmlMenu(&$files) {
$files[] = __DIR__ . '/xml/Menu/webform_mw.xml';
}
/**
* Implements hook_civicrm_tabset().
*
* Adds the Engagement Report tab to the Organization contact summary tabset.
* Other contact types (Individual, Household) get no tab.
*/
function webform_mw_civicrm_tabset($tabsetName, &$tabs, $context) {
if ($tabsetName !== 'civicrm/contact/view') {
return;
}
$cid = $context['contact_id'] ?? NULL;
if (!$cid) {
return;
}
// Restrict to Organization contacts.
$contactType = NULL;
try {
$contactType = civicrm_api3('Contact', 'getvalue', [
'id' => (int) $cid,
'return' => 'contact_type',
]);
}
catch (\Throwable $e) {
// Quietly skip — failing here should not break the contact page.
return;
}
if ($contactType !== 'Organization') {
return;
}
$tabs[] = [
'id' => 'engagement_report',
'title' => ts('Engagement Report'),
'weight' => 200,
'count' => NULL,
'icon' => 'crm-i fa-line-chart',
'url' => CRM_Utils_System::url(
'civicrm/contact/view/engagement-report',
"reset=1&cid={$cid}&snippet=1"
),
];
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="iso-8859-1" ?>
<menu>
<item>
<path>civicrm/contact/view/engagement-report</path>
<title>Engagement Report</title>
<page_callback>CRM_WebformMw_Page_Tab</page_callback>
<access_arguments>access CiviCRM</access_arguments>
</item>
</menu>
+197 -52
View File
@@ -54,6 +54,10 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null);
const [draftRestored, setDraftRestored] = useState(false);
// Section currently in view, updated as the user scrolls. Surfaces in
// the sticky SubmitBar so the org name + viewed stage remain visible
// even when the top-of-form header has scrolled out of sight.
const [viewedSectionId, setViewedSectionId] = useState<string | null>(null);
const {
register,
@@ -62,9 +66,17 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
control,
watch,
setFocus,
setValue,
formState: { errors, isDirty },
} = useForm({ mode: "onBlur" });
// Count of file uploads currently in flight. Each <FileField> calls the
// handler with +1 when it starts and -1 when it finishes; submit is
// blocked while the count is > 0 so users can't ship a half-uploaded form.
const [uploadsInFlight, setUploadsInFlight] = useState(0);
const handleUploadStateChange = (delta: 1 | -1) =>
setUploadsInFlight((n) => Math.max(0, n + delta));
// Subscribe ONLY to current_stage. That's the single field that affects
// section visibility, so re-rendering the whole form on every keystroke
// (which `watch()` with no args would do) is wasteful — particularly with
@@ -76,13 +88,22 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
(useWatch({ control, name: "current_stage" }) as string | undefined) ?? "";
// State map passed to the conditional engine and to FieldRenderer for
// readonly display values. Only fields referenced by visibility rules or
// by readonly displays need to be here — currently just current_stage,
// which gates every Stage 15 visibleWhen rule and is shown as a readonly
// field in the Stage 0 header.
// readonly display values. Includes:
// - current_stage: gates every Stage 15 visibleWhen rule and renders
// as a readonly field in Stage 0.
// - contact_first_name / contact_last_name / contact_email: readonly
// identity fields in Stage 0, sourced from /api/data (the cid contact).
// Static after load, so we read straight from load.data.contact rather
// than subscribing through react-hook-form.
const readyContact = load.kind === "ready" ? load.data.contact : undefined;
const evalState = useMemo(
() => ({ current_stage: currentStageValue }),
[currentStageValue],
() => ({
current_stage: currentStageValue,
contact_first_name: readyContact?.firstName ?? "",
contact_last_name: readyContact?.lastName ?? "",
contact_email: readyContact?.email ?? "",
}),
[currentStageValue, readyContact],
);
// ── Initial load ───────────────────────────────────────────────────────
@@ -107,17 +128,28 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const data: FormDataPayload = await res.json();
if (cancelled) return;
// Build defaults: server prefill + current stage. Then check if a
// local draft exists newer than the server data; if so, layer it on top.
// Build defaults: server prefill + current stage + readonly contact
// identity. Then check if a local draft exists newer than the server
// data; if so, layer it on top. Contact-identity fields are never
// user-editable, so a draft can't override them — they're force-set
// again after the draft merge below.
const contactDefaults: Record<string, unknown> = {
contact_first_name: data.contact?.firstName ?? "",
contact_last_name: data.contact?.lastName ?? "",
contact_email: data.contact?.email ?? "",
};
const defaults: Record<string, unknown> = {
[config.stageField]: data.currentStage,
...contactDefaults,
...data.prefill,
};
const draft = loadDraft(cid);
if (draft && Object.keys(draft.values).length > 0) {
Object.assign(defaults, draft.values);
// Re-set the stage from server (draft can never override the org's actual stage).
// Re-set the stage and contact identity from server (drafts can
// never override the org's actual stage nor the cid's contact).
defaults[config.stageField] = data.currentStage;
Object.assign(defaults, contactDefaults);
setDraftRestored(true);
setDraftSavedAt(draft.savedAt);
}
@@ -138,6 +170,55 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
};
}, [cid, cs, reset, config.stageField]);
// ── Observe which section is currently being viewed ───────────────────
// The sticky submit bar mirrors this so the user always knows which org
// they're on and which stage they're scrolled to, even after the top
// header has left the viewport. We bias intersection toward the upper
// 40% of the viewport — once a section's box enters that band, it
// becomes "viewed"; the topmost intersecting section wins ties.
useEffect(() => {
if (load.kind !== "ready") return;
const els = Array.from(
document.querySelectorAll<HTMLElement>("[data-section-id]"),
);
if (els.length === 0) return;
const intersecting = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const id = entry.target.getAttribute("data-section-id");
if (!id) continue;
if (entry.isIntersecting) intersecting.add(id);
else intersecting.delete(id);
}
// Pick the topmost intersecting section (closest to viewport top).
let bestId: string | null = null;
let bestDist = Infinity;
for (const el of els) {
const id = el.getAttribute("data-section-id");
if (!id || !intersecting.has(id)) continue;
const dist = Math.abs(el.getBoundingClientRect().top);
if (dist < bestDist) {
bestDist = dist;
bestId = id;
}
}
if (bestId) setViewedSectionId(bestId);
},
{ rootMargin: "0px 0px -60% 0px", threshold: [0, 0.1] },
);
for (const el of els) observer.observe(el);
return () => observer.disconnect();
}, [load.kind, config.sections]);
const viewedSection = useMemo(
() => (viewedSectionId ? config.sections.find((x) => x.id === viewedSectionId) ?? null : null),
[viewedSectionId, config.sections],
);
const viewedSectionLabel = viewedSection?.label ?? null;
const viewedRank = viewedSection?.rank ?? null;
// ── Auto-save draft on idle ────────────────────────────────────────────
// Debounce — save 1.5s after the user stops editing. Uses watch's
// subscription API so we get notified on every change WITHOUT re-rendering
@@ -206,6 +287,19 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}
const onSubmit = async (values: Record<string, unknown>) => {
// Block while any file upload is in flight — submitting now would
// ship the form without the pending {id, file_name} value for that
// field, which would silently clear the prior attachment.
if (uploadsInFlight > 0) {
setSubmitState({
kind: "error",
message:
uploadsInFlight === 1
? "A file is still uploading. Please wait a moment and try again."
: `${uploadsInFlight} files are still uploading. Please wait a moment and try again.`,
});
return;
}
setSubmitState({ kind: "submitting" });
try {
// Strip values for hidden fields — never write data the user couldn't see.
@@ -306,7 +400,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
? sectionsToRender[i + 1].pathwayState
: undefined;
return (
<li key={section.id} className="relative list-none">
<li
key={section.id}
data-section-id={section.id}
className="relative list-none"
>
<MobileStem show={i > 0} state={pathwayState} />
<RailMarker
rank={section.rank}
@@ -316,6 +414,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
<StageSection
section={section}
register={register}
setValue={setValue}
control={control}
errors={errors}
formValues={evalState}
@@ -323,6 +422,9 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
locked={locked}
defaultOpen={pathwayState === "current" || section.rank === 0}
options={load.data.options ?? {}}
cid={cid}
cs={cs}
onUploadStateChange={handleUploadStateChange}
/>
</li>
);
@@ -333,6 +435,9 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
state={submitState}
isDirty={isDirty}
draftSavedAt={draftSavedAt}
orgName={load.data.orgName}
viewedSectionLabel={viewedSectionLabel}
viewedRank={viewedRank}
/>
</form>
);
@@ -349,7 +454,7 @@ function SubmissionContextHeader({
}) {
return (
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Check-in for</p>
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Survey for</p>
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
{orgName}
</h1>
@@ -385,15 +490,22 @@ function resolveStageLabel(
* Six small dots representing the six stages, with the current rank filled
* and earlier ranks marked as visited. Quietly conveys progression without
* pretending to be a "100% complete" progress bar.
*
* `pulse` adds a slow halo to the active pill — used in the floating
* submit bar (where the active rank moves as the user scrolls) to draw
* the eye to the changing indicator. Left off in the static header.
*/
function StageProgress({ currentRank }: { currentRank: number }) {
function StageProgress({ currentRank, pulse = false }: { currentRank: number; pulse?: boolean }) {
return (
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
{[0, 1, 2, 3, 4, 5].map((r) => (
<span
key={r}
className={
"h-1.5 rounded-full transition-all " +
"h-1.5 rounded-full transition-all duration-500 ease-out " +
(r === currentRank && pulse
? "motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite] "
: "") +
(r < currentRank
? "w-2.5 bg-leaf-300"
: r === currentRank
@@ -438,61 +550,95 @@ function SubmitBar({
state,
isDirty,
draftSavedAt,
orgName,
viewedSectionLabel,
viewedRank,
}: {
state: SubmitStatus;
isDirty: boolean;
draftSavedAt: string | null;
orgName: string;
viewedSectionLabel: string | null;
viewedRank: number | null;
}) {
// Build the small secondary line under the org name. Priority:
// 1. submit feedback (error or in-flight) — most urgent
// 2. "Viewing: <stage>" when the user has scrolled to a section
// 3. draft / edit status — falls back when there's no section in view
// yet (e.g. immediately after load, before any scroll)
let secondary: React.ReactNode = null;
if (state.kind === "error") {
secondary = (
<p className="truncate text-xs font-medium text-clay-700" role="alert">
{state.message}
</p>
);
} else if (state.kind === "submitting") {
secondary = (
<p className="truncate text-xs text-ink-soft" role="status">
Saving your survey
</p>
);
} else if (viewedSectionLabel && viewedRank != null) {
secondary = (
<div
className="flex min-w-0 items-center gap-2.5"
aria-live="polite"
aria-label={`Viewing ${viewedSectionLabel}`}
>
<StageProgress currentRank={viewedRank} pulse />
<p className="min-w-0 truncate text-xs text-ink-mute">
<span className="text-ink-soft">{viewedSectionLabel}</span>
{isDirty && draftSavedAt && (
<span className="text-ink-mute"> · saved {formatRelative(draftSavedAt)}</span>
)}
</p>
</div>
);
} else if (isDirty && draftSavedAt) {
secondary = (
<p className="truncate text-xs text-ink-mute">
Draft saved {formatRelative(draftSavedAt)} (locally on this device)
</p>
);
} else if (isDirty) {
secondary = (
<p className="truncate text-xs text-ink-mute">
Editing your draft will save automatically
</p>
);
} else {
secondary = null;
}
return (
<div
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-6"
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex flex-col gap-0.5">
<SubmitFeedback state={state} />
{state.kind === "idle" && (
<p className="text-xs text-ink-mute">
{isDirty
? draftSavedAt
? `Draft saved ${formatRelative(draftSavedAt)} (locally on this device)`
: "Editing — your draft will save automatically"
: "Submitting saves a new check-in record on your co-op."}
</p>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-medium leading-tight text-ink sm:text-lg">
{orgName}
</p>
{secondary && <div className="mt-0.5">{secondary}</div>}
</div>
<button
type="submit"
disabled={state.kind === "submitting"}
className="inline-flex items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
>
{state.kind === "submitting" ? (
<>
<Spinner /> Saving check-in
<Spinner /> Saving survey
</>
) : (
<>Submit check-in</>
<>Submit survey</>
)}
</button>
</div>
);
}
function SubmitFeedback({ state }: { state: SubmitStatus }) {
if (state.kind === "submitting")
return (
<p className="text-sm text-ink-soft" role="status">
Saving your check-in
</p>
);
if (state.kind === "error")
return (
<p className="text-sm font-medium text-clay-700" role="alert">
{state.message}
</p>
);
return null;
}
function Spinner() {
return (
<svg
@@ -515,7 +661,7 @@ function LoadingState() {
aria-hidden
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
/>
<p className="font-display text-base text-ink-soft italic">Loading your check-in</p>
<p className="font-display text-base text-ink-soft italic">Loading your survey</p>
</div>
);
}
@@ -542,12 +688,11 @@ function SuccessDestination({
</svg>
</div>
<h2 className="mt-5 font-display text-2xl font-medium leading-tight tracking-tight text-ink sm:text-3xl">
Check-in saved
Survey saved
</h2>
<p className="mx-auto mt-3 max-w-prose text-[15px] leading-relaxed text-ink-soft">
Thank you. We&apos;ve recorded this check-in for{" "}
<span className="font-medium text-ink">{orgName}</span>. Your engagement coordinator will see
it on their next review.
Thank you. We&apos;ve recorded this survey for{" "}
<span className="font-medium text-ink">{orgName}</span>.
</p>
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<button
@@ -555,7 +700,7 @@ function SuccessDestination({
onClick={onAnother}
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
>
Submit another check-in
Submit another survey
</button>
<p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p>
</div>
@@ -570,11 +715,11 @@ function ErrorState({ message }: { message: string }) {
className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7"
>
<h2 className="font-display text-xl font-medium text-clay-700">
We couldn&apos;t open your check-in.
We couldn&apos;t open your survey.
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
<p className="mt-4 text-sm text-ink-soft">
If this keeps happening, please contact your engagement coordinator and ask for a fresh
If this keeps happening, please contact <a href="mailto:chris@fci.coop">Chris @ FCI</a> and ask for a fresh
link.
</p>
</div>
+100 -222
View File
@@ -11,6 +11,20 @@ import type {
} from "@/types/form";
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { StageIcon } from "./StageIcon";
import {
FieldHistoryGroup,
FieldHistoryRow,
formatShortDate,
computeDateRange,
Chevron,
} from "./report/FieldHistory";
import { DateTimeline } from "./report/DateTimeline";
import {
MembershipChart,
MEMBERS_ACTUAL_NAME,
MEMBERS_GOAL_NAME,
} from "./report/MembershipChart";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface ReportViewProps {
config: FormConfig;
@@ -112,6 +126,8 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
dateRange={dateRange}
/>
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
{sectionsToRender.length === 0 ? (
<EmptyState />
) : (
@@ -172,7 +188,7 @@ function ReportContextHeader({
{currentStageLabel || "—"}
</p>
<dl className="mt-5 grid grid-cols-3 gap-3 border-t border-rule-soft pt-4 text-sm sm:gap-6">
<Stat label="Check-ins" value={String(totalActivities)} />
<Stat label="Surveys" value={String(totalActivities)} />
<Stat label="Fields tracked" value={String(totalFieldsTracked)} />
<Stat
label="Span"
@@ -284,20 +300,95 @@ function ReportSection({
className="border-t border-rule-soft"
>
<div className="divide-y divide-rule-soft">
{fields.map((f) => (
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>
))}
{(() => {
// Compute the slot for the inline Membership chart: render it
// immediately after whichever of (Members current, Member Goal)
// appears last among this section's fields-with-history. If
// neither field is in this section, the index is -1 and the
// chart is skipped — naturally scoping the chart to whichever
// section those questions live in (Stage 0 today).
const memberIdx = fields.findIndex((f) => f.name === MEMBERS_ACTUAL_NAME);
const goalIdx = fields.findIndex((f) => f.name === MEMBERS_GOAL_NAME);
const chartIdx = Math.max(memberIdx, goalIdx);
const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined;
const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined;
// Build a map from field name → its group config (if any). A
// field referenced in multiple groups belongs to the first one.
const groupByField = new Map<string, { id: string; label?: string }>();
for (const g of section.fieldGroups ?? []) {
for (const fname of g.fields) {
if (!groupByField.has(fname)) {
groupByField.set(fname, { id: g.id, label: g.label });
}
}
}
// Walk fields in declared order; whenever we hit one that
// belongs to a group we haven't emitted yet, collect every
// visible field from that group (those with history) and emit
// them as one bordered cluster. Other group members get
// skipped when we encounter them later in the loop.
const fieldsWithHistorySet = new Set(fields.map((f) => f.name));
const emittedGroups = new Set<string>();
const out: React.ReactNode[] = [];
fields.forEach((f, i) => {
const grp = groupByField.get(f.name);
if (grp && !emittedGroups.has(grp.id)) {
emittedGroups.add(grp.id);
const sectionGroup = (section.fieldGroups ?? []).find((g) => g.id === grp.id)!;
const groupedFields = sectionGroup.fields
.map((name) => fields.find((ff) => ff.name === name))
.filter((ff): ff is FieldConfig => !!ff && fieldsWithHistorySet.has(ff.name));
if (groupedFields.length === 0) return;
out.push(
<FieldHistoryGroup
key={`group-${grp.id}`}
label={grp.label}
fields={groupedFields}
history={history}
options={options}
/>,
);
} else if (!grp) {
out.push(
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>,
);
}
if (i === chartIdx && chartIdx >= 0) {
out.push(
<div key={`chart-${f.name}`} className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
<MembershipChart
membersField={membersFieldInSection}
goalField={goalFieldInSection}
membersHistory={history[MEMBERS_ACTUAL_NAME]}
goalHistory={history[MEMBERS_GOAL_NAME]}
/>
</div>,
);
}
});
return out;
})()}
</div>
</div>
</section>
);
}
/**
* Mirrors the form's FieldGroupCard: a leaf-tinted left rule + small
* uppercase mini-label, with the grouped history rows stacked beneath
* and separated by the same divide-y as the standalone rows. Eats only
* ~14px of horizontal space (vs ~40px for a fully-boxed treatment).
*/
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
return (
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
@@ -321,183 +412,6 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
>
<path d="M5 8 L10 13 L15 8" />
</svg>
);
}
function FieldHistoryRow({
field,
entries,
options,
}: {
field: FieldConfig;
entries: FieldHistoryEntry[];
options: Record<number, SelectOption[]>;
}) {
const [expanded, setExpanded] = useState(false);
const latest = entries[0];
const priorEntries = entries.slice(1);
return (
<div className="px-5 py-4 sm:px-7">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
<div className="min-w-0 sm:max-w-[16rem]">
<p className="text-sm font-medium text-ink">{field.label}</p>
{field.help && (
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
)}
</div>
<div className="flex-1 min-w-0 text-left sm:text-right">
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
<FormattedValue value={latest.value} field={field} options={options} />
</p>
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
as of {formatShortDate(latest.date)}
{priorEntries.length > 0 && (
<>
{" · "}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
>
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
</button>
</>
)}
</p>
</div>
</div>
{expanded && priorEntries.length > 0 && (
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
{priorEntries.map((e) => (
<li
key={e.activityId}
className="flex items-baseline justify-between gap-4 text-sm"
>
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
{formatShortDate(e.date)}
</span>
<span className="text-right text-ink-soft tabular-nums">
<FormattedValue value={e.value} field={field} options={options} />
</span>
</li>
))}
</ol>
)}
</div>
);
}
const currencyFmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
const numberFmt = new Intl.NumberFormat("en-US");
function FormattedValue({
value,
field,
options,
}: {
value: unknown;
field: FieldConfig;
options: Record<number, SelectOption[]>;
}) {
if (value === null || value === undefined || value === "") return <></>;
const opts: SelectOption[] | undefined = field.optionGroupId
? options[field.optionGroupId]
: field.options;
switch (field.type) {
case "currency": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? currencyFmt.format(n) : String(value)}</>;
}
case "percent": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? `${n}%` : String(value)}</>;
}
case "number": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? numberFmt.format(n) : String(value)}</>;
}
case "date":
return <>{formatLongDate(String(value))}</>;
case "boolean":
return <>{value ? "Yes" : "No"}</>;
case "select":
case "readonly": {
const v = String(value);
const found = opts?.find((o) => o.value === v);
return <>{found?.label ?? v}</>;
}
case "multiselect": {
let parts: string[];
if (Array.isArray(value)) {
parts = value.map(String);
} else {
parts = String(value).split(/[|,]/).map((s) => s.trim()).filter(Boolean);
}
const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p);
return <>{labels.join(", ")}</>;
}
case "file":
return <>{String(value)}</>;
case "textarea":
case "text":
case "email":
case "phone":
default:
return <>{String(value)}</>;
}
}
function formatShortDate(iso: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function formatLongDate(iso: string): string {
if (!iso) return "—";
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
const d = m
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
: new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
}
function computeDateRange(dates: string[]): { from: string; to: string } | null {
if (dates.length === 0) return null;
const times = dates
.map((d) => new Date(d).getTime())
.filter((t) => Number.isFinite(t));
if (times.length === 0) return null;
const min = new Date(Math.min(...times)).toISOString();
const max = new Date(Math.max(...times)).toISOString();
return { from: min, to: max };
}
function RailMarker({
rank,
state,
@@ -570,39 +484,3 @@ function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
);
}
function LoadingState() {
return (
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
<div
aria-hidden
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
/>
<p className="font-display text-base text-ink-soft italic">Loading your activity report</p>
</div>
);
}
function EmptyState() {
return (
<div className="rounded-lg border border-dashed border-rule bg-paper-2/30 px-6 py-10 text-center">
<p className="font-display text-lg text-ink-soft">No entries on file yet.</p>
<p className="mt-2 text-sm text-ink-mute">
Your co-op&apos;s first check-in will appear here once it&apos;s submitted.
</p>
</div>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
<h2 className="font-display text-xl font-medium text-clay-700">
We couldn&apos;t open your report.
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
<p className="mt-4 text-sm text-ink-soft">
If this keeps happening, please contact your engagement coordinator.
</p>
</div>
);
}
+7 -7
View File
@@ -7,7 +7,7 @@ export function SiteHeader() {
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-5 sm:px-6">
<Link
href="https://fci.coop"
className="group flex items-center gap-4 focus:outline-none"
className="group flex items-center gap-4 text-ink no-underline focus:outline-none"
aria-label="Food Co-op Initiative — fci.coop"
>
<Image
@@ -19,7 +19,7 @@ export function SiteHeader() {
className="h-12 w-auto sm:h-14"
/>
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-ink-mute sm:inline-block sm:border-l sm:border-rule sm:pl-4">
Co-op Check-in
Co-op Survey
</span>
</Link>
<nav aria-label="Primary">
@@ -41,15 +41,15 @@ export function SiteFooter() {
<div className="mx-auto max-w-3xl px-4 py-7 sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-baseline sm:justify-between">
<p className="font-display text-sm italic text-ink-soft">
&ldquo;A grocery co-op begins with people who decide to feed each other well.&rdquo;
Thank you for completing this survey.
</p>
<p className="text-xs text-ink-mute">
Need a fresh link? Contact your engagement coordinator.
Have a question or need help? Contact <a href="mailto:survey@fci.coop">Chris @ FCI</a>.
</p>
</div>
<p className="mt-4 text-xs text-ink-mute leading-relaxed">
Your responses go directly to your co-op&apos;s record. Nothing is shared with third
parties. This page is only reachable through a personalized link issued to you.
<p className="mt-4 text-xs text-ink-mute">
&copy; {new Date().getFullYear()} Food Co-op Initiative &middot;{" "}
<a href="https://fci.coop">fci.coop</a>
</p>
</div>
</footer>
+620
View File
@@ -0,0 +1,620 @@
"use client";
import { useEffect, useState } from "react";
import type {
FieldConfig,
FieldHistoryEntry,
SelectOption,
StageSectionConfig,
StaffReportField,
StaffReportPayload,
StaffReportSection,
} from "@/types/form";
import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart";
import { DateTimeline } from "./report/DateTimeline";
import {
FormattedValue,
formatShortDate,
formatLongDate,
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface StaffReportViewProps {
org: number;
authKey: string;
/** CIVI_BASE_URL, used to build outbound file links. */
civiBaseUrl: string;
/** True when the page is being embedded in a CiviCRM tab via iframe. */
framed?: boolean;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: StaffReportPayload };
const STAGE_OPTION_GROUP_ID = 75;
export function StaffReportView({
org,
authKey,
civiBaseUrl,
framed = false,
}: StaffReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch(
`/api/staff/report?org=${encodeURIComponent(String(org))}&key=${encodeURIComponent(authKey)}`,
{ cache: "no-store" },
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
if (alive)
setLoad({
kind: "error",
message: body.error ?? `Request failed (${res.status})`,
});
return;
}
const data = (await res.json()) as StaffReportPayload;
if (alive) setLoad({ kind: "ready", data });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (alive) setLoad({ kind: "error", message: msg });
}
})();
return () => {
alive = false;
};
}, [org, authKey]);
// When embedded, post our content height to the parent so the Civi tab's
// iframe can resize to fit (no nested scrollbars). The receiving script
// lives in the WebForm-mw Civi extension's tab template.
useEffect(() => {
if (!framed || typeof window === "undefined") return;
if (window.parent === window) return;
const post = () => {
window.parent.postMessage(
{ type: "webform-mw-height", height: document.documentElement.scrollHeight },
"*",
);
};
post();
const ro = new ResizeObserver(post);
ro.observe(document.documentElement);
window.addEventListener("load", post);
return () => {
ro.disconnect();
window.removeEventListener("load", post);
};
}, [framed, load]);
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
const { data } = load;
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />;
const checkInSection = data.sections.find((s) => s.groupName === "Check_in_data__organizing_");
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME);
const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
const stageLabel = data.currentStage
? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
?? data.currentStage
: "—";
const dateRange = computeDateRange(data.activities.map((a) => a.date));
return (
<article className="space-y-10">
<header className="space-y-4">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Staff report · Internal use only
</p>
<h1 className="font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
{data.orgName}
</h1>
<dl className="grid grid-cols-2 gap-6 text-sm sm:grid-cols-4">
<Stat label="Current stage" value={stageLabel} />
<Stat label="Submissions" value={String(data.activities.length)} />
<Stat
label="Date range"
value={
dateRange
? `${formatShortDate(dateRange.from)} ${formatShortDate(dateRange.to)}`
: "—"
}
/>
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
</dl>
<div className="h-px bg-rule" />
</header>
<SectionAnchorNav
sections={data.sections}
hasActivities={data.activities.length > 0}
framed={framed}
/>
{membersField && membersField.history.length > 0 ? (
<MembershipChart
membersHistory={membersField.history}
goalHistory={goalField?.history}
/>
) : null}
<StaffDateTimeline data={data} />
{data.sections.map((section) => (
<StaffSection
key={section.groupName}
section={section}
options={data.options}
civiBaseUrl={civiBaseUrl}
/>
))}
<ActivityTable activities={data.activities} options={data.options} />
</article>
);
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-[0.16em] text-ink-soft">{label}</dt>
<dd className="mt-1 text-[15px] text-ink">{value}</dd>
</div>
);
}
/**
* Horizontal anchor strip — one chip per section + Submissions.
*
* Sticky in standalone mode; non-sticky when embedded in a CiviCRM tab
* (the iframe auto-resizes to fit content so there's no internal scroll
* for `sticky` to engage against).
*/
function SectionAnchorNav({
sections,
hasActivities,
framed,
}: {
sections: StaffReportSection[];
hasActivities: boolean;
framed: boolean;
}) {
const items = sections.map((s) => ({
href: `#section-${s.groupName}`,
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
}));
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur";
return (
<nav
aria-label="Section navigation"
className={`${stickyCls} -mx-4 border-y border-rule bg-paper/95 px-4 py-2 sm:-mx-6 sm:px-6`}
>
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
{items.map((it) => (
<li key={it.href}>
<a
href={it.href}
className="inline-block rounded-full border border-rule bg-paper px-2.5 py-0.5 text-ink-soft hover:border-leaf-700 hover:text-leaf-800"
>
{it.label}
</a>
</li>
))}
</ul>
</nav>
);
}
function StaffSection({
section,
options,
civiBaseUrl,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
const [showEmpty, setShowEmpty] = useState(false);
// Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields
// out into a single tabular display matching the form's matrix layout.
const isStage5 = section.groupName === "Stage_5";
const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null;
const filledOutsideMatrix =
matrixFields
? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name))
: filled;
return (
<section
id={`section-${section.groupName}`}
aria-labelledby={`heading-${section.groupName}`}
className="space-y-3 scroll-mt-16"
>
<h2
id={`heading-${section.groupName}`}
className="font-display text-2xl font-medium text-ink"
>
{section.groupTitle}
</h2>
<p className="text-[12px] uppercase tracking-[0.16em] text-ink-soft">
{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "}
{filled.length} with data
</p>
{matrixFields ? (
<Y1MatrixTable
rows={matrixFields.rows}
quarters={matrixFields.quarters}
options={options}
/>
) : null}
{filledOutsideMatrix.length > 0 ? (
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
{filledOutsideMatrix.map((f) => (
<CompactFieldRow
key={f.descriptor.name}
field={f}
options={options}
civiBaseUrl={civiBaseUrl}
/>
))}
</ul>
) : null}
{empty.length > 0 ? (
<div className="rounded-md border border-rule bg-paper">
<button
type="button"
onClick={() => setShowEmpty((v) => !v)}
className="flex w-full items-center justify-between px-3 py-1.5 text-[12px] uppercase tracking-[0.14em] text-ink-soft hover:text-ink"
>
<span>{empty.length} empty field{empty.length === 1 ? "" : "s"}</span>
<span aria-hidden>{showEmpty ? "▾" : "▸"}</span>
</button>
{showEmpty ? (
<ul className="divide-y divide-rule border-t border-rule">
{empty.map((f) => (
<li
key={f.descriptor.name}
className="flex items-baseline justify-between px-3 py-1 text-[13px] text-ink-soft"
>
<span>{f.descriptor.label}</span>
<span></span>
</li>
))}
</ul>
) : null}
</div>
) : null}
</section>
);
}
/**
* Compact, latest-only row. If the field has multiple history entries, a
* muted "N earlier entries" toggle reveals the rest inline.
*
* File-typed values render as an outbound link to CiviCRM rather than a
* proxied download — staff are already logged into Civi when they arrive
* here, and the server doesn't need to broker bytes.
*/
function CompactFieldRow({
field,
options,
civiBaseUrl,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
}) {
const [open, setOpen] = useState(false);
const latest = field.history[0];
const earlier = field.history.slice(1);
return (
<li className="px-3 py-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
<div className="text-[13px] text-ink-soft">
<FieldValue field={field} entry={latest} options={options} civiBaseUrl={civiBaseUrl} />
{latest.date ? (
<span className="ml-2 text-[11px] text-ink-soft">
· {formatShortDate(latest.date)}
</span>
) : null}
</div>
</div>
{earlier.length > 0 ? (
<div className="mt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-[11px] uppercase tracking-[0.14em] text-leaf-700 hover:underline"
>
{open ? "Hide" : `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
</button>
{open ? (
<ul className="mt-1 space-y-1 border-l border-rule pl-3">
{earlier.map((e, i) => (
<li
key={`${e.activityId}-${e.date}-${i}`}
className="flex items-baseline justify-between gap-x-4 text-[12px] text-ink-soft"
>
<FieldValue field={field} entry={e} options={options} civiBaseUrl={civiBaseUrl} />
<span className="text-[11px] text-ink-soft">{formatShortDate(e.date)}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
</li>
);
}
function FieldValue({
field,
entry,
options,
civiBaseUrl,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as { id?: number | string; file_name?: string } | null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
// Civi serves uploaded files at /civicrm/file?reset=1&id=<id>.
// The staff member is already authenticated to Civi (they came from
// there); the browser sends their session cookie automatically.
const href = civiBaseUrl
? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`
: "#";
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
);
}
return (
<FormattedValue value={entry.value} field={fieldConfigFor(field)} options={options} />
);
}
function fieldConfigFor(f: StaffReportField): FieldConfig {
return {
name: f.descriptor.name,
label: f.descriptor.label,
type: renderToFieldType(f.descriptor.render),
optionGroupId: f.descriptor.optionGroupId,
};
}
function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldConfig["type"] {
switch (r) {
case "currency":
return "currency";
case "date":
case "datetime":
return "date";
case "select":
return "select";
case "multiselect":
return "multiselect";
case "file":
return "file";
case "longtext":
return "textarea";
case "boolean":
return "boolean";
case "number":
return "number";
default:
return "text";
}
}
/**
* Stage 5 Y1 matrix: detect fields whose names match Y1_Q<n>_<metric> and
* group them into a read-only table mirroring the form's matrix layout.
* The metric set is whatever's actually present in the data so a Civi
* schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically.
*/
interface Y1MatrixRow {
metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
label: string; // human label from the first field's descriptor (sans Y1_Q<n>_ prefix)
byQuarter: Map<number, StaffReportField>;
}
interface Y1MatrixData {
rows: Y1MatrixRow[];
quarters: number[];
usedNames: Set<string>;
}
function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null {
const re = /^Y1_Q(\d+)_(.+)$/;
const used = new Set<string>();
const byMetric = new Map<string, Map<number, StaffReportField>>();
const quartersSet = new Set<number>();
const metricLabel = new Map<string, string>();
for (const f of filled) {
const m = re.exec(f.descriptor.name);
if (!m) continue;
const quarter = Number(m[1]);
const metric = m[2];
used.add(f.descriptor.name);
quartersSet.add(quarter);
if (!byMetric.has(metric)) byMetric.set(metric, new Map());
byMetric.get(metric)!.set(quarter, f);
if (!metricLabel.has(metric)) {
// Strip "Y1 Q<n> " prefix variants from the label if present.
const cleaned = f.descriptor.label
.replace(/^Y1\s*Q\d+\s*/i, "")
.replace(/_/g, " ")
.trim();
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
}
}
if (byMetric.size === 0) return null;
const quarters = Array.from(quartersSet).sort((a, b) => a - b);
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({
metric,
label: metricLabel.get(metric) ?? metric,
byQuarter,
}));
return { rows, quarters, usedNames: used };
}
function Y1MatrixTable({
rows,
quarters,
options,
}: {
rows: Y1MatrixRow[];
quarters: number[];
options: Record<number, SelectOption[]>;
}) {
return (
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
<table className="min-w-full text-sm">
<caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
Year 1 quarterly · latest values
</caption>
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
<tr>
<th className="px-3 py-2 font-medium">Metric</th>
{quarters.map((q) => (
<th key={q} className="px-3 py-2 font-medium">Q{q}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-rule">
{rows.map((row) => (
<tr key={row.metric}>
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
{quarters.map((q) => {
const f = row.byQuarter.get(q);
const latest = f?.history[0];
return (
<td key={q} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
{f && latest ? (
<FormattedValue
value={latest.value}
field={fieldConfigFor(f)}
options={options}
/>
) : (
"—"
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
function ActivityTable({
activities,
options,
}: {
activities: StaffReportPayload["activities"];
options: Record<number, SelectOption[]>;
}) {
if (activities.length === 0) {
return (
<section id="section-submissions" className="space-y-3 scroll-mt-16">
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<p className="text-sm text-ink-soft">No submissions recorded for this organization yet.</p>
</section>
);
}
const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? [];
const stageLabel = (v: string | null | undefined) =>
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
return (
<section id="section-submissions" className="space-y-3 scroll-mt-16">
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<div className="overflow-x-auto rounded-md border border-rule">
<table className="min-w-full text-sm">
<thead className="bg-paper text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
<tr>
<th className="px-3 py-2">Date</th>
<th className="px-3 py-2">Stage snapshot</th>
<th className="px-3 py-2">Subject</th>
<th className="px-3 py-2">Submitted by</th>
<th className="px-3 py-2">Activity id</th>
</tr>
</thead>
<tbody className="divide-y divide-rule">
{activities.map((a) => (
<tr key={a.id}>
<td className="px-3 py-2 text-ink">{formatShortDate(a.date)}</td>
<td className="px-3 py-2 text-ink">{stageLabel(a.stage ?? null)}</td>
<td className="px-3 py-2 text-ink">{a.subject ?? "—"}</td>
<td className="px-3 py-2 text-ink">{a.submittedBy ?? "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-ink-soft">{a.id}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
const fieldConfigs: FieldConfig[] = [];
for (const section of data.sections) {
if (section.groupKind !== "activity") continue;
for (const f of section.fields) {
if (f.descriptor.render !== "date" && f.descriptor.render !== "datetime") continue;
if (f.history.length === 0) continue;
fieldHistory[f.descriptor.name] = f.history;
fieldConfigs.push(fieldConfigFor(f));
}
}
if (fieldConfigs.length === 0) return null;
const sections: StageSectionConfig[] = [
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
];
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
}
// formatLongDate retained for symmetry with other report views; unused here
// now that file rows are compact links.
void formatLongDate;
+165 -4
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
import type {
UseFormRegister,
UseFormSetValue,
FieldValues,
FieldErrors,
Control,
@@ -16,6 +17,7 @@ import { evaluate } from "@/lib/conditional";
interface StageSectionProps {
section: StageSectionConfig;
register: UseFormRegister<FieldValues>;
setValue: UseFormSetValue<FieldValues>;
control: Control<FieldValues>;
errors: FieldErrors;
/** Live form values, used to evaluate per-field visibility rules. */
@@ -32,6 +34,12 @@ interface StageSectionProps {
defaultOpen: boolean;
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
options: Record<number, SelectOption[]>;
/** Form auth pair, passed through to file fields for /api/upload. */
cid: string;
cs: string;
/** Called by file fields when an upload starts/finishes; lets the form
* track in-flight uploads and block submit until they settle. */
onUploadStateChange?: (delta: 1 | -1) => void;
}
/**
@@ -48,6 +56,7 @@ interface StageSectionProps {
export function StageSection({
section,
register,
setValue,
control,
errors,
formValues,
@@ -55,6 +64,9 @@ export function StageSection({
locked,
defaultOpen,
options,
cid,
cs,
onUploadStateChange,
}: StageSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const headingId = useId();
@@ -79,12 +91,51 @@ export function StageSection({
return names;
}, [section.matrixGroups]);
// Names of fields claimed by a fieldGroup. These get pulled out of the
// standalone per-field grid and rendered inside their group's card. If a
// name appears in multiple groups, the first wins.
const groupedFieldNames = useMemo(() => {
const names = new Set<string>();
for (const g of section.fieldGroups ?? []) {
for (const fname of g.fields) names.add(fname);
}
return names;
}, [section.fieldGroups]);
const fieldsByName = useMemo(() => {
const map = new Map<string, FieldConfig>();
for (const f of section.fields) map.set(f.name, f);
return map;
}, [section.fields]);
// Materialize each group as its list of *visible* fields (with the group's
// declared order preserved). A group with zero visible fields renders
// nothing.
const visibleGroups = useMemo(
() =>
(section.fieldGroups ?? [])
.map((g) => ({
group: g,
fields: g.fields
.map((name) => fieldsByName.get(name))
.filter((f): f is FieldConfig => !!f)
.filter((f) => evaluate(f.visibleWhen, formValues))
.filter((f) => !matrixFieldNames.has(f.name)),
}))
.filter((g) => g.fields.length > 0),
[section.fieldGroups, fieldsByName, formValues, matrixFieldNames],
);
const visibleFields = section.fields.filter(
(f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name),
(f) =>
evaluate(f.visibleWhen, formValues) &&
!matrixFieldNames.has(f.name) &&
!groupedFieldNames.has(f.name),
);
const fieldCount =
visibleFields.length +
visibleGroups.reduce((n, g) => n + g.fields.length, 0) +
(section.matrixGroups ?? []).reduce(
(n, g) => n + g.rows.reduce((m, r) => m + r.fields.length, 0),
0,
@@ -178,7 +229,30 @@ export function StageSection({
))}
</div>
)}
{visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (
{visibleGroups.length > 0 && (
<div className="mb-7 space-y-5">
{visibleGroups.map(({ group, fields }) => (
<FieldGroupCard
key={group.id}
label={group.label}
intro={group.intro}
fields={fields}
formValues={formValues}
options={options}
register={register}
setValue={setValue}
control={control}
errors={errors}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
/>
))}
</div>
)}
{visibleFields.length === 0 &&
visibleGroups.length === 0 &&
(section.matrixGroups ?? []).length === 0 ? (
<p className="text-sm italic text-ink-mute">No fields are visible at this stage.</p>
) : visibleFields.length === 0 ? null : (
<div className="grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2">
@@ -193,10 +267,14 @@ export function StageSection({
<FieldRenderer
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
/>
</div>
);
@@ -282,12 +360,95 @@ function LockedBanner() {
<p className="text-xs leading-relaxed text-ink-soft">
<span className="font-medium text-ink-soft">A look ahead.</span>{" "}
These fields will become editable when your co-op reaches this stage. They&apos;re visible
now so you can preview the framework you&apos;ll be working through.
now so you can preview the Framework you&apos;ll be working through.
</p>
</div>
);
}
/**
* Visual cluster of related fields inside a section (e.g. "Market Study"
* grouping its date + upload fields). Lightweight treatment: a leaf-tinted
* left rule and an optional uppercase mini-label. No boxed background or
* heavy padding — preserves the horizontal space the inner 2-col grid has
* to work with, while still signaling "these belong together."
*/
function FieldGroupCard({
label,
intro,
fields,
formValues,
options,
register,
setValue,
control,
errors,
cid,
cs,
onUploadStateChange,
}: {
label?: string;
intro?: string;
fields: FieldConfig[];
formValues: Record<string, unknown>;
options: Record<number, SelectOption[]>;
register: UseFormRegister<FieldValues>;
setValue: UseFormSetValue<FieldValues>;
control: Control<FieldValues>;
errors: FieldErrors;
cid: string;
cs: string;
onUploadStateChange?: (delta: 1 | -1) => void;
}) {
return (
<div className="border-l-2 border-leaf-300/60 pl-3 sm:pl-4">
{label && (
<h3 className="font-display text-[11px] font-medium uppercase tracking-[0.1em] text-ink-soft">
{label}
</h3>
)}
{intro && (
<p
className={
"max-w-prose text-xs leading-relaxed text-ink-mute " +
(label ? "mt-0.5" : "")
}
>
{intro}
</p>
)}
<div
className={
"grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2 " +
(label || intro ? "mt-2" : "")
}
>
{fields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
const wide =
f.type === "textarea" || f.type === "boolean" || f.type === "multiselect";
return (
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
<FieldRenderer
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
/>
</div>
);
})}
</div>
</div>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
+223 -26
View File
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import type { FieldConfig, SelectOption } from "@/types/form";
import type {
UseFormRegister,
UseFormSetValue,
FieldValues,
FieldErrors,
Control,
@@ -12,6 +14,9 @@ import type {
interface FieldRendererProps {
field: FieldConfig;
register: UseFormRegister<FieldValues>;
/** RHF setValue — file fields use it to write the uploaded {id, file_name}
* back into form state after /api/upload returns. */
setValue: UseFormSetValue<FieldValues>;
errors: FieldErrors;
/** Form control — required for currency live-preview formatting. */
control: Control<FieldValues>;
@@ -23,6 +28,12 @@ interface FieldRendererProps {
* `options` array if absent.
*/
resolvedOptions?: SelectOption[];
/** Form auth pair, threaded through to file fields for /api/upload. */
cid: string;
cs: string;
/** Called by file fields when an upload starts (+1) / finishes (-1). The
* form uses the running count to block submit while uploads are in flight. */
onUploadStateChange?: (delta: 1 | -1) => void;
}
const DATE_MIN_DEFAULT = "1900-01-01";
@@ -44,10 +55,14 @@ const DATE_MAX_DEFAULT = "2100-12-31";
export function FieldRenderer({
field,
register,
setValue,
errors,
control,
readonlyValue,
resolvedOptions,
cid,
cs,
onUploadStateChange,
}: FieldRendererProps) {
const id = `field-${field.name}`;
const helpId = field.help ? `${id}-help` : undefined;
@@ -66,11 +81,25 @@ export function FieldRenderer({
// ── Readonly display field ──────────────────────────────────────────────
if (field.type === "readonly") {
const opt = effectiveOptions.find((o) => o.value === readonlyValue);
const display =
readonlyValue == null || readonlyValue === ""
? "—"
: opt?.label ?? String(readonlyValue);
// File-shaped readonly value: {id, file_name} from a CiviCRM file field
// (e.g. Certificate of Incorporation). Render the filename rather than
// "[object Object]".
let display: string;
if (
readonlyValue != null &&
typeof readonlyValue === "object" &&
!Array.isArray(readonlyValue) &&
"file_name" in (readonlyValue as Record<string, unknown>)
) {
const fn = (readonlyValue as { file_name?: unknown }).file_name;
display = typeof fn === "string" && fn ? fn : "Attachment on file";
} else {
const opt = effectiveOptions.find((o) => o.value === readonlyValue);
display =
readonlyValue == null || readonlyValue === ""
? "—"
: opt?.label ?? String(readonlyValue);
}
return (
<div className="space-y-1">
<Label id={id} field={field} />
@@ -203,28 +232,22 @@ export function FieldRenderer({
// ── File ────────────────────────────────────────────────────────────────
if (field.type === "file") {
const priorName = typeof readonlyValue === "string" ? readonlyValue : "";
const hasPrior = priorName.length > 0;
return (
<div className="space-y-1">
<Label id={id} field={field} />
{hasPrior && (
<p className="text-xs text-ink-soft" aria-live="polite">
Currently on file: <span className="font-medium text-ink">{priorName}</span>
</p>
)}
<input
id={id}
type="file"
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
{...register(field.name, { required: requiredOpt })}
className="block w-full text-sm text-ink-soft file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer transition"
/>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
<FileField
field={field}
id={id}
helpId={helpId}
errorId={errorId}
errorMsg={errorMsg}
describedBy={describedBy}
control={control}
setValue={setValue}
cid={cid}
cs={cs}
onUploadStateChange={onUploadStateChange}
register={register}
requiredOpt={requiredOpt}
/>
);
}
@@ -363,6 +386,180 @@ function CurrencyPreview({
);
}
/**
* For file fields: detects whether a previously-uploaded attachment is
* carried in this field's prefill value (RHF state) and surfaces a small
* banner with a paperclip glyph. Falls silent once the user picks a new
* file (RHF value becomes a FileList) so it doesn't contradict their
* fresh upload. Filename is derived from whatever shape Civi returned —
* a bare string filename, an object with `file_name`/`name`/`label`, or
* a numeric file id (in which case we render a generic message).
*/
function FilePriorIndicator({
control,
name,
}: {
control: Control<FieldValues>;
name: string;
}) {
const value = useWatch({ control, name });
if (value === null || value === undefined || value === "") return null;
// A FileList means the user has just picked a new file — they don't
// need a reminder about what *used* to be on file.
if (typeof FileList !== "undefined" && value instanceof FileList) return null;
let filename: string | null = null;
if (typeof value === "string") {
filename = value;
} else if (typeof value === "object" && value !== null) {
const o = value as Record<string, unknown>;
const cand = o.file_name ?? o.name ?? o.label ?? o.filename;
if (typeof cand === "string") filename = cand;
}
return (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-md border border-rule-soft bg-leaf-50/60 px-3 py-2 text-xs"
>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mt-[1px] h-3.5 w-3.5 flex-shrink-0 text-leaf-700"
>
<path d="M11.5 4.5 L6 10 a2 2 0 1 0 2.83 2.83 L13.5 7.5 a3.5 3.5 0 0 0 -4.95 -4.95 L3.5 7.5" />
</svg>
<span className="text-ink-soft leading-snug">
Attachment on file
{filename ? (
<>
: <span className="font-medium text-ink break-all">{filename}</span>
</>
) : null}
. Choose a new file below to replace it, or leave blank to keep it.
</span>
</div>
);
}
/**
* Upload-on-pick file field. The moment the user selects a file the
* browser sends it to /api/upload; on success we replace RHF state with
* the returned {id, file_name}. That same shape is what submit serializes
* and what the prefill path produces for prior attachments — so the rest
* of the pipeline doesn't care whether the value originated as prefill
* or as a fresh upload.
*
* The native <input type="file"> is intentionally NOT register()'d here:
* its `value` is a FileList that doesn't survive JSON.stringify, which is
* the whole bug we're closing. We manage state manually via setValue.
*/
function FileField({
field,
id,
helpId,
errorId,
errorMsg,
describedBy,
control,
setValue,
cid,
cs,
onUploadStateChange,
}: {
field: FieldConfig;
id: string;
helpId?: string;
errorId?: string;
errorMsg?: string;
describedBy?: string;
control: Control<FieldValues>;
setValue: UseFormSetValue<FieldValues>;
cid: string;
cs: string;
onUploadStateChange?: (delta: 1 | -1) => void;
register: UseFormRegister<FieldValues>;
requiredOpt: string | false;
}) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
// Target Civi field reference — whichever side this field maps to.
const fieldRef = field.civiField ?? field.civiContactField ?? "";
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadError(null);
setUploading(true);
onUploadStateChange?.(1);
const fd = new FormData();
fd.set("file", file);
fd.set("cid", cid);
fd.set("cs", cs);
fd.set("fieldRef", fieldRef);
try {
const res = await fetch("/api/upload", { method: "POST", body: fd });
const json = (await res.json().catch(() => ({}))) as {
id?: number;
file_name?: string;
error?: string;
};
if (!res.ok) {
setUploadError(json.error ?? `Upload failed (HTTP ${res.status}).`);
// Reset the file input so the user can retry the same file.
e.target.value = "";
} else if (json.id != null && json.file_name) {
setValue(field.name, { id: json.id, file_name: json.file_name }, {
shouldDirty: true,
shouldValidate: true,
});
} else {
setUploadError("Upload succeeded but no file id was returned.");
}
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Upload network error.");
e.target.value = "";
} finally {
setUploading(false);
onUploadStateChange?.(-1);
}
}
return (
<div className="space-y-1.5">
<Label id={id} field={field} />
<FilePriorIndicator control={control} name={field.name} />
<input
id={id}
type="file"
disabled={uploading}
aria-describedby={describedBy}
aria-invalid={errorMsg || uploadError ? true : undefined}
aria-required={field.required || undefined}
onChange={handlePick}
className="block w-full text-sm text-ink-soft file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer transition disabled:cursor-wait disabled:opacity-60"
/>
{uploading && (
<p role="status" aria-live="polite" className="text-xs text-ink-soft">
Uploading
</p>
)}
{field.help && <Help id={helpId!}>{field.help}</Help>}
{uploadError && <ErrorText id={errorId ?? `${id}-error`}>{uploadError}</ErrorText>}
{!uploadError && errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
function Label({ id, field }: { id: string; field: FieldConfig }) {
return (
<label htmlFor={id} className="block text-sm font-medium text-ink">
+331
View File
@@ -0,0 +1,331 @@
"use client";
import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form";
import { formatShortDate } from "./FieldHistory";
/**
* Date-grouped timeline strip at the top of the report. Walks every
* date-type field across all stage sections (05), pulls each field's
* most-recent entered date, and plots events into six horizontal swim
* lanes — one per stage. Stage 5's `Date_Opened` gets visual emphasis
* as the journey's anchor at the right end of the time axis. A faint
* "Today" tick anchors the reader if today falls within the range, and
* an adaptive month/year axis runs beneath the lanes. Each dot exposes
* a tooltip on hover or keyboard focus.
*/
export function DateTimeline({
sections,
fieldHistory,
}: {
sections: StageSectionConfig[];
fieldHistory: Record<string, FieldHistoryEntry[]>;
}) {
type Event = {
rank: number;
fieldLabel: string;
date: string; // YYYY-MM-DD or full ISO
isOpened: boolean;
};
const events: Event[] = [];
for (const section of sections) {
if (section.rank < 0 || section.rank > 5) continue;
for (const f of section.fields) {
if (f.type !== "date") continue;
const history = fieldHistory[f.name];
if (!history || history.length === 0) continue;
const v = history[0].value;
if (typeof v !== "string" || v.length === 0) continue;
events.push({
rank: section.rank,
fieldLabel: f.label,
date: v,
isOpened: f.name === "Date_Opened",
});
}
}
if (events.length === 0) return null;
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
if (times.length === 0) return null;
const openedEvent = events.find((e) => e.isOpened);
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
const minT = Math.min(...times);
// If Date_Opened is set, anchor the right edge there; if any other event
// is later, extend so nothing falls off the chart.
const maxT = Math.max(...times, openedTime ?? -Infinity);
const tRange = maxT - minT || 1;
const todayT = Date.now();
const todayPct =
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
const ticks = generateAxisTicks(minT, maxT);
return (
<section
aria-labelledby="timeline-heading"
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
>
<div className="flex items-baseline justify-between gap-4">
<h2
id="timeline-heading"
className="font-display text-base font-medium leading-tight text-ink"
>
Timeline
</h2>
<p className="text-[11px] uppercase tracking-[0.12em] text-ink-mute tabular-nums">
{formatShortDate(new Date(minT).toISOString())}
{" → "}
{formatShortDate(new Date(maxT).toISOString())}
</p>
</div>
<ol className="mt-4 space-y-2">
{[0, 1, 2, 3, 4, 5].map((rank) => {
const rowEvents = events.filter((e) => e.rank === rank);
const isEmpty = rowEvents.length === 0;
return (
<li
key={rank}
className="grid grid-cols-[3.5rem_1fr] items-center gap-3 sm:grid-cols-[4.5rem_1fr]"
>
<span className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
Stage {rank}
</span>
<div className="relative h-5">
<span
aria-hidden
className={
"absolute inset-x-0 top-1/2 -translate-y-1/2 h-px " +
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
}
/>
{todayPct !== null && (
<span
aria-hidden
style={{ left: `${todayPct}%` }}
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
/>
)}
{rowEvents.map((e, i) => {
const t = new Date(e.date).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
return (
<TimelineDot
key={i}
rank={rank}
event={e}
x={x}
/>
);
})}
</div>
</li>
);
})}
</ol>
{/* Adaptive month/year axis. Render only when we have ≥2 ticks so a
single tick doesn't dangle. Today gets its own labeled mark when
it falls within range. */}
{ticks.length >= 2 && (
<div className="mt-3 grid grid-cols-[3.5rem_1fr] items-start gap-3 sm:grid-cols-[4.5rem_1fr]">
<span aria-hidden />
<div className="relative h-7">
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px bg-rule-soft"
/>
{ticks.map((tk, i) => {
const x = ((tk.t - minT) / tRange) * 100;
// Edge labels shift so they don't overflow the row.
const alignClass =
x < 6
? "left-0 origin-top-left"
: x > 94
? "right-0 origin-top-right text-right"
: "left-1/2 -translate-x-1/2 text-center";
return (
<span
key={i}
style={{ left: `${x}%` }}
className="absolute top-0 -translate-x-1/2"
>
<span
aria-hidden
className="block h-1.5 w-px bg-rule mx-auto"
/>
<span
className={
"absolute top-2 block whitespace-nowrap text-[9px] uppercase tracking-[0.08em] tabular-nums text-ink-mute " +
alignClass
}
>
{tk.label}
</span>
</span>
);
})}
{todayPct !== null && (
<span
style={{ left: `${todayPct}%` }}
className="absolute top-0 -translate-x-1/2"
>
<span
aria-hidden
className="block h-1.5 w-px bg-clay-500 mx-auto"
/>
<span className="absolute top-2 left-1/2 -translate-x-1/2 block whitespace-nowrap text-[9px] font-medium uppercase tracking-[0.08em] text-clay-700">
Today
</span>
</span>
)}
</div>
</div>
)}
{/* Accessible event list — invisible to sighted users but readable by SR */}
<ul className="sr-only">
{events.map((e, i) => (
<li key={i}>
Stage {e.rank}: {e.fieldLabel} {formatShortDate(e.date)}
{e.isOpened ? " (opened)" : ""}
</li>
))}
</ul>
</section>
);
}
function stageDotBg(rank: number): string {
switch (rank) {
case 0:
return "bg-leaf-200";
case 1:
return "bg-leaf-300";
case 2:
return "bg-leaf-500";
case 3:
return "bg-leaf-600";
case 4:
return "bg-leaf-700";
case 5:
return "bg-clay-700";
default:
return "bg-leaf-600";
}
}
/**
* A single dot on the timeline plus its hover/focus tooltip. Keyboard
* users can Tab to each dot and the tooltip will appear via
* `group-focus-within`. The tooltip is anchored above the dot; for dots
* near the left or right edge of the row, anchor flips so the card
* doesn't overflow.
*/
function TimelineDot({
rank,
event,
x,
}: {
rank: number;
event: { fieldLabel: string; date: string; isOpened: boolean };
x: number;
}) {
const isOpened = event.isOpened;
const bg = isOpened ? "bg-clay-700" : stageDotBg(rank);
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
const ring = isOpened ? "ring-2" : "ring-1";
const tipAlign =
x < 18
? "left-0"
: x > 82
? "right-0"
: "left-1/2 -translate-x-1/2";
return (
<span
role="img"
tabIndex={0}
aria-label={`${event.fieldLabel}, ${formatShortDate(event.date)}${isOpened ? " (opened)" : ""}, stage ${rank}`}
style={{ left: `${x}%` }}
className={
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
`${ring} ring-paper ${size} ${bg}`
}
>
<span
role="tooltip"
className={
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
tipAlign
}
>
<span className="block font-medium">{event.fieldLabel}</span>
<span className="mt-0.5 block tabular-nums text-paper-2/80">
{formatShortDate(event.date)}
<span className="ml-2 uppercase tracking-[0.08em] text-paper-2/60">
Stage {rank}
</span>
</span>
{isOpened && (
<span className="mt-0.5 block text-clay-200 uppercase tracking-[0.08em]">
Opened
</span>
)}
</span>
</span>
);
}
/**
* Pick a "nice" tick interval for the time axis based on the visible
* span, then walk that interval from min to max producing labeled ticks.
* Uses months for shorter spans, years for longer ones; January-bordered
* month ticks include the year so the reader has an anchor.
*/
export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label: string }> {
const range = maxT - minT;
const monthMs = 30.4375 * 24 * 3600 * 1000;
const months = range / monthMs;
type Step = { unit: "month" | "year"; step: number };
let plan: Step;
if (months < 4) plan = { unit: "month", step: 1 };
else if (months < 12) plan = { unit: "month", step: 2 };
else if (months < 24) plan = { unit: "month", step: 3 };
else if (months < 48) plan = { unit: "month", step: 6 };
else if (months / 12 < 12) plan = { unit: "year", step: 1 };
else plan = { unit: "year", step: 2 };
const ticks: Array<{ t: number; label: string }> = [];
const start = new Date(minT);
let y = start.getFullYear();
let m =
plan.unit === "year"
? 0
: Math.floor(start.getMonth() / plan.step) * plan.step;
// Don't skip a tick that sits right at minT — start from minT-aligned step.
while (true) {
const t = new Date(y, m, 1).getTime();
if (t > maxT) break;
if (t >= minT) {
const d = new Date(y, m, 1);
const label =
plan.unit === "year"
? String(y)
: m === 0
? d.toLocaleDateString("en-US", { month: "short", year: "numeric" })
: d.toLocaleDateString("en-US", { month: "short" });
ticks.push({ t, label });
}
if (plan.unit === "year") {
y += plan.step;
} else {
m += plan.step;
while (m > 11) {
m -= 12;
y += 1;
}
}
}
return ticks;
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import type { FieldConfig, FieldHistoryEntry, SelectOption } from "@/types/form";
const currencyFmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
const numberFmt = new Intl.NumberFormat("en-US");
export function FormattedValue({
value,
field,
options,
}: {
value: unknown;
field: FieldConfig;
options: Record<number, SelectOption[]>;
}) {
if (value === null || value === undefined || value === "") return <></>;
const opts: SelectOption[] | undefined = field.optionGroupId
? options[field.optionGroupId]
: field.options;
switch (field.type) {
case "currency": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? currencyFmt.format(n) : String(value)}</>;
}
case "percent": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? `${n}%` : String(value)}</>;
}
case "number": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? numberFmt.format(n) : String(value)}</>;
}
case "date":
return <>{formatLongDate(String(value))}</>;
case "boolean":
return <>{value ? "Yes" : "No"}</>;
case "select":
case "readonly": {
const v = String(value);
const found = opts?.find((o) => o.value === v);
return <>{found?.label ?? v}</>;
}
case "multiselect": {
let parts: string[];
if (Array.isArray(value)) {
parts = value.map(String);
} else {
parts = String(value).split(/[|,]/).map((s) => s.trim()).filter(Boolean);
}
const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p);
return <>{labels.join(", ")}</>;
}
case "file": {
// Prefill / history wraps file values into { id, file_name } so the
// UI can show a human-readable name. Fall back to whatever scalar
// came through if the shape is different.
if (typeof value === "object" && value !== null) {
const o = value as Record<string, unknown>;
const fname = o.file_name ?? o.name ?? o.label ?? o.filename;
if (typeof fname === "string" && fname.length > 0) return <>{fname}</>;
if (typeof o.id !== "undefined") return <>Attachment #{String(o.id)}</>;
}
return <>{String(value)}</>;
}
case "textarea":
case "text":
case "email":
case "phone":
default:
return <>{String(value)}</>;
}
}
export function formatShortDate(iso: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
export function formatLongDate(iso: string): string {
if (!iso) return "—";
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
const d = m
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
: new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
}
export function computeDateRange(dates: string[]): { from: string; to: string } | null {
if (dates.length === 0) return null;
const times = dates
.map((d) => new Date(d).getTime())
.filter((t) => Number.isFinite(t));
if (times.length === 0) return null;
const min = new Date(Math.min(...times)).toISOString();
const max = new Date(Math.max(...times)).toISOString();
return { from: min, to: max };
}
export function Chevron({ open }: { open: boolean }) {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
>
<path d="M5 8 L10 13 L15 8" />
</svg>
);
}
export function FieldHistoryRow({
field,
entries,
options,
}: {
field: FieldConfig;
entries: FieldHistoryEntry[];
options: Record<number, SelectOption[]>;
}) {
const [expanded, setExpanded] = useState(false);
const latest = entries[0];
const priorEntries = entries.slice(1);
return (
<div className="px-5 py-4 sm:px-7">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
<div className="min-w-0 sm:max-w-[16rem]">
<p className="text-sm font-medium text-ink">{field.label}</p>
{field.help && (
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
)}
</div>
<div className="flex-1 min-w-0 text-left sm:text-right">
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
<FormattedValue value={latest.value} field={field} options={options} />
</p>
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
as of {formatShortDate(latest.date)}
{priorEntries.length > 0 && (
<>
{" · "}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
>
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
</button>
</>
)}
</p>
</div>
</div>
{expanded && priorEntries.length > 0 && (
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
{priorEntries.map((e) => (
<li
key={e.activityId}
className="flex items-baseline justify-between gap-4 text-sm"
>
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
{formatShortDate(e.date)}
</span>
<span className="text-right text-ink-soft tabular-nums">
<FormattedValue value={e.value} field={field} options={options} />
</span>
</li>
))}
</ol>
)}
</div>
);
}
export function FieldHistoryGroup({
label,
fields,
history,
options,
}: {
label?: string;
fields: FieldConfig[];
history: Record<string, FieldHistoryEntry[]>;
options: Record<number, SelectOption[]>;
}) {
return (
<div className="border-l-2 border-leaf-300/60">
{label && (
<p className="px-5 pt-3 pb-1 pl-7 text-[10px] uppercase tracking-[0.1em] font-medium text-ink-soft sm:px-7 sm:pl-9">
{label}
</p>
)}
<div className="divide-y divide-rule-soft">
{fields.map((f) => (
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>
))}
</div>
</div>
);
}
export { currencyFmt, numberFmt };
+386
View File
@@ -0,0 +1,386 @@
"use client";
import type { FieldConfig, FieldHistoryEntry } from "@/types/form";
import { numberFmt } from "./FieldHistory";
import { generateAxisTicks } from "./DateTimeline";
export const MEMBERS_ACTUAL_NAME = "Members__current_";
export const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
/**
* Dedicated comparison chart for the org's actual member-owner count vs the
* goal it set for the current stage. Renders when at least one of the
* two fields has historical data. Both series are drawn as step lines
* (carry-forward semantics — a measurement holds until the next one
* updates it, then extends to the right edge). Header carries a
* current/goal summary with the gap; legend sits below the chart.
*/
export function MembershipChart({
membersField,
goalField,
membersHistory,
goalHistory,
}: {
membersField?: FieldConfig;
goalField?: FieldConfig;
membersHistory?: FieldHistoryEntry[];
goalHistory?: FieldHistoryEntry[];
}) {
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
(entries ?? [])
.slice()
.reverse()
.map((e) => ({
t: new Date(e.date).getTime(),
v: typeof e.value === "number" ? e.value : Number(e.value),
}))
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
const actualPoints = toPoints(membersHistory);
const goalPoints = toPoints(goalHistory);
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
// Combined axes
const all = [...actualPoints, ...goalPoints];
const times = all.map((p) => p.t);
const values = all.map((p) => p.v);
let minT = Math.min(...times);
let maxT = Math.max(...times);
if (minT === maxT) {
// Single-point chart — pad the axis ±15 days so the dot isn't on
// top of the y-axis line.
const pad = 15 * 24 * 3600 * 1000;
minT -= pad;
maxT += pad;
}
// Always include 0 in y so growth from a small starting count reads true.
const rawMin = Math.min(...values, 0);
const rawMax = Math.max(...values);
const yTicks = niceYTicks(rawMin, rawMax, 4);
const yMin = yTicks[0];
const yMax = yTicks[yTicks.length - 1];
const yRange = yMax - yMin || 1;
const tRange = maxT - minT || 1;
// Chart geometry (viewBox units).
const W = 580;
const H = 200;
const padL = 40;
const padR = 28;
const padT = 14;
const padB = 30;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
// Both Actual and Goal use a carry-forward step line: between measurements
// the chart holds the prior value rather than interpolating diagonally,
// and the final value extends flat to the right edge of the chart. This
// way periods with no fresh measurement read as "unchanged since last
// reported" instead of suggesting a smooth dip or rise that we don't
// actually have evidence for.
const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
let actualPath = "";
if (actualCoords.length === 1) {
const c = actualCoords[0];
actualPath = `M${c.x.toFixed(2)},${c.y.toFixed(2)} L${(W - padR).toFixed(2)},${c.y.toFixed(2)}`;
} else if (actualCoords.length > 1) {
const parts: string[] = [
`M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(2)}`,
];
for (let i = 1; i < actualCoords.length; i++) {
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i - 1].y.toFixed(2)}`);
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i].y.toFixed(2)}`);
}
const last = actualCoords[actualCoords.length - 1];
parts.push(`L${(W - padR).toFixed(2)},${last.y.toFixed(2)}`);
actualPath = parts.join(" ");
}
const actualArea = actualPath
? `${actualPath} L${(W - padR).toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
: "";
// Step the goal: hold each value until the next change, then extend
// the final value to the right edge of the chart.
const goalCoords = goalPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
let goalPath = "";
if (goalCoords.length === 1) {
const c = goalCoords[0];
goalPath = `M${xOf(minT).toFixed(2)},${c.y.toFixed(2)} L${xOf(maxT).toFixed(2)},${c.y.toFixed(2)}`;
} else if (goalCoords.length > 1) {
const parts: string[] = [];
parts.push(`M${goalCoords[0].x.toFixed(2)},${goalCoords[0].y.toFixed(2)}`);
for (let i = 1; i < goalCoords.length; i++) {
// step: horizontal to next x at previous y, then vertical to new y
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i - 1].y.toFixed(2)}`);
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i].y.toFixed(2)}`);
}
// Extend the most-recent goal to the right edge as a flat line.
const lastG = goalCoords[goalCoords.length - 1];
parts.push(`L${xOf(maxT).toFixed(2)},${lastG.y.toFixed(2)}`);
goalPath = parts.join(" ");
}
// Summary stat: take most-recent of each series for the gap callout.
const latestActual = actualPoints.length ? actualPoints[actualPoints.length - 1].v : null;
const latestGoal = goalPoints.length ? goalPoints[goalPoints.length - 1].v : null;
const gap =
latestActual !== null && latestGoal !== null ? latestGoal - latestActual : null;
const gapText =
gap === null
? null
: gap > 0
? `${numberFmt.format(gap)} to go`
: gap < 0
? `${numberFmt.format(-gap)} above goal`
: "at goal";
const gapTone =
gap === null
? ""
: gap > 0
? "text-clay-700"
: gap < 0
? "text-leaf-700"
: "text-leaf-700";
const xTicks = generateAxisTicks(minT, maxT);
const todayT = Date.now();
const todayInRange = todayT >= minT && todayT <= maxT;
return (
<section
aria-labelledby="membership-chart-heading"
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
>
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<div>
<h2
id="membership-chart-heading"
className="font-display text-base font-medium leading-tight text-ink"
>
Membership goal vs. actual
</h2>
<p className="mt-0.5 text-xs text-ink-mute">
Member-owner count tracked over time against the goal set for the org&apos;s
current stage.
</p>
</div>
{latestActual !== null && (
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
<span className="font-medium text-leaf-800">
{numberFmt.format(latestActual)}
</span>
{latestGoal !== null && (
<>
{" of "}
<span className="font-medium text-clay-700">
{numberFmt.format(latestGoal)}
</span>
{gapText && (
<span className={"ml-1.5 normal-case tracking-normal " + gapTone}>
· {gapText}
</span>
)}
</>
)}
</p>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
role="img"
aria-label="Membership goal versus actual over time"
className="mt-4 block h-48 w-full sm:h-52"
>
{/* Y-axis gridlines + value labels */}
{yTicks.map((v, i) => {
const y = yOf(v);
return (
<g key={i}>
<line
x1={padL}
x2={W - padR}
y1={y}
y2={y}
className="stroke-rule-soft"
strokeWidth="0.5"
/>
<text
x={padL - 6}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-ink-mute text-[9px] tabular-nums"
>
{numberFmt.format(v)}
</text>
</g>
);
})}
{/* Today guide */}
{todayInRange && (
<line
x1={xOf(todayT)}
x2={xOf(todayT)}
y1={padT}
y2={padT + plotH}
className="stroke-clay-300"
strokeWidth="0.75"
strokeDasharray="2 3"
/>
)}
{/* Goal step line */}
{goalPath && (
<path
d={goalPath}
fill="none"
className="stroke-clay-600"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="miter"
strokeDasharray="5 3"
/>
)}
{goalCoords.map((c, i) => (
<circle
key={`g-${i}`}
cx={c.x}
cy={c.y}
r="2.5"
className="fill-clay-600"
/>
))}
{/* Actual line with faint area fill */}
{actualArea && (
<path d={actualArea} className="fill-leaf-500" opacity="0.10" />
)}
{actualPath && (
<path
d={actualPath}
fill="none"
className="stroke-leaf-700"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{actualCoords.map((c, i) => (
<circle
key={`a-${i}`}
cx={c.x}
cy={c.y}
r={i === actualCoords.length - 1 ? 3.5 : 2}
className={i === actualCoords.length - 1 ? "fill-leaf-800" : "fill-leaf-700"}
/>
))}
{/* Most-recent point value labels */}
{actualCoords.length > 0 && (
<text
x={actualCoords[actualCoords.length - 1].x + 6}
y={actualCoords[actualCoords.length - 1].y - 6}
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
</text>
)}
{goalCoords.length > 0 && (
<text
x={xOf(maxT) - 4}
y={goalCoords[goalCoords.length - 1].y - 6}
textAnchor="end"
className="fill-clay-700 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(goalCoords[goalCoords.length - 1].v)}
</text>
)}
{/* X-axis baseline + ticks */}
<line
x1={padL}
x2={W - padR}
y1={padT + plotH}
y2={padT + plotH}
className="stroke-rule"
strokeWidth="0.75"
/>
{xTicks.map((tk, i) => {
const x = xOf(tk.t);
return (
<g key={i}>
<line
x1={x}
x2={x}
y1={padT + plotH}
y2={padT + plotH + 3}
className="stroke-rule"
strokeWidth="0.75"
/>
<text
x={x}
y={padT + plotH + 14}
textAnchor="middle"
className="fill-ink-mute text-[9px] uppercase tracking-[0.08em] tabular-nums"
>
{tk.label}
</text>
</g>
);
})}
</svg>
{/* Legend */}
<ul className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-ink-soft">
<li className="inline-flex items-center gap-2">
<span aria-hidden className="block h-px w-6 bg-leaf-700">
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-leaf-700" />
</span>
<span>
Actual{membersField?.label && membersField.label !== "Member-Owners (current)" ? ` (${membersField.label})` : ""}
</span>
</li>
<li className="inline-flex items-center gap-2">
<span aria-hidden className="block h-px w-6 border-t border-dashed border-clay-600">
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-clay-600" />
</span>
<span>
Goal{goalField?.label && goalField.label !== "Member-Owner Goal for current Stage" ? ` (${goalField.label})` : ""}
</span>
</li>
</ul>
</section>
);
}
/**
* Choose 35 round-number tick values that span [min, max]. Step is snapped
* to 1 / 2 / 2.5 / 5 / 10 × 10^N so labels read as Y-axis values normally do.
*/
function niceYTicks(min: number, max: number, target = 4): number[] {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0];
if (min === max) return [min - 1, min, min + 1];
const range = max - min;
const rawStep = range / target;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalized = rawStep / magnitude;
let step: number;
if (normalized < 1.5) step = 1 * magnitude;
else if (normalized < 3) step = 2 * magnitude;
else if (normalized < 4) step = 2.5 * magnitude;
else if (normalized < 7) step = 5 * magnitude;
else step = 10 * magnitude;
const niceMin = Math.floor(min / step) * step;
const niceMax = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = niceMin; v <= niceMax + step * 0.0001; v += step) {
ticks.push(Math.round(v * 1e6) / 1e6); // de-jitter float arithmetic
}
return ticks;
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
export function LoadingState() {
return (
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
<div
aria-hidden
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
/>
<p className="font-display text-base text-ink-soft italic">Loading your activity report</p>
</div>
);
}
export function EmptyState() {
return (
<div className="rounded-lg border border-dashed border-rule bg-paper-2/30 px-6 py-10 text-center">
<p className="font-display text-lg text-ink-soft">No entries on file yet.</p>
<p className="mt-2 text-sm text-ink-mute">
Your co-op&apos;s first survey will appear here once it&apos;s submitted.
</p>
</div>
);
}
export function ErrorState({ message }: { message: string }) {
return (
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
<h2 className="font-display text-xl font-medium text-clay-700">
We couldn&apos;t open your report.
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
<p className="mt-4 text-sm text-ink-soft">
If this keeps happening, please contact your <a href="mailto:chris@fci.coop">Chris @ FCI</a>.
</p>
</div>
);
}
+273 -150
View File
@@ -1,5 +1,5 @@
/**
* Form definition for the Org Engagement Check-in form.
* Form definition for the Org Engagement Survey form.
*
* Field references and types are sourced from the live CiviCRM DEV
* (`client.crm.fci.coop`) inventory pulled 2026-05-09 via APIv4
@@ -49,13 +49,18 @@ const G3 = "Stage_3";
const G4 = "Stage_4";
const G5 = "Stage_5";
// Stage 0 — Check-in (always visible)
// Organization-contact custom group ("Food_Co_op_Organizing"). Fields here
// live on the Organization Contact, not on the Check-in activity, so they
// route through Contact.update on submit and Contact.get at form load.
const G_ORG = "Food_Co_op_Organizing";
// Stage 0 — Survey (always visible)
const stage0: StageSectionConfig = {
rank: 0,
id: "stage_0",
label: "Check-in (organizing)",
intro:
"Core check-in fields. These are visible at every stage and capture the data we follow over time across the lifecycle of the co-op.",
// intro:
// "Core survey fields. These are visible at every stage and capture the data we follow over time across the lifecycle of the co-op.",
fields: [
// current_stage is a UI-only readonly field. Its value is the Civi
// option *value* (e.g. "Organizing") sourced by /api/data from the
@@ -69,51 +74,102 @@ const stage0: StageSectionConfig = {
type: "readonly",
optionGroupId: STAGE_OPTION_GROUP_ID,
},
// Contact identity, sourced from the cid in /api/data. Display-only —
// never written back to Civi. These confirm to the form-filler which
// contact record their submission will be attributed to.
{
name: "Peer_Group_Participation",
label: "Peer Group Participation",
type: "select",
civiField: `${G0}.Peer_Group_Participation`,
optionGroupId: 140,
help: "Is this co-op currently participating in Peer Learning Groups?",
visibleWhen: []
name: "contact_first_name",
label: "First name",
type: "readonly",
},
{
name: "Internal_Startup_Assessment",
label: "Internal Startup Assessment",
type: "select",
civiField: `${G0}.Internal_Startup_Assessment`,
optionGroupId: 132,
visibleWhen: []
name: "contact_last_name",
label: "Last name",
type: "readonly",
},
{
name: "Internal_Startup_Assessment_Date",
label: "Internal Startup Assessment Date",
name: "contact_email",
label: "Email",
type: "readonly",
},
// Org-contact fields (Food_Co_op_Organizing custom group). These live
// on the Organization Contact record, not on the Check-in activity, so
// they read/write via Contact.get / Contact.update instead of the
// activity prefill walk.
{
name: "Date_Incorporated",
label: "Date Incorporated",
type: "date",
civiField: `${G0}.Internal_Startup_Assessment_Date`,
help: "Date of the latest Internal Startup Assessment rating.",
visibleWhen: []
civiContactField: `${G_ORG}.Date_Incorporated`,
help: "The date this co-op was legally incorporated.",
},
{
name: "Name_on_Incorporation_Certificate",
label: "Name on Incorporation Certificate",
type: "text",
civiContactField: `${G_ORG}.Name_on_Incorporation_Certificate`,
help: "The legal name as it appears on the incorporation certificate.",
},
{
name: "Certificate_of_Incorporation",
label: "Certificate of Incorporation",
type: "file",
civiContactField: `${G_ORG}.Certificate_of_Incorporation`,
help: "Upload a PDF, Word doc, or image of the incorporation certificate (max 5 MB).",
},
{
name: "Equity_share",
label: "Equity share",
type: "currency",
civiContactField: `${G_ORG}.Equity_share`,
help: "The cost of a single member-owner equity share.",
},
// {
// name: "Peer_Group_Participation",
// label: "Peer Group Participation",
// type: "select",
// civiField: `${G0}.Peer_Group_Participation`,
// optionGroupId: 140,
// help: "Is this co-op currently participating in Peer Learning Groups?",
// visibleWhen: []
// },
// {
// name: "Internal_Startup_Assessment",
// label: "Internal Startup Assessment",
// type: "select",
// civiField: `${G0}.Internal_Startup_Assessment`,
// optionGroupId: 132,
// visibleWhen: []
// },
// {
// name: "Internal_Startup_Assessment_Date",
// label: "Internal Startup Assessment Date",
// type: "date",
// civiField: `${G0}.Internal_Startup_Assessment_Date`,
// help: "Date of the latest Internal Startup Assessment rating.",
// visibleWhen: []
// },
{
name: "Member_Goal_for_current_Stage",
label: "Member Goal for current Stage",
label: "Member-Owner Goal for current Stage",
type: "number",
civiField: `${G0}.Member_Goal_for_current_Stage`,
help: "What is your member / owner goal for your current co-op development Stage?",
help: "What is your member-owner goal for your current co-op development Stage?",
},
{
name: "Members__current_",
label: "Members (current)",
label: "Member-Owners (current)",
type: "number",
civiField: `${G0}.Members__current_`,
help: "How many paid members does the co-op currently have? Include partially paid (e.g. installment plans).",
help: "How many paid member-owners does the co-op currently have? This can include partially paid, e.g. on installment plans.",
},
{
name: "Total_members_at_opening",
label: "Total members at opening",
label: "Total member-owners at opening",
type: "number",
civiField: `${G0}.Total_members_at_opening`,
visibleWhen: visibleAtOrAfter(S.Stabilize),
help: "How many member-owners will be required at opening for this store?",
},
// {
// name: "Volunteers_Helping_In_Store",
@@ -138,6 +194,7 @@ const stage0: StageSectionConfig = {
civiField: `${G0}.Total_square_ft`,
step: 1,
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
help: "Size, in sq ft, of the total storefront planned, including retail and non-retail areas",
},
{
name: "Retail_sq_ft",
@@ -146,6 +203,7 @@ const stage0: StageSectionConfig = {
civiField: `${G0}.Retail_sq_ft`,
step: 1,
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
help: "Size, in sq ft, of the retail areas of the store being planned",
},
// {
// name: "Latest_Sources_and_Uses_doc",
@@ -168,15 +226,6 @@ const stage0: StageSectionConfig = {
// type: "currency",
// civiField: `${G0}.Projected_sales_at_maturity`,
// },
{
name: "FTEs",
label: "Projected FTEs",
type: "number",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.FTEs`,
step: 0.1,
help: "Number of full-time equivalent (FTE) employees planned to work at the store.",
},
{
name: "Actual_FTE",
label: "Actual FTE",
@@ -184,39 +233,43 @@ const stage0: StageSectionConfig = {
civiField: `${G0}.Actual_FTE`,
step: 0.1,
visibleWhen: visibleAtOrAfter(S.Stabilize),
help: "Actual number of employees (or full-time equivalents) working in the store after open. i.e. if you have 10 employees working full time and 20 employees working half time, that would be 20 full-time equivalents.",
},
{
name: "Total_cost_of_project",
label: "Total cost of project",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Total_cost_of_project`,
help: "Current estimate of the total cost of the project, up to store opening",
},
{
name: "Member_equity_total",
label: "Member equity total needed",
type: "currency",
civiField: `${G0}.Member_equity_total`
},
// {
// name: "Member_equity_total",
// label: "Member equity total needed",
// type: "currency",
// civiField: `${G0}.Member_equity_total`
// },
{
name: "Member_equity_raised",
label: "Member equity raised",
label: "Member-Owner equity raised",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
civiField: `${G0}.Member_equity_raised`
},
{
name: "Member_loans_total",
label: "Member loans total needed",
type: "currency",
civiField: `${G0}.Member_loans_total`
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_equity_raised`,
help: "Member-owner equity raised, as of the time of survey",
},
// {
// name: "Member_loans_total",
// label: "Member loans total needed",
// type: "currency",
// civiField: `${G0}.Member_loans_total`
// },
{
name: "Member_loans_raised",
label: "Member loans raised",
label: "Member-Owner loans raised",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
civiField: `${G0}.Member_loans_raised`
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_loans_raised`,
help: "Total amount (USD) raised from member-owner loans, as of the time of survey",
},
// {
// name: "Member_preferred_shares_total",
@@ -226,18 +279,28 @@ const stage0: StageSectionConfig = {
// },
{
name: "Member_preferred_shares_raised",
label: "Member preferred shares raised",
label: "Member-Owner preferred shares raised",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_preferred_shares_raised`,
help: "Total amount (USD) raised from preferred shares sold to member-owners in the latest Sources and Uses doc",
},
// { name: "Bank_debt_total", label: "Bank debt total needed", type: "currency", civiField: `${G0}.Bank_debt_total` },
{ name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Bank_debt_raised`, help: "Total amount (USD) raised from bank debt (or other first position lender) in the latest Sources and Uses doc" },
// { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Grants_Donations_Raised`, help: "Total amount (USD) raised from Grants and Donations in the latest Sources and Uses doc" },
// { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member-owner contributions (loans or equity) or bank debt." },
// { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
{
name: "FTEs",
label: "Projected FTEs",
type: "number",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.FTEs`,
step: 0.1,
help: "Number of full-time equivalent (FTE) employees planned to work at the store",
},
{ name: "Bank_debt_total", label: "Bank debt total needed", type: "currency", civiField: `${G0}.Bank_debt_total` },
{ name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Bank_debt_raised` },
{ name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Grants_Donations_Raised` },
{ name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Other_sources_raised` },
{ name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
] as FieldConfig[],
};
@@ -252,7 +315,6 @@ const stage1: StageSectionConfig = {
name: "Preliminary_Market_Assessment",
label: "Preliminary Market Assessment",
type: "date",
required: true,
civiField: `${G1}.Preliminary_Market_Assessment`,
help: "What date was your Preliminary Market Assessment completed?",
},
@@ -261,6 +323,7 @@ const stage1: StageSectionConfig = {
label: "Preliminary Market Assessment — Upload",
type: "file",
civiField: `${G1}.Preliminary_Market_Assessment_Upload`,
help: "Please upload your Preliminary Market Assessment here.",
},
{
name: "Preliminary_Sources_Uses",
@@ -274,6 +337,7 @@ const stage1: StageSectionConfig = {
label: "Preliminary Sources and Uses — Upload",
type: "file",
civiField: `${G1}.Preliminary_Sources_Uses_Upload`,
help: "Please upload your Preliminary Sources & Uses.",
},
{
name: "Vision",
@@ -282,7 +346,7 @@ const stage1: StageSectionConfig = {
civiField: `${G1}.Vision`,
help: "What date was your vision document completed?",
},
{ name: "Vision_Upload", label: "Vision — Upload", type: "file", civiField: `${G1}.Vision_Upload` },
{ name: "Vision_Upload", label: "Vision — Upload", type: "file", civiField: `${G1}.Vision_Upload`, help: "Please upload your vision document." },
{
name: "Business_Concept",
label: "Business Concept",
@@ -295,8 +359,28 @@ const stage1: StageSectionConfig = {
label: "Business Concept — Upload",
type: "file",
civiField: `${G1}.Business_Concept_Upload`,
help: "Please upload your Business Concept",
},
] as FieldConfig[],
// Visual clustering — each pair (date + upload) reads as one item.
fieldGroups: [
{
id: "prelim_market_assessment",
fields: ["Preliminary_Market_Assessment", "Preliminary_Market_Assessment_Upload"],
},
{
id: "prelim_sources_uses",
fields: ["Preliminary_Sources_Uses", "Preliminary_Sources_Uses_Upload"],
},
{
id: "vision",
fields: ["Vision", "Vision_Upload"],
},
{
id: "business_concept",
fields: ["Business_Concept", "Business_Concept_Upload"],
},
],
};
// Stage 2 — Feasibility
@@ -306,18 +390,18 @@ const stage2: StageSectionConfig = {
label: "Stage 2 — Grow & Plan",
visibleWhen: visibleAtOrAfter(S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{ name: "Market_Study_Date", label: "Market Study Date", type: "date", civiField: `${G2}.Market_Study_Date` },
{ name: "Market_Study_Upload", label: "Market Study — Upload", type: "file", civiField: `${G2}.Market_Study_Upload` },
{ name: "Pro_Forma_date_completed", label: "Pro Forma — date completed", type: "date", civiField: `${G2}.Pro_Forma_date_completed` },
{ name: "Pro_Forma_Upload", label: "Pro Forma — Upload", type: "file", civiField: `${G2}.Pro_Forma_Upload` },
{
name: "Pro_Forma_Viability",
label: "Pro Forma Viability",
type: "select",
civiField: `${G2}.Pro_Forma_Viability`,
optionGroupId: 133,
help: "Rate the viability of the project.",
},
{ name: "Market_Study_Date", label: "Market Study Date", type: "date", civiField: `${G2}.Market_Study_Date`, help: "What date was your market study completed?" },
{ name: "Market_Study_Upload", label: "Market Study — Upload", type: "file", civiField: `${G2}.Market_Study_Upload`, help: "Please upload your most recent Market Study" },
{ name: "Pro_Forma_date_completed", label: "Pro Forma — date completed", type: "date", civiField: `${G2}.Pro_Forma_date_completed`, help: "What date was your most recent Pro Forma completed?" },
{ name: "Pro_Forma_Upload", label: "Pro Forma — Upload", type: "file", civiField: `${G2}.Pro_Forma_Upload`, help: "Please upload your most recent Pro Forma." },
// {
// name: "Pro_Forma_Viability",
// label: "Pro Forma Viability",
// type: "select",
// civiField: `${G2}.Pro_Forma_Viability`,
// optionGroupId: 133,
// help: "Rate the viability of the project.",
// },
{
name: "Business_Plan",
label: "Business Plan",
@@ -325,7 +409,7 @@ const stage2: StageSectionConfig = {
civiField: `${G2}.Business_Plan`,
help: "What date was your most recent business plan completed?",
},
{ name: "Business_Plan_Upload", label: "Business Plan — Upload", type: "file", civiField: `${G2}.Business_Plan_Upload` },
{ name: "Business_Plan_Upload", label: "Business Plan — Upload", type: "file", civiField: `${G2}.Business_Plan_Upload`, help: "Please upload the most recent copy of your business plan." },
{
name: "Board_Self_Assessment",
label: "Board Self Assessment",
@@ -338,6 +422,7 @@ const stage2: StageSectionConfig = {
label: "Board Self Assessment — Upload",
type: "file",
civiField: `${G2}.Board_Self_Assessment_Upload`,
help: "Please upload the results of your most recent Board Self Assessment.",
},
{
name: "Governance_System_Used",
@@ -347,6 +432,25 @@ const stage2: StageSectionConfig = {
help: "What governance system does your board use, or how do you make decisions?",
},
] as FieldConfig[],
// Visual clustering — each pair (date + upload) reads as one item.
fieldGroups: [
{
id: "market_study",
fields: ["Market_Study_Date", "Market_Study_Upload"],
},
{
id: "pro_forma",
fields: ["Pro_Forma_date_completed", "Pro_Forma_Upload"],
},
{
id: "business_plan",
fields: ["Business_Plan", "Business_Plan_Upload"],
},
{
id: "board_self_assessment",
fields: ["Board_Self_Assessment", "Board_Self_Assessment_Upload"],
},
],
};
// Stage 3 — Connect & Gather
@@ -361,66 +465,77 @@ const stage3: StageSectionConfig = {
label: "Site: Letter of Intent Date",
type: "date",
civiField: `${G3}.Site_Letter_of_Intent_Date`,
help: "What date was your Letter of Intent for your site signed?",
},
{
name: "Site_Letter_of_Intent_Upload",
label: "Site: Letter of Intent — Upload",
type: "file",
civiField: `${G3}.Site_Letter_of_Intent_Upload`,
help: "Please upload the Letter of Intent for your Site.",
},
{
name: "Own_the_building_property",
label: "Own the building / property",
type: "select",
civiField: `${G3}.Own_the_building_property`,
optionGroupId: 139,
help: "Will the co-op own the building or property?",
},
{
name: "Capital_Campaign_Owner_Participation_",
label: "Capital Campaign: Owner Participation %",
type: "percent",
civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
},
{
name: "Capital_Campaign_Average_Owner_Investment",
label: "Capital Campaign: Average Owner Investment",
type: "currency",
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
},
// {
// name: "Own_the_building_property",
// label: "Own the building / property",
// type: "select",
// civiField: `${G3}.Own_the_building_property`,
// optionGroupId: 139,
// help: "Will the co-op own the building or property, if so select YES. Select NO if the co-op will be leasing or have some other type of arrangement",
// },
{
name: "Capital_Stack",
label: "Capital Stack",
type: "multiselect",
civiField: `${G3}.Capital_Stack`,
optionGroupId: 134,
help: "Select all the types of funding that are part of your Sources.",
help: "Please select all the types of funding that are part of your Sources.",
},
{
name: "Project_Manager_Date_of_Hire",
label: "Project Manager — Date of Hire",
type: "date",
civiField: `${G3}.Project_Manager_Date_of_Hire`,
help: "What date did your hire a project manager for co-op development? (This is NOT the construction-specific Project Manager.)",
},
{
name: "Store_Design_Plan_Completion",
label: "Store Design — Plan Completion",
type: "date",
civiField: `${G3}.Store_Design_Plan_Completion`,
help: "What date was the design for your co-op completed?",
},
{ name: "Store_Designer", label: "Store Designer", type: "text", civiField: `${G3}.Store_Designer` },
{ name: "NCG_Member", label: "NCG Member", type: "select", civiField: `${G3}.NCG_Member`, optionGroupId: 136 },
{ name: "NCG_Corridor", label: "NCG Corridor", type: "select", civiField: `${G3}.NCG_Corridor`, optionGroupId: 143 },
{ name: "INFRA_Member", label: "INFRA Member", type: "select", civiField: `${G3}.INFRA_Member`, optionGroupId: 137 },
{ name: "Store_Designer", label: "Store Designer", type: "text", civiField: `${G3}.Store_Designer`, help: "Which professional or team created your store design?" },
// { name: "NCG_Member", label: "NCG Member", type: "select", civiField: `${G3}.NCG_Member`, optionGroupId: 136, help: "Are you an NCG member, or considering NCG Membership?" },
// { name: "NCG_Corridor", label: "NCG Corridor", type: "select", civiField: `${G3}.NCG_Corridor`, optionGroupId: 143, help: "Which NCG corridor is this co-op in?" },
// { name: "INFRA_Member", label: "INFRA Member", type: "select", civiField: `${G3}.INFRA_Member`, optionGroupId: 137, help: "Are you an INFRA member, or considering INFRA membership?" },
// {
// name: "Other_Distributors",
// label: "Other Distributors",
// type: "select",
// civiField: `${G3}.Other_Distributors`,
// optionGroupId: 138,
// help: "Are you using or considering one of these other distributors as your primary distributor?",
// },
{
name: "Other_Distributors",
label: "Other Distributors",
type: "select",
civiField: `${G3}.Other_Distributors`,
optionGroupId: 138,
help: "Are you using or considering one of these other distributors as your primary?",
name: "Capital_Campaign_Owner_Participation_",
label: "Capital Campaign: Owner Participation %",
type: "percent",
civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
help: "What percentage of your owners have contributed to your capital campaign?",
},
{
name: "Capital_Campaign_Average_Owner_Investment",
label: "Capital Campaign: Average Owner Investment",
type: "currency",
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
help: "What is the average investment size from your owners? (i.e. total raised divided by number of owners participating. If your co-op raised $100,000 from 100 owners, the average owner investment would be $1,000. $100,000 / 100 = $1,000.)",
},
] as FieldConfig[],
fieldGroups: [
{ id: "site", fields: ["Site_Letter_of_Intent_Date", "Site_Letter_of_Intent_Upload"] },
{ id: "store_design", fields: ["Store_Designer", "Store_Design_Plan_Completion"] },
// { id: "cap_camp", fields: ["Capital_Campaign_Owner_Participation_", "Capital_Campaign_Average_Owner_Investment"]},
],
};
// Stage 4 — Excite & Build
@@ -430,42 +545,24 @@ const stage4: StageSectionConfig = {
label: "Stage 4 — Excite & Build",
visibleWhen: visibleAtOrAfter(S.StoreImplementation, S.Stabilize),
fields: [
{ name: "Projected_Opening_Date", label: "Projected Opening Date", type: "date", civiField: `${G4}.Projected_Opening_Date` },
{
name: "Construction_Completion_Date",
label: "Construction Completion Date",
type: "date",
civiField: `${G4}.Construction_Completion_Date`,
},
{
name: "Missions_Transition_Plan_Date",
label: "Mission Transition Plan — Date",
type: "date",
civiField: `${G4}.Missions_Transition_Plan_Date`,
},
{
name: "Mission_Transition_Plan_Upload",
label: "Mission Transition Plan — Upload",
type: "file",
civiField: `${G4}.Mission_Transition_Plan_Upload`,
},
{ name: "General_Manager_Name", label: "General Manager — Name", type: "text", civiField: `${G4}.General_Manager_Name` },
{ name: "General_Manager_Phone", label: "General Manager Phone", type: "phone", civiField: `${G4}.General_Manager_Phone` },
{ name: "GM_Email", label: "General Manager Email", type: "email", civiField: `${G4}.GM_Email` },
{
name: "General_Manager_Date_of_Hire",
label: "General Manager — Date of Hire",
type: "date",
civiField: `${G4}.General_Manager_Date_of_Hire`,
},
{
name: "General_Manager_Background",
label: "General Manager Background",
type: "select",
civiField: `${G4}.General_Manager_Background`,
optionGroupId: 135,
help: "What professional background does your General Manager have?",
},
{
name: "General_Manager_Date_of_Hire",
label: "General Manager — Date of Hire",
type: "date",
civiField: `${G4}.General_Manager_Date_of_Hire`,
help: "What date was your GM hired?",
},
{
name: "General_Manager_Background",
label: "General Manager Background",
type: "select",
civiField: `${G4}.General_Manager_Background`,
optionGroupId: 135,
help: "What professional background does your General Manager have?",
},
{ name: "General_Manager_Name", label: "General Manager — Name", type: "text", civiField: `${G4}.General_Manager_Name`, help: "Please enter the name of your current General Manager." },
{ name: "General_Manager_Phone", label: "General Manager Phone", type: "phone", civiField: `${G4}.General_Manager_Phone`, help: "What is your GM's phone number?" },
{ name: "GM_Email", label: "General Manager Email", type: "email", civiField: `${G4}.GM_Email`, help: "What is the best email for your GM?" },
{
name: "GM_Support_Training",
label: "GM Support and Training",
@@ -480,7 +577,33 @@ const stage4: StageSectionConfig = {
civiField: `${G4}.GM_Support_Team`,
help: "Who is providing support and training to your GM?",
},
{ name: "Projected_Opening_Date", label: "Projected Opening Date", type: "date", civiField: `${G4}.Projected_Opening_Date`, help: "Date the co-op is projected to open." },
{
name: "Construction_Completion_Date",
label: "Construction Completion Date",
type: "date",
civiField: `${G4}.Construction_Completion_Date`,
help: "What date was construction completed on the co-op?",
},
{
name: "Missions_Transition_Plan_Date",
label: "Mission Transition Plan — Date",
type: "date",
civiField: `${G4}.Missions_Transition_Plan_Date`,
help: "What date was your Mission Transition Plan adopted?",
},
{
name: "Mission_Transition_Plan_Upload",
label: "Mission Transition Plan — Upload",
type: "file",
civiField: `${G4}.Mission_Transition_Plan_Upload`,
help: "Please upload your Mission Transition Plan.",
},
] as FieldConfig[],
fieldGroups: [
{ id: "gm", label: "General Manager", fields: ["General_Manager_Date_of_Hire", "General_Manager_Background", "General_Manager_Name", "General_Manager_Phone", "GM_Email", "GM_Support_Training", "GM_Support_Team"] },
{ id: "mission_transition", fields: ["Missions_Transition_Plan_Date", "Mission_Transition_Plan_Upload"] }
],
};
// Stage 5 — Fulfill & Stabilize. Field machine names have historical
@@ -574,8 +697,8 @@ const stage5: StageSectionConfig = {
label: "Stage 5 — Fulfill & Stabilize",
visibleWhen: visibleAtOrAfter(S.Stabilize),
fields: [
{ name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened` },
{ name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales` }
{ name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened`, help: "What day did the co-op officially open for business?" },
{ name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales`, help: "Total Sales from Year 1" }
] as FieldConfig[],
// STAGE_5_FIELDS,
// matrixGroups: [
@@ -626,9 +749,9 @@ const stage5: StageSectionConfig = {
export const formConfig: FormConfig = {
id: "org_engagement_check_in",
title: "Co-op Check-in",
title: "Co-op Survey",
subtitle:
"Update tracking data for your co-op as you progress through the organizing stages. The questions you'll see depend on where you are in the framework.",
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
stageField: "current_stage",
sections: [stage0, stage1, stage2, stage3, stage4, stage5],
};
+22 -3
View File
@@ -39,7 +39,19 @@ export async function loadPrefill(
activityTypeName = "Org Engagement Submission",
): Promise<PrefillResult> {
const civiSelected = fields.filter((f) => f.civiField);
const select = ["id", "activity_date_time", ...new Set(civiSelected.map((f) => f.civiField!))];
// CiviCRM File custom fields return a file id by default. To surface a
// human-readable filename in the prefill (so the prior-attachment
// indicator can show it), also request the joined `.file_name` for any
// file-type field.
const fileFieldRefs = new Set(
civiSelected.filter((f) => f.type === "file").map((f) => f.civiField!),
);
const selectSet = new Set<string>(["id", "activity_date_time"]);
for (const f of civiSelected) selectSet.add(f.civiField!);
for (const ref of fileFieldRefs) selectSet.add(`${ref}.file_name`);
const select = Array.from(selectSet);
const res = await civi<ActivityRow>("Activity", "get", {
select,
@@ -56,10 +68,17 @@ export async function loadPrefill(
for (const f of civiSelected) {
for (const row of rows) {
const v = row[f.civiField!];
if (v !== null && v !== undefined && v !== "") {
if (v === null || v === undefined || v === "") continue;
if (f.type === "file") {
const fname = row[`${f.civiField!}.file_name`];
out[f.name] = {
id: v,
file_name: typeof fname === "string" ? fname : undefined,
};
} else {
out[f.name] = v;
break;
}
break;
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Shared-secret auth for the internal staff routes.
*
* Staff hit URLs of the form /staff/report?org=<id>&key=<secret>. The
* secret is read from the STAFF_REPORT_KEY env var. If unset, the routes
* refuse every request (closed by default).
*
* Stub mode (CIVI_* unset) does NOT bypass this check — we want to test
* the auth surface in dev too. For local dev, set STAFF_REPORT_KEY=dev in
* .env.local.
*/
export function isStaffKeyValid(key: string | null | undefined): boolean {
const expected = process.env.STAFF_REPORT_KEY;
if (!expected) return false;
if (!key) return false;
return key === expected;
}
+88
View File
@@ -0,0 +1,88 @@
// @ts-check
/**
* Map a CiviCRM CustomField row (as returned by APIv4 CustomField.get with
* `custom_group_id.name` and `custom_group_id.title` joined in) to a
* StaffFieldDescriptor for the staff report.
*
* Unknown data_type/html_type combinations fall back to "text" and the
* caller should log a warning naming the field so we notice schema
* additions we haven't modeled yet.
*
* Written as JS+JSDoc rather than TS so Node's built-in --test runner
* can import this file directly without any tooling. TypeScript callers
* still get full types via the JSDoc annotations.
*
* @typedef {import("../types/form").StaffFieldDescriptor} StaffFieldDescriptor
* @typedef {import("../types/form").StaffRenderKind} StaffRenderKind
*
* The CustomFieldRow shape isn't fully expressible in JSDoc because two of
* its property names contain dots (the APIv4 joined-field syntax). TypeScript
* callers declare their own typed interface for the row; here we use a
* permissive shape so the @ts-check pass doesn't complain about the dotted
* accesses below.
*
* @typedef {Record<string, unknown> & {
* name: string;
* label: string;
* data_type: string;
* html_type: string;
* option_group_id: number | null | undefined;
* weight: number;
* }} CustomFieldRow
*/
const ORG_GROUP_NAMES = new Set(["Food_Co_op_Organizing"]);
/**
* @param {CustomFieldRow} row
* @returns {StaffFieldDescriptor}
*/
export function mapCustomFieldRow(row) {
const groupName = /** @type {string} */ (row["custom_group_id.name"]);
const groupTitle = /** @type {string} */ (row["custom_group_id.title"]);
/** @type {"activity" | "org"} */
const groupKind = ORG_GROUP_NAMES.has(groupName) ? "org" : "activity";
const optionGroupId =
typeof row.option_group_id === "number" && row.option_group_id > 0
? row.option_group_id
: undefined;
const render = inferRender(row.data_type, row.html_type, optionGroupId);
return {
groupName,
groupTitle,
groupKind,
civiField: `${groupName}.${row.name}`,
name: row.name,
label: row.label,
render,
optionGroupId,
};
}
/**
* @param {string} dataType
* @param {string} htmlType
* @param {number | undefined} optionGroupId
* @returns {StaffRenderKind}
*/
function inferRender(dataType, htmlType, optionGroupId) {
if (dataType === "Money") return "currency";
if (dataType === "Date") return "date";
if (dataType === "Timestamp") return "datetime";
if (dataType === "Boolean") return "boolean";
if (dataType === "File") return "file";
if (dataType === "Memo") return "longtext";
if (optionGroupId !== undefined) {
if (htmlType === "CheckBox" || htmlType === "Multi-Select") return "multiselect";
if (htmlType === "Select" || htmlType === "Radio" || htmlType === "Autocomplete-Select") {
return "select";
}
}
if (dataType === "Int" || dataType === "Float") return "number";
return "text";
}
+100
View File
@@ -0,0 +1,100 @@
// Run with: npm run test:mapping
import test from "node:test";
import assert from "node:assert/strict";
import { mapCustomFieldRow } from "./staff-field-mapping.mjs";
const baseRow = {
name: "Sample",
label: "Sample",
data_type: "String",
html_type: "Text",
option_group_id: null,
weight: 1,
"custom_group_id.name": "Stage_1",
"custom_group_id.title": "Stage 1",
};
test("Money → currency", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Money" });
assert.equal(d.render, "currency");
});
test("Date + Select Date → date", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Date", html_type: "Select Date" });
assert.equal(d.render, "date");
});
test("Timestamp → datetime", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Timestamp" });
assert.equal(d.render, "datetime");
});
test("Boolean → boolean", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Boolean" });
assert.equal(d.render, "boolean");
});
test("File → file", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "File" });
assert.equal(d.render, "file");
});
test("Memo → longtext", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Memo" });
assert.equal(d.render, "longtext");
});
test("Select with option_group_id → select", () => {
const d = mapCustomFieldRow({
...baseRow,
data_type: "String",
html_type: "Select",
option_group_id: 140,
});
assert.equal(d.render, "select");
assert.equal(d.optionGroupId, 140);
});
test("Radio with option_group_id → select", () => {
const d = mapCustomFieldRow({ ...baseRow, html_type: "Radio", option_group_id: 140 });
assert.equal(d.render, "select");
});
test("CheckBox with option_group_id → multiselect", () => {
const d = mapCustomFieldRow({ ...baseRow, html_type: "CheckBox", option_group_id: 140 });
assert.equal(d.render, "multiselect");
});
test("Int without options → number", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Int", html_type: "Text" });
assert.equal(d.render, "number");
});
test("Float without options → number", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "Float", html_type: "Text" });
assert.equal(d.render, "number");
});
test("Unknown combo → text fallback", () => {
const d = mapCustomFieldRow({ ...baseRow, data_type: "WeirdNewType", html_type: "Mystery" });
assert.equal(d.render, "text");
});
test("Food_Co_op_Organizing group → org kind", () => {
const d = mapCustomFieldRow({
...baseRow,
"custom_group_id.name": "Food_Co_op_Organizing",
"custom_group_id.title": "Org",
});
assert.equal(d.groupKind, "org");
});
test("Stage_1 group → activity kind", () => {
const d = mapCustomFieldRow({ ...baseRow, "custom_group_id.name": "Stage_1" });
assert.equal(d.groupKind, "activity");
});
test("civiField is built from group + name", () => {
const d = mapCustomFieldRow({ ...baseRow, name: "Foo", "custom_group_id.name": "Stage_2" });
assert.equal(d.civiField, "Stage_2.Foo");
});
+67 -31
View File
@@ -1,41 +1,38 @@
import path from "node:path";
import type { NextConfig } from "next";
/**
* Security headers applied to every response.
* Security headers.
*
* Notes on each:
* - CSP: tight default; allows Google Fonts (next/font) and the same-origin
* /api routes. No third-party scripts. `frame-ancestors 'none'` prevents
* this app being embedded in another site's iframe.
* - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is
* correct in production).
* - Permissions-Policy: drop everything we don't use.
* - Referrer-Policy: same-origin — never leak the cid+cs query string to
* other origins via the Referer header.
* - X-Content-Type-Options: prevents MIME sniffing.
* Two profiles:
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
* Applied to every route except /staff/report.
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
* Lets the CiviCRM "Engagement Report" extension embed the staff page
* in an iframe on contact pages.
*
* The catch-all source uses a negative lookahead so it does NOT match
* /staff/report — otherwise both rules apply and the browser ANDs the
* frame-ancestors directives together, blocking embedding entirely.
*/
// Next.js React dev runtime uses dynamic-script execution for fast-refresh,
// error overlays, and source-map reconstruction. Permit that ONLY in dev so
// HMR works; production CSP stays strict (no dynamic execution allowed).
const isDev = process.env.NODE_ENV !== "production";
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
const securityHeaders = [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; "),
},
const buildCsp = (frameAncestors: string) =>
[
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data:",
"connect-src 'self'",
`frame-ancestors ${frameAncestors}`,
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; ");
const sharedHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "same-origin" },
@@ -43,14 +40,53 @@ const securityHeaders = [
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
];
function civiOriginForCsp(): string {
const raw = process.env.CIVI_BASE_URL;
if (!raw) return "";
try {
return new URL(raw).origin;
} catch {
return "";
}
}
const strictHeaders = [
{ key: "Content-Security-Policy", value: buildCsp("'none'") },
...sharedHeaders,
{ key: "X-Frame-Options", value: "DENY" },
];
const staffEmbedHeaders = (() => {
const origin = civiOriginForCsp();
const frameAncestors = origin ? `'self' ${origin}` : "'self'";
return [
{ key: "Content-Security-Policy", value: buildCsp(frameAncestors) },
...sharedHeaders,
// Intentionally NO X-Frame-Options: frame-ancestors above is the policy.
];
})();
const nextConfig: NextConfig = {
poweredByHeader: false,
reactStrictMode: true,
// Pin Turbopack's filesystem root to THIS app's directory. Without this,
// Next 16 walks up to the parent civi-webform/ workspace (it sees two
// package-lock.json files and silently picks the outer one), which causes
// Turbopack to watch the parent node_modules/, .claude-flow/, .swarm/, and
// ruvector.db. Background writes in those trees trigger a recompile loop:
// compile → write .next/dev → re-trigger → memory blows up. The build-time
// warning surfaces the same issue.
turbopack: {
root: path.resolve(__dirname),
},
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
return [
{ source: "/staff/report", headers: staffEmbedHeaders },
// Catch-all that explicitly excludes /staff/report — see header notes.
{ source: "/((?!staff/report).*)", headers: strictHeaders },
];
},
};
+3 -1
View File
@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
"test:mapping": "node --test lib/staff-field-mapping.test.mjs"
},
"dependencies": {
"next": "16.2.6",
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
// scripts/inspect-org-custom-fields.mjs
//
// Dumps the CustomField metadata for the "Food_Co_op_Organizing" custom
// group (attached to Organization contacts) so we can wire the right
// machine names + types into config/form.ts.
//
// USAGE
// node --env-file=.env.local scripts/inspect-org-custom-fields.mjs
const GROUP = "Food_Co_op_Organizing";
async function civi(entity, action, params) {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
"X-Civi-Key": CIVI_SITE_KEY,
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const res = await fetch(url, {
method: "POST",
headers,
body: new URLSearchParams({ params: JSON.stringify(params) }),
});
if (!res.ok) {
throw new Error(`${entity}.${action} HTTP ${res.status}: ${await res.text()}`);
}
return res.json();
}
const result = await civi("CustomField", "get", {
select: [
"name",
"label",
"data_type",
"html_type",
"is_active",
"option_group_id",
"custom_group_id.name",
"custom_group_id.extends",
],
where: [["custom_group_id.name", "=", GROUP]],
orderBy: { weight: "ASC" },
limit: 0,
});
const rows = result.values ?? [];
console.log(`Custom group: ${GROUP}`);
if (rows.length) {
console.log(`Extends: ${rows[0]["custom_group_id.extends"]}`);
}
console.log(`Found ${rows.length} fields:\n`);
for (const f of rows) {
console.log(
`- name=${f.name} label="${f.label}" data_type=${f.data_type} html_type=${f.html_type} option_group_id=${f.option_group_id ?? "-"} active=${f.is_active}`,
);
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// scripts/list-civi-entities.mjs
//
// Lists what entities APIv4 exposes on this Civi instance, with a focus
// on file/attachment-shaped ones. Run this when Attachment.create comes
// back "API does not exist", to figure out what the real upload path is.
//
// USAGE
// node --env-file=.env.local scripts/list-civi-entities.mjs
import { Buffer } from "node:buffer";
async function civi(entity, action, params) {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
"X-Civi-Key": CIVI_SITE_KEY,
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const res = await fetch(url, {
method: "POST",
headers,
body: new URLSearchParams({ params: JSON.stringify(params) }),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch {
throw new Error(`${entity}.${action} non-JSON (HTTP ${res.status}): ${text}`);
}
if (!res.ok || json.error_message) {
throw new Error(`${entity}.${action} HTTP ${res.status}: ${json.error_message ?? text}`);
}
return json;
}
console.log("Probing APIv4 entities…\n");
// All registered entities.
const all = await civi("Entity", "get", { select: ["name"], orderBy: { name: "ASC" } });
const names = (all.values ?? []).map((r) => r.name);
console.log(`Total APIv4 entities: ${names.length}\n`);
const fileShaped = names.filter((n) =>
/attach|file|document|upload/i.test(n),
);
console.log("File/attachment-shaped entities present:");
for (const n of fileShaped) console.log(` - ${n}`);
if (fileShaped.length === 0) console.log(" (none)");
console.log("\nFor each, list available actions:");
for (const ent of fileShaped) {
try {
const a = await civi(ent, "getActions", { select: ["name"] });
const actions = (a.values ?? []).map((r) => r.name).join(", ");
console.log(`\n ${ent}: ${actions}`);
} catch (err) {
console.log(`\n ${ent}: <getActions failed: ${err.message}>`);
}
}
// Also probe: does the legacy APIv3 Attachment.create exist? APIv4
// extension surface is different from APIv3, and the form may need to
// fall back to v3 for files. Round-trip a getfields call as a probe.
console.log("\nAPIv3 probe (extern/rest.php):");
try {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/rest`;
const body = new URLSearchParams({
entity: "Attachment",
action: "getfields",
api_key: CIVI_API_KEY,
key: CIVI_SITE_KEY,
json: "1",
});
const res = await fetch(url, { method: "POST", headers, body });
const text = await res.text();
let j;
try { j = JSON.parse(text); } catch { j = null; }
if (j && !j.is_error) {
const fields = j.values ? Object.keys(j.values) : [];
console.log(` APIv3 Attachment.getfields OK. Fields: ${fields.join(", ")}`);
} else {
console.log(` APIv3 Attachment.getfields response:`, text.slice(0, 400));
}
} catch (err) {
console.log(` APIv3 probe failed: ${err.message}`);
}
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env node
// scripts/spike-attachment-upload.mjs
//
// One-off SPIKE to validate the CiviCRM file-attachment pipeline before
// we commit to a v1 design for the form's /api/upload endpoint.
//
// We need to know, against THIS Civi instance:
//
// Q1. Does APIv4 Attachment.create succeed with NO entity binding?
// (Required for our planned two-step upload pattern: upload first,
// then reference the returned id from Activity.create on submit.)
//
// Q2. If Q1 is no, does Attachment.create require entity_table +
// entity_id at upload time? In that case the activity-bound file
// fields need a different flow (create empty activity first, attach,
// then update — or attach to the org contact temporarily).
//
// Q3. Can we write the returned attachment id as the value of a custom
// File field on Contact.update? (Test against Food_Co_op_Organizing.
// Certificate_of_Incorporation specifically.)
//
// Q4. Can /api/data's existing Contact.get with `.file_name` join read
// it back correctly?
//
// Q5. Does Attachment.delete clean it up afterward? (Needed for the
// teardown step here AND for the future orphan-cleanup Civi job.)
//
// USAGE
//
// node --env-file=.env.local scripts/spike-attachment-upload.mjs \
// --org-id=<orgContactId> \
// [--keep] # don't delete the test attachment at the end
//
// REQUIRED ENV: CIVI_BASE_URL, CIVI_API_KEY, CIVI_SITE_KEY
// (plus CIVI_HTTP_AUTH_USER/PASS if Civi sits behind webserver basic auth)
import { Buffer } from "node:buffer";
const args = process.argv.slice(2);
const orgIdArg = args.find((a) => a.startsWith("--org-id="));
const KEEP = args.includes("--keep");
const ORG_ID = orgIdArg ? Number(orgIdArg.slice("--org-id=".length)) : null;
if (!ORG_ID) {
console.error(
"Usage: node --env-file=.env.local scripts/spike-attachment-upload.mjs --org-id=<n> [--keep]",
);
process.exit(1);
}
const CUSTOM_FIELD = "Food_Co_op_Organizing.Certificate_of_Incorporation";
const TEST_FILENAME = `spike-${Date.now()}.txt`;
const TEST_MIME = "text/plain";
const TEST_BODY = "civi-webform attachment spike — safe to delete";
// ── CiviCRM APIv4 client (matches lib/civicrm.ts) ─────────────────────
async function civi(entity, action, params) {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
"X-Civi-Key": CIVI_SITE_KEY,
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString(
"base64",
);
}
const res = await fetch(url, {
method: "POST",
headers,
body: new URLSearchParams({ params: JSON.stringify(params) }),
});
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`${entity}.${action} non-JSON response (HTTP ${res.status}): ${text}`);
}
if (!res.ok || json.error_message) {
throw new Error(
`${entity}.${action} failed (HTTP ${res.status}): ${json.error_message ?? text}`,
);
}
return json;
}
function divider(label) {
console.log(`\n── ${label} ${"─".repeat(Math.max(0, 60 - label.length))}`);
}
// ── Q1 / Q2: try Attachment.create both ways and see which Civi accepts.
//
// APIv4 Attachment.create expected params:
// name, mime_type, content (base64), entity_table?, entity_id?
//
async function tryUnbound() {
divider("Q1: Attachment.create WITHOUT entity binding");
try {
const res = await civi("Attachment", "create", {
values: {
name: TEST_FILENAME,
mime_type: TEST_MIME,
content: Buffer.from(TEST_BODY, "utf8").toString("base64"),
},
});
console.log("RESULT: success ✓");
console.log(JSON.stringify(res, null, 2));
return res.values?.[0]?.id ?? null;
} catch (err) {
console.log("RESULT: failed");
console.log(err.message);
return null;
}
}
async function tryBoundToContact() {
divider(`Q2: Attachment.create BOUND to civicrm_contact id=${ORG_ID}`);
try {
const res = await civi("Attachment", "create", {
values: {
name: TEST_FILENAME,
mime_type: TEST_MIME,
content: Buffer.from(TEST_BODY, "utf8").toString("base64"),
entity_table: "civicrm_contact",
entity_id: ORG_ID,
},
});
console.log("RESULT: success ✓");
console.log(JSON.stringify(res, null, 2));
return res.values?.[0]?.id ?? null;
} catch (err) {
console.log("RESULT: failed");
console.log(err.message);
return null;
}
}
// ── Q3: write the file id as the value of the custom File field on the
// org contact. If the contact already has a certificate, save and restore.
async function testCustomFieldWrite(attachmentId) {
divider(`Q3: Contact.update writing ${CUSTOM_FIELD} = ${attachmentId}`);
const before = await civi("Contact", "get", {
select: ["id", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`],
where: [["id", "=", ORG_ID]],
});
const prior = before.values?.[0] ?? {};
console.log("Prior value on contact:", JSON.stringify(prior, null, 2));
try {
const res = await civi("Contact", "update", {
where: [["id", "=", ORG_ID]],
values: { [CUSTOM_FIELD]: attachmentId },
});
console.log("Update result:", JSON.stringify(res, null, 2));
console.log("RESULT: success ✓");
return { ok: true, prior: prior[CUSTOM_FIELD] ?? null };
} catch (err) {
console.log("Update failed:", err.message);
return { ok: false, prior: prior[CUSTOM_FIELD] ?? null };
}
}
// ── Q4: read back the file via /api/data's join pattern.
async function testReadBack() {
divider("Q4: Contact.get with .file_name join");
const res = await civi("Contact", "get", {
select: ["id", "display_name", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`],
where: [["id", "=", ORG_ID]],
});
console.log(JSON.stringify(res, null, 2));
const row = res.values?.[0];
if (row && row[CUSTOM_FIELD] && row[`${CUSTOM_FIELD}.file_name`]) {
console.log("RESULT: file id + filename round-trip works ✓");
} else {
console.log("RESULT: round-trip incomplete — see payload above");
}
}
// ── Q5: clean up.
async function cleanup(attachmentId, restorePriorTo) {
if (KEEP) {
console.log("\n--keep set; not deleting attachment", attachmentId);
return;
}
divider("Q5: cleanup");
// Restore the contact's prior certificate value (so the spike doesn't
// leave the org pointing at a deleted attachment).
if (restorePriorTo !== undefined) {
try {
await civi("Contact", "update", {
where: [["id", "=", ORG_ID]],
values: { [CUSTOM_FIELD]: restorePriorTo },
});
console.log(`Restored prior ${CUSTOM_FIELD} value:`, restorePriorTo);
} catch (err) {
console.log("Restore failed:", err.message);
}
}
try {
const res = await civi("Attachment", "delete", {
where: [["id", "=", attachmentId]],
});
console.log(`Attachment.delete id=${attachmentId}:`, JSON.stringify(res));
console.log("RESULT: cleanup OK ✓");
} catch (err) {
console.log("Attachment.delete failed:", err.message);
console.log(
`*** MANUAL CLEANUP NEEDED: Attachment id=${attachmentId} is orphaned in CiviCRM ***`,
);
}
}
// ── orchestrator ─────────────────────────────────────────────────────
async function main() {
console.log("CIVI spike — attachment pipeline");
console.log("Org contact:", ORG_ID);
console.log("Custom field:", CUSTOM_FIELD);
console.log("Test file:", TEST_FILENAME);
let attachmentId = await tryUnbound();
let restorePriorTo;
if (!attachmentId) {
attachmentId = await tryBoundToContact();
if (!attachmentId) {
console.log(
"\nNeither variant of Attachment.create succeeded. Stop here and",
"investigate Civi permissions / extension version.",
);
process.exit(2);
}
console.log(
"\nNOTE: Civi rejected unbound attachment. v1 design must attach",
"the file to an entity at upload time (cannot decouple upload from",
"submit). Document this and adjust the plan.",
);
}
const write = await testCustomFieldWrite(attachmentId);
restorePriorTo = write.prior;
await testReadBack();
await cleanup(attachmentId, restorePriorTo);
console.log("\nSpike complete.");
}
main().catch((err) => {
console.error("\nFATAL:", err);
process.exit(1);
});
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env node
// scripts/spike-file-upload.mjs
//
// Supersedes spike-attachment-upload.mjs. The earlier spike showed APIv4
// Attachment.create is not exposed on this Civi instance, but APIv4
// `File` and `EntityFile` ARE present, and APIv3 Attachment is reachable
// as a fallback.
//
// CiviCRM custom file fields store the file id directly in the custom
// column on the entity's custom-value table -- the EntityFile linkage
// table is only needed for general attachments (e.g. on an Activity's
// "Attachments" tab). So for our form's custom-field-bound files we
// only need:
//
// File row in civicrm_file <-- File.create
// │
// │ (file id stored directly as the custom field value)
// ▼
// Custom field on the entity <-- Contact.update / Activity.create
//
// This spike confirms that pipeline end-to-end on a real org contact.
//
// USAGE
// node --env-file=.env.local scripts/spike-file-upload.mjs --org-id=<n> [--keep]
import { Buffer } from "node:buffer";
const args = process.argv.slice(2);
const orgIdArg = args.find((a) => a.startsWith("--org-id="));
const KEEP = args.includes("--keep");
const ORG_ID = orgIdArg ? Number(orgIdArg.slice("--org-id=".length)) : null;
if (!ORG_ID) {
console.error(
"Usage: node --env-file=.env.local scripts/spike-file-upload.mjs --org-id=<n> [--keep]",
);
process.exit(1);
}
const CUSTOM_FIELD = "Food_Co_op_Organizing.Certificate_of_Incorporation";
const TEST_FILENAME = `spike-${Date.now()}.txt`;
const TEST_MIME = "text/plain";
const TEST_BODY = "civi-webform file spike — safe to delete";
async function civi(entity, action, params) {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
"X-Civi-Key": CIVI_SITE_KEY,
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const res = await fetch(url, {
method: "POST",
headers,
body: new URLSearchParams({ params: JSON.stringify(params) }),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch {
throw new Error(`${entity}.${action} non-JSON (HTTP ${res.status}): ${text}`);
}
if (!res.ok || json.error_message) {
throw new Error(`${entity}.${action} HTTP ${res.status}: ${json.error_message ?? text}`);
}
return json;
}
function divider(label) {
console.log(`\n── ${label} ${"─".repeat(Math.max(0, 60 - label.length))}`);
}
// ── Q1: what fields does APIv4 File.create accept on this Civi?
divider("Q1: File.getFields");
const fields = await civi("File", "getFields", { action: "create" });
const fieldNames = (fields.values ?? []).map((f) => f.name);
console.log("File create-action fields:", fieldNames.join(", "));
const acceptsContent = fieldNames.includes("content");
console.log(`Accepts 'content' param: ${acceptsContent ? "yes ✓" : "NO -- must POST file differently"}`);
if (!acceptsContent) {
console.log(
"\nFile.create on this Civi doesn't accept inline content. The upload path",
"needs to use a different mechanism (likely the legacy APIv3 Attachment.create",
"or the civicrm/upload endpoint). Stopping spike to avoid guessing.",
);
process.exit(2);
}
// ── Q2: create a File row.
divider("Q2: File.create with base64 content");
const created = await civi("File", "create", {
values: {
file_name: TEST_FILENAME,
mime_type: TEST_MIME,
content: Buffer.from(TEST_BODY, "utf8").toString("base64"),
},
});
const fileId = created.values?.[0]?.id;
console.log("File.create result:", JSON.stringify(created, null, 2));
if (!fileId) {
console.log("RESULT: failed — no id returned");
process.exit(2);
}
console.log(`RESULT: file id = ${fileId}`);
// ── Q3: read current value, write file id to custom field, read back.
divider(`Q3: Contact.update ${CUSTOM_FIELD} = ${fileId}`);
const before = await civi("Contact", "get", {
select: ["id", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`],
where: [["id", "=", ORG_ID]],
});
const priorRow = before.values?.[0] ?? {};
const priorValue = priorRow[CUSTOM_FIELD] ?? null;
console.log("Prior value on contact:", JSON.stringify(priorRow, null, 2));
let writeOk = false;
try {
const upd = await civi("Contact", "update", {
where: [["id", "=", ORG_ID]],
values: { [CUSTOM_FIELD]: fileId },
});
console.log("Update result:", JSON.stringify(upd, null, 2));
writeOk = true;
console.log("RESULT: write OK ✓");
} catch (err) {
console.log(`Update failed: ${err.message}`);
}
// ── Q4: read back through the join /api/data uses.
if (writeOk) {
divider("Q4: read-back via Contact.get + .file_name join");
const after = await civi("Contact", "get", {
select: ["id", "display_name", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`],
where: [["id", "=", ORG_ID]],
});
console.log(JSON.stringify(after, null, 2));
const row = after.values?.[0];
if (row && Number(row[CUSTOM_FIELD]) === Number(fileId) && row[`${CUSTOM_FIELD}.file_name`]) {
console.log("RESULT: round-trip OK ✓ — pipeline is viable");
} else {
console.log("RESULT: read-back incomplete or id mismatch — see payload");
}
}
// ── Cleanup: restore prior value, delete file row.
if (KEEP) {
console.log(`\n--keep set; leaving file id=${fileId} and contact pointing at it.`);
} else {
divider("Cleanup: restore prior contact value + File.delete");
try {
await civi("Contact", "update", {
where: [["id", "=", ORG_ID]],
values: { [CUSTOM_FIELD]: priorValue },
});
console.log(`Restored ${CUSTOM_FIELD} = ${priorValue}`);
} catch (err) {
console.log("Restore failed:", err.message);
}
try {
const del = await civi("File", "delete", { where: [["id", "=", fileId]] });
console.log(`File.delete id=${fileId}:`, JSON.stringify(del));
console.log("Cleanup OK ✓");
} catch (err) {
console.log("File.delete failed:", err.message);
console.log(`*** MANUAL CLEANUP NEEDED: File id=${fileId} is orphaned in CiviCRM ***`);
}
}
console.log("\nSpike complete.");
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env node
// scripts/sync-help-from-civi.mjs
//
// One-off / on-demand sync of per-field help text from CiviCRM into
// config/form.ts. Civi stores help on CustomField as `help_pre` (shown
// above the input) and `help_post` (shown below); this script prefers
// help_pre and falls back to help_post.
//
// USAGE
// node --env-file=.env.local scripts/sync-help-from-civi.mjs # dry run
// node --env-file=.env.local scripts/sync-help-from-civi.mjs --write # apply changes
// node scripts/sync-help-from-civi.mjs --debug # print parsed fields, no Civi call
//
// Or via npm:
// npm run sync-help
// npm run sync-help -- --write
//
// REQUIRED ENV
// CIVI_BASE_URL, CIVI_API_KEY, CIVI_SITE_KEY
// (optional) CIVI_HTTP_AUTH_USER, CIVI_HTTP_AUTH_PASS for sites behind Basic Auth.
//
// WHY A SCRIPT, NOT A RUNTIME FETCH
// This form is low-traffic and the help text doesn't change once in
// production use. Running this manually when staff edit help in Civi
// is leaner than coupling every form load (or every build) to a Civi
// API call. Keeps git history honest: every help-text change shows up
// as a normal source edit you can review/revert.
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const args = process.argv.slice(2);
const WRITE = args.includes("--write");
const DEBUG = args.includes("--debug");
// Keep in sync with config/form.ts G0..G5 declarations. If a new stage
// custom group is added there, mirror it here.
const GROUPS = {
G0: "Check_in_data__organizing_",
G1: "Stage_1",
G2: "Stage_2",
G3: "Stage_3",
G4: "Stage_4",
G5: "Stage_5",
};
const HERE = dirname(fileURLToPath(import.meta.url));
const FORM_TS_PATH = resolve(HERE, "..", "config", "form.ts");
// ── CiviCRM APIv4 client (minimal, matches lib/civicrm.ts) ─────────────
async function civi(entity, action, params) {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
throw new Error(
"Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY. " +
"Put them in .env.local and run via `node --env-file=.env.local ...`.",
);
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
"X-Civi-Key": CIVI_SITE_KEY,
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const res = await fetch(url, {
method: "POST",
headers,
body: new URLSearchParams({ params: JSON.stringify(params) }),
});
if (!res.ok) {
throw new Error(
`Civi ${entity}.${action} failed (HTTP ${res.status}): ${await res.text()}`,
);
}
return res.json();
}
async function fetchCiviHelp() {
const result = await civi("CustomField", "get", {
select: ["name", "custom_group_id.name", "help_pre", "help_post"],
where: [["custom_group_id.name", "IN", Object.values(GROUPS)]],
limit: 0,
});
const map = new Map(); // "Group_Name.Field_Name" -> { pre, post }
for (const row of result.values ?? []) {
map.set(`${row["custom_group_id.name"]}.${row.name}`, {
pre: row.help_pre ?? "",
post: row.help_post ?? "",
});
}
return map;
}
// ── form.ts parser ─────────────────────────────────────────────────────
// Build a masked copy of the file where `//` line comments and block
// comments are replaced with spaces of equal length. Offsets stay
// aligned with the original. We do NOT mask `${G0}`-style template-
// literal substitutions: each one contains a matched `{` and `}` that
// balance to net zero, so the brace walker ignores them naturally, and
// keeping them intact lets the marker regex still capture the group key.
function mask(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
}
function parseFormTs(text) {
const masked = mask(text);
const fields = [];
const markerRe = /\bciviField:\s*`\$\{(G\d)\}\.(\w+)`/g;
let m;
// Scan the masked text so commented-out `civiField:` lines don't get
// picked up and paired with the wrong enclosing `name:`.
while ((m = markerRe.exec(masked)) !== null) {
// Backward walk: find the `{` that opens the enclosing object.
let depth = 0;
let openOff = -1;
for (let i = m.index; i >= 0; i--) {
const ch = masked[i];
if (ch === "}") depth++;
else if (ch === "{") {
if (depth === 0) {
openOff = i;
break;
}
depth--;
}
}
if (openOff < 0) continue;
// Forward walk: find the matching `}`.
let closeOff = -1;
depth = 0;
for (let i = openOff; i < masked.length; i++) {
const ch = masked[i];
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
closeOff = i;
break;
}
}
}
if (closeOff < 0) continue;
const block = text.slice(openOff, closeOff + 1);
const nameM = block.match(/\bname:\s*"([^"]+)"/);
if (!nameM) continue;
const helpM = block.match(/\bhelp:\s*"((?:[^"\\]|\\.)*)"/);
const helpIdxInBlock = helpM ? block.indexOf(helpM[0]) : -1;
fields.push({
name: nameM[1],
groupKey: m[1],
civiName: m[2],
currentHelp: helpM ? helpM[1] : null,
blockStart: openOff,
blockEnd: closeOff + 1,
helpStart: helpIdxInBlock >= 0 ? openOff + helpIdxInBlock : null,
helpEnd: helpIdxInBlock >= 0 ? openOff + helpIdxInBlock + helpM[0].length : null,
});
}
return fields;
}
// ── Diff + in-place rewrite ────────────────────────────────────────────
function escForJsString(s) {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
}
function buildChanges(fields, civiMap) {
const changes = [];
for (const f of fields) {
const groupName = GROUPS[f.groupKey];
if (!groupName) continue;
const civi = civiMap.get(`${groupName}.${f.civiName}`);
if (!civi) {
changes.push({ field: f, kind: "missing-in-civi" });
continue;
}
const civiHelp = (civi.pre || civi.post || "").trim();
if (!civiHelp) {
// Civi has no help. Don't blank out a non-empty config help - surface it
// for awareness but don't rewrite.
if (f.currentHelp) changes.push({ field: f, kind: "civi-empty" });
continue;
}
if (f.currentHelp === civiHelp) continue;
changes.push({
field: f,
kind: f.currentHelp ? "differs" : "missing-in-config",
civiHelp,
});
}
return changes;
}
function rewriteText(text, changes) {
// Bottom-up so earlier offsets stay valid as we splice.
const writable = changes
.filter((c) => c.kind === "differs" || c.kind === "missing-in-config")
.sort((a, b) => b.field.blockStart - a.field.blockStart);
let out = text;
for (const c of writable) {
const f = c.field;
const newLiteral = `help: "${escForJsString(c.civiHelp)}"`;
if (f.helpStart != null) {
out = out.slice(0, f.helpStart) + newLiteral + out.slice(f.helpEnd);
continue;
}
const block = out.slice(f.blockStart, f.blockEnd);
const isOneLine = !block.includes("\n");
if (isOneLine) {
const closeIdx = f.blockEnd - 1; // position of `}`
const before = out.slice(0, closeIdx);
const trailM = before.match(/[\s,]+$/);
const stripped = trailM ? before.slice(0, before.length - trailM[0].length) : before;
out = stripped + `, ${newLiteral} ` + out.slice(closeIdx);
} else {
// Multi-line: insert a new help: line just before the closing `}` line,
// using the indent of the first property after `{`.
//
// The previous-property line may or may not end with a trailing comma
// (JS allows the last property to drop the comma). When it doesn't,
// inserting a new `help:` line below it produces `prevProp <NL> help:`
// which is a syntax error. So: if the line right before the closing
// `}` line doesn't end with a comma (ignoring trailing whitespace),
// append one before we splice the new line in.
const indentM = block.match(/\{\s*\n([ \t]+)\S/);
const indent = indentM ? indentM[1] : " ";
const closeIdx = f.blockEnd - 1;
const lastNL = out.lastIndexOf("\n", closeIdx);
let beforeClose = out.slice(0, lastNL);
const afterNL = out.slice(lastNL);
const prevPropTail = beforeClose.match(/([^\s,])\s*$/);
if (prevPropTail) {
// Insert a `,` right after the last non-whitespace, non-comma char.
const insertAt = beforeClose.length - prevPropTail[0].length + 1;
beforeClose = beforeClose.slice(0, insertAt) + "," + beforeClose.slice(insertAt);
}
out = beforeClose + "\n" + indent + newLiteral + "," + afterNL;
}
}
return out;
}
function truncate(s, n = 90) {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "...";
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
const text = await readFile(FORM_TS_PATH, "utf8");
const fields = parseFormTs(text);
console.log(`Parsed ${fields.length} field(s) with civiField from config/form.ts`);
if (DEBUG) {
for (const f of fields) {
const grp = GROUPS[f.groupKey] ?? f.groupKey;
console.log(
` - ${f.name} -> ${grp}.${f.civiName} ` +
(f.currentHelp ? `(help: ${truncate(f.currentHelp, 60)})` : "(no help)"),
);
}
return;
}
const civiMap = await fetchCiviHelp();
console.log(`Fetched ${civiMap.size} CustomField row(s) from Civi`);
const changes = buildChanges(fields, civiMap);
if (changes.length === 0) {
console.log("\nAll help text matches Civi. Nothing to do.");
return;
}
console.log(`\n${changes.length} difference(s):\n`);
for (const c of changes) {
const f = c.field;
switch (c.kind) {
case "missing-in-civi":
console.log(` - ${f.name}: no matching CustomField in Civi (orphan in form.ts?)`);
if (f.currentHelp) console.log(` form: ${truncate(f.currentHelp)}`);
break;
case "civi-empty":
console.log(` - ${f.name}: form has help, Civi help is empty (keeping form's value, no rewrite)`);
console.log(` form: ${truncate(f.currentHelp)}`);
break;
case "missing-in-config":
console.log(` - ${f.name}: missing in config, will add`);
console.log(` civi: ${truncate(c.civiHelp)}`);
break;
case "differs":
console.log(` - ${f.name}: differs`);
console.log(` form: ${truncate(f.currentHelp)}`);
console.log(` civi: ${truncate(c.civiHelp)}`);
break;
}
}
if (!WRITE) {
console.log("\n(Dry run. Re-run with --write to apply changes.)");
return;
}
const updated = rewriteText(text, changes);
await writeFile(FORM_TS_PATH, updated, "utf8");
console.log("\nWrote changes to config/form.ts");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+140 -1
View File
@@ -84,9 +84,17 @@ export interface FieldConfig {
/**
* The CiviCRM custom-field reference. APIv4 format: `<group_name>.<field_name>`.
* Leave undefined for fields that don't write back to Civi (e.g. transient
* UI helpers).
* UI helpers). Use this for fields that target the per-submission Activity
* record (Check_in_data__organizing_, Stage_1..5).
*/
civiField?: string;
/**
* Like `civiField`, but for fields that live on the **Organization Contact**
* (e.g. `Food_Co_op_Organizing.Date_Incorporated`). /api/data reads these
* from the org contact at form-load time; /api/submit writes them back via
* Contact.update on submit. Mutually exclusive with `civiField`.
*/
civiContactField?: string;
/**
* If this field's options come from a CiviCRM option group, set its ID here.
* `/api/data` will fetch the option-group values and embed them in
@@ -128,6 +136,29 @@ export interface MatrixGroupConfig {
}>;
}
/**
* Visual cluster for closely-related fields inside a section — e.g. a
* "Market Study" pair (date + file upload) that should read as one item
* with two inputs. Pure presentation: fields referenced here still live
* in `StageSectionConfig.fields` and submit/visibility logic walks them
* the same way as standalone fields. The renderer pulls grouped fields
* out of the section's per-field grid and renders them in their own
* bordered card above (or interleaved with) the ungrouped fields.
*/
export interface FieldGroupConfig {
/** Unique id within the section, e.g. "market_study". */
id: string;
/** Group heading shown above the cluster. Omit for a heading-less card. */
label?: string;
/** Optional helper text rendered below the label. */
intro?: string;
/**
* Names of fields in the parent section that belong to this group, in
* left-to-right / top-to-bottom render order.
*/
fields: string[];
}
export interface StageSectionConfig {
/** Stage rank, 0..5. Used by the conditional engine and the accordion. */
rank: number;
@@ -144,6 +175,15 @@ export interface StageSectionConfig {
*/
visibleWhen?: VisibilityRule;
fields: FieldConfig[];
/**
* Optional visual clusters of related fields. Fields referenced here are
* still defined in `fields` above; this list is an overlay that tells the
* renderer to draw them as a sub-card with a shared heading. A field
* named in more than one group is rendered in the first group it appears
* in. Field-level `visibleWhen` still applies inside a group — a fully
* hidden group renders nothing.
*/
fieldGroups?: FieldGroupConfig[];
/**
* Matrix groups rendered above the per-field grid. Fields referenced in
* any matrix are excluded from the per-field grid (so a Y1 monthly sales
@@ -168,6 +208,17 @@ export interface FormConfig {
sections: StageSectionConfig[];
}
/**
* Identifying details for the contact submitting the form, derived from the
* cid resolved by /api/data. Surfaced as readonly fields in Stage 0 so the
* person filling out the survey can confirm who's logged as the submitter.
*/
export interface ContactIdentity {
firstName: string;
lastName: string;
email: string;
}
/**
* The shape returned by /api/data. Used by the form on initial load.
*/
@@ -176,6 +227,8 @@ export interface FormDataPayload {
orgName: string;
/** Current Framework Stage (text value). */
currentStage: string;
/** Identifying details of the contact behind the cid (readonly display). */
contact?: ContactIdentity;
/**
* Per-field-most-recent prefill values, keyed by the FieldConfig.name.
* Fields with no prior value are simply absent from this map.
@@ -220,6 +273,12 @@ export interface ActivitySummary {
/** Stage value carried on this activity (non-empty → staff transition). */
stage?: string | null;
subject?: string | null;
/**
* Display name of the source contact (form submitter). Only present on
* activities that have a source_contact_id; staff-created stage-change
* activities may not. The staff report shows this in its activity table.
*/
submittedBy?: string | null;
}
/**
@@ -243,3 +302,83 @@ export interface ReportPayload {
fieldHistory: Record<string, FieldHistoryEntry[]>;
options?: Record<number, SelectOption[]>;
}
/**
* Render kinds the staff report knows how to display. Built from CiviCRM
* field metadata at request time. `text` is the fallback for unknown
* data_type × html_type combinations.
*/
export type StaffRenderKind =
| "text"
| "number"
| "currency"
| "date"
| "datetime"
| "select"
| "multiselect"
| "file"
| "longtext"
| "boolean";
/**
* One field discovered via CustomField.get, normalised for the staff report.
*/
export interface StaffFieldDescriptor {
/** custom_group_id.name, e.g. "Stage_1" or "Food_Co_op_Organizing". */
groupName: string;
/** custom_group_id.title (human label). */
groupTitle: string;
/** Where the field lives: on the Activity or on the Organization Contact. */
groupKind: "activity" | "org";
/** APIv4 reference: "<group_name>.<field_name>". */
civiField: string;
/** Just <field_name>. */
name: string;
/** Human label. */
label: string;
/** How the UI should render this field's value. */
render: StaffRenderKind;
/** For select/multiselect — which option group to resolve labels from. */
optionGroupId?: number;
}
/**
* One field + its history (per-activity values for activity fields; a
* single current value for org fields).
*/
export interface StaffReportField {
descriptor: StaffFieldDescriptor;
/**
* For activity fields: ordered history (latest first), one entry per
* activity that has a non-empty value.
* For org fields: at most one entry (the current value); activityId is
* set to 0 and date is the empty string since neither applies.
*/
history: FieldHistoryEntry[];
}
/**
* A section in the staff report — one per CiviCRM custom group.
*/
export interface StaffReportSection {
groupName: string;
groupTitle: string;
groupKind: "activity" | "org";
fields: StaffReportField[];
}
/**
* The shape returned by /api/staff/report.
*/
export interface StaffReportPayload {
orgId: number;
orgName: string;
/** Current Framework Stage text value, or null if no stage on record. */
currentStage: string | null;
/** Sections in render order: org section first, then activity groups. */
sections: StaffReportSection[];
/** Every Check-in (organizing) activity, ordered DESC by date. */
activities: ActivitySummary[];
/** Option-group labels for any select/multiselect/stage field referenced. */
options: Record<number, SelectOption[]>;
}