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