feat(phase9): isolate forms and counters for nonproduction

This commit is contained in:
DeCentN2Madness committed 2026-10-21 23:02:13 -07:00
1 parent e874b26bb9
commit d20372fe33
20 files changed
+454 -157

No files matched your search

+6
View File
@@ -1,2 +1,8 @@
PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com
PUBLIC_COUNTERS_MODE=off
PUBLIC_COUNTERS_LIVE_APPROVED=false
PUBLIC_COUNTERS_MOCK_RESULT=success
PUBLIC_AIA_FORMS_MODE=off
PUBLIC_AIA_FORMS_LIVE_APPROVED=false
PUBLIC_AIA_FORMS_MOCK_RESULT=success
PUBLIC_ALLOW_INDEXING=false PUBLIC_ALLOW_INDEXING=false
@@ -0,0 +1,48 @@
# Phase 9 P9-03 — Local Astro Form Isolation
Date: 2026-10-21
Status: Implemented and verified locally; Jotform creation and live integration remain unauthorized.
## Scope
The native empty-action forms were replaced with an explicit fail-closed presentation layer. `PUBLIC_AIA_FORMS_MODE` defaults to `off`; invalid values and `live` without an explicit approval flag resolve to `off`. The runtime currently exposes only `off` and `mock`; no live Jotform adapter or URL exists.
### Modes
- **off:** no native `<form>`, no action, no POST surface, no Jotform resource, disabled controls, and a localized telephone/contact fallback.
- **mock:** local synthetic button states (`success`, `failed`, or `unavailable`) bound by `mock-form.ts`; no persistence, backend, notification, cookie, analytics, or external request.
- **live:** reserved for a separately authorized adapter and approved form IDs. It fails closed today. The historical source-template ID `250727122848156` is not embedded.
The logical route map records nine existing native enquiry instances and five campaign journeys. It is data only and does not activate a submission path.
## Changed files
- `src/utils/form-integration.ts` — typed mode resolver, logical form map, mock-result resolver.
- `src/data/form-integrations.ts` — public mapping export.
- `src/scripts/mock-form.ts` — local mock form and counter interaction binder.
- `src/components/ContactForm.astro` — off/mock presentation with telephone fallback.
- `src/components/FormShell.astro` — off/mock presentation and localized status.
- `src/components/ServiceInquiryDrawer.astro` — off/mock drawer presentation; existing drawer behavior retained.
- `src/layouts/PageLayout.astro` — explicit referral/service logical form mapping.
- `src/utils/form-messages.ts` — localized mock and fallback messages.
- `.env.example` — off-by-default mode, approval flag and mock result settings.
- `tools/seo.test.mjs`, `tools/library.test.mjs`, `tools/phase8-interactions.mjs` — regression assertions for the inactive form surface.
- `tools/phase9-nonproduction.test.mjs` — mode, mapping and source-isolation coverage.
## Mapping
The shared implementation covers `/contact`, `/es/contact`, `/client-consultation`, `/es/client-consultation`, `/schedule-consultation`, `/referrals`, `/aba-therapy`, `/autism-evaluations`, and `/learner-social-club`. The campaign journeys `/lp/aba-therapy`, `/lp/aba-insurance`, `/lp/aba-ahcccs`, `/lp/aba-private-pay`, and `/lp/early-intervention` are mapped to the future Service Consultations form without changing their designs or routes.
## Validation
- `npm run check` — passed (125 files, no diagnostics).
- Markdown 20/20; publication 33/33; SEO 35/35; Library 53/53; routes 10/10; Phase 8 5/5; campaign 11/11; P9 nonproduction 5/5.
- Normal build — passed (128 pages).
- Mock build (`PUBLIC_AIA_FORMS_MODE=mock`, failed fixture) — passed; no Jotform URL or external form request was emitted.
- Guarded Chromium — 41 checks, 0 failures, with the off-mode Spanish form and no-JavaScript checks passing. `productionAttempt: false`; external Leaflet requests were blocked by the test guard.
- Link, image and blog audits — passed with zero findings.
- Explicit offline route reconciliation was regenerated as `reports/route-reconciliation-2026-10-22-offline.json` from saved request evidence and passed its subsequent `--check`; no production crawl was performed. The final normal staging build contained 128 HTML pages, all `noindex,nofollow`, an empty sitemap and zero hreflang links.
## Deferred gates
The owner-created three-form Jotform architecture, field requirements, Builder settings, bilingual behavior, notification privacy, synthetic submission testing, CSP/referrer policy, and production activation remain P9-03/P9-07/P9-09 decisions. No form data reaches Contabo, Google, GA4 or a Jotform endpoint in this batch.
@@ -0,0 +1,63 @@
# Phase 9 P9-09 — Contabo Deployment Readiness and Rollback Plan
Date: 2026-10-21
Status: Planning only; no VPS, DNS, Cloudflare, nginx or TLS system was contacted or changed.
## Proposed topology
The preferred target is a static Astro artifact served by an owner-controlled Contabo VPS, with the existing non-PHI API kept separate. Jotform would receive protected lead submissions directly after independent approval. The Raspberry Pi 4B remains a fallback. HubSpot remains the current public host until Phase 10 authorization.
This is a proposal, not a verified description of the production machine. The owner must supply sanitized facts for the VPS operating system/NixOS generation, deployment user and permissions, nginx or equivalent service, TLS termination, Cloudflare/DNS ownership, current API boundary, backup location, monitoring, and rollback authority. No secrets, private keys or credentials are required for this planning report.
## Artifact and release layout
Build in a clean, locked toolchain and publish an immutable artifact such as:
```text
/srv/aia/releases/<git-sha>/dist/
/srv/aia/current -> /srv/aia/releases/<git-sha>/dist
/srv/aia/shared/ # only reviewed non-secret shared files
```
Upload to a temporary release directory, verify the manifest and hashes, apply ownership/permissions, then atomically switch `current`. Keep at least the last known-good release and record the build SHA, Node/npm versions, lockfile hash and environment mode. Never put `.env` secrets in the static artifact.
## Cutover and rollback
1. Build and audit locally in normal staging mode, then separately build the approved indexing-enabled artifact.
2. Verify route, asset, robots, sitemap, redirect and 404 manifests before upload.
3. Upload the immutable release without changing DNS.
4. Run loopback or host-local checks through the intended web server.
5. During an approved window, atomically switch the symlink and verify HTTPS, canonical host, slashless routes, query preservation, static assets, real 404s and redirect targets.
6. If health checks fail, stop traffic changes and atomically point `current` back to the prior release. Preserve logs and manifests for review; do not hot-edit the failed artifact.
Rollback triggers include unexpected 5xx responses, wrong host/canonical behavior, missing assets, a non-empty staging sitemap, indexing headers on a staging artifact, redirect loops, unapproved form/API/counter traffic, or inability to reproduce the release manifest.
## Required server behavior
- HTTPS and canonical `www.azinstitute4autism.com` behavior must be verified by the hosting owner.
- Slashless public URLs, query strings and fragments must remain intact.
- Static assets need long-lived immutable caching only when their names are content/version safe; HTML should use short revalidation appropriate to the cutover plan.
- Staging must retain `noindex,nofollow`, an empty sitemap and no hreflang. `noindex` is not access control; access restriction must be separately configured if needed.
- Production requires the approved sitemap/robots policy and real 404 handling.
- The temporary `/lp` review index must be excluded from the intended production artifact or explicitly owner-approved before a release.
## Integration isolation
`PUBLIC_COUNTERS_MODE=off` and `PUBLIC_AIA_FORMS_MODE=off` are the safe staging defaults. A live counter adapter, Jotform IDs, analytics, advertising and conversion events require separate approvals and environment gates. Jotform answers must not traverse Contabo; the existing API may handle only approved non-PHI operations. No arbitrary query strings, referrers or form answers may enter logs or tracking.
## Local rehearsal
The installed environment did not expose an `nginx` executable, so no nginx service or configuration was touched. The normal Astro artifact was built successfully and inspected locally; the guarded loopback Chromium run recorded zero production attempts. A future rehearsal may use an isolated temporary nginx prefix or a loopback static server, provided it cannot affect active services or reach production systems.
## Phase 10 acceptance checklist
- Owner supplies and reviews sanitized topology/configuration facts.
- Reproducible build and artifact manifest are archived.
- HTTPS, canonical host, redirects, 404s, query handling and assets pass from the intended server.
- Staging/indexing modes are proven separate.
- Counter/form modes and third-party requests are verified in the target environment.
- Backups, release retention, monitoring and rollback operator are named.
- Real accessibility, language, clinical, privacy and legal approvals are recorded.
- A release owner authorizes go/no-go and rollback authority.
P9-09 remains pending. This report does not authorize deployment or infrastructure changes.
@@ -0,0 +1,44 @@
# Phase 9 P9-04 — Likes/Views Isolation
Date: 2026-10-21
Status: Implemented and verified locally; live activation remains prohibited.
## Scope
This batch removes the previous implicit production API fallback from the article engagement component. `LikeViewCounter.astro` now resolves an explicit `off`, `mock`, or future `live` configuration through `src/utils/counter-mode.ts`. The default is `off`; an API URL by itself cannot enable live behavior.
`live` is accepted only when `PUBLIC_COUNTERS_LIVE_APPROVED=true` and the configured origin is HTTPS, then remains fail-closed at runtime because no live adapter is implemented in this batch. `counterModeForRuntime()` exposes only `off` or `mock` until a separately authorized production adapter exists.
## Runtime contract
| Mode | Runtime behavior | Network/cookie behavior |
| --- | --- | --- |
| `off` (default) | Disabled like control, unavailable message, no real metric value | No API request, credential, cookie read or cookie write |
| `mock` | Deterministic local interaction and demo-only status | No external request, persistence, credential or cookie |
| `live` | Fails closed to `off` in this release | No live traffic; future activation requires a separate approval and adapter |
`PUBLIC_COUNTERS_MOCK_RESULT` controls only the deterministic local fixture state. It does not represent a production metric. English, Spanish and Arabic article messages remain localized, including the unavailable state and RTL direction attributes.
## Changed files
- `src/components/LikeViewCounter.astro` — removed fetch/cookie/API fallback and added explicit mode presentation.
- `src/utils/counter-mode.ts` — typed resolver and fail-closed runtime mode.
- `src/scripts/likes-views.ts` — exports the resolver rather than a live client.
- `src/utils/article-messages.ts` — localized unavailable engagement text.
- `src/scripts/mock-form.ts` — deterministic local counter interaction without persistence.
- `.env.example` — documents off-by-default mode, approval flag and mock result.
- `tools/phase9-nonproduction.test.mjs` — mode, source-isolation and deterministic behavior coverage.
## Validation
- `npm run check` — passed (125 files, no diagnostics).
- Existing Markdown, publication, SEO, Library, route, Phase 8 and campaign suites — passed (167 tests total).
- `node --experimental-strip-types --test tools/phase9-nonproduction.test.mjs` — 5/5 passed.
- Normal Astro build — passed (128 pages).
- Mock build with counter/forms mock settings — passed; no external form URL or API URL was emitted.
- Guarded Chromium interaction run — 41 checks, 0 failures, `productionAttempt: false`. The only review result was the pre-existing native dialog/browser focus boundary; no production request was transmitted. Leaflet requests were blocked by the guard.
- A separate loopback Chromium probe opened `/library/aba-therapy-benefits` in off mode: the control was disabled, the unavailable message remained, `document.cookie` stayed empty, and the guarded request list contained zero non-loopback or mutating requests.
## Future approval requirements
P9-04 remains pending. Before any live adapter is implemented, AIA/privacy/security owners must approve the API origin, CORS and credential policy, cookie behavior, environment allowlist, staging isolation, consent implications, and production monitoring. A future live test must use an approved environment and synthetic data; this batch intentionally performs no production API request.
+20 -13
View File
@@ -3,31 +3,38 @@ import Button from './Button.astro';
import ContactObfuscation from './ContactObfuscation.astro'; import ContactObfuscation from './ContactObfuscation.astro';
import { formMessages } from '../utils/form-messages'; import { formMessages } from '../utils/form-messages';
import { formModeForRuntime, resolveFormMode, resolveMockFormResult, type LogicalForm } from '../utils/form-integration';
interface Props { interface Props {
lang?: string; lang?: string;
title?: string; title?: string;
logicalForm?: LogicalForm;
} }
const { lang = 'en' } = Astro.props; const { lang = 'en', logicalForm = 'general' } = Astro.props;
const text = formMessages(lang); const text = formMessages(lang);
const title = Astro.props.title ?? text.contactHeading; const title = Astro.props.title ?? text.contactHeading;
const config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED);
const mode = formModeForRuntime(config);
const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT);
--- ---
<div class="contact-form-card" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form={logicalForm} data-mock-result={mockResult} data-mock-success={text.mockSuccess} data-mock-failed={text.mockFailed} data-mock-unavailable={text.mockUnavailable}>
<form class="contact-form-card" method="post" action="">
<!-- TODO: Wire this form to Netlify Forms, a custom API endpoint, or another backend service. -->
<!-- TODO: Add spam protection before production launch. -->
<h3 class="contact-form-card__title">{title}</h3> <h3 class="contact-form-card__title">{title}</h3>
<div class="contact-form-card__fields"> <div class="contact-form-card__fields">
<label>{text.name}<input name="name" autocomplete="name" /></label> <label>{text.name}<input name="name" autocomplete="name" disabled={mode === 'off'} /></label>
<label>{text.email}<input type="email" name="email" autocomplete="email" /></label> <label>{text.email}<input type="email" name="email" autocomplete="email" disabled={mode === 'off'} /></label>
<label>{text.phone}<input type="tel" name="phone" autocomplete="tel" /></label> <label>{text.phone}<input type="tel" name="phone" autocomplete="tel" disabled={mode === 'off'} /></label>
<label>{text.service}<select name="service">{text.options.map((label) => <option>{label}</option>)}</select></label> <label>{text.service}<select name="service" disabled={mode === 'off'}>{text.options.map((label) => <option>{label}</option>)}</select></label>
<label>{text.message}<textarea name="message" rows="5"></textarea></label> <label>{text.message}<textarea name="message" rows="5" disabled={mode === 'off'}></textarea></label>
<Button type="submit" disabled aria-describedby="contact-form-note" class="contact-form-card__submit">{text.submit}</Button> <Button type="button" data-mock-submit disabled={mode === 'off'} aria-describedby="contact-form-note" class="contact-form-card__submit">{text.submit}</Button>
<p id="contact-form-note" class="contact-form-card__note">{text.disabled} <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</p> <p id="contact-form-note" class="contact-form-card__note" data-form-status>{mode === 'mock' ? text.mock : <>{text.disabled} <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</>}</p>
</div> </div>
</form> </div>
<script>
import { bindMockForms } from '../scripts/mock-form';
bindMockForms();
</script>
<style> <style>
.contact-form-card__note :global(a) { .contact-form-card__note :global(a) {
+19 -11
View File
@@ -2,23 +2,31 @@
import Button from './Button.astro'; import Button from './Button.astro';
import ContactObfuscation from './ContactObfuscation.astro'; import ContactObfuscation from './ContactObfuscation.astro';
import { formMessages } from '../utils/form-messages'; import { formMessages } from '../utils/form-messages';
import { formModeForRuntime, resolveFormMode, resolveMockFormResult, type LogicalForm } from '../utils/form-integration';
const { lang = 'en', showBackground = false } = Astro.props; const { lang = 'en', showBackground = false } = Astro.props;
const text = formMessages(lang); const text = formMessages(lang);
const title = Astro.props.title ?? text.heading; const title = Astro.props.title ?? text.heading;
const logicalForm: LogicalForm = Astro.props.logicalForm ?? (Astro.props.title === 'Physician & Insurer Referrals' ? 'referral' : 'service');
const config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED);
const mode = formModeForRuntime(config);
const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT);
--- ---
<section class:list={['form-section', { 'form-section-background': showBackground }]} aria-labelledby="form-title"> <section class:list={['form-section', { 'form-section-background': showBackground }]} aria-labelledby="form-title">
<div class="container form-grid"> <div class="container form-grid">
<div><p class="eyebrow">{text.eyebrow}</p><h2 id="form-title">{title}</h2><p>{text.intro}</p></div> <div><p class="eyebrow">{text.eyebrow}</p><h2 id="form-title">{title}</h2><p>{text.intro}</p></div>
<form class="form" method="post" action=""> <div class="form" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form={logicalForm} data-mock-result={mockResult} data-mock-success={text.mockSuccess} data-mock-failed={text.mockFailed} data-mock-unavailable={text.mockUnavailable}>
<!-- TODO: Wire this form to Netlify Forms, a custom API endpoint, or another backend service. --> <label>{text.name}<input name="name" autocomplete="name" disabled={mode === 'off'} /></label>
<!-- TODO: Add spam protection before production launch. --> <label>{text.email}<input type="email" name="email" autocomplete="email" disabled={mode === 'off'} /></label>
<label>{text.name}<input name="name" autocomplete="name" /></label> <label>{text.phone}<input type="tel" name="phone" autocomplete="tel" disabled={mode === 'off'} /></label>
<label>{text.email}<input type="email" name="email" autocomplete="email" /></label> <label>{text.service}<select name="service" disabled={mode === 'off'}>{text.options.map((label) => <option>{label}</option>)}</select></label>
<label>{text.phone}<input type="tel" name="phone" autocomplete="tel" /></label> <label>{text.message}<textarea name="message" rows="5" disabled={mode === 'off'}></textarea></label>
<label>{text.service}<select name="service">{text.options.map((label) => <option>{label}</option>)}</select></label> <Button type="button" data-mock-submit disabled={mode === 'off'} aria-describedby="form-note">{text.submit}</Button>
<label>{text.message}<textarea name="message" rows="5"></textarea></label> <p id="form-note" class="form-note" data-form-status>{mode === 'mock' ? text.mock : <>{text.disabled} <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</>}</p>
<Button type="submit" disabled aria-describedby="form-note">{text.submit}</Button> </div>
<p id="form-note" class="form-note">{text.disabled} <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</p>
</form>
</div> </div>
</section> </section>
<script>
import { bindMockForms } from '../scripts/mock-form';
bindMockForms();
</script>
+18 -98
View File
@@ -1,116 +1,35 @@
--- ---
import { articleMessages } from '../utils/article-messages'; import { articleMessages } from '../utils/article-messages';
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
interface Props { slug: string; lang: string } interface Props { slug: string; lang: string }
const { slug, lang } = Astro.props; const { slug, lang } = Astro.props;
const text = articleMessages(lang); const text = articleMessages(lang);
const counterConfig = resolveCounterConfig(
import.meta.env.PUBLIC_COUNTERS_MODE,
import.meta.env.PUBLIC_AIA_API_BASE,
import.meta.env.PUBLIC_COUNTERS_LIVE_APPROVED,
);
const runtimeMode = counterModeForRuntime(counterConfig);
const mockResult = import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT === 'failed' || import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT === 'unavailable'
? import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT
: 'success';
const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
--- ---
<div class="like-view-counter" data-counter> <div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-mock-result={mockResult}>
<span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>0</span> {text.views}</span> <span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span>
<span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}> <span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}>
<span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span> <span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span>
<button class="like-button" type="button" data-like data-slug={slug} aria-label={text.like} aria-pressed="false"> <button class="like-button" type="button" data-like data-slug={slug} aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode === 'off'}>
<svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon"> <svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon">
<path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path> <path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path>
</svg> </svg>
</button> </button>
</span> </span>
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : text.counterUnavailable}</span>
</div> </div>
<script> <script>
const apiBase = import.meta.env.PUBLIC_AIA_API_BASE || 'https://api.azinstitute4autism.com'; import { bindMockForms } from '../scripts/mock-form';
bindMockForms();
const cleanSlug = (raw: string | undefined) =>
raw ? raw.split('?')[0].replace(/^\/+|\/+$/g, '') : '';
const readCookies = () =>
Object.fromEntries(
document.cookie
.split('; ')
.filter(Boolean)
.map((pair) => pair.split('=').map(decodeURIComponent)),
);
const setLikedCookie = (name: string, liked: boolean) => {
if (!liked) {
document.cookie = `${encodeURIComponent(name)}=; Max-Age=0; path=/`;
return;
}
const expires = new Date(Date.now() + 365 * 864e5).toUTCString();
document.cookie = `${encodeURIComponent(name)}=1; expires=${expires}; path=/`;
};
const fetchJson = async (url: string, options: RequestInit) => {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
};
document.querySelectorAll<HTMLElement>('[data-counter]').forEach(async (counter) => {
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
const slug = cleanSlug(button?.dataset.slug);
const likes = counter.querySelector<HTMLElement>('[data-likes]');
const views = counter.querySelector<HTMLElement>('[data-views]');
if (!button || !slug) return;
const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
if (isLocalhost) {
counter.dataset.unavailable = 'true';
if (likes) likes.textContent = '0';
if (views) views.textContent = '0';
return;
}
const cookieName = `liked_${slug}`;
const renderLiked = (liked: boolean) => {
button.classList.toggle('liked', liked);
button.setAttribute('aria-pressed', String(liked));
};
renderLiked(Object.prototype.hasOwnProperty.call(readCookies(), cookieName));
try {
const data = await fetchJson(`${apiBase}/stats/batch`, {
method: 'POST',
credentials: 'include',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [slug] }),
});
const stats = data[slug];
if (likes) likes.textContent = String(stats?.likes ?? 0);
if (views) views.textContent = String(stats?.views ?? 0);
} catch {
counter.dataset.unavailable = 'true';
if (likes) likes.textContent = '0';
if (views) views.textContent = '0';
}
button.addEventListener('click', async () => {
const willLike = !button.classList.contains('liked');
button.disabled = true;
try {
const data = await fetchJson(
willLike ? `${apiBase}/likes` : `${apiBase}/likes/${encodeURIComponent(slug)}`,
willLike
? {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug }),
}
: { method: 'DELETE', credentials: 'include' },
);
setLikedCookie(cookieName, willLike);
renderLiked(willLike);
if (likes) likes.textContent = String(data.count ?? 0);
} catch {
counter.dataset.unavailable = 'true';
} finally {
button.disabled = false;
}
});
});
</script> </script>
<style> <style>
@@ -119,6 +38,7 @@ const text = articleMessages(lang);
.like-group { align-items: center; display: inline-flex; gap: .35rem; } .like-group { align-items: center; display: inline-flex; gap: .35rem; }
.like-view-counter button { align-items: center; background: white; border: none; color: inherit; display: inline-flex; gap: 0; padding: 0; } .like-view-counter button { align-items: center; background: white; border: none; color: inherit; display: inline-flex; gap: 0; padding: 0; }
.like-view-counter .like-count { color: var(--color-text); min-width: 1ch; } .like-view-counter .like-count { color: var(--color-text); min-width: 1ch; }
.like-view-counter .counter-status { color: var(--color-muted); font-size: .75rem; }
.like-view-counter .like-icon { fill: none; flex: 0 0 auto; stroke: #d92d20; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; } .like-view-counter .like-icon { fill: none; flex: 0 0 auto; stroke: #d92d20; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
.like-view-counter button.liked .like-icon { fill: #d92d20; stroke: #d92d20; } .like-view-counter button.liked .like-icon { fill: #d92d20; stroke: #d92d20; }
.like-view-counter button:disabled { cursor: wait; opacity: .65; } .like-view-counter button:disabled { cursor: wait; opacity: .65; }
+22 -18
View File
@@ -1,5 +1,6 @@
--- ---
import ContactObfuscation from './ContactObfuscation.astro'; import ContactObfuscation from './ContactObfuscation.astro';
import { formModeForRuntime, resolveFormMode, resolveMockFormResult } from '../utils/form-integration';
type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club'; type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club';
@@ -42,6 +43,9 @@ const copy = serviceCopy[service];
const drawerId = `service-inquiry-${service}`; const drawerId = `service-inquiry-${service}`;
const titleId = `${drawerId}-title`; const titleId = `${drawerId}-title`;
const noteId = `${drawerId}-note`; const noteId = `${drawerId}-note`;
const config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED);
const mode = formModeForRuntime(config);
const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT);
--- ---
<dialog <dialog
@@ -68,17 +72,15 @@ const noteId = `${drawerId}-note`;
<p>{copy.prompt}</p> <p>{copy.prompt}</p>
</header> </header>
<form class="service-inquiry-form" method="post" action=""> <div class="service-inquiry-form" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form="service" data-mock-result={mockResult} data-mock-success="Demo only: the local fixture completed without sending anything." data-mock-failed="Demo only: the local fixture simulated a failed submission." data-mock-unavailable="Demo only: the local fixture simulated an unavailable service.">
<!-- TODO: Wire this form to Netlify Forms, a custom API endpoint, or another backend service. -->
<!-- TODO: Add spam protection before production launch. -->
<div class="service-inquiry-form__grid"> <div class="service-inquiry-form__grid">
<label> <label>
<span>First Name<span class="required-mark" aria-hidden="true">*</span></span> <span>First Name<span class="required-mark" aria-hidden="true">*</span></span>
<input name="first_name" autocomplete="given-name" required /> <input name="first_name" autocomplete="given-name" required disabled={mode === 'off'} />
</label> </label>
<label> <label>
<span>Last Name<span class="required-mark" aria-hidden="true">*</span></span> <span>Last Name<span class="required-mark" aria-hidden="true">*</span></span>
<input name="last_name" autocomplete="family-name" required /> <input name="last_name" autocomplete="family-name" required disabled={mode === 'off'} />
</label> </label>
<label> <label>
<span>Phone Number<span class="required-mark" aria-hidden="true">*</span></span> <span>Phone Number<span class="required-mark" aria-hidden="true">*</span></span>
@@ -90,29 +92,29 @@ const noteId = `${drawerId}-note`;
</span> </span>
<span class="service-inquiry-form__phone-number"> <span class="service-inquiry-form__phone-number">
<span aria-hidden="true">+1</span> <span aria-hidden="true">+1</span>
<input type="tel" name="phone" autocomplete="tel-national" required /> <input type="tel" name="phone" autocomplete="tel-national" required disabled={mode === 'off'} />
</span> </span>
</span> </span>
</label> </label>
<label> <label>
<span>Email<span class="required-mark" aria-hidden="true">*</span></span> <span>Email<span class="required-mark" aria-hidden="true">*</span></span>
<input type="email" name="email" autocomplete="email" required /> <input type="email" name="email" autocomplete="email" required disabled={mode === 'off'} />
</label> </label>
<label> <label>
<span>Child First Name</span> <span>Child First Name</span>
<input name="child_first_name" autocomplete="off" /> <input name="child_first_name" autocomplete="off" disabled={mode === 'off'} />
</label> </label>
<label> <label>
<span>Child Last Name</span> <span>Child Last Name</span>
<input name="child_last_name" autocomplete="off" /> <input name="child_last_name" autocomplete="off" disabled={mode === 'off'} />
</label> </label>
<label class="service-inquiry-form__wide"> <label class="service-inquiry-form__wide">
<span>Child Birth Date</span> <span>Child Birth Date</span>
<input name="child_birth_date" inputmode="numeric" autocomplete="bday" placeholder="MM - DD - YYYY" /> <input name="child_birth_date" inputmode="numeric" autocomplete="bday" placeholder="MM - DD - YYYY" disabled={mode === 'off'} />
</label> </label>
<label> <label>
<span>Location Interest</span> <span>Location Interest</span>
<select name="location_interest"> <select name="location_interest" disabled={mode === 'off'}>
<option value="" selected>Please Select</option> <option value="" selected>Please Select</option>
<option value="aia_scottsdale">AIA Scottsdale</option> <option value="aia_scottsdale">AIA Scottsdale</option>
<option value="aia_glendale">AIA Glendale</option> <option value="aia_glendale">AIA Glendale</option>
@@ -121,7 +123,7 @@ const noteId = `${drawerId}-note`;
</label> </label>
<label> <label>
<span>Service Interest</span> <span>Service Interest</span>
<select name="service_interest"> <select name="service_interest" disabled={mode === 'off'}>
<option value="" selected>Please Select</option> <option value="" selected>Please Select</option>
<option value="behavioral">Behavioral - ABA Therapy Service</option> <option value="behavioral">Behavioral - ABA Therapy Service</option>
<option value="psychological">Psychological - Autism Evaluation Service</option> <option value="psychological">Psychological - Autism Evaluation Service</option>
@@ -131,7 +133,7 @@ const noteId = `${drawerId}-note`;
</label> </label>
<label> <label>
<span>Insurance &amp; Payment Options</span> <span>Insurance &amp; Payment Options</span>
<select name="payment_options"> <select name="payment_options" disabled={mode === 'off'}>
<option value="" selected>Please select</option> <option value="" selected>Please select</option>
<option>United Healthcare AHCCCS</option> <option>United Healthcare AHCCCS</option>
<option>BCBS AZ</option> <option>BCBS AZ</option>
@@ -145,7 +147,7 @@ const noteId = `${drawerId}-note`;
</label> </label>
<label> <label>
<span>How did you hear about us?</span> <span>How did you hear about us?</span>
<select name="referred_by"> <select name="referred_by" disabled={mode === 'off'}>
<option value="" selected>Please select</option> <option value="" selected>Please select</option>
<option>Facebook</option> <option>Facebook</option>
<option>Google</option> <option>Google</option>
@@ -158,18 +160,20 @@ const noteId = `${drawerId}-note`;
</label> </label>
<label class="service-inquiry-form__wide"> <label class="service-inquiry-form__wide">
<span>Additional Information/Comments</span> <span>Additional Information/Comments</span>
<textarea name="comment" rows="2"></textarea> <textarea name="comment" rows="2" disabled={mode === 'off'}></textarea>
</label> </label>
</div> </div>
<div class="service-inquiry-form__footer"> <div class="service-inquiry-form__footer">
<p id={noteId}>Online submission is not yet connected. Please call <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</p> <p id={noteId} data-form-status>{mode === 'mock' ? 'Demo only: this local fixture never sends information.' : <>Online submission is not yet connected. Please call <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</>}</p>
<button type="submit" disabled aria-describedby={noteId}>Submit</button> <button type="button" data-mock-submit disabled={mode === 'off'} aria-describedby={noteId}>Submit</button>
</div> </div>
</form> </div>
</dialog> </dialog>
<script> <script>
import { bindMockForms } from '../scripts/mock-form';
bindMockForms();
const initializeServiceInquiryDrawers = () => { const initializeServiceInquiryDrawers = () => {
document.querySelectorAll('[data-service-inquiry-drawer]').forEach((element) => { document.querySelectorAll('[data-service-inquiry-drawer]').forEach((element) => {
if (!(element instanceof HTMLDialogElement) || element.dataset.initialized === 'true') return; if (!(element instanceof HTMLDialogElement) || element.dataset.initialized === 'true') return;
+1
View File
@@ -0,0 +1 @@
export { formRouteMapping, logicalFormForRoute } from '../utils/form-integration';
+1 -1
View File
@@ -79,5 +79,5 @@ if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team)
? <ContactPage title={bannerTitle} lang={entry.data.lang}><slot /></ContactPage> ? <ContactPage title={bannerTitle} lang={entry.data.lang}><slot /></ContactPage>
: <article class:list={['prose', 'source-page', 'container', { 'service-page': isService }]}><slot /></article>} : <article class:list={['prose', 'source-page', 'container', { 'service-page': isService }]}><slot /></article>}
{isService && entry.data.lang === 'en' && <ServiceInquiryDrawer service={entry.data.slug} />} {isService && entry.data.lang === 'en' && <ServiceInquiryDrawer service={entry.data.slug} />}
{showForm && <FormShell lang={entry.data.lang} title={bannerTitle} showBackground={entry.data.slug === 'client-consultation'} />} {showForm && <FormShell lang={entry.data.lang} title={bannerTitle} logicalForm={entry.data.slug === 'referrals' ? 'referral' : 'service'} showBackground={entry.data.slug === 'client-consultation'} />}
</BaseLayout> </BaseLayout>
+2 -1
View File
@@ -1 +1,2 @@
export const AIA_API_BASE = import.meta.env.PUBLIC_AIA_API_BASE || 'https://api.azinstitute4autism.com'; /** Live counter behavior is intentionally not enabled by this migration. */
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
+32
View File
@@ -0,0 +1,32 @@
export function bindMockForms(root: ParentNode = document) {
root.querySelectorAll<HTMLElement>('[data-aia-form][data-form-mode="mock"]').forEach((form) => {
if (form.dataset.mockBound === 'true') return;
form.dataset.mockBound = 'true';
const button = form.querySelector<HTMLButtonElement>('[data-mock-submit]');
const status = form.querySelector<HTMLElement>('[data-form-status]');
if (!button || !status) return;
button.addEventListener('click', () => {
const result = form.dataset.mockResult === 'failed' || form.dataset.mockResult === 'unavailable'
? form.dataset.mockResult
: 'success';
status.textContent = form.dataset[`mock${result[0].toUpperCase()}${result.slice(1)}`] ?? 'Demo only: no information was sent.';
status.dataset.mockState = result;
});
});
root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="mock"]').forEach((counter) => {
if (counter.dataset.mockBound === 'true') return;
counter.dataset.mockBound = 'true';
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
const status = counter.querySelector<HTMLElement>('.counter-status');
if (!button || !status) return;
button.addEventListener('click', () => {
const liked = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-pressed', String(!liked));
button.classList.toggle('liked', !liked);
status.textContent = 'Demo only: no counter request or cookie was sent.';
});
});
}
bindMockForms();
+2
View File
@@ -1,11 +1,13 @@
/** Arabic article chrome; preserve the existing English/Spanish labels. */ /** Arabic article chrome; preserve the existing English/Spanish labels. */
const english = { const english = {
views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article', views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article',
counterUnavailable: 'Engagement metrics are unavailable',
navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post', navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post',
related: 'Similar Blog Posts', readFull: 'Read full post', related: 'Similar Blog Posts', readFull: 'Read full post',
}; };
const arabic: typeof english = { const arabic: typeof english = {
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال', views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
counterUnavailable: 'إحصاءات التفاعل غير متاحة',
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي', navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً', related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً',
}; };
+37
View File
@@ -0,0 +1,37 @@
export type CounterMode = 'off' | 'mock' | 'live';
export interface CounterConfig {
mode: CounterMode;
apiBase?: string;
reason: 'default-off' | 'mock' | 'live-approved' | 'invalid-mode' | 'live-not-approved' | 'invalid-api-base';
}
const isHttpsOrigin = (value: string | undefined) => {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === 'https:' && Boolean(url.host);
} catch {
return false;
}
};
export function resolveCounterConfig(
rawMode: string | undefined,
apiBase: string | undefined,
liveApproved: string | undefined,
): CounterConfig {
const mode = rawMode?.trim().toLowerCase();
if (!mode) return { mode: 'off', reason: 'default-off' };
if (mode === 'mock') return { mode: 'mock', reason: 'mock' };
if (mode !== 'live') return { mode: 'off', reason: 'invalid-mode' };
if (liveApproved?.trim().toLowerCase() !== 'true') return { mode: 'off', reason: 'live-not-approved' };
if (!isHttpsOrigin(apiBase)) return { mode: 'off', reason: 'invalid-api-base' };
return { mode: 'live', apiBase, reason: 'live-approved' };
}
export function counterModeForRuntime(config: CounterConfig): 'off' | 'mock' {
// Live behavior is intentionally not implemented by the migration. Keep an
// approved future setting fail-closed until a separately authorized adapter exists.
return config.mode === 'mock' ? 'mock' : 'off';
}
+52
View File
@@ -0,0 +1,52 @@
export type FormMode = 'off' | 'mock' | 'live';
export type LogicalForm = 'general' | 'service' | 'referral';
export type MockFormResult = 'success' | 'failed' | 'unavailable';
export interface FormModeConfig {
mode: FormMode;
reason: 'default-off' | 'mock' | 'live-approved' | 'invalid-mode' | 'live-not-approved';
}
export function resolveFormMode(rawMode: string | undefined, liveApproved: string | undefined): FormModeConfig {
const mode = rawMode?.trim().toLowerCase();
if (!mode) return { mode: 'off', reason: 'default-off' };
if (mode === 'mock') return { mode: 'mock', reason: 'mock' };
if (mode !== 'live') return { mode: 'off', reason: 'invalid-mode' };
if (liveApproved?.trim().toLowerCase() !== 'true') return { mode: 'off', reason: 'live-not-approved' };
return { mode: 'live', reason: 'live-approved' };
}
export function formModeForRuntime(config: FormModeConfig): 'off' | 'mock' {
// A live adapter and approved form IDs do not exist yet. Keep live fail-closed.
return config.mode === 'mock' ? 'mock' : 'off';
}
export function resolveMockFormResult(rawResult: string | undefined): MockFormResult {
const result = rawResult?.trim().toLowerCase();
return result === 'failed' || result === 'unavailable' ? result : 'success';
}
export const formRouteMapping: ReadonlyArray<{
route: string;
logicalForm: LogicalForm;
kind: 'native-instance' | 'landing-journey';
}> = [
{ route: '/contact', logicalForm: 'general', kind: 'native-instance' },
{ route: '/es/contact', logicalForm: 'general', kind: 'native-instance' },
{ route: '/client-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/es/client-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/schedule-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/referrals', logicalForm: 'referral', kind: 'native-instance' },
{ route: '/aba-therapy', logicalForm: 'service', kind: 'native-instance' },
{ route: '/autism-evaluations', logicalForm: 'service', kind: 'native-instance' },
{ route: '/learner-social-club', logicalForm: 'service', kind: 'native-instance' },
{ route: '/lp/aba-therapy', logicalForm: 'service', kind: 'landing-journey' },
{ route: '/lp/aba-insurance', logicalForm: 'service', kind: 'landing-journey' },
{ route: '/lp/aba-ahcccs', logicalForm: 'service', kind: 'landing-journey' },
{ route: '/lp/aba-private-pay', logicalForm: 'service', kind: 'landing-journey' },
{ route: '/lp/early-intervention', logicalForm: 'service', kind: 'landing-journey' },
];
export function logicalFormForRoute(route: string): LogicalForm | undefined {
return formRouteMapping.find((entry) => entry.route === route)?.logicalForm;
}
+2
View File
@@ -7,6 +7,7 @@ const messages = {
service: 'Service of interest', options: ['ABA Therapy', 'Autism Evaluation', 'Learner Social Club', 'Other'], service: 'Service of interest', options: ['ABA Therapy', 'Autism Evaluation', 'Learner Social Club', 'Other'],
message: 'How can we help?', submit: 'Submit', message: 'How can we help?', submit: 'Submit',
disabled: 'Online submission is not yet connected. Please call', disabled: 'Online submission is not yet connected. Please call',
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', map: 'Map showing Arizona Institute for Autism in Scottsdale',
tile: 'OpenStreetMap tile showing area around Arizona Institute for Autism in Scottsdale, AZ', 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'
@@ -18,6 +19,7 @@ const messages = {
service: 'Servicio de interés', options: ['Terapia ABA', 'Evaluación de autismo', 'Learner Social Club', 'Otro'], service: 'Servicio de interés', options: ['Terapia ABA', 'Evaluación de autismo', 'Learner Social Club', 'Otro'],
message: '¿Cómo podemos ayudarle?', submit: 'Enviar', message: '¿Cómo podemos ayudarle?', submit: 'Enviar',
disabled: 'El envío en línea aún no está habilitado. Llame al', disabled: 'El envío en línea aún no está habilitado. Llame al',
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', 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', 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'
+6 -5
View File
@@ -1071,14 +1071,15 @@ test('Phase 7 privacy page preserves policy sections and reciprocal eligible nav
} }
}); });
test('Phase 7 rendered forms localize Spanish without enabling submission', () => { test('Phase 9 off-mode form surfaces localize Spanish without enabling submission', () => {
for (const route of ['/es/contact','/es/client-consultation','/contact','/client-consultation','/referrals','/schedule-consultation']) { for (const route of ['/es/contact','/es/client-consultation','/contact','/client-consultation','/referrals','/schedule-consultation']) {
const $ = load(artifacts.get('staging').html.get(route)); const $ = load(artifacts.get('staging').html.get(route));
const form = $('form.contact-form-card, form.form'); const form = $('[data-aia-form]');
assert.equal(form.length, 1, route); assert.equal(form.length, 1, route);
assert.equal(form.attr('action'), ''); assert.equal(form.attr('data-form-mode'), 'off');
assert.equal(form.attr('method'), 'post'); assert.equal($('form').length, 0, route);
assert.equal(form.find('button[type=submit][disabled]').length, 1); assert.equal(form.find('[name]:not([disabled])').length, 0);
assert.equal(form.find('[data-mock-submit]:not([disabled])').length, 0);
assert.deepEqual(form.find('input').map((_i,e) => $(e).attr('name')).get(), ['name','email','phone']); assert.deepEqual(form.find('input').map((_i,e) => $(e).attr('name')).get(), ['name','email','phone']);
assert.equal(form.find('select option').length, 4); assert.equal(form.find('select option').length, 4);
assert.match(form.text(), route.startsWith('/es/') ? /Nombre del padre, la madre o el tutor/ : /Parent or guardian name/); assert.match(form.text(), route.startsWith('/es/') ? /Nombre del padre, la madre o el tutor/ : /Parent or guardian name/);
+1 -1
View File
@@ -57,7 +57,7 @@ await test('service drawer delayed opening and keyboard containment',async()=>{a
await test('page six boundaries',async()=>{const rows=[];for(const route of ['/library/page/6','/library/author/rula-diab/page/6']){await go(p,route);const cards=p.locator('article.blog-card');assert.equal(await cards.count(),5);const links=await p.locator('.library-pagination a').evaluateAll(es=>es.map(e=>e.getAttribute('href')));assert.ok(links.some(h=>h.endsWith('/5')));assert.ok(!links.some(h=>h.endsWith('/7')));rows.push({route,cards:5,links});}return rows;}); await test('page six boundaries',async()=>{const rows=[];for(const route of ['/library/page/6','/library/author/rula-diab/page/6']){await go(p,route);const cards=p.locator('article.blog-card');assert.equal(await cards.count(),5);const links=await p.locator('.library-pagination a').evaluateAll(es=>es.map(e=>e.getAttribute('href')));assert.ok(links.some(h=>h.endsWith('/5')));assert.ok(!links.some(h=>h.endsWith('/7')));rows.push({route,cards:5,links});}return rows;});
await test('image link and browser Back',async()=>{await go(p,'/library/play-leisure-skills');const link=p.locator('main a[href$=".png"]').first();const href=await link.getAttribute('href');await link.focus();await p.keyboard.press('Enter');await p.waitForURL(origin+href);await p.goBack({waitUntil:'networkidle'});assert.equal(new URL(p.url()).pathname,'/library/play-leisure-skills');return {href};}); await test('image link and browser Back',async()=>{await go(p,'/library/play-leisure-skills');const link=p.locator('main a[href$=".png"]').first();const href=await link.getAttribute('href');await link.focus();await p.keyboard.press('Enter');await p.waitForURL(origin+href);await p.goBack({waitUntil:'networkidle'});assert.equal(new URL(p.url()).pathname,'/library/play-leisure-skills');return {href};});
await test('picky eating section fragments',async()=>{await go(p,'/library/picky-eating-autism-parent-guide');const links=await p.locator('main a[href^="#"]').evaluateAll(es=>es.map(e=>e.getAttribute('href')));assert.equal(links.length,4);for(const href of links){await p.locator(`main a[href="${href}"]`).click();assert.equal(new URL(p.url()).hash,href);assert.equal(await p.locator(`[id="${href.slice(1)}"]`).count(),1);}return links;}); await test('picky eating section fragments',async()=>{await go(p,'/library/picky-eating-autism-parent-guide');const links=await p.locator('main a[href^="#"]').evaluateAll(es=>es.map(e=>e.getAttribute('href')));assert.equal(links.length,4);for(const href of links){await p.locator(`main a[href="${href}"]`).click();assert.equal(new URL(p.url()).hash,href);assert.equal(await p.locator(`[id="${href.slice(1)}"]`).count(),1);}return links;});
await test('disabled Spanish form and intercepted submission fixture',async()=>{await go(p,'/es/contact');const form=p.locator('main form').first();const html=await form.evaluate(e=>e.outerHTML);assert.equal(await form.getAttribute('action'),'');assert.equal(await form.locator('[type=submit]').isDisabled(),true);const fixture=await context(390);let prevented=false;await fixture.route(origin+'/__phase8_form_fixture',r=>{if(r.request().method()!=='GET'){prevented=true;return r.abort();}return r.fulfill({contentType:'text/html',body:'<!doctype html><html lang="es"><title>Isolated form fixture</title><body>'+html+'</body></html>'});});const page=await fixture.newPage();await page.goto(origin+'/__phase8_form_fixture');await page.evaluate(()=>HTMLFormElement.prototype.submit.call(document.querySelector('form')));await page.waitForTimeout(500);await fixture.close();assert.equal(prevented,true);return {programmaticPostPrevented:true,meaning:'Disabled submit is not complete form isolation; P9-03 remains pending. No field data supplied.'};}); await test('off-mode Spanish form has no native submission surface',async()=>{await go(p,'/es/contact');assert.equal(await p.locator('main form').count(),0);const surface=p.locator('main [data-aia-form]').first();assert.equal(await surface.getAttribute('data-form-mode'),'off');assert.equal(await surface.locator('[name]:enabled').count(),0);assert.equal(await surface.locator('[data-mock-submit]:enabled').count(),0);return {nativeFormCount:0,enabledNamedControls:0,meaning:'Off mode has no native POST surface; no field data supplied.'};});
await c.close(); await c.close();
const nojs=await context(390,false),n=await nojs.newPage();n.setDefaultTimeout(6000); const nojs=await context(390,false),n=await nojs.newPage();n.setDefaultTimeout(6000);
for(const route of ['/es/contact','/library','/ar/library/aba-school-readiness-guide'])await test(`no-JS keyboard ${route}`,async()=>{await go(n,route);const menu=n.locator('.mobile-nav > summary');await menu.focus();await n.keyboard.press('Enter');assert.equal(await n.locator('.mobile-nav').evaluate(e=>e.open),true);await n.keyboard.press('Enter');const language=n.locator('.lang-switcher summary');await language.focus();await n.keyboard.press('Enter');assert.equal(await n.locator('.lang-switcher__menu').isVisible(),true);if(route.includes('school-readiness')){const d=n.locator('details.faq').first();const before=await d.evaluate(e=>e.open);await d.locator('summary').focus();await n.keyboard.press('Enter');assert.equal(await d.evaluate(e=>e.open),!before);}return {h1:await n.locator('main h1').innerText(),searchNotice:await n.locator('.library-search__noscript').count()};}); for(const route of ['/es/contact','/library','/ar/library/aba-school-readiness-guide'])await test(`no-JS keyboard ${route}`,async()=>{await go(n,route);const menu=n.locator('.mobile-nav > summary');await menu.focus();await n.keyboard.press('Enter');assert.equal(await n.locator('.mobile-nav').evaluate(e=>e.open),true);await n.keyboard.press('Enter');const language=n.locator('.lang-switcher summary');await language.focus();await n.keyboard.press('Enter');assert.equal(await n.locator('.lang-switcher__menu').isVisible(),true);if(route.includes('school-readiness')){const d=n.locator('details.faq').first();const before=await d.evaluate(e=>e.open);await d.locator('summary').focus();await n.keyboard.press('Enter');assert.equal(await d.evaluate(e=>e.open),!before);}return {h1:await n.locator('main h1').innerText(),searchNotice:await n.locator('.library-search__noscript').count()};});
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import {
counterModeForRuntime,
resolveCounterConfig,
} from '../src/utils/counter-mode.ts';
import {
formModeForRuntime,
formRouteMapping,
logicalFormForRoute,
resolveFormMode,
resolveMockFormResult,
} from '../src/utils/form-integration.ts';
test('counter configuration defaults off and requires explicit approval', () => {
assert.deepEqual(resolveCounterConfig(undefined, undefined, undefined), { mode: 'off', reason: 'default-off' });
assert.equal(resolveCounterConfig('invalid', 'https://api.example.test', 'true').mode, 'off');
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off');
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live');
assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off');
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'off');
assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock');
});
test('form configuration defaults off and live remains fail-closed', () => {
assert.deepEqual(resolveFormMode(undefined, undefined), { mode: 'off', reason: 'default-off' });
assert.equal(resolveFormMode('invalid', undefined).mode, 'off');
assert.equal(resolveFormMode('live', 'false').mode, 'off');
assert.equal(resolveFormMode('live', 'true').mode, 'live');
assert.equal(formModeForRuntime(resolveFormMode('live', 'true')), 'off');
assert.equal(resolveFormMode('mock', undefined).mode, 'mock');
assert.equal(resolveMockFormResult(undefined), 'success');
assert.equal(resolveMockFormResult('failed'), 'failed');
assert.equal(resolveMockFormResult('unavailable'), 'unavailable');
assert.equal(resolveMockFormResult('anything-else'), 'success');
});
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);
assert.equal(logicalFormForRoute('/contact'), 'general');
assert.equal(logicalFormForRoute('/es/client-consultation'), 'service');
assert.equal(logicalFormForRoute('/referrals'), 'referral');
assert.equal(logicalFormForRoute('/lp/early-intervention'), 'service');
assert.equal(logicalFormForRoute('/ar/contact'), undefined);
});
test('inactive source has no native form or live vendor destination', () => {
const sources = [
'src/components/ContactForm.astro',
'src/components/FormShell.astro',
'src/components/ServiceInquiryDrawer.astro',
].map((file) => fs.readFileSync(file, 'utf8')).join('\n');
assert.doesNotMatch(sources, /<form\b/);
assert.doesNotMatch(sources, /250727122848156/);
assert.doesNotMatch(sources, /jotform\.com/i);
assert.match(sources, /data-form-mode/);
});
test('counter source has no implicit production fallback or credentialed fetch', () => {
const source = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
assert.doesNotMatch(source, /api\.azinstitute4autism\.com/);
assert.doesNotMatch(source, /credentials:\s*['"]include/);
assert.doesNotMatch(source, /method:\s*['"](?:POST|DELETE)/);
assert.match(source, /data-counter-mode/);
});
+11 -9
View File
@@ -568,7 +568,7 @@ test('Phase 5A conversion CTAs resolve to the intended published local route or
} }
}); });
test('Phase 5A preserves disabled forms and one localized H1 on every targeted page', () => { test('Phase 9 preserves isolated off-mode form surfaces and one localized H1 on every targeted page', () => {
const slugs = ['aba-therapy', 'autism-evaluations', 'learner-social-club', 'services', 'client-consultation', 'aba-therapy-intake-process']; const slugs = ['aba-therapy', 'autism-evaluations', 'learner-social-club', 'services', 'client-consultation', 'aba-therapy-intake-process'];
const routes = ['/referrals', '/schedule-consultation', '/insurance', ...['en', 'es'].flatMap((lang) => slugs.map((slug) => `${prefix(lang)}/${slug}`))]; const routes = ['/referrals', '/schedule-consultation', '/insurance', ...['en', 'es'].flatMap((lang) => slugs.map((slug) => `${prefix(lang)}/${slug}`))];
for (const route of routes) { for (const route of routes) {
@@ -577,12 +577,14 @@ test('Phase 5A preserves disabled forms and one localized H1 on every targeted p
assert.equal($('html').attr('lang'), route.startsWith('/es/') ? 'es' : 'en', route); assert.equal($('html').attr('lang'), route.startsWith('/es/') ? 'es' : 'en', route);
assert.equal($('html').attr('dir'), 'ltr', route); assert.equal($('html').attr('dir'), 'ltr', route);
assert.equal($('iframe[src*="jotform"], script[src*="jotform"]').length, 0, route); assert.equal($('iframe[src*="jotform"], script[src*="jotform"]').length, 0, route);
$('main form').each((_, el) => { const surfaces = $('main [data-aia-form]');
const form = $(el); assert.equal($('main form').length, 0, route);
assert.equal(form.attr('action'), '', route); surfaces.each((_, el) => {
assert.equal(form.find('[type="submit"]:not([disabled])').length, 0, route); const surface = $(el);
assert.ok(form.find('[type="submit"][disabled]').length, route); assert.equal(surface.attr('data-form-mode'), 'off', route);
assert.match(form.text(), route.startsWith('/es/') ? /El envío en línea aún no está habilitado/ : /Online submission is not yet connected/, route); assert.equal(surface.find('[name]:not([disabled])').length, 0, route);
assert.equal(surface.find('[data-mock-submit]:not([disabled])').length, 0, route);
assert.match(surface.text(), route.startsWith('/es/') ? /El envío en línea aún no está habilitado/ : /Online submission is not yet connected/, route);
}); });
} }
assert.equal(meta(page('/autism-evaluations'), 'description').attr('content'), "Get comprehensive childhood autism evaluations in Scottsdale, AZ. Our expert team provides accurate assessments to support your child's unique development."); assert.equal(meta(page('/autism-evaluations'), 'description').attr('content'), "Get comprehensive childhood autism evaluations in Scottsdale, AZ. Our expert team provides accurate assessments to support your child's unique development.");
@@ -672,8 +674,8 @@ test('Phase 5B consultation and six intake-step CTAs resolve in each homepage la
assert.equal(link.length, 1); assert.equal(link.length, 1);
assert.equal(link.attr('href'), `${localePrefix}/client-consultation`); assert.equal(link.attr('href'), `${localePrefix}/client-consultation`);
const destination = page(link.attr('href')); const destination = page(link.attr('href'));
assert.equal(destination('form [type="submit"]:not([disabled])').length, 0); assert.equal(destination('[data-aia-form] [data-mock-submit]:not([disabled])').length, 0);
assert.match(destination('form').text(), localePrefix ? /El envío en línea aún no está habilitado/ : /Online submission is not yet connected/); assert.match(destination('[data-aia-form]').text(), localePrefix ? /El envío en línea aún no está habilitado/ : /Online submission is not yet connected/);
} }
const links = $('.process-grid a'); const links = $('.process-grid a');
assert.equal(links.length, 6); assert.equal(links.length, 6);