Files
HOALedgerIQ_Website/app.js
olsch01 1219117adf Block example.com emails and homesites under 10
Spam submissions were still getting through with placeholder data. Two
more content filters on the public form endpoints:

- Reject emails from reserved documentation domains (example.com/.org/
  /.net/.edu and subdomains) and reserved TLDs (.test/.example/.invalid/
  localhost). testing@example.com and friends are never real leads.
- Reject a homesites count below 10. Real associations are larger; the
  junk uses 0/1/2.

Both are validated server-side in security.js (validateEmail gains a
domain blocklist, new validateHomesites) and mirrored client-side in
app.js for immediate feedback. The homesites input min attribute goes
from 1 to 10. Blocked submissions return 400 with a `field` hint and
store nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:30:15 -04:00

459 lines
18 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}$/;
// Reserved documentation domains / TLDs — mirrors security.js. Real leads
// never come from these; spam bots use them.
const BLOCKED_EMAIL_DOMAINS = ['example.com', 'example.org', 'example.net', 'example.edu'];
const BLOCKED_EMAIL_TLDS = ['test', 'example', 'invalid', 'localhost'];
function isValidEmail(v) {
if (v.length > 254 || v.indexOf('@') > 64) return false;
if (v.includes('..')) return false;
if (!EMAIL_RX.test(v)) return false;
const domain = v.slice(v.indexOf('@') + 1).toLowerCase();
const tld = domain.slice(domain.lastIndexOf('.') + 1);
if (BLOCKED_EMAIL_TLDS.includes(tld)) return false;
if (BLOCKED_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith('.' + d))) return false;
return true;
}
const MIN_HOMESITES = 10;
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;
}
if (homesites < MIN_HOMESITES) {
showCalcError(`Please enter the number of homesites in your community (minimum ${MIN_HOMESITES}).`);
document.getElementById('calcHomesites')?.focus();
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) {
setCalcLoading(false);
showCalcError(data.error || 'We could not verify this submission. Please try again.');
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
if (data.field === 'homesites') document.getElementById('calcHomesites')?.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 */ }
}
});