← back to Butlr
polish: (1) 44-business preset directory across 13 categories with IVR notes per business — Chase/AmEx/Cedars-Sinai/UCLA/Anthem/Delta/Comcast/IRS/etc; (2) 4-step onboarding wizard (category → business picker → goal + account → callback + consent) replacing the single mega-form, with progress bar + hidden-input state carry; (3) Twilio Voice worker — dry-run mode by default, advances calls through queued → dialing → on_hold → connected → done over ~50s simulated; live mode scaffolded behind TWILIO_DRY_RUN=0 + creds; 5s tick interval.
c889dbbf4553335c106a56e534a02418f9b8b433 · 2026-05-12 11:55:27 -0700 · SteveStudio2
Files touched
A data/businesses.jsonM lib/data.jsA lib/twilio.jsM public/css/site.cssA public/js/wizard.jsM routes/public.jsM server.jsA views/partials/wizard-progress.ejsA views/partials/wizard-state.ejsA views/public/wizard-1-category.ejsA views/public/wizard-2-business.ejsA views/public/wizard-3-details.ejsA views/public/wizard-4-callback.ejs
Diff
commit c889dbbf4553335c106a56e534a02418f9b8b433
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 11:55:27 2026 -0700
polish: (1) 44-business preset directory across 13 categories with IVR notes per business — Chase/AmEx/Cedars-Sinai/UCLA/Anthem/Delta/Comcast/IRS/etc; (2) 4-step onboarding wizard (category → business picker → goal + account → callback + consent) replacing the single mega-form, with progress bar + hidden-input state carry; (3) Twilio Voice worker — dry-run mode by default, advances calls through queued → dialing → on_hold → connected → done over ~50s simulated; live mode scaffolded behind TWILIO_DRY_RUN=0 + creds; 5s tick interval.
---
data/businesses.json | 310 +++++++++++++++++++++++++++++++++++++
lib/data.js | 17 ++
lib/twilio.js | 131 ++++++++++++++++
public/css/site.css | 56 +++++++
public/js/wizard.js | 28 ++++
routes/public.js | 146 ++++++++++++++---
server.js | 10 ++
views/partials/wizard-progress.ejs | 9 ++
views/partials/wizard-state.ejs | 12 ++
views/public/wizard-1-category.ejs | 32 ++++
views/public/wizard-2-business.ejs | 64 ++++++++
views/public/wizard-3-details.ejs | 71 +++++++++
views/public/wizard-4-callback.ejs | 93 +++++++++++
13 files changed, 957 insertions(+), 22 deletions(-)
diff --git a/data/businesses.json b/data/businesses.json
new file mode 100644
index 0000000..7b14be1
--- /dev/null
+++ b/data/businesses.json
@@ -0,0 +1,310 @@
+[
+ {
+ "slug": "chase-credit-card",
+ "name": "Chase Credit Card Customer Service",
+ "phone": "1-800-432-3117",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "After greeting, say 'representative' twice or press 0. Hold time avg 8-15 min weekdays."
+ },
+ {
+ "slug": "amex-customer-service",
+ "name": "American Express Customer Service",
+ "phone": "1-800-528-4800",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "Say 'representative'. AmEx routes Platinum/Centurion to dedicated lines via account verification."
+ },
+ {
+ "slug": "capital-one",
+ "name": "Capital One Credit Card",
+ "phone": "1-800-227-4825",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "Press 2 for credit card, then say 'representative'."
+ },
+ {
+ "slug": "citi-cards",
+ "name": "Citi Cards Customer Service",
+ "phone": "1-800-950-5114",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "Press # then 0 to skip account-number prompt and go to queue."
+ },
+ {
+ "slug": "discover",
+ "name": "Discover Card Customer Service",
+ "phone": "1-800-347-2683",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "Say 'agent' at any point. Hold time typically short (5-10 min)."
+ },
+ {
+ "slug": "boa-credit-card",
+ "name": "Bank of America Credit Card",
+ "phone": "1-800-732-9194",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "Press 0 multiple times to reach agent."
+ },
+ {
+ "slug": "wells-fargo-cc",
+ "name": "Wells Fargo Credit Card",
+ "phone": "1-800-642-4720",
+ "category_hints": ["credit-card-increase", "credit-card-dispute"],
+ "ivr_notes": "Say 'customer service' or press 0."
+ },
+ {
+ "slug": "cedars-sinai-billing",
+ "name": "Cedars-Sinai Patient Billing",
+ "phone": "1-800-233-2771",
+ "category_hints": ["hospital-billing"],
+ "ivr_notes": "Press 1 for billing, then 2 for billing question. Hold time can be 30+ min during peak."
+ },
+ {
+ "slug": "ucla-health-billing",
+ "name": "UCLA Health Billing",
+ "phone": "1-310-301-8800",
+ "category_hints": ["hospital-billing"],
+ "ivr_notes": "Press 2 for patient financial services."
+ },
+ {
+ "slug": "kaiser-billing",
+ "name": "Kaiser Permanente Billing",
+ "phone": "1-800-390-3510",
+ "category_hints": ["hospital-billing", "insurance-claim"],
+ "ivr_notes": "Members say 'member services'. Non-members press 0."
+ },
+ {
+ "slug": "anthem-blue-cross",
+ "name": "Anthem Blue Cross",
+ "phone": "1-855-333-5730",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Have member ID ready. Press 1 for member, then 2 for claims."
+ },
+ {
+ "slug": "united-healthcare",
+ "name": "UnitedHealthcare",
+ "phone": "1-866-633-2446",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Member ID required. Say 'representative' to bypass IVR."
+ },
+ {
+ "slug": "aetna",
+ "name": "Aetna Member Services",
+ "phone": "1-800-872-3862",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Member ID + DOB required. Hold typically 10-20 min."
+ },
+ {
+ "slug": "delta-airlines",
+ "name": "Delta Air Lines Reservations",
+ "phone": "1-800-221-1212",
+ "category_hints": ["airline-change"],
+ "ivr_notes": "SkyMiles members get priority. Say 'agent' at greeting."
+ },
+ {
+ "slug": "american-airlines",
+ "name": "American Airlines Reservations",
+ "phone": "1-800-433-7300",
+ "category_hints": ["airline-change"],
+ "ivr_notes": "AAdvantage Executive Platinum has its own line. Press 0 repeatedly to reach agent."
+ },
+ {
+ "slug": "united-airlines",
+ "name": "United Airlines Reservations",
+ "phone": "1-800-864-8331",
+ "category_hints": ["airline-change"],
+ "ivr_notes": "Premier members can press 2 for direct routing."
+ },
+ {
+ "slug": "southwest-airlines",
+ "name": "Southwest Airlines Customer Service",
+ "phone": "1-800-435-9792",
+ "category_hints": ["airline-change"],
+ "ivr_notes": "Generally short holds. Say 'agent' to skip menu."
+ },
+ {
+ "slug": "spectrum",
+ "name": "Spectrum (Charter) Cable & Internet",
+ "phone": "1-833-267-6094",
+ "category_hints": ["cable-cancel"],
+ "ivr_notes": "Say 'cancel service' to route to retention. Retention will offer aggressive discounts."
+ },
+ {
+ "slug": "comcast-xfinity",
+ "name": "Comcast / Xfinity",
+ "phone": "1-800-934-6489",
+ "category_hints": ["cable-cancel", "utility-dispute"],
+ "ivr_notes": "Say 'cancel'. Routes to retention. Have account number ready."
+ },
+ {
+ "slug": "att-wireless",
+ "name": "AT&T Wireless",
+ "phone": "1-800-331-0500",
+ "category_hints": ["cable-cancel"],
+ "ivr_notes": "Press 0 repeatedly. Say 'cancel' to route to loyalty team."
+ },
+ {
+ "slug": "verizon-wireless",
+ "name": "Verizon Wireless Customer Service",
+ "phone": "1-800-922-0204",
+ "category_hints": ["cable-cancel"],
+ "ivr_notes": "Say 'cancel service' for loyalty team routing. Have last 4 SSN ready."
+ },
+ {
+ "slug": "tmobile",
+ "name": "T-Mobile Customer Care",
+ "phone": "1-800-937-8997",
+ "category_hints": ["cable-cancel"],
+ "ivr_notes": "Press 0 or say 'agent'. T-Mobile's IVR is friendlier than competitors."
+ },
+ {
+ "slug": "ladwp",
+ "name": "Los Angeles Department of Water & Power",
+ "phone": "1-800-342-5397",
+ "category_hints": ["utility-dispute"],
+ "ivr_notes": "Press 3 for billing. Long holds common."
+ },
+ {
+ "slug": "socalgas",
+ "name": "Southern California Gas",
+ "phone": "1-800-427-2200",
+ "category_hints": ["utility-dispute"],
+ "ivr_notes": "Press 2 for billing question, then 1 for residential."
+ },
+ {
+ "slug": "sce-edison",
+ "name": "Southern California Edison",
+ "phone": "1-800-655-4555",
+ "category_hints": ["utility-dispute"],
+ "ivr_notes": "Press 2 for billing, then 1 for residential."
+ },
+ {
+ "slug": "opentable",
+ "name": "OpenTable Customer Support",
+ "phone": "1-800-673-6822",
+ "category_hints": ["restaurant-reservation"],
+ "ivr_notes": "For consumer reservation help. Restaurants direct have higher availability."
+ },
+ {
+ "slug": "irs",
+ "name": "IRS Individual Tax Help",
+ "phone": "1-800-829-1040",
+ "category_hints": ["other"],
+ "ivr_notes": "Famously long holds (1-3 hrs). Press 2 then 1 then 3 then 2 to route fastest. Mon-Fri 7am-7pm local."
+ },
+ {
+ "slug": "social-security",
+ "name": "Social Security Administration",
+ "phone": "1-800-772-1213",
+ "category_hints": ["other"],
+ "ivr_notes": "Long holds. Say 'agent' to bypass automated menu."
+ },
+ {
+ "slug": "medicare",
+ "name": "Medicare",
+ "phone": "1-800-633-4227",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Press 0 to bypass IVR menus. Have Medicare ID ready."
+ },
+ {
+ "slug": "amazon-cs",
+ "name": "Amazon Customer Service",
+ "phone": "1-888-280-4331",
+ "category_hints": ["warranty-claim", "subscription-cancel"],
+ "ivr_notes": "Generally pickup is fast. Have order # ready."
+ },
+ {
+ "slug": "apple-support",
+ "name": "Apple Support",
+ "phone": "1-800-275-2273",
+ "category_hints": ["warranty-claim"],
+ "ivr_notes": "Apple ID required. Hold typically <10 min for Genius routing."
+ },
+ {
+ "slug": "best-buy-geek",
+ "name": "Best Buy Geek Squad",
+ "phone": "1-800-433-5778",
+ "category_hints": ["warranty-claim"],
+ "ivr_notes": "Press 0. Have member ID and serial number ready."
+ },
+ {
+ "slug": "rocket-mortgage",
+ "name": "Rocket Mortgage Servicing",
+ "phone": "1-800-508-0944",
+ "category_hints": ["mortgage-loan"],
+ "ivr_notes": "Press 1 for existing mortgage. Have loan number ready."
+ },
+ {
+ "slug": "wells-fargo-mortgage",
+ "name": "Wells Fargo Home Mortgage",
+ "phone": "1-800-357-6675",
+ "category_hints": ["mortgage-loan"],
+ "ivr_notes": "Press 2 for existing loan, then 3 for hardship."
+ },
+ {
+ "slug": "netflix",
+ "name": "Netflix Customer Service",
+ "phone": "1-866-579-7172",
+ "category_hints": ["subscription-cancel"],
+ "ivr_notes": "Cancellation is easier through web. Phone agents handle billing disputes."
+ },
+ {
+ "slug": "comcast-xfinity-mobile",
+ "name": "Xfinity Mobile Customer Service",
+ "phone": "1-888-936-4968",
+ "category_hints": ["cable-cancel"],
+ "ivr_notes": "Press 0 to reach agent."
+ },
+ {
+ "slug": "doordash-cs",
+ "name": "DoorDash Customer Service",
+ "phone": "1-855-973-1040",
+ "category_hints": ["other"],
+ "ivr_notes": "Order # required."
+ },
+ {
+ "slug": "uber-support",
+ "name": "Uber Critical Safety / Account Support",
+ "phone": "1-800-593-7069",
+ "category_hints": ["other"],
+ "ivr_notes": "For critical issues only — most support is in-app."
+ },
+ {
+ "slug": "geico",
+ "name": "Geico Customer Service",
+ "phone": "1-800-841-3000",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Have policy # ready. Press 2 for existing policy."
+ },
+ {
+ "slug": "state-farm",
+ "name": "State Farm Customer Service",
+ "phone": "1-800-782-8332",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Local agent routing typically faster than central."
+ },
+ {
+ "slug": "progressive",
+ "name": "Progressive Insurance",
+ "phone": "1-800-776-4737",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Press 2 for existing policy, then 1 for claim."
+ },
+ {
+ "slug": "allstate",
+ "name": "Allstate Customer Service",
+ "phone": "1-800-255-7828",
+ "category_hints": ["insurance-claim"],
+ "ivr_notes": "Press 0 for general agent."
+ },
+ {
+ "slug": "us-bank",
+ "name": "US Bank Customer Service",
+ "phone": "1-800-872-2657",
+ "category_hints": ["credit-card-increase", "mortgage-loan"],
+ "ivr_notes": "Press 0 to bypass account-number prompt."
+ },
+ {
+ "slug": "ally",
+ "name": "Ally Bank Customer Care",
+ "phone": "1-877-247-2559",
+ "category_hints": ["mortgage-loan"],
+ "ivr_notes": "Generally fast pickup. 24/7 support."
+ }
+]
diff --git a/lib/data.js b/lib/data.js
index 0aa6bd9..b30acaf 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -157,8 +157,25 @@ function updateStatus(id, status) {
return all[idx];
}
+// ── Preset business directory ─────────────────────────────────────────
+let _businesses = null;
+function loadBusinesses() {
+ if (_businesses) return _businesses;
+ try { _businesses = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'businesses.json'), 'utf8')); }
+ catch (e) { _businesses = []; }
+ return _businesses;
+}
+function businessBySlug(slug) {
+ return loadBusinesses().find(b => b.slug === slug) || null;
+}
+function businessesForCategory(catId) {
+ if (!catId) return loadBusinesses();
+ return loadBusinesses().filter(b => (b.category_hints || []).includes(catId));
+}
+
module.exports = {
CATEGORIES, categoryById,
validatePhone, sanitizeRow,
addCall, listCalls, getCall, updateStatus,
+ loadBusinesses, businessBySlug, businessesForCategory,
};
diff --git a/lib/twilio.js b/lib/twilio.js
new file mode 100644
index 0000000..093b08d
--- /dev/null
+++ b/lib/twilio.js
@@ -0,0 +1,131 @@
+// Twilio Voice integration — dial, wait on hold, detect human, bridge to caller.
+//
+// PHASE 2 — DRY-RUN BY DEFAULT.
+//
+// Modes (env-controlled):
+// - TWILIO_DRY_RUN=1 → log what we would do, advance status, never spend credits (DEFAULT)
+// - TWILIO_DRY_RUN=0 → real outbound calls via Twilio
+//
+// Requires env when live:
+// TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, PUBLIC_URL
+//
+// Flow (live mode, planned):
+// 1. POST /v1/Accounts/{sid}/Calls.json
+// - To: business_phone
+// - From: TWILIO_PHONE_NUMBER
+// - Url: ${PUBLIC_URL}/twilio/twiml/hold/${call_id}
+// - StatusCallback: ${PUBLIC_URL}/twilio/status/${call_id}
+// - MachineDetection: 'DetectMessageEnd' (separate IVR from human)
+// - AmdStatusCallback: ${PUBLIC_URL}/twilio/amd/${call_id}
+// 2. Hold TwiML: <Play loop="0"> a silence file + <Gather> with speech_timeout to listen for human
+// 3. On AmdStatusCallback with AnsweredBy=human → bridge:
+// - REST: Modify the call's TwiML to <Dial><Number>callback_phone</Number></Dial>
+// - Optional: <Say> a one-liner with the goal so the rep hears context
+// 4. On call.completed → update status to 'done' (or 'failed' if duration < threshold)
+//
+// The worker polls data/calls.json for status='queued' rows and dispatches.
+// 5-second tick. PM2 keeps it alive in prod.
+
+const fs = require('fs');
+const path = require('path');
+const data = require('./data');
+
+const DRY_RUN = process.env.TWILIO_DRY_RUN !== '0'; // default ON
+
+function log(...args) { console.log('[twilio]', new Date().toISOString(), ...args); }
+
+// Dry-run state machine — simulates a real call advancing through stages.
+// queued → dialing → on_hold → connected → done
+// Each transition takes a few seconds so the UI shows live progression.
+const DRY_RUN_STAGES = [
+ { from: 'queued', to: 'dialing', delay_ms: 2000 },
+ { from: 'dialing', to: 'on_hold', delay_ms: 4000 },
+ { from: 'on_hold', to: 'connected', delay_ms: 12000 }, // simulated 12s hold
+ { from: 'connected', to: 'done', delay_ms: 30000 }, // simulated 30s call
+];
+
+function pickNextStage(currentStatus) {
+ return DRY_RUN_STAGES.find(s => s.from === currentStatus);
+}
+
+async function dialCall(call) {
+ if (DRY_RUN) {
+ log(`DRY-RUN call=${call.id} business="${call.business_name}" phone=${call.business_phone}`);
+ log(` goal="${call.goal.slice(0, 80)}…"`);
+ log(` callback=${call.callback_phone} (${call.callback_name})`);
+ data.updateStatus(call.id, 'dialing');
+ return { ok: true, dry_run: true };
+ }
+ // LIVE — would call Twilio REST API here
+ // const accountSid = process.env.TWILIO_ACCOUNT_SID;
+ // const authToken = process.env.TWILIO_AUTH_TOKEN;
+ // const fromNum = process.env.TWILIO_PHONE_NUMBER;
+ // const publicUrl = process.env.PUBLIC_URL;
+ // const body = new URLSearchParams({
+ // To: call.business_phone,
+ // From: fromNum,
+ // Url: `${publicUrl}/twilio/twiml/hold/${call.id}`,
+ // StatusCallback: `${publicUrl}/twilio/status/${call.id}`,
+ // MachineDetection: 'DetectMessageEnd',
+ // AmdStatusCallback: `${publicUrl}/twilio/amd/${call.id}`,
+ // AmdStatusCallbackMethod: 'POST',
+ // });
+ // const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`, {
+ // method: 'POST',
+ // headers: {
+ // 'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
+ // 'Content-Type': 'application/x-www-form-urlencoded',
+ // },
+ // body,
+ // });
+ // const json = await resp.json();
+ // if (!resp.ok) { log('LIVE dial failed:', json); return { ok: false, error: json }; }
+ // data.updateStatus(call.id, 'dialing');
+ // return { ok: true, sid: json.sid };
+ log('LIVE mode requested but not yet implemented — leaving call queued');
+ return { ok: false, error: 'live_mode_not_implemented' };
+}
+
+function advanceDryRun(call) {
+ const stage = pickNextStage(call.status);
+ if (!stage) return false;
+ const updatedAt = call.updated_at ? new Date(call.updated_at).getTime() : 0;
+ const age = Date.now() - updatedAt;
+ if (age < stage.delay_ms) return false;
+ log(`DRY-RUN call=${call.id} ${stage.from} → ${stage.to}`);
+ data.updateStatus(call.id, stage.to);
+ return true;
+}
+
+let _tickRunning = false;
+async function tick() {
+ if (_tickRunning) return;
+ _tickRunning = true;
+ try {
+ const all = data.listCalls(); // listCalls returns the masked view, but status/id are unmasked
+ for (const c of all) {
+ // Get unmasked row for actual work
+ const full = data.getCall(c.id, true);
+ if (!full) continue;
+
+ if (full.status === 'queued') {
+ await dialCall(full);
+ } else if (DRY_RUN && ['dialing','on_hold','connected'].includes(full.status)) {
+ advanceDryRun(full);
+ }
+ }
+ } catch (e) {
+ log('tick error:', e.message);
+ } finally {
+ _tickRunning = false;
+ }
+}
+
+function start({ intervalMs = 5000 } = {}) {
+ log(`worker starting · DRY_RUN=${DRY_RUN} · interval=${intervalMs}ms`);
+ setInterval(tick, intervalMs);
+ // also tick once immediately
+ setTimeout(tick, 500);
+}
+
+module.exports = { start, tick, dialCall, DRY_RUN };
diff --git a/public/css/site.css b/public/css/site.css
index a2fec23..b94184c 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -294,3 +294,59 @@ html[data-theme='dark'] .theme-toggle-sun { display: none; }
.site-footer .wrap p { margin: .25rem 0; }
.site-footer a { color: var(--text-muted); }
.site-footer a:hover { color: var(--text); }
+
+/* Wizard progress bar */
+.wizard-progress { margin: 1rem 0 2rem; }
+.wizard-progress ol {
+ list-style: none; padding: 0; margin: 0 0 .5rem;
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: .25rem;
+}
+.wizard-progress li {
+ display: flex; align-items: center; gap: .4rem;
+ padding: .35rem .55rem; font-size: .8rem;
+ color: var(--text-muted);
+ border-bottom: 2px solid var(--border);
+}
+.wizard-progress li.done { color: var(--text); border-bottom-color: var(--accent); }
+.wizard-progress li.current { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
+.wizard-progress .wp-num {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 22px; height: 22px; border-radius: 50%;
+ background: var(--bg-alt); color: var(--text-muted);
+ font-size: .75rem; font-weight: 600; flex-shrink: 0;
+}
+.wizard-progress li.done .wp-num { background: var(--accent); color: var(--accent-fg); }
+.wizard-progress li.current .wp-num { background: var(--accent); color: var(--accent-fg); }
+.wizard-progress-bar { height: 3px; background: var(--bg-alt); border-radius: 999px; overflow: hidden; }
+.wizard-progress-fill { height: 100%; background: var(--accent); transition: width .35s ease; }
+@media (max-width: 600px) {
+ .wizard-progress ol { grid-template-columns: 1fr 1fr; gap: .5rem; }
+}
+
+/* Business picker (step 2) */
+.business-pick {
+ display: flex; flex-direction: column; gap: .55rem; margin: .5rem 0;
+}
+.business-pick-card {
+ display: flex; align-items: flex-start; gap: .75rem;
+ padding: .85rem 1rem;
+ background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
+ cursor: pointer;
+ transition: border-color .15s, background .15s;
+}
+.business-pick-card:hover { border-color: var(--accent); }
+.business-pick-card.selected { border-color: var(--accent); background: rgba(37,69,179,.06); }
+.business-pick-card input { margin-top: .3rem; flex-shrink: 0; }
+.business-pick-body { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-width: 0; }
+.business-pick-body strong { font-family: var(--font-serif); font-weight: 500; font-size: 1rem; }
+.business-pick-ivr { font-style: italic; }
+.business-pick-custom { margin-top: .25rem; background: var(--surface); }
+.business-pick-custom span { font-weight: 500; }
+
+/* Wizard review card (step 4) */
+.review-card {
+ background: var(--bg-alt); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 1rem 1.25rem; margin: 1.25rem 0;
+}
+.review-card h3 { font-family: var(--font-serif); font-size: 1.1rem; margin: 0 0 .5rem; }
+.review-card .kv { grid-template-columns: max-content 1fr; gap: .25rem 1rem; }
diff --git a/public/js/wizard.js b/public/js/wizard.js
new file mode 100644
index 0000000..283c02a
--- /dev/null
+++ b/public/js/wizard.js
@@ -0,0 +1,28 @@
+// Wizard helpers — toggle "selected" state on business radio click,
+// auto-blank custom-name fields when a preset is picked.
+(function(){
+ // Business picker radios — visual feedback + auto-clear custom inputs
+ document.querySelectorAll('.business-pick-card').forEach(label => {
+ label.addEventListener('click', () => {
+ document.querySelectorAll('.business-pick-card.selected').forEach(el => el.classList.remove('selected'));
+ label.classList.add('selected');
+ });
+ });
+
+ // Category picker (step 1)
+ document.querySelectorAll('.category-pick-card').forEach(label => {
+ label.addEventListener('click', () => {
+ document.querySelectorAll('.category-pick-card.selected').forEach(el => el.classList.remove('selected'));
+ label.classList.add('selected');
+ });
+ });
+
+ // Phone formatting on blur
+ document.querySelectorAll('input[type=tel]').forEach(el => {
+ el.addEventListener('blur', () => {
+ const d = el.value.replace(/\D/g, '');
+ if (d.length === 10) el.value = `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`;
+ else if (d.length === 11 && d[0] === '1') el.value = `+1 (${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`;
+ });
+ });
+})();
diff --git a/routes/public.js b/routes/public.js
index 5f8e575..ad675b1 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -8,7 +8,6 @@ router.use((req, res, next) => {
next();
});
-// Home — hero + CTA
router.get('/', (req, res) => {
res.render('public/home', {
title: 'HoldForMe — We wait on hold so you don\'t have to',
@@ -16,49 +15,153 @@ router.get('/', (req, res) => {
});
});
-// New-call form — full onboarding
+// ── 4-step wizard ─────────────────────────────────────────────────────
+// State carried via hidden inputs (no cookies, no session — works behind
+// any rate-limiter, survives back/forward). Each step renders the next.
+//
+// Step 1: /new — pick category
+// Step 2: /new/business — pick from preset directory OR custom
+// Step 3: /new/details — goal + account info
+// Step 4: /new/callback — callback + limits + consent → submits
+
+function pickState(b) {
+ // Whitelist what carries between steps. Same fields as sanitizeRow but
+ // without the generated id/status — we accumulate during the wizard.
+ return {
+ category: b.category || '',
+ business_slug: b.business_slug || '',
+ business_name: b.business_name || '',
+ business_phone: b.business_phone || '',
+ goal: b.goal || '',
+ notes: b.notes || '',
+ account_number: b.account_number || '',
+ last4_ssn: b.last4_ssn || '',
+ billing_zip: b.billing_zip || '',
+ date_of_birth: b.date_of_birth || '',
+ auth_password: b.auth_password || '',
+ callback_name: b.callback_name || '',
+ callback_phone: b.callback_phone || '',
+ callback_email: b.callback_email || '',
+ best_time_window: b.best_time_window || '',
+ max_hold_minutes: b.max_hold_minutes || '60',
+ max_spend_cents: b.max_spend_cents || '500',
+ consent_recording: b.consent_recording === 'on' || b.consent_recording === true,
+ notify_sms: b.notify_sms === 'on' || b.notify_sms === true || !b.notify_sms, // default on
+ notify_email: b.notify_email === 'on' || b.notify_email === true,
+ consent_terms: b.consent_terms === 'on' || b.consent_terms === true,
+ };
+}
+
+// Step 1 — Category
router.get('/new', (req, res) => {
- res.render('public/new', {
- title: 'Start a call — HoldForMe',
- meta_desc: 'Tell us who to call and what to get done. We\'ll wait on hold, then connect you when an agent picks up.',
+ const state = pickState(req.query);
+ if (req.query.category) state.category = req.query.category;
+ res.render('public/wizard-1-category', {
+ title: 'Start a call — Step 1 of 4 — HoldForMe',
+ meta_desc: 'Step 1: pick the kind of call.',
categories: data.CATEGORIES,
- preset: data.categoryById(req.query.category) || null,
- form: {}, // empty form
- errors: [],
+ state, step: 1,
+ });
+});
+
+// Step 2 — Business (preset picker or custom)
+router.post('/new/business', (req, res) => {
+ const state = pickState(req.body);
+ if (!state.category) {
+ return res.redirect('/new');
+ }
+ const businesses = data.businessesForCategory(state.category);
+ res.render('public/wizard-2-business', {
+ title: 'Start a call — Step 2 of 4 — HoldForMe',
+ meta_desc: 'Step 2: who to call.',
+ categories: data.CATEGORIES,
+ businesses,
+ state, step: 2,
+ preset: data.categoryById(state.category),
+ });
+});
+
+// Step 3 — Details (goal + account info)
+router.post('/new/details', (req, res) => {
+ const state = pickState(req.body);
+ if (!state.category) return res.redirect('/new');
+
+ // If they picked a preset, hydrate name + phone from the directory
+ if (state.business_slug && state.business_slug !== '__custom__') {
+ const biz = data.businessBySlug(state.business_slug);
+ if (biz) {
+ state.business_name = biz.name;
+ state.business_phone = biz.phone;
+ }
+ }
+ // Validate at least one of preset-picked OR custom-filled
+ if (!state.business_name || !state.business_phone) {
+ const businesses = data.businessesForCategory(state.category);
+ return res.render('public/wizard-2-business', {
+ title: 'Start a call — Step 2 of 4 — HoldForMe',
+ meta_desc: 'Step 2: who to call.',
+ categories: data.CATEGORIES,
+ businesses,
+ state, step: 2,
+ preset: data.categoryById(state.category),
+ errors: ['Pick a preset or enter both a business name and phone number.'],
+ });
+ }
+
+ res.render('public/wizard-3-details', {
+ title: 'Start a call — Step 3 of 4 — HoldForMe',
+ meta_desc: 'Step 3: what you need done.',
+ state, step: 3,
+ preset: data.categoryById(state.category),
});
});
-// Submit a new call
-router.post('/new', (req, res) => {
+// Step 4 — Callback + limits + consent (final, then submits)
+router.post('/new/callback', (req, res) => {
+ const state = pickState(req.body);
+ if (!state.category) return res.redirect('/new');
+ if (!state.goal || state.goal.trim().length < 8) {
+ return res.render('public/wizard-3-details', {
+ title: 'Start a call — Step 3 of 4 — HoldForMe',
+ meta_desc: 'Step 3: what you need done.',
+ state, step: 3,
+ preset: data.categoryById(state.category),
+ errors: ['Goal is required (8+ chars). Be specific about what you want the rep to do.'],
+ });
+ }
+ res.render('public/wizard-4-callback', {
+ title: 'Start a call — Step 4 of 4 — HoldForMe',
+ meta_desc: 'Step 4: how to reach you back.',
+ state, step: 4,
+ });
+});
+
+// Final submit
+router.post('/new/submit', (req, res) => {
const r = data.addCall(req.body || {});
if (!r.ok) {
- return res.status(400).render('public/new', {
- title: 'Start a call — HoldForMe',
- meta_desc: 'Tell us who to call and what to get done.',
- categories: data.CATEGORIES,
- preset: data.categoryById(req.body.category) || null,
- form: req.body || {},
+ const state = pickState(req.body);
+ return res.status(400).render('public/wizard-4-callback', {
+ title: 'Start a call — Step 4 of 4 — HoldForMe',
+ meta_desc: 'Step 4: how to reach you back.',
+ state, step: 4,
errors: r.errors,
});
}
res.redirect('/calls/' + r.call.id);
});
-// Your queue
router.get('/calls', (req, res) => {
res.render('public/calls', {
title: 'Your call queue — HoldForMe',
- meta_desc: 'Calls you\'ve queued up. Status, history, results.',
+ meta_desc: 'Calls you\'ve queued up.',
calls: data.listCalls(),
});
});
-// Single call detail
router.get('/calls/:id', (req, res) => {
const id = req.params.id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — HoldForMe' });
- // Reveal only if explicitly requested via ?reveal=1 — keeps sensitive fields
- // hidden by default even on the detail page
const c = data.getCall(id, req.query.reveal === '1');
if (!c) return res.status(404).render('public/404', { title: 'Not found — HoldForMe' });
res.render('public/call', {
@@ -69,7 +172,6 @@ router.get('/calls/:id', (req, res) => {
});
});
-// JSON API for the form's category-picker → field hints
router.get('/api/category/:id', (req, res) => {
const c = data.categoryById(req.params.id);
if (!c) return res.status(404).json({ ok: false });
diff --git a/server.js b/server.js
index 65d86d5..8eeabc5 100644
--- a/server.js
+++ b/server.js
@@ -79,4 +79,14 @@ app.use((err, req, res, next) => {
app.listen(PORT, () => {
console.log(`holdforme listening on :${PORT} (${IS_PROD ? 'prod' : 'dev'})`);
+ // Start the call worker — DRY_RUN by default; set TWILIO_DRY_RUN=0 + Twilio creds for live.
+ // Skip if HFM_NO_WORKER=1 (useful for unit tests that don't want the background tick).
+ if (!process.env.HFM_NO_WORKER) {
+ try {
+ const twilio = require('./lib/twilio');
+ twilio.start({ intervalMs: 5000 });
+ } catch (e) {
+ console.error('[twilio] failed to start worker:', e.message);
+ }
+ }
});
diff --git a/views/partials/wizard-progress.ejs b/views/partials/wizard-progress.ejs
new file mode 100644
index 0000000..f27377b
--- /dev/null
+++ b/views/partials/wizard-progress.ejs
@@ -0,0 +1,9 @@
+<nav class="wizard-progress" aria-label="Wizard progress">
+ <ol>
+ <li class="<%= step >= 1 ? 'done' : '' %><%= step === 1 ? ' current' : '' %>"><span class="wp-num">1</span> Category</li>
+ <li class="<%= step >= 2 ? 'done' : '' %><%= step === 2 ? ' current' : '' %>"><span class="wp-num">2</span> Business</li>
+ <li class="<%= step >= 3 ? 'done' : '' %><%= step === 3 ? ' current' : '' %>"><span class="wp-num">3</span> What & account</li>
+ <li class="<%= step >= 4 ? 'done' : '' %><%= step === 4 ? ' current' : '' %>"><span class="wp-num">4</span> Callback</li>
+ </ol>
+ <div class="wizard-progress-bar"><div class="wizard-progress-fill" style="width: <%= ((step-1)/3)*100 %>%"></div></div>
+</nav>
diff --git a/views/partials/wizard-state.ejs b/views/partials/wizard-state.ejs
new file mode 100644
index 0000000..c4ed17f
--- /dev/null
+++ b/views/partials/wizard-state.ejs
@@ -0,0 +1,12 @@
+<%
+ // Carry every state field as a hidden input so step N+1 has everything from steps 1..N.
+ // Skip the field the current step is about to overwrite (passed in as `skip`).
+ var skipSet = (typeof skip !== 'undefined' && skip) ? new Set(skip) : new Set();
+ var allFields = ['category','business_slug','business_name','business_phone','goal','notes','account_number','last4_ssn','billing_zip','date_of_birth','auth_password','callback_name','callback_phone','callback_email','best_time_window','max_hold_minutes','max_spend_cents'];
+%>
+<% allFields.forEach(function(f) { if (!skipSet.has(f) && state[f]) { %>
+<input type="hidden" name="<%= f %>" value="<%= state[f] %>">
+<% } }); %>
+<% ['consent_recording','notify_sms','notify_email','consent_terms'].forEach(function(f) { if (!skipSet.has(f) && state[f]) { %>
+<input type="hidden" name="<%= f %>" value="on">
+<% } }); %>
diff --git a/views/public/wizard-1-category.ejs b/views/public/wizard-1-category.ejs
new file mode 100644
index 0000000..636441c
--- /dev/null
+++ b/views/public/wizard-1-category.ejs
@@ -0,0 +1,32 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="/">← Home</a></p>
+ <%- include('../partials/wizard-progress', { step }) %>
+ <h1>What kind of call?</h1>
+ <p class="muted">Pick the closest fit. We'll preset the right fields for the next steps.</p>
+
+ <form method="POST" action="/new/business" class="wizard-form" autocomplete="off">
+ <div class="category-pick">
+ <% categories.forEach(function(c) { var selected = state.category === c.id; %>
+ <label class="category-pick-card <%= selected ? 'selected' : '' %>">
+ <input type="radio" name="category" value="<%= c.id %>" <%= selected ? 'checked' : '' %> required>
+ <span class="category-pick-icon"><%= c.icon %></span>
+ <span class="category-pick-label"><%= c.label %></span>
+ </label>
+ <% }); %>
+ </div>
+
+ <div class="form-actions">
+ <button type="submit" class="btn primary">Next: who to call →</button>
+ <a class="btn ghost" href="/">Cancel</a>
+ </div>
+ </form>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/wizard-2-business.ejs b/views/public/wizard-2-business.ejs
new file mode 100644
index 0000000..6dc3bcf
--- /dev/null
+++ b/views/public/wizard-2-business.ejs
@@ -0,0 +1,64 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="/new">← Back</a></p>
+ <%- include('../partials/wizard-progress', { step }) %>
+
+ <h1>Who do we call?</h1>
+ <p class="muted">Pick from the directory below, or scroll down to enter a custom business.</p>
+
+ <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
+ <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+ <% } %>
+
+ <form method="POST" action="/new/details" class="wizard-form" autocomplete="off">
+ <%- include('../partials/wizard-state', { state, skip: ['business_slug', 'business_name', 'business_phone'] }) %>
+
+ <% if (businesses && businesses.length) { %>
+ <fieldset>
+ <legend>Pick from the directory <span class="muted small">(<%= businesses.length %> preset business<%= businesses.length === 1 ? '' : 'es' %> for <%= preset && preset.label %>)</span></legend>
+ <div class="business-pick">
+ <% businesses.forEach(function(b) { var selected = state.business_slug === b.slug; %>
+ <label class="business-pick-card <%= selected ? 'selected' : '' %>">
+ <input type="radio" name="business_slug" value="<%= b.slug %>" <%= selected ? 'checked' : '' %>>
+ <div class="business-pick-body">
+ <strong><%= b.name %></strong>
+ <span class="muted small"><%= b.phone %></span>
+ <% if (b.ivr_notes) { %><span class="muted small business-pick-ivr">IVR: <%= b.ivr_notes %></span><% } %>
+ </div>
+ </label>
+ <% }); %>
+ </div>
+ </fieldset>
+ <% } %>
+
+ <fieldset>
+ <legend>Or enter a custom business</legend>
+ <label class="business-pick-card business-pick-custom <%= state.business_slug === '__custom__' ? 'selected' : '' %>">
+ <input type="radio" name="business_slug" value="__custom__" <%= state.business_slug === '__custom__' ? 'checked' : '' %>>
+ <span>Custom — fill in below</span>
+ </label>
+ <label class="field">
+ <span class="field-label">Business name</span>
+ <input type="text" name="business_name" value="<%= state.business_name || '' %>" maxlength="120" placeholder="e.g. My local clinic billing dept">
+ </label>
+ <label class="field">
+ <span class="field-label">Phone number to call</span>
+ <input type="tel" name="business_phone" value="<%= state.business_phone || '' %>" maxlength="20" placeholder="1-800-...">
+ </label>
+ </fieldset>
+
+ <div class="form-actions">
+ <button type="submit" class="btn primary">Next: what you want done →</button>
+ <button type="submit" formmethod="POST" formaction="/new" formnovalidate class="btn ghost">← Back</button>
+ </div>
+ </form>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
+<script src="/js/wizard.js" defer></script>
diff --git a/views/public/wizard-3-details.ejs b/views/public/wizard-3-details.ejs
new file mode 100644
index 0000000..3a908c6
--- /dev/null
+++ b/views/public/wizard-3-details.ejs
@@ -0,0 +1,71 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="#" onclick="history.back();return false">← Back</a></p>
+ <%- include('../partials/wizard-progress', { step }) %>
+
+ <h1>What do you want done?</h1>
+ <p class="muted">
+ Calling <strong><%= state.business_name %></strong> at <%= state.business_phone %>. Be specific about the outcome.
+ </p>
+
+ <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
+ <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+ <% } %>
+
+ <form method="POST" action="/new/callback" class="wizard-form" autocomplete="off">
+ <%- include('../partials/wizard-state', { state, skip: ['goal','notes','account_number','last4_ssn','billing_zip','date_of_birth','auth_password'] }) %>
+
+ <fieldset>
+ <legend>The goal of the call</legend>
+ <label class="field">
+ <span class="field-label">What should the rep do? <em>required, 8+ chars</em></span>
+ <textarea name="goal" rows="5" maxlength="1500" placeholder="<%= preset && preset.defaultGoal ? preset.defaultGoal : 'Be specific. \"Request credit limit increase from $5,000 to $10,000\" is better than \"help with credit card.\"' %>" required><%= state.goal || (preset && preset.defaultGoal) || '' %></textarea>
+ </label>
+ <label class="field">
+ <span class="field-label">Notes the rep should know <span class="muted">— optional</span></span>
+ <textarea name="notes" rows="3" maxlength="500" placeholder="Context, history, prior case numbers..."><%= state.notes || '' %></textarea>
+ </label>
+ </fieldset>
+
+ <fieldset>
+ <legend>Account info <span class="muted">(only if the rep will ask)</span></legend>
+ <p class="muted small">Masked in queue views, only revealed on the call's own detail page when you click Reveal.</p>
+ <div class="field-row">
+ <label class="field">
+ <span class="field-label">Account number</span>
+ <input type="text" name="account_number" value="<%= state.account_number || '' %>" maxlength="40" placeholder="Last 4 minimum, full if needed" autocomplete="off">
+ </label>
+ <label class="field">
+ <span class="field-label">Last 4 of SSN</span>
+ <input type="text" name="last4_ssn" value="<%= state.last4_ssn || '' %>" maxlength="4" inputmode="numeric" pattern="\d{4}" placeholder="1234" autocomplete="off">
+ </label>
+ </div>
+ <div class="field-row">
+ <label class="field">
+ <span class="field-label">Billing ZIP</span>
+ <input type="text" name="billing_zip" value="<%= state.billing_zip || '' %>" maxlength="5" inputmode="numeric" pattern="\d{5}" placeholder="90210" autocomplete="off">
+ </label>
+ <label class="field">
+ <span class="field-label">Date of birth</span>
+ <input type="text" name="date_of_birth" value="<%= state.date_of_birth || '' %>" maxlength="10" placeholder="MM/DD/YYYY" autocomplete="off">
+ </label>
+ </div>
+ <label class="field">
+ <span class="field-label">Verbal-auth password / security answer</span>
+ <input type="text" name="auth_password" value="<%= state.auth_password || '' %>" maxlength="60" placeholder='"Maiden name: Smith"' autocomplete="off">
+ </label>
+ </fieldset>
+
+ <div class="form-actions">
+ <button type="submit" class="btn primary">Next: how to reach you →</button>
+ </div>
+ </form>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/wizard-4-callback.ejs b/views/public/wizard-4-callback.ejs
new file mode 100644
index 0000000..e4b1b62
--- /dev/null
+++ b/views/public/wizard-4-callback.ejs
@@ -0,0 +1,93 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="#" onclick="history.back();return false">← Back</a></p>
+ <%- include('../partials/wizard-progress', { step }) %>
+
+ <h1>How do we reach you?</h1>
+ <p class="muted">When an agent answers, we'll text and call this number so you can jump in fresh.</p>
+
+ <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
+ <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+ <% } %>
+
+ <form method="POST" action="/new/submit" class="wizard-form" autocomplete="off">
+ <%- include('../partials/wizard-state', { state, skip: ['callback_name','callback_phone','callback_email','best_time_window','max_hold_minutes','max_spend_cents','consent_recording','notify_sms','notify_email','consent_terms'] }) %>
+
+ <fieldset>
+ <legend>Your callback</legend>
+ <div class="field-row">
+ <label class="field">
+ <span class="field-label">Your name <em>required</em></span>
+ <input type="text" name="callback_name" value="<%= state.callback_name || '' %>" maxlength="80" required>
+ </label>
+ <label class="field">
+ <span class="field-label">Your phone <em>required</em></span>
+ <input type="tel" name="callback_phone" value="<%= state.callback_phone || '' %>" maxlength="20" placeholder="we bridge here on pickup" required>
+ </label>
+ </div>
+ <label class="field">
+ <span class="field-label">Your email <span class="muted">— optional</span></span>
+ <input type="email" name="callback_email" value="<%= state.callback_email || '' %>" maxlength="120">
+ </label>
+ <label class="field">
+ <span class="field-label">Best time window <span class="muted">— optional</span></span>
+ <input type="text" name="best_time_window" value="<%= state.best_time_window || '' %>" maxlength="60" placeholder='e.g. "weekdays 9am-6pm PT"'>
+ </label>
+ </fieldset>
+
+ <fieldset>
+ <legend>Limits</legend>
+ <div class="field-row">
+ <label class="field">
+ <span class="field-label">Max hold time</span>
+ <select name="max_hold_minutes">
+ <option value="15" <%= state.max_hold_minutes === '15' ? 'selected' : '' %>>15 min — quick try</option>
+ <option value="30" <%= state.max_hold_minutes === '30' ? 'selected' : '' %>>30 min</option>
+ <option value="60" <%= !state.max_hold_minutes || state.max_hold_minutes === '60' ? 'selected' : '' %>>60 min (default)</option>
+ <option value="120" <%= state.max_hold_minutes === '120' ? 'selected' : '' %>>2 hours</option>
+ <option value="180" <%= state.max_hold_minutes === '180' ? 'selected' : '' %>>3 hours (insurance / IRS worst-case)</option>
+ </select>
+ </label>
+ <label class="field">
+ <span class="field-label">Max spend</span>
+ <select name="max_spend_cents">
+ <option value="100" <%= state.max_spend_cents === '100' ? 'selected' : '' %>>$1.00</option>
+ <option value="250" <%= state.max_spend_cents === '250' ? 'selected' : '' %>>$2.50</option>
+ <option value="500" <%= !state.max_spend_cents || state.max_spend_cents === '500' ? 'selected' : '' %>>$5.00 (default)</option>
+ <option value="1000" <%= state.max_spend_cents === '1000' ? 'selected' : '' %>>$10.00</option>
+ <option value="2500" <%= state.max_spend_cents === '2500' ? 'selected' : '' %>>$25.00</option>
+ </select>
+ </label>
+ </div>
+ </fieldset>
+
+ <fieldset>
+ <legend>Notifications & consent</legend>
+ <label class="check"><input type="checkbox" name="notify_sms" <%= state.notify_sms ? 'checked' : 'checked' %>> Text me when an agent picks up</label>
+ <label class="check"><input type="checkbox" name="notify_email" <%= state.notify_email ? 'checked' : '' %>> Email me the call summary afterwards</label>
+ <label class="check"><input type="checkbox" name="consent_recording" <%= state.consent_recording ? 'checked' : '' %>> Record the call (two-party-consent states get an in-call disclosure)</label>
+ <label class="check req"><input type="checkbox" name="consent_terms" <%= state.consent_terms ? 'checked' : '' %> required> I authorize HoldForMe to dial the business on my behalf and bridge me when a human picks up <em>required</em></label>
+ </fieldset>
+
+ <div class="review-card">
+ <h3>Quick review</h3>
+ <dl class="kv">
+ <dt>Calling</dt><dd><strong><%= state.business_name %></strong> · <%= state.business_phone %></dd>
+ <dt>For</dt><dd><%= state.goal && state.goal.length > 140 ? state.goal.slice(0,140)+'…' : state.goal %></dd>
+ <% if (state.account_number) { %><dt>Account</dt><dd>•••••• provided</dd><% } %>
+ </dl>
+ </div>
+
+ <div class="form-actions">
+ <button type="submit" class="btn primary">Queue this call →</button>
+ </div>
+ </form>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
← af3a901 initial scaffold — HoldForMe phase-1 onboarding MVP. Capture
·
back to Butlr
·
perfect: (1) AES-256-GCM encryption at rest for account_numb 282926f →