From 7a6cab29dba479e24f6b23a7f1306173011714e3 Mon Sep 17 00:00:00 2001 From: Jeffrey Hales Date: Sun, 25 Oct 2026 13:04:00 -0700 Subject: [PATCH] feat(forms): add local Form A presentation preview --- www/.env.example | 2 + www/integrations/jotform/README.md | 46 +++ www/integrations/jotform/aia-form-a.css | 192 ++++++++++++ ...ase-9-form-a-meeting-preview-2026-10-22.md | 121 ++++++++ www/src/components/ContactForm.astro | 10 +- www/src/components/FormAPreview.astro | 279 ++++++++++++++++++ .../campaign/ConsultationForm.astro | 12 +- www/src/components/pages/ContactPage.astro | 6 +- www/src/utils/form-integration.ts | 15 + www/src/utils/form-messages.ts | 30 +- www/tools/form-a-preview-browser.mjs | 210 +++++++++++++ www/tools/form-a-preview.test.mjs | 59 ++++ www/tools/phase9-nonproduction.test.mjs | 9 + 13 files changed, 983 insertions(+), 8 deletions(-) create mode 100644 www/integrations/jotform/README.md create mode 100644 www/integrations/jotform/aia-form-a.css create mode 100644 www/reports/phase-9-form-a-meeting-preview-2026-10-22.md create mode 100644 www/src/components/FormAPreview.astro create mode 100644 www/tools/form-a-preview-browser.mjs create mode 100644 www/tools/form-a-preview.test.mjs diff --git a/www/.env.example b/www/.env.example index e514425..2942d30 100644 --- a/www/.env.example +++ b/www/.env.example @@ -5,4 +5,6 @@ PUBLIC_COUNTERS_MOCK_RESULT=success PUBLIC_AIA_FORMS_MODE=off PUBLIC_AIA_FORMS_LIVE_APPROVED=false PUBLIC_AIA_FORMS_MOCK_RESULT=success +# Explicit, local-only Form A meeting preview; requires PUBLIC_AIA_FORMS_MODE=mock. +PUBLIC_AIA_FORM_A_PREVIEW=false PUBLIC_ALLOW_INDEXING=false diff --git a/www/integrations/jotform/README.md b/www/integrations/jotform/README.md new file mode 100644 index 0000000..d4c0920 --- /dev/null +++ b/www/integrations/jotform/README.md @@ -0,0 +1,46 @@ +# AIA Form A Jotform styling and conditional-button guide + +This directory contains the nonproduction visual handoff for the new AIA Form A. [`aia-form-a.css`](./aia-form-a.css) is intended for Jotform Classic Forms' **Form Designer → Styles → Inject Custom CSS** after the form is created and its settings are approved. + +The CSS translates the existing AIA form treatment: Rubik typography, blue labels and text, `#fcf9f5` inputs with `#edeef3` borders, the `#ffbd59` filled button, the `#ff914d` hover/focus border, four-pixel corners, visible validation errors, and strong keyboard focus. The Astro page supplies the heading, introduction, privacy-policy link, and surrounding card. The stylesheet therefore removes the Jotform surface rather than adding another header, footer, or nested card. + +The selectors target Jotform Classic Forms identifiers such as `#form`, `.form-all`, `.form-line`, `.form-label`, `.form-sub-label`, `.form-required`, `.form-textbox`, `.form-dropdown`, `.form-textarea`, `.form-error-message`, and `.form-submit-button`. Jotform's final generated DOM must be checked in the account before relying on pixel-level parity. The stylesheet does not hide required indicators, error messages, honeypots, or focus outlines, and it does not request fonts or other external assets. + +## Installation and comparison + +1. Create or open the approved blank Form A in AIA's HIPAA-enabled Jotform account. +2. Keep the form disabled while field, PHI, notification, conditional-logic, and language review is pending. +3. Paste the contents of `aia-form-a.css` into Jotform's custom CSS area. +4. Compare the Jotform preview with the Astro Form A mock at 390px and 1440px. Check the form surface, label rhythm, input height, button fill, outlined hover/focus state, error state, and mobile reflow. +5. Check both English and Spanish configurations. Confirm translated labels, required indicators, validation messages, confirmation copy, and the accessible iframe title supplied by the parent Astro integration. +6. Test the form in a real embedded iframe and on the direct hosted-form fallback. Confirm the iframe has a practical mobile height, does not create horizontal overflow, and does not trap keyboard focus. + +The current Astro meeting preview remains `off` by default and can only be shown in a non-indexing mock build. It does not load Jotform and is not a live submission test. + +## Form context and button labels + +The current Form A design uses a hidden `formContext` value with fixed, approved values such as `contact` and `landing`. Jotform does not provide a native conditional rule that changes one Submit button's accessible label safely in every context. If owner-approved context-specific labels are required, use multiple native Jotform Submit buttons and Jotform's conditional visibility rules: + +- Add one native Submit button for each approved context/language label. +- Show exactly one intended Submit button for the current, validated `formContext`. +- Keep every button a real Jotform Submit control; do not use CSS `::before`/`::after` text as a label. +- Verify that every visible variant submits through the same approved form and preserves the honeypot and validation behavior. +- Make the no-context and invalid-context states fail closed: no ambiguous extra button and no unintended submission path. +- Verify keyboard order, focus visibility, screen-reader names, English/Spanish labels, and mobile layout for every variant. + +The four campaign pages remain mapped to Form B in the production architecture. Their current Form A mock override is temporary meeting-preview behavior only. Do not add an external Astro button that attempts to submit a cross-origin iframe. + +## Verification checklist + +- [ ] Form A is disabled while configuration is reviewed. +- [ ] Exact question types, required flags, choices, conditional logic, and `formContext` values are approved. +- [ ] English and Spanish labels, validation, confirmation, and button variants are reviewed by fluent-language owners. +- [ ] The hidden honeypot remains present, effective, and excluded from user-facing visual changes. +- [ ] Exactly one native Submit button is visible for every valid context. +- [ ] Invalid or missing context does not expose an unintended submission path. +- [ ] No PHI-bearing answer, PDF, recipient, redirect, webhook, or HTTP POST forwarding is inherited without privacy/security approval. +- [ ] The iframe title, loading/error state, keyboard behavior, and mobile height are accepted. +- [ ] Synthetic testing uses only approved synthetic values and a separately authorized test form. +- [ ] No Jotform, Contabo, analytics, advertising, or production API request is made by off/mock builds. + +JF-01 through JF-09, privacy/security review, operations approval, fluent-language review, and production activation remain pending. This guide does not authorize Jotform account changes or live integration. diff --git a/www/integrations/jotform/aia-form-a.css b/www/integrations/jotform/aia-form-a.css new file mode 100644 index 0000000..a2d9ca1 --- /dev/null +++ b/www/integrations/jotform/aia-form-a.css @@ -0,0 +1,192 @@ +/* + * AIA Form A — Jotform Classic Forms custom CSS + * + * Paste into Jotform Form Designer > Styles > Inject Custom CSS after + * creating the approved Form A. These selectors are scoped to Jotform's + * Classic Forms structure and do not replace Jotform validation or controls. + * Validate the final generated form DOM before activation; no account access + * or pixel-perfect Jotform comparison was performed for this artifact. + */ + +/* The Astro page supplies the surrounding heading, copy, privacy link and + card. Keep the iframe's own surface quiet so it does not create a second + card inside the page's existing form container. */ +#form, +#form .form-all { + background: #fff; + border: 0; + box-shadow: none; + color: #254080; + font-family: Rubik, Arial, sans-serif; + max-width: 100%; +} + +#form .form-all { + margin: 0; + padding: 0; +} + +#form .form-line { + background: transparent; + border: 0; + margin: 0; + padding: 0 0 1rem; +} + +#form .form-label, +#form .form-label-left, +#form .form-label-right, +#form .form-label-top { + color: #254080; + font-size: .9rem; + font-weight: 700; + line-height: 1.5; + margin: 0 0 .4rem; + padding: 0; +} + +#form .form-sub-label { + color: #254080; + font-size: .78rem; + line-height: 1.5; +} + +#form .form-required { + color: #a21c28; + margin-left: .2rem; +} + +#form input.form-textbox, +#form input.form-textbox.form-phone, +#form input.form-textbox.form-email, +#form select.form-dropdown, +#form textarea.form-textarea { + background: #fcf9f5; + border: 1px solid #edeef3; + border-radius: 4px; + box-sizing: border-box; + color: #254080; + font: inherit; + line-height: 1.5; + min-height: 2.9rem; + padding: .7rem .8rem; + width: 100%; +} + +#form textarea.form-textarea { + min-height: 7rem; + resize: vertical; +} + +#form input.form-textbox:focus-visible, +#form select.form-dropdown:focus-visible, +#form textarea.form-textarea:focus-visible, +#form input[type='radio']:focus-visible + label, +#form input[type='checkbox']:focus-visible + label, +#form .form-submit-button:focus-visible { + outline: 3px solid #115bbd; + outline-offset: 3px; +} + +#form .form-line.form-line-error input.form-textbox, +#form .form-line.form-line-error select.form-dropdown, +#form .form-line.form-line-error textarea.form-textarea { + border-color: #a21c28; + outline: 2px solid #a21c28; + outline-offset: 1px; +} + +#form .form-error-message, +#form .form-line-error .form-error-message { + color: #a21c28; + font-size: .85rem; + font-weight: 700; + line-height: 1.5; + margin: .45rem 0 0; +} + +#form .form-radio-item, +#form .form-checkbox-item { + color: #254080; + line-height: 1.5; + margin: 0 1rem .5rem 0; +} + +#form .form-radio, +#form .form-checkbox { + accent-color: #254080; +} + +#form .form-buttons-wrapper { + background: transparent; + border: 0; + margin: .25rem 0 0; + padding: 0; + text-align: left; +} + +#form .form-submit-button, +#form button[type='submit'] { + background: #ffbd59; + border: 2px solid #ffbd59; + border-radius: 4px; + box-shadow: none; + color: #254080; + cursor: pointer; + font: inherit; + font-weight: 400; + line-height: 1.5; + min-height: 3rem; + padding: .65rem 1.2rem; + transition: .3s linear all; + width: 100%; +} + +#form .form-submit-button:hover, +#form .form-submit-button:focus-visible, +#form button[type='submit']:hover, +#form button[type='submit']:focus-visible { + background: transparent; + border-color: #ff914d; + color: #254080; +} + +#form .form-submit-button:disabled, +#form button[type='submit']:disabled { + cursor: not-allowed; + opacity: .65; +} + +/* Jotform error and required content must remain visible. Do not hide these + classes in a campaign-specific override. */ +#form .form-validation-error, +#form .form-required, +#form .form-error-message { + visibility: visible; +} + +@media (max-width: 640px) { + #form .form-line, + #form .form-label, + #form .form-label-left, + #form .form-label-right, + #form .form-label-top { + width: 100%; + } + + #form .form-all { + width: 100%; + } + + #form .form-submit-button, + #form button[type='submit'] { + min-height: 3rem; + } +} + +@media (prefers-reduced-motion: reduce) { + #form .form-submit-button, + #form button[type='submit'] { + transition: none; + } +} diff --git a/www/reports/phase-9-form-a-meeting-preview-2026-10-22.md b/www/reports/phase-9-form-a-meeting-preview-2026-10-22.md new file mode 100644 index 0000000..00d6129 --- /dev/null +++ b/www/reports/phase-9-form-a-meeting-preview-2026-10-22.md @@ -0,0 +1,121 @@ +# Phase 9 Form A Meeting Preview — 2026-10-22 + +## Scope and baseline + +This report records a local, non-submitting preview of the new Jotform Form A visitor journey. The repository remained on `faithful-astro-migration` at `ce17728e0b9a0e148941cfa47b2d074859b24456` when this work began. The Git index was empty, and no production configuration or Jotform account setting was changed. + +The preview is available only when all of the following are true: + +```text +PUBLIC_AIA_FORMS_MODE=mock +PUBLIC_AIA_FORM_A_PREVIEW=true +PUBLIC_ALLOW_INDEXING is not true +``` + +Default builds remain `off`. `live` remains fail-closed because no approved live adapter or production form identifier is present. Indexing-enabled builds omit the preview and retain the ordinary inactive fallback. + +## Placements and fields + +The full Form A preview is rendered on `/contact` and `/es/contact`. It contains the required name, required phone, conditionally required email, preferred contact method, optional message, sensitive-information caution, and a local demonstration control. It intentionally does not include an enquiry-category field. Spanish labels, including the dynamic email-requirement text and internal-preview label, are supplied through the existing locale message architecture. + +The compact preview is rendered on `/lp/aba-therapy`, `/lp/aba-insurance`, `/lp/aba-ahcccs`, and `/lp/aba-private-pay`. It contains only name, phone, conditional email, and preferred contact method. It has no message, enquiry-category, or sensitive-information notice. `/lp/early-intervention` was not changed and remains outside this meeting-preview override. + +The contact buttons use `Submit` and `Enviar`. Campaign buttons use the approved page CTA: `Request a Free Consultation`, `Request Insurance Verification`, `Request a Benefits Check`, or `Discuss Private-Pay Options`. The production route contract still assigns all four campaign pages to Form B; Form A is a temporary mock-only review override. + +Final copy cleanup was verified on 2026-10-23 UTC. In mock mode, each compact campaign preview has one short status line beneath its controls: “Preview only — nothing is submitted.” The same line retains the telephone and privacy-policy links. In off mode, the four campaign panels contain no form-availability notice; a plain support line retains the telephone and privacy-policy links. The compact panels contain no free-text field and therefore contain no sensitive-information disclaimer. Early Intervention likewise contains no free-text field, disclaimer, or unavailable-form notice; its existing telephone CTAs, hours, clinic address, and directions remain available. The hero and separate clinic-contact block retain their contact information without repeating form status. + +## Visual fidelity + +The mock now uses the established AIA form values from the existing Astro components and tokens: `#ffbd59` button fill and border, transparent hover/focus fill, `#ff914d` hover/focus border, `#254080` text, four-pixel corners, `#fcf9f5` input backgrounds, and `#edeef3` input borders. Keyboard focus retains a separate three-pixel outline. The compact variant relies on the campaign page's existing white form container instead of introducing a nested card surface. + +`integrations/jotform/aia-form-a.css` translates these values to narrowly scoped Jotform Classic Forms selectors. `integrations/jotform/README.md` documents installation, iframe/mobile comparison, multilingual verification, honeypot checks, and the multiple-native-Submit-button pattern needed for context-specific accessible labels. The Jotform selectors still require verification against the form's actual generated DOM. + +The component does not contain a form element, action, iframe, Jotform URL, form ID, fetch call, persistence mechanism, logging call, email operation, analytics event, or advertising event. The control is `type="button"`; its success state explicitly says that no information was sent or saved. The preview's fields are disabled in the ordinary/off build. + +## Validation behavior + +Chromium verifies that: + +- Name, phone, and preferred contact are required. +- Selecting Email makes the email field required and validates its format. +- Phone preference permits the optional email field to remain empty. +- The local success state is shown only after the local checks pass. +- English and Spanish labels render in their respective pages. +- Every placement uses its approved contextual button text as actual accessible text. +- The rendered button begins with `#ffbd59` fill and border, changes to a transparent fill with `#ff914d` border on hover/focus, retains `#254080` text, and keeps a visible focus outline. +- The sensitive-information caution appears only with the contact-page message field. +- No native `
` or iframe is emitted by the preview. +- The six preview routes have one main H1 and no horizontal overflow at 390px and 1440px. +- JavaScript-disabled `/contact` and `/lp/aba-therapy` retain the preview content and an explicit local-preview/no-send notice. +- All five landing pages contain no unavailable/not-enabled form wording and no sensitive-information disclaimer without a free-text field. +- All five landing pages retain non-submitting controls and avoid horizontal overflow at 390px and 1440px in both off and mock builds. + +## Guarded browser evidence + +Command: + +```bash +PLAYWRIGHT_MODULE=/home/alice/projects/wrk/aia/www/aia-aba-landing-pages/node_modules/playwright/index.mjs \ +CHROMIUM_EXECUTABLE=/etc/profiles/per-user/alice/bin/chromium \ +node tools/form-a-preview-browser.mjs +``` + +The final mock run completed 16 checks: 14 route/viewport checks (the two Contact pages, four campaign pages, and Early Intervention at 390px and 1440px) and two JavaScript-disabled checks. A separate off-mode run completed ten checks across all five landing pages at the same two widths. Both passed with zero failures and zero production API/form/analytics attempts. The mock run intercepted nine automatic Leaflet requests to `unpkg.com` from the Contact-page map; they were recorded as blocked external resources and did not transmit. No Jotform, AIA API, Google Analytics, Google Tag Manager, Google Ads, or other production request was observed or attempted. + +The first preview pass is preserved as the before set in `/tmp/aia-form-a-preview-furat7/`. The final copy-cleanup evidence is in `/tmp/aia-form-a-preview-3YPMT1/`: + +| Evidence | Before SHA-256 | After SHA-256 | +|---|---|---| +| `contact-390.png` | `8add0699084e6bced38a8cb0e1c3946da01c2af8695c68d436448fb6108aa1db` | `2a53669580143e0ce0f5fe284bd93b2023bf899e676e4863165e7930ed08b4fc` | +| `contact-1440.png` | `1955196fe16620a5d6e358b4d512989b391389cb3bf02e292f23364202dbb142` | `9d0433eeb76c3f120fd7f75cf8af3edc7e08835736946edbc23312bafc359d64` | +| `campaign-390.png` | `2ed1f939fe8c82a7c282e2568bd174bbe3f7bdf9dd9c3ac5e0107120439a9c0e` | `b63a2dbd9c4b1bf8266021044084a414673ffd6500e2f9a45d24034f77008c84` | +| `campaign-1440.png` | `e352eacd860530ec75cc887a2784dca7287d8520b351e0ed16bda4a340b9e597` | `138c5030e406af1f7689f9ba39fb17a3faf2339c077f86320ea8a9cb56f0bf67` | + +The final mock result is `/tmp/aia-form-a-preview-3YPMT1/results.json` with SHA-256 `177711789606a776a747432b6f94d7c9281ac65a8580d20f4075bace933e10a7`. The off-mode result is `/tmp/aia-form-a-preview-e5FUCA/results.json` with SHA-256 `99ff3cb2837f650b72afc37f4c5ff0ad0927bb6117ebeb29af406b72fb1cd8ff`. + +### Follow-up validation — 2026-10-25 + +The final implementation review corrected the Spanish internal-preview label and dynamic email-requirement text. A guarded Chromium mock run completed 16 checks across `/contact`, `/es/contact`, the four campaign pages, and Early Intervention at 390px and 1440px, plus two JavaScript-disabled checks. A separate off-mode run completed ten checks across the five landing pages at the same widths. Both runs passed with zero failures and zero Jotform, AIA API, analytics, advertising, or other production attempts. The Contact-page map made nine automatic Leaflet requests to `unpkg.com` during the mock run; the test guard blocked them before transmission. + +## Automated validation + +The following checks passed: + +| Check | Result | +|---|---:| +| `npm run check` | 133 files, 0 diagnostics | +| `npm run test:markdown` | 20 passed | +| `npm run test:publication` | 33 passed | +| `npm run test:seo` | 35 passed | +| `npm run test:library` | 53 passed | +| `npm run test:routes` | 10 passed | +| Phase 9, Form A, and campaign tests | 21 passed | +| `npm run audit:links` | 0 broken internal links | +| `npm run audit:images` | 0 missing mapped images | +| `npm run audit:blog` | 0 audit failures | +| Normal build | 128 pages | +| Indexing-enabled build with preview flag set | 127 pages; preview omitted | +| `phase10-artifact-verify.mjs --mode=staging` | passed; 128 HTML routes, 0 sitemap URLs, 0 hreflang links | + +The final normal staging output was restored after the indexing-enabled verification. Its six target pages contain no active Form A preview marker and retain `noindex,nofollow`. The temporary `/lp` review index remains a staging-only route. + +## Files changed + +- `src/components/FormAPreview.astro` — new full/compact local preview component. +- `src/components/pages/ContactPage.astro` — explicit gated full preview placement. +- `src/components/campaign/ConsultationForm.astro` — explicit gated compact preview placement for the four Form B campaign journeys. +- `src/utils/form-integration.ts` — non-indexing mock-only Form A preview gate. +- `src/utils/form-messages.ts` — English and Spanish preview labels and states. +- `.env.example` — default-disabled preview flag documentation. +- `tools/form-a-preview.test.mjs` — source-level safety and placement regression checks. +- `tools/form-a-preview-browser.mjs` — guarded Chromium, responsive, validation, no-JavaScript, and network checks. +- `tools/phase9-nonproduction.test.mjs` — preview-gate regression coverage. +- `integrations/jotform/aia-form-a.css` — scoped Jotform Classic Forms visual handoff. +- `integrations/jotform/README.md` — installation and conditional-button verification guide. +- `reports/phase-9-form-a-meeting-preview-2026-10-22.md` — this evidence report. + +## Remaining approval requirements + +This is suitable for an internal Clinical Director demonstration only. It is not a live intake path. The owner still must complete the new Form A Jotform configuration review, including minimum fields, required flags, English/Spanish behavior, PHI protection, notification and autoresponder contents, confirmation behavior, recipient access, retention, embedding, and synthetic acceptance. The Form A logical mapping remains distinct from the four campaign pages' production Form B mapping. No live Jotform ID or URL was added. + +P9-03 privacy/security approval, P9-05 analytics and advertising approval, P9-07 privacy/legal approval, and Phase 10 production authorization remain open. The local preview does not establish HIPAA compliance, operational readiness, or successful submission handling. diff --git a/www/src/components/ContactForm.astro b/www/src/components/ContactForm.astro index 5573996..afe5232 100644 --- a/www/src/components/ContactForm.astro +++ b/www/src/components/ContactForm.astro @@ -25,7 +25,8 @@ const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_R - + +

{text.formAPreview.caution}

{mode === 'mock' ? text.mock : <>{text.disabled} .}

@@ -99,4 +100,11 @@ const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_R margin: 0; text-align: center; } + + .contact-form-card__caution { + color: var(--color-muted); + font-size: .85rem; + line-height: 1.65; + margin: -.25rem 0 0; + } diff --git a/www/src/components/FormAPreview.astro b/www/src/components/FormAPreview.astro new file mode 100644 index 0000000..2c316ec --- /dev/null +++ b/www/src/components/FormAPreview.astro @@ -0,0 +1,279 @@ +--- +import { formAPreviewEnabled, formModeForRuntime, resolveFormMode } from '../utils/form-integration'; +import { formMessages } from '../utils/form-messages'; + +interface Props { + lang?: 'en' | 'es'; + variant?: 'full' | 'compact'; + submitText?: string; +} + +const { lang = 'en', variant = 'full', submitText } = Astro.props; +const text = formMessages(lang).formAPreview; +const config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED); +const mode = formModeForRuntime(config); +const enabled = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIEW, import.meta.env.PUBLIC_ALLOW_INDEXING, mode); +const prefix = `form-a-${variant}`; +const buttonLabel = submitText ?? text.submit; +--- +
+

{text.internalPreview}

+

{text.heading}

+ {variant === 'full' &&

{text.previewNote}

} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {variant === 'full' &&
+ + +

{text.caution}

+
} +
+ + + {variant === 'compact' ? ( +

+ {text.previewOnly} {text.callAia}. {text.privacyPolicy}. +

+ ) : ( +

{enabled ? text.previewNote : text.noScript}

+ )} + {variant === 'full' && } +
+ +{enabled && } + + diff --git a/www/src/components/campaign/ConsultationForm.astro b/www/src/components/campaign/ConsultationForm.astro index 570baa6..c61cf9a 100644 --- a/www/src/components/campaign/ConsultationForm.astro +++ b/www/src/components/campaign/ConsultationForm.astro @@ -1,5 +1,7 @@ --- import Icon from './Icon.astro'; +import FormAPreview from '../FormAPreview.astro'; +import { formAPreviewEnabled, formModeForRuntime, resolveFormMode } from '../../utils/form-integration'; interface Props { sourcePage: 'general' | 'commercial' | 'ahcccs' | 'private_pay'; title: string; @@ -9,6 +11,8 @@ interface Props { const { sourcePage, title, intro, buttonText } = Astro.props; const phone = import.meta.env.PUBLIC_AIA_PHONE || '480-687-7099'; const tel = phone.replace(/[^0-9+]/g, ''); +const formConfig = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED); +const formPreview = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIEW, import.meta.env.PUBLIC_ALLOW_INDEXING, formModeForRuntime(formConfig)); ---
@@ -18,9 +22,9 @@ const tel = phone.replace(/[^0-9+]/g, '');

{intro}

- - -
+ {formPreview ? :
+ +

Start with your contact details

Use this form to request a conversation. An AIA team member can explain the next steps for your family.

@@ -53,7 +57,7 @@ const tel = phone.replace(/[^0-9+]/g, '');

Questions? Call AIA at {phone}. Review AIA's privacy policy.

-
+
}
diff --git a/www/src/components/pages/ContactPage.astro b/www/src/components/pages/ContactPage.astro index 7ae7966..60a97cf 100644 --- a/www/src/components/pages/ContactPage.astro +++ b/www/src/components/pages/ContactPage.astro @@ -1,6 +1,8 @@ --- import ContactForm from '../ContactForm.astro'; +import FormAPreview from '../FormAPreview.astro'; import { formMessages } from '../../utils/form-messages'; +import { formAPreviewEnabled, formModeForRuntime, resolveFormMode } from '../../utils/form-integration'; interface Props { title: string; @@ -9,6 +11,8 @@ interface Props { const { title, lang } = Astro.props; const text = formMessages(lang); +const formConfig = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED); +const formPreview = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIEW, import.meta.env.PUBLIC_ALLOW_INDEXING, formModeForRuntime(formConfig)); ---
@@ -16,7 +20,7 @@ const text = formMessages(lang);
- + {formPreview ? : }
diff --git a/www/src/utils/form-integration.ts b/www/src/utils/form-integration.ts index 51191e8..2922ae9 100644 --- a/www/src/utils/form-integration.ts +++ b/www/src/utils/form-integration.ts @@ -21,6 +21,21 @@ export function formModeForRuntime(config: FormModeConfig): 'off' | 'mock' { return config.mode === 'mock' ? 'mock' : 'off'; } +/** + * The Form A meeting preview is deliberately narrower than the general mock + * mode: it is available only for an explicitly requested, non-indexing mock + * build. This keeps the review UI out of default and production-shaped output. + */ +export function formAPreviewEnabled( + rawPreview: string | undefined, + allowIndexing: string | undefined, + runtimeMode: 'off' | 'mock' +): boolean { + return rawPreview?.trim().toLowerCase() === 'true' + && allowIndexing?.trim().toLowerCase() !== 'true' + && runtimeMode === 'mock'; +} + export function resolveMockFormResult(rawResult: string | undefined): MockFormResult { const result = rawResult?.trim().toLowerCase(); return result === 'failed' || result === 'unavailable' ? result : 'success'; diff --git a/www/src/utils/form-messages.ts b/www/src/utils/form-messages.ts index 9636859..bd6b984 100644 --- a/www/src/utils/form-messages.ts +++ b/www/src/utils/form-messages.ts @@ -10,7 +10,20 @@ const messages = { mock: 'Demo only: this local fixture never sends information.', mockSuccess: 'Demo only: the local fixture completed without sending anything.', mockFailed: 'Demo only: the local fixture simulated a failed submission.', mockUnavailable: 'Demo only: the local fixture simulated an unavailable service.', map: 'Map showing Arizona Institute for Autism in Scottsdale', tile: 'OpenStreetMap tile showing area around Arizona Institute for Autism in Scottsdale, AZ', - zoomIn: 'Zoom in', zoomOut: 'Zoom out', marker: 'Arizona Institute for Autism' + zoomIn: 'Zoom in', zoomOut: 'Zoom out', marker: 'Arizona Institute for Autism', + formAPreview: { + internalPreview: 'Internal meeting preview', heading: 'General enquiries preview', + name: 'Your name', phone: 'Phone number', email: 'Email address', + preferredContact: 'Preferred contact method', phoneOption: 'Phone', emailOption: 'Email', + message: 'How can we help?', caution: 'Please avoid entering detailed medical, diagnostic, or insurance-member information here. An AIA team member can gather any needed details through the appropriate follow-up process.', + submit: 'Submit', optional: 'optional', emailRequired: 'required for email contact', + choose: 'Choose one', previewNote: 'Internal preview only: nothing is sent, saved or emailed.', + previewOnly: 'Preview only — nothing is submitted.', callAia: 'Call AIA at (480) 687-7099', privacyPolicy: 'Privacy policy', + noScript: 'This local preview needs JavaScript for its demonstration controls. No information is sent.', + requiredError: 'Please complete your name, phone number and preferred contact method.', + emailError: 'Please enter an email address when Email is your preferred contact method.', + success: 'Demo only: Form A preview completed locally. No information was sent or saved.' + } }, es: { heading: 'Programe su consulta gratuita', eyebrow: 'Comience aquí', @@ -22,7 +35,20 @@ const messages = { mock: 'Solo demostración: esta prueba local nunca envía información.', mockSuccess: 'Solo demostración: la prueba local terminó sin enviar información.', mockFailed: 'Solo demostración: la prueba local simuló un envío fallido.', mockUnavailable: 'Solo demostración: la prueba local simuló un servicio no disponible.', map: 'Mapa de la ubicación de Arizona Institute for Autism en Scottsdale', tile: 'Mapa de OpenStreetMap de los alrededores de Arizona Institute for Autism en Scottsdale, Arizona', - zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism' + zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism', + formAPreview: { + internalPreview: 'Vista previa para reunión interna', heading: 'Vista previa de consultas generales', + name: 'Su nombre', phone: 'Número de teléfono', email: 'Correo electrónico', + preferredContact: 'Método de contacto preferido', phoneOption: 'Teléfono', emailOption: 'Correo electrónico', + message: '¿Cómo podemos ayudarle?', caution: 'Evite ingresar aquí información médica o diagnóstica detallada, o información del miembro del seguro. Un miembro del equipo de AIA puede recopilar los detalles necesarios mediante el proceso de seguimiento adecuado.', + submit: 'Enviar', optional: 'opcional', emailRequired: 'obligatorio para contacto por correo', + choose: 'Elija una opción', previewNote: 'Solo para revisión interna: no se envía, guarda ni comparte información.', + previewOnly: 'Solo vista previa: no se envía nada.', callAia: 'Llame a AIA al (480) 687-7099', privacyPolicy: 'Política de privacidad', + noScript: 'Esta vista previa local necesita JavaScript para sus controles de demostración. No se envía información.', + requiredError: 'Complete su nombre, número de teléfono y método de contacto preferido.', + emailError: 'Ingrese un correo electrónico cuando el correo sea su método de contacto preferido.', + success: 'Solo demostración: la vista previa del Formulario A terminó localmente. No se envió ni guardó información.' + } } }; diff --git a/www/tools/form-a-preview-browser.mjs b/www/tools/form-a-preview-browser.mjs new file mode 100644 index 0000000..9efdfd2 --- /dev/null +++ b/www/tools/form-a-preview-browser.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const moduleName = process.env.PLAYWRIGHT_MODULE ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright'; +const expectedMode = process.env.FORM_A_BROWSER_MODE === 'off' ? 'off' : 'mock'; +const campaignRoutes = ['/lp/aba-therapy', '/lp/aba-insurance', '/lp/aba-ahcccs', '/lp/aba-private-pay']; +const earlyInterventionRoute = '/lp/early-intervention'; +const routes = expectedMode === 'off' ? [...campaignRoutes, earlyInterventionRoute] : ['/contact', '/es/contact', ...campaignRoutes, earlyInterventionRoute]; +const output = await fs.mkdtemp('/tmp/aia-form-a-preview-'); +const evidence = { mode: expectedMode, routes, widths: [390, 1440], checks: 0, failures: [], blocked: [], productionAttempts: [], screenshots: [], status: 'failed' }; +const reserve = net.createServer(); +await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); }); +const port = reserve.address().port; +await new Promise((resolve) => reserve.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const isProduction = (url) => /(?:jotform|api\.azinstitute4autism\.com|google-analytics|googletagmanager|doubleclick)/i.test(url.hostname + url.pathname); +let browser; +let preview; +try { + const { chromium } = await import(moduleName); + browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE ? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } : { headless: true }); + preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { cwd: root, env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' }, stdio: 'ignore' }); + const reachable = () => new Promise((resolve) => { + const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); }); + request.setTimeout(500, () => request.destroy()); + request.on('error', () => resolve(false)); + }); + for (let i = 0; i < 60 && !(await reachable()); i++) await delay(250); + assert.equal(await reachable(), true); + + for (const width of evidence.widths) { + const context = await browser.newContext({ viewport: { width, height: width < 500 ? 844 : 900 }, serviceWorkers: 'block' }); + await context.routeWebSocket('**/*', (socket) => socket.close()); + await context.route('**/*', (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) { + const item = { origin: url.origin, path: url.pathname, method: request.method() }; + evidence.blocked.push(item); + if (isProduction(url)) evidence.productionAttempts.push(item); + return route.abort(); + } + return route.continue(); + }); + const page = await context.newPage(); + for (const route of routes) { + evidence.checks++; + try { + const response = await page.goto(origin + route, { waitUntil: 'domcontentloaded', timeout: 15000 }); + assert.equal(response?.status(), 200); + if (route === earlyInterventionRoute) { + assert.equal(await page.locator('[data-form-a-preview]').count(), 0); + assert.equal(await page.locator('#consultation[data-forms-mode="off"]').count(), 1); + assert.equal(await page.locator('#consultation button[type="button"][disabled]').count(), 1); + assert.equal(await page.locator('[data-form-status-notice], #consultation-unavailable').count(), 0); + assert.doesNotMatch(await page.locator('.qr-landing').textContent(), /(?:online )?(?:forms?|requests?|submissions?) (?:are |is )?(?:unavailable|not enabled)|detailed medical|diagnostic|insurance-member/i); + assert.ok(await page.locator('a[href="tel:+14806877099"]').count() >= 1); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + continue; + } + if (expectedMode === 'off') { + assert.equal(await page.locator('[data-form-a-preview]').count(), 0); + assert.equal(await page.locator('#lead-form[data-forms-mode="off"]').count(), 1); + assert.equal(await page.locator('[data-form-status-notice], #campaign-form-status').count(), 0); + assert.equal(await page.locator('#lead-form a[href^="tel:"]').count(), 1); + assert.equal(await page.locator('#lead-form a[href="/privacy-policy"]').count(), 1); + assert.doesNotMatch(await page.locator('#lead-form').textContent(), /(?:online )?(?:forms?|requests?|submissions?) (?:are |is )?(?:unavailable|not enabled)|detailed medical|diagnostic|insurance-member/i); + assert.equal(await page.locator('#lead-form button[type="button"][disabled]').count(), 1); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + continue; + } + const compact = route.startsWith('/lp/'); + const fields = compact ? ['name', 'phone', 'email', 'preferredContact'] : ['name', 'phone', 'email', 'preferredContact', 'message']; + const result = await page.evaluate(({ fields: expected, compact: isCompact }) => ({ + enabled: document.querySelector('[data-form-a-preview]')?.dataset.formAPreviewEnabled, + names: [...document.querySelectorAll('[data-form-a-preview] [name]')].map((el) => el.getAttribute('name')), + forms: document.querySelectorAll('form').length, + iframe: document.querySelectorAll('iframe').length, + h1: document.querySelectorAll('main h1').length, + width: document.documentElement.scrollWidth, + expectedCount: expected.length, + isCompact + }), { fields, compact }); + assert.equal(result.enabled, 'true'); + assert.deepEqual(result.names, fields); + assert.equal(result.forms, 0); + assert.equal(result.iframe, 0); + assert.equal(result.h1, 1); + assert.ok(result.width <= width + 1, `overflow ${result.width}`); + + const preview = page.locator('[data-form-a-preview]'); + if (route === '/es/contact') { + assert.match(await preview.textContent(), /Su nombre|Método de contacto preferido/); + assert.doesNotMatch(await preview.textContent(), /Your name|Preferred contact method/); + } + const expectedButtonText = route === '/contact' ? 'Submit' : route === '/es/contact' ? 'Enviar' : { + '/lp/aba-therapy': 'Request a Free Consultation', + '/lp/aba-insurance': 'Request Insurance Verification', + '/lp/aba-ahcccs': 'Request a Benefits Check', + '/lp/aba-private-pay': 'Discuss Private-Pay Options' + }[route]; + assert.equal(await preview.locator('[data-preview-status]').count(), 1); + assert.equal(await preview.locator('.form-a-preview__caution').count(), compact ? 0 : 1); + if (compact) { + assert.equal(await page.locator('[data-form-status-notice]').count(), 1); + assert.equal((await page.locator('[data-form-status-notice]').textContent()).match(/Preview only — nothing is submitted\./g)?.length, 1); + assert.equal(await page.locator('[data-form-status-notice] a[href="tel:+14806877099"]').count(), 1); + assert.equal(await page.locator('[data-form-status-notice] a[href="/privacy-policy"]').count(), 1); + assert.doesNotMatch(await page.locator('#consultation').textContent(), /(?:online )?(?:forms?|requests?|submissions?) (?:are |is )?(?:unavailable|not enabled)|detailed medical|diagnostic|insurance-member/i); + } + const button = preview.locator('[data-preview-submit]'); + assert.equal((await button.textContent()).trim(), expectedButtonText); + const filledStyle = await button.evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, border: style.borderColor, borderWidth: style.borderWidth, color: style.color }; + }); + assert.equal(filledStyle.background, 'rgb(255, 189, 89)'); + assert.equal(filledStyle.border, 'rgb(255, 189, 89)'); + assert.equal(filledStyle.borderWidth, '2px'); + assert.equal(filledStyle.color, 'rgb(37, 64, 128)'); + await button.hover(); + await delay(350); + const hoverStyle = await button.evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, border: style.borderColor }; + }); + assert.equal(hoverStyle.background, 'rgba(0, 0, 0, 0)'); + assert.equal(hoverStyle.border, 'rgb(255, 145, 77)'); + await button.focus(); + assert.notEqual(await button.evaluate((element) => getComputedStyle(element).outlineWidth), '0px'); + await button.click(); + assert.doesNotMatch(await preview.locator('[data-preview-status]').textContent(), /completed locally|terminó localmente/); + await preview.locator('input[name="name"]').fill('Demo Reviewer'); + await preview.locator('input[name="phone"]').fill('480-555-0100'); + await preview.locator('select[name="preferredContact"]').selectOption('phone'); + await button.click(); + if (compact) assert.match(await preview.locator('[data-preview-status]').textContent(), /Preview only — nothing is submitted/); + else assert.match(await preview.locator('[data-preview-status]').textContent(), /completed locally|terminó localmente/); + await preview.locator('select[name="preferredContact"]').selectOption('email'); + if (route === '/es/contact') { + assert.equal( + (await preview.locator('[id$="-email-requirement"]').textContent())?.trim(), + '(obligatorio para contacto por correo)' + ); + } + await button.click(); + await assert.match(await preview.locator('[data-preview-status]').textContent(), /email|correo/i); + await preview.locator('input[name="email"]').fill('demo@example.test'); + await button.click(); + if (compact) assert.match(await preview.locator('[data-preview-status]').textContent(), /Preview only — nothing is submitted/); + else assert.match(await preview.locator('[data-preview-status]').textContent(), /completed locally|terminó localmente/); + if ((route === '/contact' || route === '/lp/aba-therapy') && (width === 390 || width === 1440)) { + const file = `${route === '/contact' ? 'contact' : 'campaign'}-${width}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + evidence.screenshots.push({ route, width, file }); + } + } catch (error) { + evidence.failures.push({ route, width, error: String(error.message) }); + } + } + await context.close(); + } + if (expectedMode === 'mock') { + const noJsContext = await browser.newContext({ viewport: { width: 390, height: 844 }, javaScriptEnabled: false, serviceWorkers: 'block' }); + await noJsContext.route('**/*', (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) { + const item = { origin: url.origin, path: url.pathname, method: request.method(), noJs: true }; + evidence.blocked.push(item); + if (isProduction(url)) evidence.productionAttempts.push(item); + return route.abort(); + } + return route.continue(); + }); + for (const route of ['/contact', '/lp/aba-therapy']) { + evidence.checks++; + const page = await noJsContext.newPage(); + const response = await page.goto(origin + route, { waitUntil: 'domcontentloaded', timeout: 15000 }); + assert.equal(response?.status(), 200); + assert.equal(await page.locator('[data-form-a-preview]').count(), 1); + assert.equal(await page.locator('form').count(), 0); + const bodyText = await page.locator('body').textContent(); + if (route === '/contact') assert.match(bodyText, /JavaScript|JavaScript is required|Se requiere JavaScript/); + else assert.match(bodyText, /Preview only — nothing is submitted/); + } + await noJsContext.close(); + } + evidence.status = evidence.failures.length || evidence.productionAttempts.length ? 'failed' : 'passed'; +} catch (error) { + evidence.error = String(error.message); +} finally { + await browser?.close(); + if (preview && preview.exitCode === null) { + preview.kill('SIGTERM'); + await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]); + if (preview.exitCode === null) preview.kill('SIGKILL'); + } + evidence.finishedAt = new Date().toISOString(); + await fs.writeFile(path.join(output, 'results.json'), JSON.stringify(evidence, null, 2) + '\n'); + console.log(JSON.stringify({ ...evidence, output }, null, 2)); + if (evidence.status !== 'passed') process.exitCode = 1; +} diff --git a/www/tools/form-a-preview.test.mjs b/www/tools/form-a-preview.test.mjs new file mode 100644 index 0000000..8ccc6cc --- /dev/null +++ b/www/tools/form-a-preview.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { formAPreviewEnabled } from '../src/utils/form-integration.ts'; + +const read = (file) => fs.readFileSync(file, 'utf8'); + +test('Form A preview is explicit and has no live destination in source', () => { + const source = read('src/components/FormAPreview.astro'); + assert.match(source, /data-form-a-preview/); + assert.match(source, /type="button"/); + assert.match(source, /submitText/); + assert.match(source, /text\.internalPreview/); + assert.match(source, /dataset\.emailRequired/); + assert.match(source, /border: 2px solid var\(--color-accent/); + assert.doesNotMatch(source, /jotform\.com|api\.azinstitute4autism\.com|fetch\(/i); + assert.equal(formAPreviewEnabled('true', 'false', 'mock'), true); + assert.equal(formAPreviewEnabled('true', 'true', 'mock'), false); +}); + +test('Form A preview placements are explicit and compact previews omit message cautions', () => { + const contact = read('src/components/pages/ContactPage.astro'); + const contactForm = read('src/components/ContactForm.astro'); + const campaign = read('src/components/campaign/ConsultationForm.astro'); + const preview = read('src/components/FormAPreview.astro'); + const messages = read('src/utils/form-messages.ts'); + const early = read('src/components/campaign/EarlyInterventionPage.astro'); + assert.match(contact, /FormAPreview/); + assert.match(contact, /submitText=\{lang === 'es' \? 'Enviar' : 'Submit'\}/); + assert.match(campaign, /FormAPreview/); + assert.match(campaign, /submitText=\{buttonText\}/); + assert.doesNotMatch(early, /FormAPreview/); + assert.doesNotMatch(preview, /name="inquiryType"/); + assert.match(preview, /variant === 'full'.*form-a-preview__caution/s); + assert.match(contactForm, /textarea[^>]+aria-describedby="contact-sensitive-note"/); + assert.match(contactForm, /text\.formAPreview\.caution/); + assert.match(messages, /Preview only — nothing is submitted/); + assert.match(messages, /Vista previa para reunión interna/); + assert.doesNotMatch(campaign, /detailed medical|diagnostic|insurance-member/i); + assert.doesNotMatch(early, /(?:online )?(?:forms?|requests?|submissions?) (?:are |is )?(?:unavailable|not enabled)|detailed medical|diagnostic|insurance-member/i); +}); + +test('default environment keeps the preview disabled', () => { + assert.match(read('.env.example'), /PUBLIC_AIA_FORM_A_PREVIEW=false/); + assert.equal(formAPreviewEnabled('false', 'false', 'off'), false); +}); + +test('Jotform handoff preserves AIA form tokens and accessible native controls', () => { + const css = read('integrations/jotform/aia-form-a.css'); + const guide = read('integrations/jotform/README.md'); + for (const value of ['#ffbd59', '#ff914d', '#254080', '#fcf9f5', '#edeef3']) assert.match(css, new RegExp(value)); + assert.match(css, /#form \.form-submit-button/); + assert.match(css, /:focus-visible/); + assert.doesNotMatch(css, /https?:\/\/|@import|url\(/i); + assert.match(guide, /formContext/); + assert.match(guide, /multiple native Jotform Submit buttons/i); + assert.match(guide, /honeypot/i); + assert.match(guide, /cross-origin iframe/i); +}); diff --git a/www/tools/phase9-nonproduction.test.mjs b/www/tools/phase9-nonproduction.test.mjs index 4f086a4..207d515 100644 --- a/www/tools/phase9-nonproduction.test.mjs +++ b/www/tools/phase9-nonproduction.test.mjs @@ -6,6 +6,7 @@ import { resolveCounterConfig, } from '../src/utils/counter-mode.ts'; import { + formAPreviewEnabled, formModeForRuntime, formRouteMapping, logicalFormForRoute, @@ -36,6 +37,14 @@ test('form configuration defaults off and live remains fail-closed', () => { assert.equal(resolveMockFormResult('anything-else'), 'success'); }); +test('Form A meeting preview requires explicit mock mode and remains staging-only', () => { + assert.equal(formAPreviewEnabled(undefined, 'false', 'mock'), false); + assert.equal(formAPreviewEnabled('true', undefined, 'off'), false); + assert.equal(formAPreviewEnabled('true', 'true', 'mock'), false); + assert.equal(formAPreviewEnabled('true', 'false', 'mock'), true); + assert.equal(formAPreviewEnabled('TRUE', 'FALSE', 'mock'), true); +}); + test('form mapping covers nine native instances and five landing journeys', () => { assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 9); assert.equal(formRouteMapping.filter((entry) => entry.kind === 'landing-journey').length, 5);