Compare commits
8 Commits
b658f50c9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b26903c4ee | |||
| ec0b8feac5 | |||
| 4bddd96b40 | |||
| 8a369f6a57 | |||
| f195c6082d | |||
| bf70efc0d7 | |||
| c95fd7d424 | |||
| 15d35cff66 |
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
ADMIN_KEY=your-admin-key-here
|
||||||
|
|
||||||
|
# AI ROI Estimator (OpenAI-compatible API)
|
||||||
|
AI_API_URL=https://integrate.api.nvidia.com/v1
|
||||||
|
AI_API_KEY=your_nvidia_api_key_here
|
||||||
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
|
AI_DEBUG=false
|
||||||
201
AI_SETUP.md
201
AI_SETUP.md
@@ -1,10 +1,8 @@
|
|||||||
# HOA LedgerIQ — AI API Configuration Guide
|
# HOA LedgerIQ — AI Investment Advisor Configuration
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The **Benefit Calculator widget** currently runs entirely client-side using conservative
|
The **Benefit Calculator** uses an AI model to generate a personalized investment recommendation. It supports any **OpenAI-compatible API** — including OpenAI, NVIDIA NIM, Together AI, Groq, Ollama, and others — configured via environment variables.
|
||||||
fixed-rate math. This guide explains how to upgrade it to call a real AI API
|
|
||||||
(Claude or OpenAI) so the recommendation text is generated dynamically by a language model.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,162 +11,72 @@ fixed-rate math. This guide explains how to upgrade it to call a real AI API
|
|||||||
```
|
```
|
||||||
Browser (app.js)
|
Browser (app.js)
|
||||||
└─► POST /api/calculate (server.js)
|
└─► POST /api/calculate (server.js)
|
||||||
└─► Anthropic / OpenAI API
|
└─► OpenAI-compatible API (AI_API_URL)
|
||||||
└─► Returns AI-generated investment recommendation text
|
└─► Returns AI-generated recommendation text
|
||||||
└─► JSON response back to browser
|
└─► JSON response back to browser
|
||||||
|
(falls back to client-side math text if unavailable)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1 — Add your API key to `.env`
|
## Configuration (.env)
|
||||||
|
|
||||||
Open `.env` in the project root and add one of the following:
|
Add these variables to your `.env` file (or systemd `EnvironmentFile`):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# For Claude (Anthropic) — recommended
|
# AI Investment Advisor (OpenAI-compatible API)
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
AI_API_URL=https://integrate.api.nvidia.com/v1
|
||||||
|
AI_API_KEY=your_api_key_here
|
||||||
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
|
|
||||||
# OR for OpenAI
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
OPENAI_API_KEY=sk-...
|
AI_DEBUG=false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Provider Examples
|
||||||
|
|
||||||
|
| Provider | AI_API_URL | Example Model |
|
||||||
|
|---|---|---|
|
||||||
|
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
|
||||||
|
| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `qwen/qwen3.5-397b-a17b` |
|
||||||
|
| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3-70b-chat-hf` |
|
||||||
|
| Groq | `https://api.groq.com/openai/v1` | `llama3-70b-8192` |
|
||||||
|
| Ollama (local) | `http://localhost:11434/v1` | `llama3` |
|
||||||
|
|
||||||
|
> If `AI_API_KEY` is not set, the `/api/calculate` endpoint returns 503 and the calculator falls back to client-side generated text automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Install the SDK
|
## How It Works
|
||||||
|
|
||||||
```bash
|
`server.js` initializes the OpenAI client with your configured base URL and key:
|
||||||
# Claude (Anthropic)
|
|
||||||
npm install @anthropic-ai/sdk
|
|
||||||
|
|
||||||
# OR OpenAI
|
|
||||||
npm install openai
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Add the `/api/calculate` endpoint to `server.js`
|
|
||||||
|
|
||||||
Add this block after the existing `/api/health` route:
|
|
||||||
|
|
||||||
### Using Claude (Anthropic)
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const Anthropic = require('@anthropic-ai/sdk');
|
const aiClient = AI_API_KEY
|
||||||
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
? new OpenAI({ apiKey: AI_API_KEY, baseURL: AI_API_URL })
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
app.post('/api/calculate', async (req, res) => {
|
The `POST /api/calculate` endpoint builds a prompt from the form inputs and calls:
|
||||||
const { homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 } = req.body ?? {};
|
|
||||||
|
|
||||||
if (!homesites || !annualIncome) {
|
```js
|
||||||
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
const completion = await aiClient.chat.completions.create({
|
||||||
}
|
model: AI_MODEL,
|
||||||
|
|
||||||
const fmt = n => '$' + Math.round(n).toLocaleString();
|
|
||||||
const freqLabel = { monthly: 'monthly', quarterly: 'quarterly', annually: 'annual' }[paymentFreq] || 'monthly';
|
|
||||||
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
|
||||||
|
|
||||||
const prompt = `You are a conservative HOA financial advisor. Given the following community data, provide a brief (3-4 sentence) plain-English investment income recommendation. Use only conservative, realistic estimates. Do not speculate beyond what the data supports.
|
|
||||||
|
|
||||||
Community: ${homesites}-unit ${typeLabel} association
|
|
||||||
Annual dues income: ${fmt(annualIncome)} (collected ${freqLabel})
|
|
||||||
Reserve fund balance: ${fmt(reserveFunds || 0)}
|
|
||||||
Interest income earned in 2025: ${fmt(interest2025 || 0)}
|
|
||||||
|
|
||||||
Provide a recommendation focused on:
|
|
||||||
1. How much of the reserve funds could conservatively be invested and in what vehicle (e.g. CD ladder, money market, T-bills)
|
|
||||||
2. How much operating cash could earn interest between collection and expense periods
|
|
||||||
3. A realistic estimated annual interest income potential
|
|
||||||
4. A single sentence comparing that to their 2025 actual if provided
|
|
||||||
|
|
||||||
Keep the tone professional and factual. No bullet points — flowing paragraph only.`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = await anthropic.messages.create({
|
|
||||||
model: 'claude-opus-4-6',
|
|
||||||
max_tokens: 300,
|
max_tokens: 300,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
});
|
|
||||||
|
|
||||||
const text = message.content[0]?.text ?? '';
|
|
||||||
res.json({ recommendation: text });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('AI API error:', err.message);
|
|
||||||
res.status(502).json({ error: 'AI service unavailable. Showing estimated result.' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using OpenAI (GPT-4o)
|
`app.js` calls this endpoint on form submit and falls back to the client-side text if the server returns an error or is unreachable.
|
||||||
|
|
||||||
```js
|
|
||||||
const OpenAI = require('openai');
|
|
||||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
||||||
|
|
||||||
app.post('/api/calculate', async (req, res) => {
|
|
||||||
const { homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 } = req.body ?? {};
|
|
||||||
|
|
||||||
// ... (same prompt construction as above) ...
|
|
||||||
|
|
||||||
try {
|
|
||||||
const completion = await openai.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
max_tokens: 300,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = completion.choices[0]?.message?.content ?? '';
|
|
||||||
res.json({ recommendation: text });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('AI API error:', err.message);
|
|
||||||
res.status(502).json({ error: 'AI service unavailable.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4 — Update `app.js` to call the API endpoint
|
## Restart & Verify
|
||||||
|
|
||||||
In the `initCalculator` function, replace this line in the submitBtn handler:
|
|
||||||
|
|
||||||
```js
|
|
||||||
document.getElementById('calcAiText').textContent = ai; // current: client-side text
|
|
||||||
```
|
|
||||||
|
|
||||||
With this:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// Call the AI endpoint; fall back to client-side text if unavailable
|
|
||||||
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; // fallback
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
document.getElementById('calcAiText').textContent = ai; // fallback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: The `submitBtn` handler must be declared `async` for the `await` above to work:
|
|
||||||
> ```js
|
|
||||||
> submitBtn?.addEventListener('click', async () => { ... });
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5 — Restart the server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl restart hoaledgeriqweb
|
sudo systemctl restart hoaledgeriqweb
|
||||||
|
|
||||||
# Verify the endpoint is live
|
# Test the endpoint
|
||||||
curl -X POST http://localhost:3000/api/calculate \
|
curl -X POST http://localhost:3000/api/calculate \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"homesites":150,"propertyType":"sfh","annualIncome":300000,"paymentFreq":"monthly","reserveFunds":500000,"interest2025":4200}'
|
-d '{"homesites":150,"propertyType":"sfh","annualIncome":300000,"paymentFreq":"monthly","reserveFunds":500000,"interest2025":4200}'
|
||||||
@@ -176,44 +84,35 @@ curl -X POST http://localhost:3000/api/calculate \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prompt Tuning Tips
|
## Prompt Tuning
|
||||||
|
|
||||||
The prompt in Step 3 is the core of the AI's behavior. You can adjust it to:
|
Edit the prompt in `server.js` (inside the `/api/calculate` route) to adjust tone or output:
|
||||||
|
|
||||||
| Goal | Change |
|
| Goal | Change |
|
||||||
|---|---|
|
|---|---|
|
||||||
| More optimistic estimates | Change "conservative" to "moderate" in the prompt |
|
| More optimistic estimates | Change "conservative" to "moderate" |
|
||||||
| Shorter output | Reduce `max_tokens` to `150` |
|
| Shorter output | Reduce `max_tokens` to `150` |
|
||||||
| Include specific investment products | Add "mention specific products like Vanguard Federal Money Market or 6-month T-bills" |
|
| Specific products | Add "mention Vanguard Federal Money Market or 6-month T-bills" |
|
||||||
| Add a disclaimer | Append "End with one sentence reminding them this is not financial advice." |
|
| Add disclaimer | Append "End with one sentence reminding them this is not financial advice." |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cost Estimate
|
## Debug Logging
|
||||||
|
|
||||||
| Model | Approx. cost per calculator use |
|
Set `AI_DEBUG=true` in `.env` to log the full prompt and response to the server console. Useful for testing new models or prompt changes.
|
||||||
|---|---|
|
|
||||||
| Claude Opus 4.6 | ~$0.002 |
|
|
||||||
| Claude Sonnet 4.6 | ~$0.0004 |
|
|
||||||
| GPT-4o | ~$0.002 |
|
|
||||||
| GPT-4o-mini | ~$0.00005 |
|
|
||||||
|
|
||||||
For a landing page with low traffic, even Claude Opus is negligible cost. For scale,
|
|
||||||
`claude-sonnet-4-6` is the best balance of quality and price.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Notes
|
## Security
|
||||||
|
|
||||||
- **Never expose your API key in `app.js` or any client-side code.** All AI calls must go through `server.js`.
|
- **Never put `AI_API_KEY` in `app.js`** — all AI calls go through `server.js`.
|
||||||
- Rate-limit the `/api/calculate` endpoint to prevent abuse (e.g. with `express-rate-limit`):
|
- Rate-limit the endpoint to prevent abuse:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install express-rate-limit
|
npm install express-rate-limit --ignore-scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const calcLimiter = rateLimit({ windowMs: 60 * 1000, max: 10 }); // 10 req/min per IP
|
app.use('/api/calculate', rateLimit({ windowMs: 60_000, max: 10 }));
|
||||||
app.use('/api/calculate', calcLimiter);
|
|
||||||
```
|
```
|
||||||
|
|||||||
29
app.js
29
app.js
@@ -52,19 +52,32 @@
|
|||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
|
||||||
submitBtn?.addEventListener('click', () => {
|
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 () => {
|
||||||
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;
|
||||||
const annualIncome = parseFloat(document.getElementById('calcAnnualIncome').value) || 0;
|
const annualIncome = parseFloat(document.getElementById('calcAnnualIncome').value) || 0;
|
||||||
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
|
||||||
@@ -116,11 +129,23 @@
|
|||||||
ai += `This would represent entirely new interest income for your community at no additional risk.`;
|
ai += `This would represent entirely new interest income for your community at no additional risk.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('calcAiText').textContent = ai;
|
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
|
||||||
|
try {
|
||||||
|
await fetch('/api/calculate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||||
|
email: calcEmail, optIn: calcOptIn,
|
||||||
|
totalPotential, opInterest, resInterest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (_) { /* best-effort — DB save failed silently */ }
|
||||||
|
|
||||||
// ── 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');
|
||||||
});
|
});
|
||||||
|
|||||||
33
index.html
33
index.html
@@ -8,6 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<link rel="cononical" href="https://www.hoaledgeriq.com" />
|
||||||
</head>
|
</head>
|
||||||
<!-- Google tag (gtag.js) -->
|
<!-- Google tag (gtag.js) -->
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-RTWNVXPMRF"></script>
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-RTWNVXPMRF"></script>
|
||||||
@@ -436,8 +437,8 @@
|
|||||||
|
|
||||||
<div class="calc-header">
|
<div class="calc-header">
|
||||||
<div class="section-label">ROI Calculator</div>
|
<div class="section-label">ROI Calculator</div>
|
||||||
<h2>See What LedgerIQ Could Earn Your Community</h2>
|
<h2>See What HOA LedgerIQ Could Earn Your Community</h2>
|
||||||
<p>Answer a few quick questions for a personalized, conservative interest income estimate.</p>
|
<p>Answer a few quick questions for a personalized, AI-Driven interest income estimate.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- INPUTS -->
|
<!-- INPUTS -->
|
||||||
@@ -488,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 HOA LedgerIQ to provide informative insights over time.</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>
|
||||||
|
|
||||||
@@ -514,9 +532,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-ai-bubble">
|
<!-- AI text captured to DB only — not displayed -->
|
||||||
<span class="ai-label">✦ LedgerIQ AI Estimate</span>
|
<p id="calcAiText" style="display:none"></p>
|
||||||
<p id="calcAiText"></p>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="calc-cta">
|
<div class="calc-cta">
|
||||||
|
|||||||
1286
package-lock.json
generated
Normal file
1286
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"express": "^4.18.3"
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"openai": "^6.27.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
137
server.js
137
server.js
@@ -14,9 +14,20 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
|
const OpenAI = require('openai');
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────
|
// ── Config ──────────────────────────────────────────────
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// ── AI client (OpenAI-compatible) ────────────────────────
|
||||||
|
const AI_API_URL = process.env.AI_API_URL || 'https://api.openai.com/v1';
|
||||||
|
const AI_API_KEY = process.env.AI_API_KEY || '';
|
||||||
|
const AI_MODEL = process.env.AI_MODEL || 'gpt-4o-mini';
|
||||||
|
const AI_DEBUG = process.env.AI_DEBUG === 'true';
|
||||||
|
|
||||||
|
const aiClient = AI_API_KEY
|
||||||
|
? new OpenAI({ apiKey: AI_API_KEY, baseURL: AI_API_URL })
|
||||||
|
: null;
|
||||||
const DB_DIR = path.join(__dirname, 'data');
|
const DB_DIR = path.join(__dirname, 'data');
|
||||||
const DB_PATH = path.join(DB_DIR, 'leads.db');
|
const DB_PATH = path.join(DB_DIR, 'leads.db');
|
||||||
|
|
||||||
@@ -42,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');
|
||||||
@@ -56,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
|
||||||
@@ -127,6 +170,100 @@ app.get('/api/leads', (req, res) => {
|
|||||||
res.json({ count: leads.length, leads });
|
res.json({ count: leads.length, leads });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/calculate — AI-powered investment recommendation
|
||||||
|
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) {
|
||||||
|
saveCalcSubmission(null);
|
||||||
|
return res.status(503).json({ error: 'AI service not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||||
|
email, optIn, totalPotential, opInterest, resInterest,
|
||||||
|
} = req.body ?? {};
|
||||||
|
|
||||||
|
if (!homesites || !annualIncome) {
|
||||||
|
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = n => '$' + Math.round(n).toLocaleString();
|
||||||
|
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
||||||
|
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;
|
||||||
|
const installmentAmt = annualIncome / freqDivisor;
|
||||||
|
const freqDesc = { monthly: 'monthly installments', quarterly: 'quarterly installments', annually: 'one lump-sum annual payment' }[paymentFreq] || 'monthly installments';
|
||||||
|
|
||||||
|
const prompt = `You are a conservative HOA financial advisor. Given the following community data, provide a brief (3-4 sentence) plain-English investment income recommendation. Use only conservative, realistic estimates. Do not speculate beyond what the data supports.
|
||||||
|
|
||||||
|
Community: ${homesites}-unit ${typeLabel} association
|
||||||
|
Total annual dues income: ${fmt(annualIncome)} per year
|
||||||
|
Dues collection schedule: collected in ${freqDesc} of approximately ${fmt(installmentAmt)} per cycle (this affects operating cash flow timing, not the total annual amount)
|
||||||
|
Reserve fund balance: ${fmt(reserveFunds || 0)}
|
||||||
|
Interest income earned in 2025: ${fmt(interest2025 || 0)}
|
||||||
|
|
||||||
|
Provide a recommendation focused on:
|
||||||
|
1. How much of the reserve funds could conservatively be invested and in what vehicle (e.g. CD ladder, money market, T-bills)
|
||||||
|
2. How much operating cash float could earn interest between each collection cycle and upcoming expenses, given the ${freqDesc} schedule
|
||||||
|
3. A realistic estimated annual interest income potential based on the full ${fmt(annualIncome)} annual dues total
|
||||||
|
4. A single sentence comparing that to their 2025 actual if provided
|
||||||
|
|
||||||
|
Keep the tone professional and factual. No bullet points — flowing paragraph only.`;
|
||||||
|
|
||||||
|
if (AI_DEBUG) {
|
||||||
|
console.log('[AI_DEBUG] model:', AI_MODEL);
|
||||||
|
console.log('[AI_DEBUG] prompt:', prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await aiClient.chat.completions.create({
|
||||||
|
model: AI_MODEL,
|
||||||
|
max_tokens: 300,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = completion.choices[0]?.message?.content ?? '';
|
||||||
|
|
||||||
|
if (AI_DEBUG) console.log('[AI_DEBUG] response:', text);
|
||||||
|
|
||||||
|
saveCalcSubmission(text);
|
||||||
|
res.json({ recommendation: text });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AI API error:', err.message);
|
||||||
|
saveCalcSubmission(null);
|
||||||
|
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