feat(portal): add service drawers and employee portal preview

This commit is contained in:
DeCentN2Madness committed 2026-10-30 00:03:21 -07:00
1 parent ff80ee516f
commit e97f0ea9b4
39 files changed
+1446 -130

No files matched your search

+3 -1
View File
@@ -1,6 +1,7 @@
---
interface Props {
href?: string;
serviceInquiry?: string;
variant?: 'filled' | 'outlined' | 'light';
size?: 'default' | 'small';
center?: boolean;
@@ -14,6 +15,7 @@ interface Props {
const {
href,
serviceInquiry,
variant = 'filled',
size = 'default',
center = false,
@@ -34,7 +36,7 @@ const classes = [
---
{href ? (
<a class:list={classes} href={href} target={target} rel={rel}><slot /></a>
<a class:list={classes} href={href} data-service-inquiry-trigger={serviceInquiry} target={target} rel={rel}><slot /></a>
) : (
<button class:list={classes} type={type} disabled={disabled} aria-describedby={ariaDescribedby}><slot /></button>
)}
+13 -4
View File
@@ -88,13 +88,20 @@ const headingId = `${idBase}-heading`;
if (timer) window.clearTimeout(timer);
// A late frame load supersedes the transport timeout. It is still not
// treated as a success, conversion, or cross-origin health signal.
container.dataset.jotformDocumentLoaded = 'true';
hideFallback();
};
const dispose = () => {
const dispose = (event) => {
disposed = true;
if (timer) window.clearTimeout(timer);
frame.removeEventListener('load', onLoad);
container.removeEventListener('aia:jotform-activate', activate);
window.removeEventListener('pagehide', dispose);
document.removeEventListener('astro:before-swap', dispose);
delete container.dataset.jotformFallbackInitialized;
if (event?.type === 'pagehide' && event.persisted) {
window.addEventListener('pageshow', () => { if (container.isConnected) initialize(container); }, { once: true });
}
};
const activate = () => {
@@ -103,9 +110,11 @@ const headingId = `${idBase}-heading`;
const approvedUrl = frame.dataset.jotformIframeUrl;
if (deferred && !approvedUrl) return;
activated = true;
frame.addEventListener('load', onLoad, { once: true });
timer = window.setTimeout(showFallback, fallbackTimeoutMs);
if (deferred) frame.src = approvedUrl;
if (container.dataset.jotformDocumentLoaded !== 'true') {
frame.addEventListener('load', onLoad, { once: true });
timer = window.setTimeout(showFallback, fallbackTimeoutMs);
}
if (deferred && frame.getAttribute('src') !== approvedUrl) frame.src = approvedUrl;
container.dataset.jotformIframeActivated = 'true';
};
+10 -103
View File
@@ -8,7 +8,7 @@ import {
resolveMockFormResult,
} from '../utils/form-integration';
type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club';
import { serviceInquiryCopy, type ServiceKey } from '../data/service-inquiry';
type SupportedLocale = 'en' | 'es';
interface Props {
@@ -22,11 +22,13 @@ const serviceNames: Record<SupportedLocale, Record<ServiceKey, string>> = {
'aba-therapy': 'ABA Therapy',
'autism-evaluations': 'Autism Evaluations',
'learner-social-club': 'Learner Social Club',
'aia-preparatory-academy': 'AIA Preparatory Academy',
},
es: {
'aba-therapy': 'Terapia ABA',
'autism-evaluations': 'Evaluaciones de autismo',
'learner-social-club': 'Learner Social Club',
'aia-preparatory-academy': 'AIA Preparatory Academy',
},
};
@@ -116,6 +118,7 @@ const drawerCopy: Record<SupportedLocale, {
const { service, lang, route } = Astro.props;
const copy = drawerCopy[lang];
const serviceName = serviceNames[lang][service];
const serviceCopy = serviceInquiryCopy[service][lang];
const drawerId = `service-inquiry-${lang}-${service}`;
const titleId = `${drawerId}-title`;
const noteId = `${drawerId}-note`;
@@ -137,15 +140,6 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
---
{consultationTestEnabled && formAIframe.url && <>
<button
class="service-inquiry-drawer__open"
type="button"
data-service-inquiry-open
aria-controls={drawerId}
aria-haspopup="dialog"
>
{copy.open(serviceName)}
</button>
<noscript>
<p class="service-inquiry-drawer__noscript">
{copy.noScript} <a href={formAIframe.url} target="_blank" rel="noopener noreferrer">{copy.hostedLink}</a>
@@ -157,8 +151,8 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
id={drawerId}
class="service-inquiry-drawer"
data-service-inquiry-drawer
data-explicit-activation={consultationTestEnabled ? 'true' : undefined}
data-dismiss-key={consultationTestEnabled ? undefined : `aia-service-inquiry-dismissed:${lang}:${service}`}
data-consultation-test={consultationTestEnabled ? 'true' : undefined}
data-service={service}
aria-labelledby={titleId}
>
<button class="service-inquiry-drawer__close" type="button" data-service-inquiry-close aria-label={copy.close}>
@@ -168,14 +162,14 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
</button>
<header class="service-inquiry-drawer__intro">
<h2 id={titleId} data-service-inquiry-title tabindex="-1">{consultationTestEnabled ? copy.heading(serviceName) : serviceName}</h2>
<p>{consultationTestEnabled ? copy.introduction : copy.legacyStatus}</p>
<h2 id={titleId} data-service-inquiry-title tabindex="-1">{consultationTestEnabled ? serviceCopy.heading : serviceName}</h2>
<p>{consultationTestEnabled ? serviceCopy.introduction : copy.legacyStatus}</p>
{consultationTestEnabled && <p class="service-inquiry-drawer__privacy">{copy.privacy}</p>}
</header>
{consultationTestEnabled
? <div class="service-inquiry-drawer__iframe">
<JotformIframe config={formAIframe} heading={copy.heading(serviceName)} variant="drawer" deferUntilActivated hideHeading />
<JotformIframe config={formAIframe} heading={serviceCopy.heading} variant="drawer" deferUntilActivated hideHeading />
</div>
: <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.">
<div class="service-inquiry-form__grid">
@@ -278,95 +272,8 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
<script>
import { bindMockForms } from '../scripts/mock-form';
import { initializeServiceInquiryDrawers } from '../scripts/service-inquiry';
bindMockForms();
const initializeServiceInquiryDrawers = () => {
document.querySelectorAll('[data-service-inquiry-drawer]').forEach((element) => {
if (!(element instanceof HTMLDialogElement) || element.dataset.initialized === 'true') return;
element.dataset.initialized = 'true';
const explicitActivation = element.dataset.explicitActivation === 'true';
const closeButton = element.querySelector('[data-service-inquiry-close]');
const title = element.querySelector('[data-service-inquiry-title]');
const frameAdapter = element.querySelector('[data-jotform-form-a]');
const triggers = Array.from(document.querySelectorAll(`[data-service-inquiry-open][aria-controls="${element.id}"]`));
const dismissKey = element.dataset.dismissKey;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let closeTimer: number | undefined;
let trigger: HTMLElement | undefined;
const wasDismissed = () => {
try {
return dismissKey ? sessionStorage.getItem(dismissKey) === 'true' : false;
} catch {
return false;
}
};
const rememberDismissal = () => {
try {
if (dismissKey) sessionStorage.setItem(dismissKey, 'true');
} catch {
// Storage can be unavailable in privacy-focused browsing modes.
}
};
const finishClose = () => {
if (element.open) element.close();
document.documentElement.classList.remove('service-inquiry-open');
};
const closeDrawer = () => {
if (!element.open) return;
if (!explicitActivation) rememberDismissal();
element.classList.remove('is-open');
window.clearTimeout(closeTimer);
if (reduceMotion.matches) {
finishClose();
return;
}
closeTimer = window.setTimeout(finishClose, 500);
};
const openDrawer = (openedBy?: EventTarget | null) => {
if (element.open || (!explicitActivation && wasDismissed())) return;
trigger = openedBy instanceof HTMLElement ? openedBy : undefined;
element.showModal();
document.documentElement.classList.add('service-inquiry-open');
if (explicitActivation && frameAdapter instanceof HTMLElement) {
frameAdapter.dispatchEvent(new Event('aia:jotform-activate'));
}
requestAnimationFrame(() => requestAnimationFrame(() => {
element.classList.add('is-open');
if (closeButton instanceof HTMLElement) closeButton.focus({ preventScroll: true });
else if (title instanceof HTMLElement) title.focus({ preventScroll: true });
}));
};
closeButton?.addEventListener('click', closeDrawer);
triggers.forEach((openButton) => openButton.addEventListener('click', () => openDrawer(openButton)));
element.addEventListener('cancel', (event) => {
event.preventDefault();
closeDrawer();
});
element.addEventListener('click', (event) => {
if (event.target === element) closeDrawer();
});
element.addEventListener('close', () => {
element.classList.remove('is-open');
document.documentElement.classList.remove('service-inquiry-open');
if (trigger instanceof HTMLElement) {
trigger.focus({ preventScroll: true });
trigger = undefined;
}
});
if (!explicitActivation) window.setTimeout(() => openDrawer(undefined), 10_000);
});
};
initializeServiceInquiryDrawers();
</script>
@@ -0,0 +1,42 @@
---
import { readPortalInput } from '../../utils/portal-input.mjs';
import Button from '../Button.astro';
// Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build.
const payload = readPortalInput({
approved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED,
indexing: import.meta.env.PUBLIC_ALLOW_INDEXING,
environment: import.meta.env.PUBLIC_AIA_FORMS_ENVIRONMENT,
file: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE,
});
const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') : 'null';
---
<section class="employee-portal container" data-employee-portal>
<div data-portal-prompt>
<h2>Access Code Required</h2>
<p>Please enter the appropriate access code to view your Arizona Institute for Autism Employee Portal.</p>
<!-- Local unlock only: no native form, action, server submission or persistence. -->
<div class="employee-portal__access" role="group" aria-label="Employee portal access">
<label for="employee-portal-code">Access Code</label>
<input id="employee-portal-code" type="password" autocomplete="off" disabled aria-describedby="employee-portal-feedback" data-portal-code />
<Button type="button" disabled>Unlock</Button>
</div>
<p id="employee-portal-feedback" role="status" aria-live="polite" data-portal-feedback>Portal access is unavailable until JavaScript and approved encrypted content are available.</p>
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
</div>
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
</section>
<script>import '../../scripts/employee-portal';</script>
<style>
.employee-portal { padding-block: 3rem; overflow-wrap: anywhere; }
.employee-portal__access { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
input:focus-visible, .employee-portal__content:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
[hidden] { display: none; }
.employee-portal__content :global(a) { color: var(--color-primary); text-decoration: underline; }
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; }
.employee-portal__content :global(td), .employee-portal__content :global(th) { padding: .5rem; }
@media (max-width: 500px) { .employee-portal__access { align-items: stretch; flex-direction: column; } }
</style>
+1 -1
View File
@@ -50,7 +50,7 @@ Our goal is to empower families with the knowledge, tools, and support they need
These consultations provide parents with personalized guidance, training, and strategies tailored to their child’s unique needs.
<Button href="/client-consultation" center>Make an Appointment</Button>
<Button href="/client-consultation" serviceInquiry="aba-therapy" center>Request a Consultation</Button>
## About Rula Diab, Chief Clinical Director, BCBA, LBA
@@ -50,4 +50,4 @@ ADOS is a structured evaluation tool used by our specialists to assess behaviors
This evaluation involves observing how your child interacts with various play-based social scenarios. For younger children, this may include activities like playing with blocks or having a snack, while older children who communicate verbally may participate in conversations about daily activities, such as attending school.
<Button href="/client-consultation" center>Make an Appointment</Button>
<Button href="/client-consultation" serviceInquiry="autism-evaluations" center>Request a Consultation</Button>
@@ -53,4 +53,4 @@ The Learner Social Club currently serves youth learners aged **8–17 years**. O
Get a **1-Day Trial Pass** by emailing us at <ContactObfuscation email="info@azinstitute4autism.com" obfuscatedText="info [at] azinstitute4autism [dot] com" />.
<Button href="/client-consultation" center>Enroll Now</Button>
<Button href="/client-consultation" serviceInquiry="learner-social-club" center>Request a Consultation</Button>
+1 -1
View File
@@ -50,7 +50,7 @@ Nuestro objetivo es empoderar a las familias con el conocimiento, las herramient
Estas consultas brindan a los padres orientación, capacitación y estrategias personalizadas adaptadas a las necesidades únicas de su hijo.
<Button href="/es/client-consultation" center>Hacer una cita</Button>
<Button href="/es/client-consultation" serviceInquiry="aba-therapy" center>Solicitar una consulta</Button>
## Acerca de Rula Diab, Directora Clínica Principal, BCBA, LBA
@@ -50,4 +50,4 @@ ADOS es una herramienta de evaluación estructurada que utilizan nuestros especi
Esta evaluación implica observar cómo su hijo/a interactúa en diferentes situaciones sociales basadas en el juego. Para los niños más pequeños, esto puede incluir actividades como jugar con bloques o tomar una merienda, mientras que los niños mayores que se comunican verbalmente pueden participar en conversaciones sobre actividades cotidianas, como asistir a la escuela.
<Button href="/es/client-consultation" center>Programar una cita</Button>
<Button href="/es/client-consultation" serviceInquiry="autism-evaluations" center>Solicitar una consulta</Button>
@@ -61,4 +61,4 @@ Learner Social Club actualmente atiende a jóvenes de 8 a 17 años. Nuestras opc
Obtenga un pase de prueba de 1 día enviándonos un correo electrónico a <ContactObfuscation email="info@azinstitute4autism.com" obfuscatedText="info [at] azinstitute4autism [dot] com" />.
<Button href="/es/client-consultation">Enroll Now</Button>
<Button href="/es/client-consultation" serviceInquiry="learner-social-club">Solicitar una consulta</Button>
+22
View File
@@ -0,0 +1,22 @@
export type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club' | 'aia-preparatory-academy';
export type ServiceLocale = 'en' | 'es';
// Editorial defaults authorized for this development pass; fluent review pending.
// Configuration is intentionally independent of the published route allowlist.
export const serviceInquiryCopy: Record<ServiceKey, Record<ServiceLocale, { heading: string; introduction: string }>> = {
'aba-therapy': {
en: { heading: 'Explore ABA Therapy at AIA', introduction: 'Learn about individualized ABA support for children ages 18 months through 8 years. An AIA client advocate can explain how to get started.' },
es: { heading: 'Conozca la terapia ABA de AIA', introduction: 'Infórmese sobre el apoyo individualizado de terapia ABA para niños de 18 meses a 8 años. Un representante de AIA puede explicarle cómo comenzar.' },
},
'autism-evaluations': {
en: { heading: 'Questions About an Autism Evaluation?', introduction: 'Speak with an AIA client advocate about your questions and possible next steps.' },
es: { heading: '¿Tiene preguntas sobre una evaluación de autismo?', introduction: 'Hable con un representante de AIA sobre sus preguntas y los posibles próximos pasos.' },
},
'learner-social-club': {
en: { heading: 'Explore the Learner Social Club', introduction: 'Ask about group activities and social opportunities for youth ages 8–17. Our team can discuss program fit and next steps.' },
es: { heading: 'Conozca el Learner Social Club', introduction: 'Pregunte por las actividades grupales y las oportunidades sociales para jóvenes de 8 a 17 años. Nuestro equipo puede conversar con usted sobre el programa y los próximos pasos.' },
},
'aia-preparatory-academy': {
en: { heading: 'Explore AIA Preparatory Academy', introduction: 'Learn about individualized ABA support and purposeful group learning for children ages 2–6. Speak with AIA about academic readiness and next steps.' },
es: { heading: 'Conozca AIA Preparatory Academy', introduction: 'Infórmese sobre el apoyo individualizado de terapia ABA y el aprendizaje grupal con propósito para niños de 2 a 6 años. Hable con AIA sobre la preparación académica y los próximos pasos.' },
},
};
+3
View File
@@ -1,4 +1,5 @@
---
import EmployeePortal from '../components/pages/EmployeePortal.astro';
import BaseLayout from './BaseLayout.astro';
import FormShell from '../components/FormShell.astro';
import TeamPage from '../components/pages/TeamPage.astro';
@@ -64,6 +65,8 @@ if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team)
<PageHero title={entry.data.displayH1 ?? bannerTitle} subtitle={entry.data.slug === 'faqs' ? 'FAQs' : undefined} image={heroImage} constrain />
{entry.data.slug === 'team' && entry.data.lang === 'en'
? <TeamPage content={entry.data.team!} />
: entry.data.slug === 'employee-portal' && entry.data.lang === 'en'
? <EmployeePortal />
: entry.data.slug === 'about' && entry.data.lang === 'en'
? <AboutPage />
: entry.data.slug === 'services' && entry.data.lang === 'en'
+74
View File
@@ -0,0 +1,74 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
* embedded forms and styles require owner review and are not loaded on unlock. */
export function safePortalContent(html: string): DocumentFragment {
const template = document.createElement('template');
template.innerHTML = html;
const allowed = new Set(['DIV','SECTION','ARTICLE','HEADER','FOOTER','H1','H2','H3','H4','H5','H6','P','SPAN','STRONG','EM','B','I','U','SMALL','BR','HR','UL','OL','LI','DL','DT','DD','TABLE','THEAD','TBODY','TR','TH','TD','CAPTION','A']);
const output = document.createDocumentFragment();
const copy = (source: Node, target: Node) => {
if (source.nodeType === Node.TEXT_NODE) { target.appendChild(document.createTextNode(source.textContent ?? '')); return; }
if (!(source instanceof HTMLElement)) return;
if (!allowed.has(source.tagName)) return;
const element = document.createElement(source.tagName.toLowerCase());
if (source.tagName === 'A') {
const href = source.getAttribute('href') ?? '';
try {
const url = new URL(href, location.origin);
if (['https:', 'mailto:', 'tel:'].includes(url.protocol) || (url.origin === location.origin && url.protocol === location.protocol)) {
element.setAttribute('href', url.href);
element.setAttribute('rel', 'noopener noreferrer');
element.setAttribute('referrerpolicy', 'no-referrer');
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
}
} catch { /* Invalid destination remains visible text without navigation. */ }
}
for (const child of source.childNodes) copy(child, element);
target.appendChild(element);
};
for (const child of template.content.childNodes) copy(child, output);
return output;
}
function initialize() {
const root = document.querySelector<HTMLElement>('[data-employee-portal]');
if (!root || root.dataset.bound) return;
root.dataset.bound = 'true';
const input = root.querySelector<HTMLInputElement>('[data-portal-code]')!;
const button = root.querySelector<HTMLButtonElement>('button')!;
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
let data: ReturnType<typeof validatePortalPayload>;
try {
if (!crypto.subtle) throw new Error();
data = validatePortalPayload(JSON.parse(root.querySelector('[data-portal-payload]')?.textContent ?? 'null'));
input.disabled = false; button.disabled = false;
feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
const unlock = async () => {
if (button.disabled) return;
button.disabled = true;
input.setAttribute('aria-invalid', 'false');
feedback.textContent = '';
let pass = input.value;
try {
for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass);
if (plain !== null) {
content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false;
input.value = ''; content.focus(); return;
}
}
input.setAttribute('aria-invalid', 'true'); feedback.textContent = 'Invalid Code'; input.focus();
} catch { feedback.textContent = 'Portal access is unavailable. Please try again or contact AIA.'; }
finally { pass = ''; button.disabled = false; }
};
button.addEventListener('click', unlock);
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
// BFCache must not restore an already unlocked view.
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; });
}
initialize();
document.addEventListener('astro:page-load', initialize);
+118
View File
@@ -0,0 +1,118 @@
import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference';
let disposeAll: (() => void) | undefined;
// Memory preference also covers storage-denied browsing during this document lifetime.
let memoryExpiry: string | null = null;
function suppressed() {
try { return isSuppressed(localStorage.getItem(suppressionKey)) || isSuppressed(memoryExpiry); }
catch { return isSuppressed(memoryExpiry); }
}
function remember() {
memoryExpiry = String(Date.now() + suppressionDuration);
try { localStorage.setItem(suppressionKey, memoryExpiry); } catch { /* Functional preference only. */ }
}
export function initializeServiceInquiryDrawers() {
disposeAll?.();
const disposers: (() => void)[] = [];
document.querySelectorAll<HTMLDialogElement>('[data-service-inquiry-drawer]').forEach((dialog) => {
const controller = new AbortController();
const { signal } = controller;
const authorized = dialog.dataset.consultationTest === 'true';
const closeButton = dialog.querySelector<HTMLButtonElement>('[data-service-inquiry-close]');
const triggers = [...document.querySelectorAll<HTMLAnchorElement>('[data-service-inquiry-trigger]')]
.filter((anchor) => anchor.dataset.serviceInquiryTrigger === dialog.dataset.service);
let timer: number | undefined;
let closeTimer: number | undefined;
let firstFrame = 0;
let secondFrame = 0;
let trigger: HTMLElement | null = null;
let autoConsumed = false;
let closing = false;
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
const stopAuto = () => {
clearTimeout(timer);
removeEventListener('scroll', checkScroll);
removeEventListener('resize', checkScroll);
observer?.disconnect();
};
const conflict = () => Boolean(document.querySelector('dialog[open]')) ||
document.activeElement?.matches('input:not([disabled]), textarea, select, [contenteditable="true"], iframe');
const open = (manual?: HTMLElement) => {
if (dialog.open) return;
if (!manual && (autoConsumed || suppressed())) { stopAuto(); return; }
// One opportunity, never interrupt another modal or an edited field; CTA remains usable.
if (!manual) autoConsumed = true;
stopAuto();
if (conflict() && !manual) return;
if (document.querySelector('dialog[open]')) return;
autoConsumed = true;
trigger = manual ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
dialog.showModal();
document.documentElement.classList.add('service-inquiry-open');
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate'));
firstFrame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => {
if (!dialog.open || closing) return;
dialog.classList.add('is-open');
closeButton?.focus({ preventScroll: true });
}); });
};
const dismiss = () => {
if (!dialog.open || closing) return;
closing = true;
remember();
stopAuto();
dialog.classList.remove('is-open');
const finish = () => { dialog.close(); closing = false; };
if (reduceMotion.matches) finish();
else closeTimer = window.setTimeout(finish, 500);
};
function checkScroll() {
if (reachedHalf(scrollY, document.documentElement.scrollHeight, innerHeight)) open();
}
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(checkScroll) : undefined;
closeButton?.addEventListener('click', dismiss, { signal });
dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal });
dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal });
dialog.addEventListener('close', () => {
stopAuto();
dialog.classList.remove('is-open');
document.documentElement.classList.remove('service-inquiry-open');
if (trigger?.isConnected) trigger.focus({ preventScroll: true });
trigger = null;
}, { signal });
if (authorized) {
for (const anchor of triggers) {
anchor.setAttribute('aria-haspopup', 'dialog');
anchor.setAttribute('aria-controls', dialog.id);
anchor.addEventListener('click', (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (document.querySelector('dialog[open]')) return;
event.preventDefault(); open(anchor);
}, { signal });
}
if (!suppressed()) {
timer = window.setTimeout(() => open(), 4000);
addEventListener('scroll', checkScroll, { passive: true, signal });
addEventListener('resize', checkScroll, { passive: true, signal });
observer?.observe(document.documentElement);
checkScroll();
}
} else if (!suppressed()) {
// Retain the existing inactive/mock demonstration timer outside test mode.
timer = window.setTimeout(() => open(), 10000);
}
addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal });
disposers.push(() => {
stopAuto(); clearTimeout(closeTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
controller.abort(); dialog.classList.remove('is-open');
if (dialog.open) dialog.close();
for (const anchor of triggers) { anchor.removeAttribute('aria-controls'); anchor.removeAttribute('aria-haspopup'); }
document.documentElement.classList.remove('service-inquiry-open');
});
});
disposeAll = () => { disposers.forEach((dispose) => dispose()); disposeAll = undefined; };
}
document.addEventListener('astro:before-swap', () => disposeAll?.());
document.addEventListener('astro:page-load', initializeServiceInquiryDrawers);
window.addEventListener('pagehide', () => disposeAll?.());
window.addEventListener('pageshow', (event) => { if (event.persisted) initializeServiceInquiryDrawers(); });
+12
View File
@@ -0,0 +1,12 @@
export const suppressionKey = 'aia:service-inquiry:auto-suppressed-until';
export const suppressionDuration = 30 * 24 * 60 * 60 * 1000;
export function isSuppressed(value: string | null, now = Date.now()): boolean {
if (!value || !/^\d+$/.test(value)) return false;
const expiry = Number(value);
// Reject corrupted values and clock jumps beyond the maximum preference life.
return Number.isSafeInteger(expiry) && expiry > now && expiry <= now + suppressionDuration;
}
export function reachedHalf(scrollY: number, scrollHeight: number, viewportHeight: number): boolean {
const distance = scrollHeight - viewportHeight;
return distance > 1 && Math.max(0, scrollY) / distance >= 0.5;
}
+2 -2
View File
@@ -117,7 +117,7 @@ export const formAIframePlacements: ReadonlyArray<FormAIframePlacement> = [
* Consultation placements are a separate, non-production allowlist. They
* cannot be activated by the production approval gate, even when every
* production value is true. Service drawers defer assigning their iframe src
* until an explicit visitor action.
* until the authorized drawer opens.
*/
export const formAConsultationTestPlacements: ReadonlyArray<FormAIframePlacement> = [
{ route: '/client-consultation', locale: 'en', context: 'consultation' },
@@ -196,7 +196,7 @@ export function resolveFormAIframe(options: ResolveFormAIframeOptions): FormAIfr
: standardPlacement;
if (!placement) return disabled('route-not-authorized');
if (options.locale !== placement.locale) return disabled('locale-not-authorized');
if (placement.context === 'consultation' && !isTrue(options.consultationTestApproved)) {
if (placement.context === 'consultation' && (options.allowIndexing !== 'false' || !isTrue(options.consultationTestApproved))) {
return disabled('consultation-test-not-approved');
}
+42
View File
@@ -0,0 +1,42 @@
// Legacy wire compatibility: PBKDF2 512 bits -> AES key then HMAC key.
// HMAC authenticates IV || ciphertext before AES-CBC decryption.
export const portalAlgorithm = 'AES-256-CBC+HMAC-SHA256';
const decode = (value) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
const encode = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(''));
const concat = (a, b) => { const out = new Uint8Array(a.length + b.length); out.set(a); out.set(b, a.length); return out; };
export function validatePortalPayload(data) {
if (!data || data.alg !== portalAlgorithm || !Array.isArray(data.streams) || data.streams.length !== 2) throw new Error('Invalid portal payload');
const ids = new Set();
for (const s of data.streams) {
if (typeof s.id !== 'string' || ids.has(s.id) || s.iter !== 200000) throw new Error('Invalid portal stream');
ids.add(s.id);
for (const key of ['salt', 'iv', 'hmac', 'ct']) {
if (typeof s[key] !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(s[key]) || s[key].length > 8_000_000) throw new Error('Invalid portal encoding');
}
if (decode(s.salt).length !== 16 || decode(s.iv).length !== 16 || decode(s.hmac).length !== 32 || !decode(s.ct).length || decode(s.ct).length % 16) throw new Error('Invalid portal lengths');
}
// Strip all unrelated input properties; only ciphertext reaches an approved preview.
return { alg: portalAlgorithm, streams: data.streams.map(({ id, iter, salt, iv, hmac, ct }) => ({ id, iter, salt, iv, hmac, ct })) };
}
async function keys(pass, salt, iterations) {
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(pass), 'PBKDF2', false, ['deriveBits']);
const bits = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, 512));
const aes = await crypto.subtle.importKey('raw', bits.slice(0, 32), 'AES-CBC', false, ['decrypt', 'encrypt']);
const mac = await crypto.subtle.importKey('raw', bits.slice(32), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
bits.fill(0);
return { aes, mac };
}
export async function decryptPortalStream(stream, pass) {
const { aes, mac } = await keys(pass, decode(stream.salt), stream.iter);
const iv = decode(stream.iv), ct = decode(stream.ct);
if (!await crypto.subtle.verify('HMAC', mac, decode(stream.hmac), concat(iv, ct))) return null;
return new TextDecoder('utf-8', { fatal: true }).decode(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aes, ct));
}
export async function encryptPortalStream(id, html, pass) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(16));
const { aes, mac } = await keys(pass, salt, 200000);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aes, new TextEncoder().encode(html)));
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', mac, concat(iv, ct)));
return { id, iter: 200000, salt: encode(salt), iv: encode(iv), hmac: encode(hmac), ct: encode(ct) };
}
+10
View File
@@ -0,0 +1,10 @@
// Build-only reader; never imported by a browser script.
import fs from 'node:fs';
import { validatePortalPayload } from './portal-crypto.mjs';
export function readPortalInput({ approved, indexing, environment, file }) {
if (approved !== 'true' || indexing !== 'false' || environment !== 'isolated-test' || !file) return undefined;
try {
if (fs.statSync(file).size > 12_000_000) return undefined;
return validatePortalPayload(JSON.parse(fs.readFileSync(file, 'utf8')));
} catch { return undefined; }
}