feat(phase9): isolate forms and counters for nonproduction
This commit is contained in:
20 files changed
+454
-157
No files matched your search
@@ -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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 & Payment Options</span>
|
<span>Insurance & 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;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { formRouteMapping, logicalFormForRoute } from '../utils/form-integration';
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
@@ -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: 'قراءة المقال كاملاً',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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()};});
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user