Files
HOALedgerIQ_Website/app.js
olsch01 156adeab59 Validate email addresses on both public form endpoints
The ROI calculator accepted the email field with no validation at all and
stored whatever arrived, which is where the spam submissions were putting
shell-command payloads. Nothing was executable — there is no child_process
or eval in the codebase and all writes are parameterized — but the junk was
being persisted, and the field is the obvious place to stop it.

Adds security.validateEmail(), deliberately stricter than RFC 5322: the
local part is limited to the characters real addresses use, which excludes
every shell metacharacter (; | & ` $ ( ) < > \ " ' space) and CSV-injection
lead-ins. Also rejects control characters (including the CR/LF used for
mail-header injection), caps lengths at 254/64/253, rejects non-strings,
and normalizes to trimmed lowercase before storage.

Applied to /api/calculate (optional field — empty is fine, present must be
valid) and to /api/leads, replacing its much weaker regex. The client
mirrors the check for immediate feedback; the server remains authoritative.

Also hardens the /api/leads required-field checks, which called .trim() on
unvalidated input and returned a 500 rather than a 400 when a bot posted a
non-string.

Trade-off: RFC-legal but vanishingly rare addresses (foo!bar$baz@x.com, a
leading + in the local part) are rejected. Those characters are the
injection surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:25:22 -04:00

440 lines
17 KiB
JavaScript

/* HOA LedgerIQ — Frontend form handler + countdown */
// ── Dynamic Countdown ────────────────────────────────────
// Launch date = March 1, 2026 + 60 days = April 30, 2026
// On 3/1/2026 the banner shows "60 days", then counts down daily.
(function initCountdown() {
const launchDate = new Date('2026-04-30T00:00:00');
const now = new Date();
const msPerDay = 1000 * 60 * 60 * 24;
const daysLeft = Math.max(0, Math.ceil((launchDate - now) / msPerDay));
const label = daysLeft === 1 ? 'Day' : 'Days';
const text = daysLeft > 0
? `Launching in ${daysLeft} ${label}`
: 'Now Live!';
const bannerEl = document.getElementById('bannerCountdown');
const signupEl = document.getElementById('signupCountdown');
if (bannerEl) bannerEl.textContent = text;
if (signupEl) signupEl.textContent = text;
})();
// ── Benefit Calculator ───────────────────────────────────
(function initCalculator() {
const overlay = document.getElementById('calcOverlay');
const openBtn = document.getElementById('openCalc');
const closeBtn = document.getElementById('calcClose');
const submitBtn = document.getElementById('calcSubmit');
const recalcBtn = document.getElementById('calcRecalc');
const calcForm = document.getElementById('calcForm');
const calcRes = document.getElementById('calcResults');
const calcErr = document.getElementById('calcError');
const ctaBtn = document.getElementById('calcCTABtn');
if (!overlay) return;
// ── Abuse protection ───────────────────────────────────
// A signed, single-use token is fetched when the form is opened; the server
// uses it to prove the form was really loaded and that a human took at least a
// few seconds to fill it in. When Turnstile keys are configured server-side, a
// CAPTCHA widget is rendered too.
const captchaSlot = document.getElementById('calcCaptcha');
let formToken = null;
let captchaWidget = null;
let siteKeyPromise = null;
async function fetchFormToken() {
try {
const res = await fetch('/api/form-token', { cache: 'no-store' });
formToken = (await res.json()).token || null;
} catch (_) { formToken = null; }
}
function loadTurnstileScript() {
return new Promise((resolve, reject) => {
if (window.turnstile) return resolve();
const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function initCaptcha() {
if (!captchaSlot || captchaWidget !== null) return;
siteKeyPromise = siteKeyPromise || fetch('/api/form-config', { cache: 'no-store' })
.then(r => r.json())
.then(c => c.turnstileSiteKey)
.catch(() => null);
const siteKey = await siteKeyPromise;
if (!siteKey) return; // CAPTCHA not configured — other layers still apply
try {
await loadTurnstileScript();
captchaWidget = window.turnstile.render(captchaSlot, {
sitekey: siteKey,
theme: 'light',
action: 'roi_calculator',
});
} catch (_) { /* widget unavailable — server decides whether to allow */ }
}
function captchaResponse() {
if (captchaWidget === null || !window.turnstile) return '';
return window.turnstile.getResponse(captchaWidget) || '';
}
function resetCaptcha() {
if (captchaWidget !== null && window.turnstile) window.turnstile.reset(captchaWidget);
}
function open() {
overlay.classList.add('open');
document.body.style.overflow = 'hidden';
fetchFormToken(); // starts the minimum-fill-time clock
initCaptcha();
}
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
openBtn?.addEventListener('click', open);
// Footer CTA also opens the modal (v2.js handles its analytics) — it needs the
// same form token and CAPTCHA set-up.
document.getElementById('openCalc2')?.addEventListener('click', open);
closeBtn?.addEventListener('click', close);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
recalcBtn?.addEventListener('click', () => {
calcRes.classList.add('hidden');
calcForm.classList.remove('hidden');
});
// Close modal and scroll to signup when CTA clicked
ctaBtn?.addEventListener('click', () => {
close();
});
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
// Mirrors security.js validateEmail — the server is the authority, this just
// gives immediate feedback instead of a round-trip.
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
function isValidEmail(v) {
if (v.length > 254 || v.indexOf('@') > 64) return false;
if (v.includes('..')) return false;
return EMAIL_RX.test(v);
}
function showCalcError(msg) {
if (!calcErr) return;
calcErr.textContent = msg;
calcErr.classList.remove('hidden');
}
function setCalcLoading(on) {
if (!submitBtn) return;
submitBtn.disabled = on;
calcBtnText?.classList.toggle('hidden', on);
calcBtnLoading?.classList.toggle('hidden', !on);
}
submitBtn?.addEventListener('click', async () => {
const homesites = parseFloat(document.getElementById('calcHomesites').value) || 0;
const propertyType = document.getElementById('calcPropertyType').value;
const annualIncome = parseFloat(document.getElementById('calcAnnualIncome').value) || 0;
const paymentFreq = document.getElementById('calcPaymentFreq').value;
const reserveFunds = parseFloat(document.getElementById('calcReserveFunds').value) || 0;
const interest2025 = parseFloat(document.getElementById('calcInterest2025').value) || 0;
const calcEmail = document.getElementById('calcEmail')?.value.trim() || '';
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
if (!homesites || !annualIncome) {
showCalcError('Please fill in homesites and annual dues income to continue.');
return;
}
// Email is optional, but anything entered must be a real address.
if (calcEmail && !isValidEmail(calcEmail)) {
showCalcError('Please enter a valid email address.');
document.getElementById('calcEmail')?.focus();
return;
}
calcErr.classList.add('hidden');
setCalcLoading(true);
// ── Abuse checks: server verifies the token, honeypot and CAPTCHA ──
const guardBody = {
formToken,
captchaToken: captchaResponse(),
hp_company_url: document.getElementById('hpCompanyUrl')?.value || '',
};
// ── Conservative investment assumptions ──
// Operating cash: depending on payment frequency, portion investable in high-yield savings
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
const opRate = 0.040; // 4.0% money market / HYSA
const resRatio = 0.65; // 65% of reserves investable (keep 35% liquid)
const resRate = 0.0425; // 4.25% CD ladder / short-term treasuries
const investableOp = annualIncome * opMultiplier;
const investableRes = reserveFunds * resRatio;
const opInterest = Math.round(investableOp * opRate);
const resInterest = Math.round(investableRes * resRate);
const totalPotential = opInterest + resInterest;
const increase = totalPotential - interest2025;
const pctIncrease = interest2025 > 0
? Math.round((increase / interest2025) * 100)
: (totalPotential > 0 ? 100 : 0);
// ── Populate results ──
const fmt = n => '$' + Math.round(n).toLocaleString();
document.getElementById('resultAmount').textContent = fmt(totalPotential);
document.getElementById('resultCurrent').textContent = fmt(interest2025);
document.getElementById('resultOperating').textContent = fmt(opInterest);
document.getElementById('resultReserve').textContent = fmt(resInterest);
const badge = document.getElementById('resultBadge');
if (increase > 0) {
badge.textContent = `+${fmt(increase)} · +${pctIncrease}%`;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
// ── AI-style suggestion text ──
const typeLabels = { sfh:'single-family home', townhomes:'townhome', condos:'condo', mixed:'mixed-use', '':'' };
const typeLabel = typeLabels[propertyType] || '';
const freqLabel = { monthly:'monthly', quarterly:'quarterly', annually:'annual' }[paymentFreq];
const communityDesc = [homesites && `${homesites}-unit`, typeLabel, 'community'].filter(Boolean).join(' ');
let ai = `Based on your ${communityDesc} collecting ${fmt(annualIncome)} in ${freqLabel} dues`;
if (reserveFunds > 0) ai += ` and ${fmt(reserveFunds)} in reserve funds`;
ai += `, a conservative investment strategy could generate approximately ${fmt(totalPotential)} in annual interest income. `;
if (resInterest > 0) ai += `Deploying ${fmt(investableRes)} of your reserve funds into a short-term CD ladder at ~4.25% yields ${fmt(resInterest)} annually. `;
if (opInterest > 0) ai += `Keeping a ${fmt(investableOp)} operating cash buffer in a high-yield money market at ~4.0% adds another ${fmt(opInterest)}. `;
if (interest2025 > 0 && increase > 0) {
ai += `That's a ${fmt(increase)} improvement (+${pctIncrease}%) over your 2025 interest income of ${fmt(interest2025)} — with no additional risk.`;
} else if (interest2025 === 0) {
ai += `This would represent entirely new interest income for your community at no additional risk.`;
}
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
try {
const res = await fetch('/api/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...guardBody,
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
email: calcEmail, optIn: calcOptIn,
totalPotential, opInterest, resInterest,
}),
});
// A blocked submission stops here — the estimate is not shown and nothing
// is stored. AI/service errors (502/503) fall through to the local result.
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.blocked || data.field === 'email') {
setCalcLoading(false);
showCalcError(data.error || 'We could not verify this submission. Please try again.');
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
resetCaptcha();
await fetchFormToken(); // tokens are single-use; issue a fresh one
return;
}
}
} catch (_) { /* best-effort — DB save failed silently */ }
// Token is spent on a successful submission; get another for a recalculation.
fetchFormToken();
resetCaptcha();
// ── Animate the main number ──
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
setCalcLoading(false);
calcForm.classList.add('hidden');
calcRes.classList.remove('hidden');
});
function animateValue(el, from, to) {
const duration = 900;
const start = performance.now();
function step(now) {
const progress = Math.min((now - start) / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3);
el.textContent = '$' + Math.round(from + (to - from) * ease).toLocaleString();
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
})();
// ── Screenshot Carousel ──────────────────────────────────
(function initCarousel() {
const carousel = document.getElementById('screenshotCarousel');
if (!carousel) return;
const slides = carousel.querySelectorAll('.carousel-slide');
const dots = carousel.querySelectorAll('.carousel-dot');
let current = 0;
let timer;
function goTo(index) {
slides[current].classList.remove('active');
dots[current].classList.remove('active');
current = (index + slides.length) % slides.length;
slides[current].classList.add('active');
dots[current].classList.add('active');
}
function next() { goTo(current + 1); }
function prev() { goTo(current - 1); }
function startAuto() {
timer = setInterval(next, 4500);
}
function resetAuto() {
clearInterval(timer);
startAuto();
}
carousel.querySelector('.carousel-next').addEventListener('click', () => { next(); resetAuto(); });
carousel.querySelector('.carousel-prev').addEventListener('click', () => { prev(); resetAuto(); });
dots.forEach(dot => {
dot.addEventListener('click', () => {
goTo(parseInt(dot.dataset.index, 10));
resetAuto();
});
});
// Pause on hover
carousel.addEventListener('mouseenter', () => clearInterval(timer));
carousel.addEventListener('mouseleave', startAuto);
startAuto();
})();
// ── Form Handler ─────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('signupForm');
const submitBtn = document.getElementById('submitBtn');
const btnText = submitBtn?.querySelector('.btn-text');
const btnLoading = submitBtn?.querySelector('.btn-loading');
const successDiv = document.getElementById('signupSuccess');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const firstName = form.firstName.value.trim();
const lastName = form.lastName.value.trim();
const email = form.email.value.trim();
const orgName = form.orgName?.value.trim() || '';
const state = form.state?.value || '';
// Basic client-side validation
if (!firstName || !lastName || !email || !orgName || !state) {
highlightEmpty(form);
return;
}
if (!isValidEmail(email)) {
form.email.style.borderColor = '#ef4444';
form.email.focus();
return;
}
// Show loading state
setLoading(true);
const payload = {
firstName,
lastName,
email,
orgName: form.orgName?.value.trim() || '',
state: form.state?.value || '',
role: form.role.value,
unitCount: form.unitCount.value,
betaInterest: form.betaInterest?.checked || false,
source: 'landing_page',
timestamp: new Date().toISOString(),
};
try {
const res = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
showSuccess();
} else {
const data = await res.json().catch(() => ({}));
if (res.status === 409) {
// Already registered
showSuccess('You\'re already on the list! We\'ll be in touch soon.');
} else {
alert(data.error || 'Something went wrong. Please try again.');
setLoading(false);
}
}
} catch (err) {
// Network error — fall back to localStorage so we don't lose the lead
saveLocally(payload);
showSuccess();
}
});
function setLoading(on) {
if (!btnText || !btnLoading) return;
submitBtn.disabled = on;
btnText.classList.toggle('hidden', on);
btnLoading.classList.toggle('hidden', !on);
}
function showSuccess(msg) {
form.classList.add('hidden');
if (msg && successDiv) {
const p = successDiv.querySelector('p');
if (p) p.textContent = msg;
}
successDiv?.classList.remove('hidden');
}
function highlightEmpty(f) {
['firstName', 'lastName', 'email', 'orgName', 'state'].forEach(name => {
const el = f[name];
if (el && !el.value.trim()) {
el.style.borderColor = '#ef4444';
el.addEventListener('input', () => { el.style.borderColor = ''; }, { once: true });
el.addEventListener('change', () => { el.style.borderColor = ''; }, { once: true });
}
});
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function saveLocally(payload) {
try {
const existing = JSON.parse(localStorage.getItem('hoa_leads') || '[]');
existing.push(payload);
localStorage.setItem('hoa_leads', JSON.stringify(existing));
} catch (_) { /* best-effort */ }
}
});