Files
HOALedgerIQ_Website/app.js
olsch01 bf70efc0d7 Add OpenAI-compatible AI investment advisor to benefit calculator
- server.js: add /api/calculate endpoint using openai SDK with configurable
  AI_API_URL, AI_API_KEY, AI_MODEL, AI_DEBUG env vars (works with any
  OpenAI-compatible provider: NVIDIA NIM, Together AI, Groq, Ollama, etc.)
- app.js: make calculator submit handler async; call /api/calculate with
  graceful fallback to client-side generated text if AI is unavailable
- package.json: add openai and dotenv dependencies
- AI_SETUP.md: rewrite to document new unified env var config with provider examples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 10:05:41 -04:00

314 lines
12 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;
function open() { overlay.classList.add('open'); document.body.style.overflow = 'hidden'; }
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
openBtn?.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();
});
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;
if (!homesites || !annualIncome) {
calcErr.classList.remove('hidden');
return;
}
calcErr.classList.add('hidden');
// ── 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; fall back to client-side text ──
try {
const aiRes = await fetch('/api/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 }),
});
if (aiRes.ok) {
const { recommendation } = await aiRes.json();
document.getElementById('calcAiText').textContent = recommendation;
} else {
document.getElementById('calcAiText').textContent = ai;
}
} catch (_) {
document.getElementById('calcAiText').textContent = ai;
}
// ── Animate the main number ──
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
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 */ }
}
});