← back to Butlr
views/public/wizard-2-business.ejs
246 lines
<%- 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">Search by name (after you share location or enter ZIP), pick from the directory, or scroll down to enter manually.</p>
<!-- Location-gated business search (OpenStreetMap Nominatim) -->
<section style="padding:16px; border:1px solid var(--line); border-radius:10px; background:var(--panel); margin-bottom:20px;">
<div id="loc-gate">
<strong>📍 Location required for search</strong>
<p class="muted small" style="margin:6px 0 12px;">Searching globally for "AAA" returns junk. Share location OR enter a US ZIP to bias to your area (25 km radius).</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<button type="button" id="loc-btn" style="padding:8px 16px;">📍 Use my location</button>
<span class="muted small">or</span>
<input id="zip-input" inputmode="numeric" maxlength="5" placeholder="ZIP" style="padding:8px 12px; width:80px; border:1px solid var(--line); border-radius:6px;" />
<button type="button" id="zip-btn" style="padding:8px 16px;">Use ZIP</button>
<span id="loc-status" class="muted small"></span>
</div>
</div>
<div id="search-active" style="display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<strong>🔎 Search businesses</strong>
<a href="#" id="loc-change" class="muted small">change location</a>
</div>
<input id="search-input" placeholder="Start typing a business name..." style="width:100%; box-sizing:border-box; padding:10px 14px; border:1px solid var(--line); border-radius:6px; font-size:16px;" />
<div id="search-results" style="display:flex; flex-direction:column; gap:6px; margin-top:10px;"></div>
</div>
</section>
<% 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" id="wizard-business-phone" name="business_phone" value="<%= state.business_phone || '' %>" maxlength="20" placeholder="1-800-...">
<span id="wizard-dnc-status" class="muted small" style="display:block;font-size:11px;min-height:14px;margin-top:4px;"></span>
</label>
</fieldset>
<div class="form-actions">
<button id="wizard-next-btn" 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>
<script>
(function(){
// DNC pre-check on phone field — same contract as /calls quick-dial.
// Fires on blur of the custom-phone input OR when the user picks a
// directory preset (the radio button has a sibling muted "phone" line).
const phoneEl = document.getElementById('wizard-business-phone');
const stat = document.getElementById('wizard-dnc-status');
const nextBtn = document.getElementById('wizard-next-btn');
if (!phoneEl || !stat || !nextBtn) return;
async function check(num) {
if (!num) { stat.textContent = ''; nextBtn.disabled = false; return; }
stat.textContent = 'checking DNC…';
stat.style.color = 'var(--ink-dim)';
try {
const r = await fetch('/api/dnc-check?phone=' + encodeURIComponent(num));
const j = await r.json();
if (j.allowed) {
stat.textContent = j.dry_run_bypass ? '✓ ok (dry-run mode)' : '✓ not on DNC';
stat.style.color = 'var(--accent,#1a56db)';
nextBtn.disabled = false;
} else {
const msg = j.reason === 'federal_dnc' ? 'on federal DNC — Butlr will not dial this number'
: j.reason === 'internal_suppression' ? 'in internal suppression — Butlr will not dial this number'
: j.reason === 'gate_error' ? 'DNC list unavailable — fail-closed; cannot dial right now'
: 'blocked: ' + (j.reason || 'unknown');
stat.textContent = '🚫 ' + msg;
stat.style.color = '#991b1b';
nextBtn.disabled = true;
}
} catch (e) {
stat.textContent = '(DNC check failed; will re-check on submit)';
stat.style.color = 'var(--ink-dim)';
nextBtn.disabled = false;
}
}
phoneEl.addEventListener('blur', () => check(phoneEl.value.trim()));
// Also check when a preset is selected — pull the phone from the radio's
// sibling muted line and trigger.
document.querySelectorAll('input[name="business_slug"]').forEach(r => {
r.addEventListener('change', () => {
if (r.value === '__custom__') return; // custom uses the input field
const phoneLine = r.parentElement.querySelector('.business-pick-body .muted.small');
if (phoneLine) check(phoneLine.textContent.trim());
});
});
})();
// ── Location-gated places search (Nominatim) ────────────────────────
(function() {
const locGate = document.getElementById('loc-gate');
const searchBox = document.getElementById('search-active');
const locStatus = document.getElementById('loc-status');
const locBtn = document.getElementById('loc-btn');
const zipBtn = document.getElementById('zip-btn');
const zipInput = document.getElementById('zip-input');
const locChange = document.getElementById('loc-change');
const qInput = document.getElementById('search-input');
const results = document.getElementById('search-results');
let location = null; // {lat,lng} or {zip}
// Restore prior location from localStorage
try {
const saved = JSON.parse(localStorage.getItem('butlr.search.location') || 'null');
if (saved && (saved.zip || (saved.lat && saved.lng))) {
location = saved;
showSearchBox();
}
} catch {}
function setLoc(loc) {
location = loc;
try { localStorage.setItem('butlr.search.location', JSON.stringify(loc)); } catch {}
showSearchBox();
}
function showSearchBox() {
locGate.style.display = 'none';
searchBox.style.display = '';
qInput.focus();
}
function showGate() {
locGate.style.display = '';
searchBox.style.display = 'none';
location = null;
try { localStorage.removeItem('butlr.search.location'); } catch {}
}
locBtn.addEventListener('click', () => {
if (!navigator.geolocation) { locStatus.textContent = 'geolocation not supported'; locStatus.style.color = '#d33'; return; }
locStatus.textContent = 'requesting…';
navigator.geolocation.getCurrentPosition(
pos => { setLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); },
err => { locStatus.textContent = '✗ ' + err.message; locStatus.style.color = '#d33'; },
{ enableHighAccuracy: false, timeout: 8000 }
);
});
zipBtn.addEventListener('click', () => {
const z = (zipInput.value || '').trim();
if (!/^\d{5}$/.test(z)) { locStatus.textContent = 'enter a 5-digit ZIP'; locStatus.style.color = '#d33'; return; }
setLoc({ zip: z });
});
if (locChange) locChange.addEventListener('click', e => { e.preventDefault(); showGate(); });
let timer = null;
qInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => doSearch(qInput.value.trim()), 350);
});
async function doSearch(q) {
if (!q || q.length < 2) { results.innerHTML = ''; return; }
if (!location) return;
results.innerHTML = '<p class="muted small">searching…</p>';
const params = new URLSearchParams({ q });
if (location.zip) params.set('zip', location.zip);
if (location.lat) { params.set('lat', location.lat); params.set('lng', location.lng); }
try {
const r = await fetch('/api/places/search?' + params.toString());
const j = await r.json();
if (!j.ok) { results.innerHTML = '<p class="muted small" style="color:#d33;">' + (j.message || j.error) + '</p>'; return; }
if (!j.results.length) { results.innerHTML = '<p class="muted small">no nearby matches</p>'; return; }
results.innerHTML = j.results.map((b, i) => `
<div class="place-result" data-i="${i}" style="padding:10px 12px; border:1px solid var(--line); border-radius:6px; cursor:pointer; background:var(--panel-2);">
<strong>${escapeHtml(b.name)}</strong>
${b.phone ? `<span class="muted small" style="margin-left:8px;">📞 ${escapeHtml(b.phone)}</span>` : '<span class="muted small" style="margin-left:8px; color:#d33;">no phone on file</span>'}
<div class="muted small">${escapeHtml(b.city_state || b.address.split(',').slice(0,3).join(','))}</div>
</div>
`).join('');
results.querySelectorAll('.place-result').forEach((el, idx) => {
el.addEventListener('click', () => pickResult(j.results[idx]));
});
} catch (e) {
results.innerHTML = '<p class="muted small" style="color:#d33;">' + e.message + '</p>';
}
}
function pickResult(b) {
// Set the "custom" radio + fill name/phone fields if present.
const customRadio = document.querySelector('input[name="business_slug"][value="__custom__"]');
if (customRadio) { customRadio.checked = true; customRadio.dispatchEvent(new Event('change', { bubbles: true })); }
const nameI = document.querySelector('input[name="business_name"]');
const phoneI = document.querySelector('input[name="business_phone"]');
if (nameI) nameI.value = b.name;
if (phoneI && b.phone) phoneI.value = b.phone;
if (!b.phone && phoneI) { phoneI.focus(); phoneI.placeholder = 'no phone on file — enter manually'; }
// Scroll to the custom-entry block
if (phoneI) phoneI.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
}
})();
</script>