diff --git a/amplify.yml b/amplify.yml index 757f9f3..f26449d 100644 --- a/amplify.yml +++ b/amplify.yml @@ -9,10 +9,63 @@ applications: # 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 + Secrets in the build - # shell but does NOT inject them into the SSR Lambda runtime. - # Write them to .env.production so Next.js bundles them into - # the server output. .env* is gitignored. + # Amplify exposes Environment Variables in the build shell as + # plain `$VAR` references, but Secrets are SecureString entries + # in SSM Parameter Store at `/amplify///` + # 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; 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; 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" diff --git a/app/api/data/route.ts b/app/api/data/route.ts index 879d5c1..70a05b5 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -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, @@ -158,13 +163,25 @@ 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([ + // 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 }>("Contact", "get", { select: ["id", "display_name"], 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 +207,19 @@ 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; + const payload: FormDataPayload = { orgName: org.display_name, currentStage, + contact, prefill, options, }; diff --git a/app/globals.css b/app/globals.css index 7dc2a24..acebabe 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); } diff --git a/app/layout.tsx b/app/layout.tsx index f638c3e..2400376 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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,13 +16,14 @@ 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", }); diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index ed2e538..4f93c19 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -76,13 +76,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 1–5 visibleWhen rule and is shown as a readonly - // field in the Stage 0 header. + // readonly display values. Includes: + // - current_stage: gates every Stage 1–5 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 +116,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 = { + contact_first_name: data.contact?.firstName ?? "", + contact_last_name: data.contact?.lastName ?? "", + contact_email: data.contact?.email ?? "", + }; const defaults: Record = { [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); } diff --git a/config/form.ts b/config/form.ts index ed5683e..b6dadf5 100644 --- a/config/form.ts +++ b/config/form.ts @@ -68,6 +68,24 @@ const stage0: StageSectionConfig = { label: "Current stage", 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: "contact_first_name", + label: "First name", + type: "readonly", + }, + { + name: "contact_last_name", + label: "Last name", + type: "readonly", + }, + { + name: "contact_email", + label: "Email", + type: "readonly", }, // { // name: "Peer_Group_Participation", diff --git a/types/form.ts b/types/form.ts index 96c77c2..a984245 100644 --- a/types/form.ts +++ b/types/form.ts @@ -168,6 +168,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 check-in 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 +187,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.