feat(phase9): isolate forms and counters for nonproduction
This commit is contained in:
20 files changed
+454
-157
No files matched your search
@@ -3,31 +3,38 @@ import Button from './Button.astro';
|
||||
import ContactObfuscation from './ContactObfuscation.astro';
|
||||
|
||||
import { formMessages } from '../utils/form-messages';
|
||||
import { formModeForRuntime, resolveFormMode, resolveMockFormResult, type LogicalForm } from '../utils/form-integration';
|
||||
|
||||
interface Props {
|
||||
lang?: string;
|
||||
title?: string;
|
||||
logicalForm?: LogicalForm;
|
||||
}
|
||||
|
||||
const { lang = 'en' } = Astro.props;
|
||||
const { lang = 'en', logicalForm = 'general' } = Astro.props;
|
||||
const text = formMessages(lang);
|
||||
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);
|
||||
---
|
||||
|
||||
<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. -->
|
||||
<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}>
|
||||
<h3 class="contact-form-card__title">{title}</h3>
|
||||
<div class="contact-form-card__fields">
|
||||
<label>{text.name}<input name="name" autocomplete="name" /></label>
|
||||
<label>{text.email}<input type="email" name="email" autocomplete="email" /></label>
|
||||
<label>{text.phone}<input type="tel" name="phone" autocomplete="tel" /></label>
|
||||
<label>{text.service}<select name="service">{text.options.map((label) => <option>{label}</option>)}</select></label>
|
||||
<label>{text.message}<textarea name="message" rows="5"></textarea></label>
|
||||
<Button type="submit" disabled 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>
|
||||
<label>{text.name}<input name="name" autocomplete="name" disabled={mode === 'off'} /></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" disabled={mode === 'off'} /></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" disabled={mode === 'off'}></textarea></label>
|
||||
<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" data-form-status>{mode === 'mock' ? text.mock : <>{text.disabled} <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</>}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { bindMockForms } from '../scripts/mock-form';
|
||||
bindMockForms();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.contact-form-card__note :global(a) {
|
||||
|
||||
@@ -2,23 +2,31 @@
|
||||
import Button from './Button.astro';
|
||||
import ContactObfuscation from './ContactObfuscation.astro';
|
||||
import { formMessages } from '../utils/form-messages';
|
||||
import { formModeForRuntime, resolveFormMode, resolveMockFormResult, type LogicalForm } from '../utils/form-integration';
|
||||
const { lang = 'en', showBackground = false } = Astro.props;
|
||||
const text = formMessages(lang);
|
||||
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">
|
||||
<div class="container form-grid">
|
||||
<div><p class="eyebrow">{text.eyebrow}</p><h2 id="form-title">{title}</h2><p>{text.intro}</p></div>
|
||||
<form class="form" 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. -->
|
||||
<label>{text.name}<input name="name" autocomplete="name" /></label>
|
||||
<label>{text.email}<input type="email" name="email" autocomplete="email" /></label>
|
||||
<label>{text.phone}<input type="tel" name="phone" autocomplete="tel" /></label>
|
||||
<label>{text.service}<select name="service">{text.options.map((label) => <option>{label}</option>)}</select></label>
|
||||
<label>{text.message}<textarea name="message" rows="5"></textarea></label>
|
||||
<Button type="submit" disabled aria-describedby="form-note">{text.submit}</Button>
|
||||
<p id="form-note" class="form-note">{text.disabled} <ContactObfuscation phone="(480) 687-7099" hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />.</p>
|
||||
</form>
|
||||
<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}>
|
||||
<label>{text.name}<input name="name" autocomplete="name" disabled={mode === 'off'} /></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" disabled={mode === 'off'} /></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" disabled={mode === 'off'}></textarea></label>
|
||||
<Button type="button" data-mock-submit disabled={mode === 'off'} aria-describedby="form-note">{text.submit}</Button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { bindMockForms } from '../scripts/mock-form';
|
||||
bindMockForms();
|
||||
</script>
|
||||
@@ -1,116 +1,35 @@
|
||||
---
|
||||
import { articleMessages } from '../utils/article-messages';
|
||||
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
|
||||
interface Props { slug: string; lang: string }
|
||||
const { slug, lang } = Astro.props;
|
||||
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>
|
||||
<span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>0</span> {text.views}</span>
|
||||
<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}>—</span> {text.views}</span>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
</span>
|
||||
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : text.counterUnavailable}</span>
|
||||
</div>
|
||||
<script>
|
||||
const apiBase = import.meta.env.PUBLIC_AIA_API_BASE || 'https://api.azinstitute4autism.com';
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
import { bindMockForms } from '../scripts/mock-form';
|
||||
bindMockForms();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -119,6 +38,7 @@ const text = articleMessages(lang);
|
||||
.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 .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 button.liked .like-icon { fill: #d92d20; stroke: #d92d20; }
|
||||
.like-view-counter button:disabled { cursor: wait; opacity: .65; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import ContactObfuscation from './ContactObfuscation.astro';
|
||||
import { formModeForRuntime, resolveFormMode, resolveMockFormResult } from '../utils/form-integration';
|
||||
|
||||
type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club';
|
||||
|
||||
@@ -42,6 +43,9 @@ const copy = serviceCopy[service];
|
||||
const drawerId = `service-inquiry-${service}`;
|
||||
const titleId = `${drawerId}-title`;
|
||||
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
|
||||
@@ -68,17 +72,15 @@ const noteId = `${drawerId}-note`;
|
||||
<p>{copy.prompt}</p>
|
||||
</header>
|
||||
|
||||
<form class="service-inquiry-form" 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. -->
|
||||
<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">
|
||||
<label>
|
||||
<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>
|
||||
<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>
|
||||
<span>Phone Number<span class="required-mark" aria-hidden="true">*</span></span>
|
||||
@@ -90,29 +92,29 @@ const noteId = `${drawerId}-note`;
|
||||
</span>
|
||||
<span class="service-inquiry-form__phone-number">
|
||||
<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>
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
<span>Child First Name</span>
|
||||
<input name="child_first_name" autocomplete="off" />
|
||||
<input name="child_first_name" autocomplete="off" disabled={mode === 'off'} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Child Last Name</span>
|
||||
<input name="child_last_name" autocomplete="off" />
|
||||
<input name="child_last_name" autocomplete="off" disabled={mode === 'off'} />
|
||||
</label>
|
||||
<label class="service-inquiry-form__wide">
|
||||
<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>
|
||||
<span>Location Interest</span>
|
||||
<select name="location_interest">
|
||||
<select name="location_interest" disabled={mode === 'off'}>
|
||||
<option value="" selected>Please Select</option>
|
||||
<option value="aia_scottsdale">AIA Scottsdale</option>
|
||||
<option value="aia_glendale">AIA Glendale</option>
|
||||
@@ -121,7 +123,7 @@ const noteId = `${drawerId}-note`;
|
||||
</label>
|
||||
<label>
|
||||
<span>Service Interest</span>
|
||||
<select name="service_interest">
|
||||
<select name="service_interest" disabled={mode === 'off'}>
|
||||
<option value="" selected>Please Select</option>
|
||||
<option value="behavioral">Behavioral - ABA Therapy Service</option>
|
||||
<option value="psychological">Psychological - Autism Evaluation Service</option>
|
||||
@@ -131,7 +133,7 @@ const noteId = `${drawerId}-note`;
|
||||
</label>
|
||||
<label>
|
||||
<span>Insurance & Payment Options</span>
|
||||
<select name="payment_options">
|
||||
<select name="payment_options" disabled={mode === 'off'}>
|
||||
<option value="" selected>Please select</option>
|
||||
<option>United Healthcare AHCCCS</option>
|
||||
<option>BCBS AZ</option>
|
||||
@@ -145,7 +147,7 @@ const noteId = `${drawerId}-note`;
|
||||
</label>
|
||||
<label>
|
||||
<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>Facebook</option>
|
||||
<option>Google</option>
|
||||
@@ -158,18 +160,20 @@ const noteId = `${drawerId}-note`;
|
||||
</label>
|
||||
<label class="service-inquiry-form__wide">
|
||||
<span>Additional Information/Comments</span>
|
||||
<textarea name="comment" rows="2"></textarea>
|
||||
<textarea name="comment" rows="2" disabled={mode === 'off'}></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button type="submit" disabled aria-describedby={noteId}>Submit</button>
|
||||
<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="button" data-mock-submit disabled={mode === 'off'} aria-describedby={noteId}>Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
import { bindMockForms } from '../scripts/mock-form';
|
||||
bindMockForms();
|
||||
const initializeServiceInquiryDrawers = () => {
|
||||
document.querySelectorAll('[data-service-inquiry-drawer]').forEach((element) => {
|
||||
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>
|
||||
: <article class:list={['prose', 'source-page', 'container', { 'service-page': isService }]}><slot /></article>}
|
||||
{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>
|
||||
@@ -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. */
|
||||
const english = {
|
||||
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',
|
||||
related: 'Similar Blog Posts', readFull: 'Read full post',
|
||||
};
|
||||
const arabic: typeof english = {
|
||||
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
|
||||
counterUnavailable: 'إحصاءات التفاعل غير متاحة',
|
||||
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
|
||||
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'],
|
||||
message: 'How can we help?', submit: 'Submit',
|
||||
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',
|
||||
tile: 'OpenStreetMap tile showing area around Arizona Institute for Autism in Scottsdale, AZ',
|
||||
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'],
|
||||
message: '¿Cómo podemos ayudarle?', submit: 'Enviar',
|
||||
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',
|
||||
tile: 'Mapa de OpenStreetMap de los alrededores de Arizona Institute for Autism en Scottsdale, Arizona',
|
||||
zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism'
|
||||
|
||||
Reference in new issue
Block a user