Add calculator UX enhancements and submission tracking
- Button loading spinner + disable on submit to prevent duplicate AI calls - Email field and opt-in consent checkbox on calculator form (checked by default) - Privacy assurance blurb below opt-in - Refinement blurb on results screen explaining live cash-flow optimization - calc_submissions SQLite table stores form inputs, computed results, AI text, email, opt-in - GET /api/calc-submissions endpoint (x-admin-key protected) to retrieve submissions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
20
app.js
20
app.js
@@ -52,6 +52,16 @@
|
|||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||||
|
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
||||||
|
|
||||||
|
function setCalcLoading(on) {
|
||||||
|
if (!submitBtn) return;
|
||||||
|
submitBtn.disabled = on;
|
||||||
|
calcBtnText?.classList.toggle('hidden', on);
|
||||||
|
calcBtnLoading?.classList.toggle('hidden', !on);
|
||||||
|
}
|
||||||
|
|
||||||
submitBtn?.addEventListener('click', async () => {
|
submitBtn?.addEventListener('click', async () => {
|
||||||
const homesites = parseFloat(document.getElementById('calcHomesites').value) || 0;
|
const homesites = parseFloat(document.getElementById('calcHomesites').value) || 0;
|
||||||
const propertyType = document.getElementById('calcPropertyType').value;
|
const propertyType = document.getElementById('calcPropertyType').value;
|
||||||
@@ -59,12 +69,15 @@
|
|||||||
const paymentFreq = document.getElementById('calcPaymentFreq').value;
|
const paymentFreq = document.getElementById('calcPaymentFreq').value;
|
||||||
const reserveFunds = parseFloat(document.getElementById('calcReserveFunds').value) || 0;
|
const reserveFunds = parseFloat(document.getElementById('calcReserveFunds').value) || 0;
|
||||||
const interest2025 = parseFloat(document.getElementById('calcInterest2025').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) {
|
if (!homesites || !annualIncome) {
|
||||||
calcErr.classList.remove('hidden');
|
calcErr.classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
calcErr.classList.add('hidden');
|
calcErr.classList.add('hidden');
|
||||||
|
setCalcLoading(true);
|
||||||
|
|
||||||
// ── Conservative investment assumptions ──
|
// ── Conservative investment assumptions ──
|
||||||
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
||||||
@@ -121,7 +134,11 @@
|
|||||||
const aiRes = await fetch('/api/calculate', {
|
const aiRes = await fetch('/api/calculate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 }),
|
body: JSON.stringify({
|
||||||
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||||
|
email: calcEmail, optIn: calcOptIn,
|
||||||
|
totalPotential, opInterest, resInterest,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (aiRes.ok) {
|
if (aiRes.ok) {
|
||||||
const { recommendation } = await aiRes.json();
|
const { recommendation } = await aiRes.json();
|
||||||
@@ -136,6 +153,7 @@
|
|||||||
// ── Animate the main number ──
|
// ── Animate the main number ──
|
||||||
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
||||||
|
|
||||||
|
setCalcLoading(false);
|
||||||
calcForm.classList.add('hidden');
|
calcForm.classList.add('hidden');
|
||||||
calcRes.classList.remove('hidden');
|
calcRes.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
|
|||||||
24
index.html
24
index.html
@@ -489,7 +489,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
||||||
<button class="btn btn-primary btn-lg calc-submit-btn" id="calcSubmit">Calculate My Potential →</button>
|
<!-- Email + opt-in -->
|
||||||
|
<div class="calc-email-row">
|
||||||
|
<div class="calc-field calc-field--full">
|
||||||
|
<label for="calcEmail">Your Email Address <span class="calc-optional">(optional)</span></label>
|
||||||
|
<input type="email" id="calcEmail" placeholder="you@example.com" />
|
||||||
|
</div>
|
||||||
|
<label class="calc-optin-label">
|
||||||
|
<input type="checkbox" id="calcOptIn" checked />
|
||||||
|
<span>I'd like LedgerIQ to follow up with a refined, community-specific estimate</span>
|
||||||
|
</label>
|
||||||
|
<p class="calc-privacy">🔒 Your email and submitted data are used solely to provide your ROI estimate and will never be shared with any third party or outside organization.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
||||||
|
<button class="btn btn-primary btn-lg calc-submit-btn" id="calcSubmit">
|
||||||
|
<span class="calc-btn-text">Calculate My Potential →</span>
|
||||||
|
<span class="calc-btn-loading hidden"><span class="calc-spinner"></span> Calculating…</span>
|
||||||
|
</button>
|
||||||
<p class="calc-fine">Conservative estimates for illustrative purposes only. Not financial advice.</p>
|
<p class="calc-fine">Conservative estimates for illustrative purposes only. Not financial advice.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -520,6 +537,11 @@
|
|||||||
<p id="calcAiText"></p>
|
<p id="calcAiText"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="calc-refine-blurb">
|
||||||
|
<span class="calc-refine-icon">⚙</span>
|
||||||
|
<p>This is a high-level estimate based on your inputs. Inside HOA LedgerIQ, your investment strategy is continuously refined against your live cash flows, upcoming expenses, and reserve study timelines — automatically maximizing yield while keeping your community fully liquid.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="calc-cta">
|
<div class="calc-cta">
|
||||||
<p>Ready to put these gains to work for your community?</p>
|
<p>Ready to put these gains to work for your community?</p>
|
||||||
<a href="#preview-signup" class="btn btn-primary btn-lg calc-cta-btn" id="calcCTABtn">Get Early Access — Reserve My Spot →</a>
|
<a href="#preview-signup" class="btn btn-primary btn-lg calc-cta-btn" id="calcCTABtn">Get Early Access — Reserve My Spot →</a>
|
||||||
|
|||||||
71
server.js
71
server.js
@@ -53,6 +53,25 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS calc_submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT,
|
||||||
|
opt_in INTEGER DEFAULT 1,
|
||||||
|
homesites REAL,
|
||||||
|
property_type TEXT,
|
||||||
|
annual_income REAL,
|
||||||
|
payment_freq TEXT,
|
||||||
|
reserve_funds REAL,
|
||||||
|
interest_2025 REAL,
|
||||||
|
total_potential REAL,
|
||||||
|
op_interest REAL,
|
||||||
|
res_interest REAL,
|
||||||
|
ai_recommendation TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// Migrate existing DBs: add new columns if they don't exist yet
|
// Migrate existing DBs: add new columns if they don't exist yet
|
||||||
const cols = db.pragma('table_info(leads)').map(c => c.name);
|
const cols = db.pragma('table_info(leads)').map(c => c.name);
|
||||||
if (!cols.includes('org_name')) db.exec('ALTER TABLE leads ADD COLUMN org_name TEXT');
|
if (!cols.includes('org_name')) db.exec('ALTER TABLE leads ADD COLUMN org_name TEXT');
|
||||||
@@ -67,6 +86,19 @@ const insertLead = db.prepare(`
|
|||||||
|
|
||||||
const findByEmail = db.prepare(`SELECT id FROM leads WHERE email = ? LIMIT 1`);
|
const findByEmail = db.prepare(`SELECT id FROM leads WHERE email = ? LIMIT 1`);
|
||||||
|
|
||||||
|
const insertCalcSubmission = db.prepare(`
|
||||||
|
INSERT INTO calc_submissions
|
||||||
|
(email, opt_in, homesites, property_type, annual_income, payment_freq,
|
||||||
|
reserve_funds, interest_2025, total_potential, op_interest, res_interest, ai_recommendation)
|
||||||
|
VALUES
|
||||||
|
(@email, @optIn, @homesites, @propertyType, @annualIncome, @paymentFreq,
|
||||||
|
@reserveFunds, @interest2025, @totalPotential, @opInterest, @resInterest, @aiRecommendation)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getAllCalcSubmissions = db.prepare(`
|
||||||
|
SELECT * FROM calc_submissions ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
const getAllLeads = db.prepare(`
|
const getAllLeads = db.prepare(`
|
||||||
SELECT id, first_name, last_name, email, org_name, state, role, unit_count, beta_interest, source, created_at
|
SELECT id, first_name, last_name, email, org_name, state, role, unit_count, beta_interest, source, created_at
|
||||||
FROM leads
|
FROM leads
|
||||||
@@ -140,11 +172,36 @@ app.get('/api/leads', (req, res) => {
|
|||||||
|
|
||||||
// POST /api/calculate — AI-powered investment recommendation
|
// POST /api/calculate — AI-powered investment recommendation
|
||||||
app.post('/api/calculate', async (req, res) => {
|
app.post('/api/calculate', async (req, res) => {
|
||||||
|
function saveCalcSubmission(aiRecommendation) {
|
||||||
|
try {
|
||||||
|
insertCalcSubmission.run({
|
||||||
|
email: email?.trim() || null,
|
||||||
|
optIn: optIn ? 1 : 0,
|
||||||
|
homesites: homesites || null,
|
||||||
|
propertyType: propertyType || null,
|
||||||
|
annualIncome: annualIncome || null,
|
||||||
|
paymentFreq: paymentFreq || null,
|
||||||
|
reserveFunds: reserveFunds || null,
|
||||||
|
interest2025: interest2025 || null,
|
||||||
|
totalPotential: totalPotential || null,
|
||||||
|
opInterest: opInterest || null,
|
||||||
|
resInterest: resInterest || null,
|
||||||
|
aiRecommendation: aiRecommendation || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save calc submission:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!aiClient) {
|
if (!aiClient) {
|
||||||
|
saveCalcSubmission(null);
|
||||||
return res.status(503).json({ error: 'AI service not configured.' });
|
return res.status(503).json({ error: 'AI service not configured.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 } = req.body ?? {};
|
const {
|
||||||
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||||
|
email, optIn, totalPotential, opInterest, resInterest,
|
||||||
|
} = req.body ?? {};
|
||||||
|
|
||||||
if (!homesites || !annualIncome) {
|
if (!homesites || !annualIncome) {
|
||||||
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
||||||
@@ -185,13 +242,25 @@ Keep the tone professional and factual. No bullet points — flowing paragraph o
|
|||||||
|
|
||||||
if (AI_DEBUG) console.log('[AI_DEBUG] response:', text);
|
if (AI_DEBUG) console.log('[AI_DEBUG] response:', text);
|
||||||
|
|
||||||
|
saveCalcSubmission(text);
|
||||||
res.json({ recommendation: text });
|
res.json({ recommendation: text });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('AI API error:', err.message);
|
console.error('AI API error:', err.message);
|
||||||
|
saveCalcSubmission(null);
|
||||||
res.status(502).json({ error: 'AI service unavailable. Showing estimated result.' });
|
res.status(502).json({ error: 'AI service unavailable. Showing estimated result.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/calc-submissions — internal: list all calculator submissions
|
||||||
|
app.get('/api/calc-submissions', (req, res) => {
|
||||||
|
const secret = req.headers['x-admin-key'];
|
||||||
|
if (!secret || secret !== process.env.ADMIN_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized.' });
|
||||||
|
}
|
||||||
|
const rows = getAllCalcSubmissions.all();
|
||||||
|
res.json({ count: rows.length, submissions: rows });
|
||||||
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));
|
app.get('/api/health', (_req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
44
styles.css
44
styles.css
@@ -840,6 +840,50 @@ body {
|
|||||||
.calc-recalc { font-size: 14px; color: var(--gray-500); }
|
.calc-recalc { font-size: 14px; color: var(--gray-500); }
|
||||||
.calc-recalc:hover { color: var(--gray-200); }
|
.calc-recalc:hover { color: var(--gray-200); }
|
||||||
|
|
||||||
|
/* ── Calc email + opt-in ── */
|
||||||
|
.calc-email-row { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.calc-field--full { grid-column: 1 / -1; }
|
||||||
|
.calc-optional { font-weight: 400; color: var(--gray-600); font-size: 11px; }
|
||||||
|
.calc-optin-label {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
font-size: 13px; color: var(--gray-300); cursor: pointer; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.calc-optin-label input[type="checkbox"] {
|
||||||
|
margin-top: 2px; accent-color: var(--blue); flex-shrink: 0;
|
||||||
|
width: 15px; height: 15px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.calc-privacy {
|
||||||
|
font-size: 11px; color: var(--gray-600); line-height: 1.5;
|
||||||
|
border-left: 2px solid rgba(255,255,255,0.06); padding-left: 10px; margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Calc button spinner ── */
|
||||||
|
.calc-btn-loading { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.calc-spinner {
|
||||||
|
display: inline-block; width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Results refinement blurb ── */
|
||||||
|
.calc-refine-blurb {
|
||||||
|
display: flex; gap: 12px; align-items: flex-start;
|
||||||
|
background: rgba(14,165,233,0.07);
|
||||||
|
border: 1px solid rgba(14,165,233,0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.calc-refine-icon {
|
||||||
|
font-size: 18px; color: var(--sky); flex-shrink: 0; margin-top: 1px;
|
||||||
|
}
|
||||||
|
.calc-refine-blurb p {
|
||||||
|
font-size: 12.5px; color: var(--gray-400); line-height: 1.6; margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Screenshot Carousel ---- */
|
/* ---- Screenshot Carousel ---- */
|
||||||
.screenshot-carousel {
|
.screenshot-carousel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user