feat(portal): add service drawers and employee portal preview
This commit is contained in:
39 files changed
+1446
-130
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.' },
|
||||
},
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
@@ -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(); });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user