← back to NationalPaperHangers
Add "Call this installer" Butlr integration to installer pages
9f104a2a7ad6da428b66ae84fb61eb5581fc353e · 2026-05-19 13:34:56 -0700 · SteveStudio2
A 3-mode calling concierge on every installer profile (all 6 template
variants + legacy layout). The customer taps "Call this installer",
picks a mode in a modal, and NPH hands the call to Butlr's external API.
Modes:
hold — Butlr waits through IVR/hold, calls the customer back
bridge — click-to-call, joins customer + installer on one live call
ai_agent — AI agent delivers the customer's project brief, reports back
lib/butlr.js — thin client for Butlr POST /api/external/place-call
routes/call-installer.js — POST /installer/:slug/call-installer, validates,
normalizes phones, maps Butlr's 429 cap to a clear msg
partials/call-installer.ejs — button + modal, brief field shown only for
ai_agent, inline AJAX result
server.js — mounts route, 6 calls/hr/IP rate limit
public.js — passes butlrEnabled (butlr.isConfigured()) to render
Button hidden site-wide when BUTLR_EXTERNAL_SECRET is unset or the studio
has no phone on file. CSRF-protected via the global middleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M .env.exampleA lib/butlr.jsA routes/call-installer.jsM routes/public.jsM server.jsA views/partials/call-installer.ejsM views/public/installer-tpl-bilingue.ejsM views/public/installer-tpl-concierge.ejsM views/public/installer-tpl-editorial.ejsM views/public/installer-tpl-heritage.ejsM views/public/installer-tpl-studio.ejsM views/public/installer-tpl-trade-pro.ejsM views/public/installer.ejs
Diff
commit 9f104a2a7ad6da428b66ae84fb61eb5581fc353e
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 19 13:34:56 2026 -0700
Add "Call this installer" Butlr integration to installer pages
A 3-mode calling concierge on every installer profile (all 6 template
variants + legacy layout). The customer taps "Call this installer",
picks a mode in a modal, and NPH hands the call to Butlr's external API.
Modes:
hold — Butlr waits through IVR/hold, calls the customer back
bridge — click-to-call, joins customer + installer on one live call
ai_agent — AI agent delivers the customer's project brief, reports back
lib/butlr.js — thin client for Butlr POST /api/external/place-call
routes/call-installer.js — POST /installer/:slug/call-installer, validates,
normalizes phones, maps Butlr's 429 cap to a clear msg
partials/call-installer.ejs — button + modal, brief field shown only for
ai_agent, inline AJAX result
server.js — mounts route, 6 calls/hr/IP rate limit
public.js — passes butlrEnabled (butlr.isConfigured()) to render
Button hidden site-wide when BUTLR_EXTERNAL_SECRET is unset or the studio
has no phone on file. CSRF-protected via the global middleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
.env.example | 9 ++
lib/butlr.js | 87 +++++++++++++
routes/call-installer.js | 118 +++++++++++++++++
routes/public.js | 6 +-
server.js | 5 +
views/partials/call-installer.ejs | 212 +++++++++++++++++++++++++++++++
views/public/installer-tpl-bilingue.ejs | 1 +
views/public/installer-tpl-concierge.ejs | 1 +
views/public/installer-tpl-editorial.ejs | 1 +
views/public/installer-tpl-heritage.ejs | 1 +
views/public/installer-tpl-studio.ejs | 1 +
views/public/installer-tpl-trade-pro.ejs | 1 +
views/public/installer.ejs | 2 +
13 files changed, 444 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index 3a5a7aa..d19e0b3 100644
--- a/.env.example
+++ b/.env.example
@@ -31,6 +31,15 @@ EMAIL_FROM_NAME=National Paper Hangers
# Public origin (for emails / Stripe redirects)
PUBLIC_URL=http://localhost:9765
+# Butlr — "Call this installer" calling concierge integration.
+# BUTLR_API_URL: base URL of the Butlr service.
+# dev → http://localhost:9932 prod → https://butlr.agentabrams.com
+# BUTLR_EXTERNAL_SECRET: shared secret sent in the X-Butlr-External-Secret
+# header. MUST exactly match BUTLR_EXTERNAL_SECRET in Butlr's .env.
+# If unset, the "Call this installer" button is hidden site-wide.
+BUTLR_API_URL=http://localhost:9932
+BUTLR_EXTERNAL_SECRET=
+
# HMAC key for booking-view tokens. Falls back to SESSION_SECRET if unset;
# in production set explicitly so rotating SESSION_SECRET doesn't invalidate
# every outstanding booking link in customer inboxes.
diff --git a/lib/butlr.js b/lib/butlr.js
new file mode 100644
index 0000000..544e3df
--- /dev/null
+++ b/lib/butlr.js
@@ -0,0 +1,87 @@
+// Butlr client — places "Call this installer" calls via Butlr's external
+// call-trigger API (POST /api/external/place-call).
+//
+// NPH and Butlr are fully separate services with separate databases. The
+// ONLY contract between them is this HTTP endpoint. NPH never writes Butlr's
+// data and Butlr never writes NPH's.
+//
+// Configuration (NPH .env):
+// BUTLR_API_URL — base URL of the Butlr service
+// (default http://localhost:9932; prod
+// https://butlr.agentabrams.com)
+// BUTLR_EXTERNAL_SECRET — shared secret. Sent in the X-Butlr-External-Secret
+// header. MUST match the value in Butlr's .env.
+// If unset here, placeCall() fails fast — we never
+// silently no-op a call the customer asked for.
+
+const API_URL = (process.env.BUTLR_API_URL || 'http://localhost:9932').replace(/\/+$/, '');
+const SECRET = process.env.BUTLR_EXTERNAL_SECRET || '';
+
+// Modes the NPH modal offers — must match Butlr's routes/external.js MODES.
+const MODES = new Set(['hold', 'bridge', 'ai_agent']);
+
+// placeCall — hand a call off to Butlr.
+// opts.mode — 'hold' | 'bridge' | 'ai_agent' (required)
+// opts.installerPhone — E.164-ish business number (required)
+// opts.installerName — business name for the announcement
+// opts.customerPhone — customer's number (required)
+// opts.customerName — customer's name
+// opts.brief — project brief (required for ai_agent)
+//
+// Returns { ok, status, body } — `status` is the HTTP status, `body` the
+// parsed JSON. Network/timeout/missing-secret failures resolve with
+// ok:false and an `error` string (this function never throws).
+async function placeCall(opts = {}) {
+ if (!SECRET) {
+ return { ok: false, status: 0, error: 'butlr_not_configured',
+ message: 'Butlr integration is not configured (BUTLR_EXTERNAL_SECRET unset).' };
+ }
+ if (!MODES.has(opts.mode)) {
+ return { ok: false, status: 0, error: 'bad_mode', message: 'Unknown call mode.' };
+ }
+
+ const payload = {
+ mode: opts.mode,
+ installer_phone: opts.installerPhone || '',
+ installer_name: opts.installerName || '',
+ customer_phone: opts.customerPhone || '',
+ customer_name: opts.customerName || '',
+ brief: opts.brief || '',
+ };
+
+ // 10s timeout — Butlr's place-call kicks the dial async and returns fast,
+ // but we still bound the wait so a hung Butlr can't pin an NPH request.
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 10_000);
+ try {
+ const resp = await fetch(`${API_URL}/api/external/place-call`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Butlr-External-Secret': SECRET,
+ },
+ body: JSON.stringify(payload),
+ signal: ctrl.signal,
+ });
+ let body = null;
+ try { body = await resp.json(); } catch { /* non-JSON body */ }
+ return { ok: resp.ok && !!(body && body.ok), status: resp.status, body };
+ } catch (e) {
+ const aborted = e.name === 'AbortError';
+ return {
+ ok: false, status: 0,
+ error: aborted ? 'butlr_timeout' : 'butlr_unreachable',
+ message: aborted ? 'Butlr did not respond in time.' : 'Could not reach the call service.',
+ };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+// isConfigured — used by the route + view to decide whether to show the
+// "Call this installer" button at all.
+function isConfigured() {
+ return !!SECRET;
+}
+
+module.exports = { placeCall, isConfigured, MODES, API_URL };
diff --git a/routes/call-installer.js b/routes/call-installer.js
new file mode 100644
index 0000000..a21c1cb
--- /dev/null
+++ b/routes/call-installer.js
@@ -0,0 +1,118 @@
+// "Call this installer" — places a Butlr call on the customer's behalf.
+//
+// The customer taps "Call this installer" on an installer profile, picks one
+// of three modes in a modal, and this endpoint hands the call to Butlr's
+// external API (lib/butlr.js → POST /api/external/place-call).
+//
+// COMPLIANCE: the customer explicitly initiates the call to a business they
+// chose to view — that tap is the consent. Nothing here auto-dials. The
+// AI-agent mode identifies itself per FCC norms; that disclosure lives in
+// Butlr's announcement preamble (lib/twilio.js buildAnnouncement).
+//
+// DATA SEPARATION: NPH only reads the installer's phone from its own DB and
+// POSTs it to Butlr. It never writes to Butlr's store.
+
+const express = require('express');
+const db = require('../lib/db');
+const butlr = require('../lib/butlr');
+const router = express.Router();
+
+const MODES = new Set(['hold', 'bridge', 'ai_agent']);
+
+// Loose E.164-ish validation — strip non-digits, require 10-15 digits.
+// Butlr does its own canonicalization; this is just a fast reject.
+function normalizePhone(raw) {
+ const digits = String(raw || '').replace(/\D/g, '');
+ if (digits.length < 10 || digits.length > 15) return null;
+ if (digits.length === 10) return '+1' + digits;
+ if (digits.length === 11 && digits.startsWith('1')) return '+' + digits;
+ return '+' + digits;
+}
+
+// POST /installer/:slug/call-installer
+// Body: { mode, customer_phone, customer_name?, brief? }
+// Responds JSON — the profile-page modal renders the result inline.
+router.post('/installer/:slug/call-installer', express.urlencoded({ extended: false }), async (req, res) => {
+ try {
+ if (!butlr.isConfigured()) {
+ return res.status(503).json({ ok: false, error: 'unavailable',
+ message: 'The call service is not available right now. Please use the contact form.' });
+ }
+
+ const installer = await db.one(
+ `SELECT id, slug, business_name, phone, claim_status, status
+ FROM installers
+ WHERE slug = $1 AND (status = 'active' OR claim_status = 'unclaimed')`,
+ [req.params.slug]
+ );
+ if (!installer) {
+ return res.status(404).json({ ok: false, error: 'not_found', message: 'Installer not found.' });
+ }
+
+ const installerPhone = normalizePhone(installer.phone);
+ if (!installerPhone) {
+ return res.status(422).json({ ok: false, error: 'no_phone',
+ message: 'This installer has no phone number on file. Please use the contact form.' });
+ }
+
+ const f = req.body || {};
+ const mode = String(f.mode || '').trim();
+ const customerPhone = normalizePhone(f.customer_phone);
+ const customerName = String(f.customer_name || '').trim().slice(0, 80) || 'A potential client';
+ const brief = String(f.brief || '').trim().slice(0, 1500);
+
+ const errors = [];
+ if (!MODES.has(mode)) errors.push('Pick how you\'d like the call placed.');
+ if (!customerPhone) errors.push('Enter a valid phone number we can reach you on.');
+ if (mode === 'ai_agent' && brief.length < 8) {
+ errors.push('Describe your project (at least a sentence) so the AI agent has something to say.');
+ }
+ if (errors.length) {
+ return res.status(400).json({ ok: false, error: 'validation', errors });
+ }
+
+ const result = await butlr.placeCall({
+ mode,
+ installerPhone,
+ installerName: installer.business_name,
+ customerPhone,
+ customerName,
+ brief,
+ });
+
+ if (!result.ok) {
+ // Butlr's 4-call-per-number hard cap surfaces as HTTP 429.
+ if (result.status === 429) {
+ return res.status(429).json({ ok: false, error: 'too_many_calls',
+ message: `${installer.business_name} has already been called the maximum number of times today. Please use the contact form or try again later.` });
+ }
+ const msg = (result.body && (result.body.message
+ || (result.body.errors && result.body.errors.join('; '))))
+ || result.message
+ || 'The call could not be placed. Please try again or use the contact form.';
+ const code = result.status && result.status >= 400 && result.status < 500 ? result.status : 502;
+ return res.status(code).json({ ok: false, error: result.error || 'butlr_error', message: msg });
+ }
+
+ const dryRun = !!(result.body && result.body.dry_run);
+ const modeLabels = {
+ hold: 'wait through the hold queue and call you back when a person answers',
+ bridge: 'connect you both on a live call',
+ ai_agent: 'have an AI agent deliver your project brief and report back',
+ };
+ res.json({
+ ok: true,
+ mode,
+ dry_run: dryRun,
+ message: dryRun
+ ? `Test mode: the call was queued but no real phone call was placed. (Butlr is in dry-run; real dialing is disabled.) When live, Butlr would ${modeLabels[mode]}.`
+ : `Done — Butlr will ${modeLabels[mode]}. You'll get a text update shortly.`,
+ });
+ } catch (err) {
+ console.error('[call-installer]', err && err.stack ? err.stack : err);
+ res.status(500).json({ ok: false, error: 'server_error',
+ message: 'Something went wrong placing the call. Please try again.' });
+ }
+});
+
+module.exports = router;
diff --git a/routes/public.js b/routes/public.js
index 05b0f36..0e4abb9 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -1,6 +1,7 @@
const express = require('express');
const db = require('../lib/db');
const bookingToken = require('../lib/booking-token');
+const butlr = require('../lib/butlr');
const { requireInstaller } = require('../lib/auth');
const router = express.Router();
@@ -471,7 +472,10 @@ router.get('/installer/:slug', async (req, res, next) => {
canonicalPath: `/installer/${installer.slug}`,
ogImage: _ogImage,
installer, portfolio, reviews, acceptance, credentials, featuredVideos,
- paperContributions
+ paperContributions,
+ // "Call this installer" — shown only when the Butlr integration is
+ // configured (BUTLR_EXTERNAL_SECRET set) AND the studio has a phone.
+ butlrEnabled: butlr.isConfigured()
});
} catch (err) { next(err); }
});
diff --git a/server.js b/server.js
index 7b7b4b8..b7acb44 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,7 @@ const apiRoutes = require('./routes/api');
const claimRoutes = require('./routes/claim');
const webhookRoutes = require('./routes/webhooks');
const unsubscribeRoutes = require('./routes/unsubscribe');
+const callInstallerRoutes = require('./routes/call-installer');
const app = express();
const PORT = parseInt(process.env.PORT || '9765', 10);
@@ -181,6 +182,8 @@ const claimLimiter = hourLimiter(5); // account-claim — sensitive takeover
const notifyLimiter = hourLimiter(10); // notify-when-live — low-value visitor signup
const coiLimiter = hourLimiter(20); // COI requests — designers hit several installers per project
const commentLimiter = hourLimiter(20); // paper-thread comments — knowledge contributors post in bursts
+const callLimiter = hourLimiter(6); // call-installer — places a real phone call; keep tight to
+ // protect installers from a flood of customer-triggered dials.
const bookLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 8,
standardHeaders: true, legacyHeaders: false,
@@ -205,6 +208,7 @@ app.use('/api/installers/:slug/book', bookLimiter);
app.use('/api/installers.geo', geoLimiter);
app.use('/installer/:slug/notify-when-live', notifyLimiter);
app.use('/installer/:slug/coi-request', coiLimiter);
+app.use('/installer/:slug/call-installer', callLimiter);
app.use('/papers/:slug/comments', commentLimiter);
app.use('/papers/:slug/comments/:id/helpful', voteLimiter);
// 60 votes/min/IP — high enough
@@ -213,6 +217,7 @@ app.use('/papers/:slug/comments/:id/helpful', voteLimiter);
// ballot stuffing visible.
app.use('/', publicRoutes);
+app.use('/', callInstallerRoutes);
app.use('/', authRoutes);
app.use('/', require('./routes/auth-google'));
app.use('/', require('./routes/auth-linkedin'));
diff --git a/views/partials/call-installer.ejs b/views/partials/call-installer.ejs
new file mode 100644
index 0000000..d972543
--- /dev/null
+++ b/views/partials/call-installer.ejs
@@ -0,0 +1,212 @@
+<%# Shared "Call this installer" section — used by all installer-template
+ variants. Renders a button that opens a 3-mode modal. The customer picks
+ one of: hold-for-me, click-to-call bridge, or AI agent. Submitting POSTs
+ to /installer/:slug/call-installer which hands the call to Butlr.
+
+ Renders nothing if the studio has no phone on file or the integration
+ is not configured (butlrEnabled passed false by the route).
+
+ Expects in scope: installer, csrfToken, butlrEnabled (boolean). %>
+<% if (typeof butlrEnabled !== 'undefined' && butlrEnabled && installer.phone) { %>
+ <section id="call-installer-section" class="profile-section"
+ style="max-width:680px;margin:32px auto;padding:24px;border:1px solid var(--border,#ddd);border-radius:8px">
+ <h2 style="margin:0 0 4px;font-size:22px;letter-spacing:0.01em">Skip the phone tag</h2>
+ <p class="muted" style="margin:0 0 16px;font-size:14px;line-height:1.55;color:var(--fg-muted,#6a6a6a)">
+ Let our calling concierge reach <strong><%= installer.business_name %></strong> for you —
+ wait on hold, bridge you in live, or send an AI agent with your project brief.
+ </p>
+ <button type="button" class="btn btn-primary" data-call-open
+ style="font-size:15px;padding:11px 20px;border:1px solid var(--accent,#6b1a1a);background:var(--accent,#6b1a1a);color:var(--accent-fg,#f5f1e8);border-radius:6px;cursor:pointer;font-family:var(--sans,inherit)">
+ 📞 Call this installer
+ </button>
+
+ <%# ── Modal ──────────────────────────────────────────────────── %>
+ <div id="call-installer-modal" role="dialog" aria-modal="true" aria-labelledby="call-modal-title"
+ style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(14,14,14,0.55);
+ align-items:flex-start;justify-content:center;overflow-y:auto;padding:40px 16px">
+ <div style="background:var(--bg,#f5f1e8);color:var(--fg,#0e0e0e);max-width:520px;width:100%;
+ border:1px solid var(--border-strong,#ccc);border-radius:10px;padding:28px 26px;
+ box-shadow:0 12px 48px rgba(0,0,0,0.3);font-family:var(--sans,inherit)">
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px">
+ <h2 id="call-modal-title" style="margin:0;font-size:22px">Call <%= installer.business_name %></h2>
+ <button type="button" data-call-cancel aria-label="Close"
+ style="border:none;background:none;font-size:24px;line-height:1;cursor:pointer;color:var(--fg-muted,#6a6a6a)">×</button>
+ </div>
+ <p class="muted" style="font-size:13px;line-height:1.5;margin:8px 0 18px;color:var(--fg-muted,#6a6a6a)">
+ Choose how you'd like the call placed. You're starting this call to a business you chose —
+ nothing dials without your tap.
+ </p>
+
+ <form id="call-installer-form" method="POST"
+ action="/installer/<%= installer.slug %>/call-installer"
+ style="display:grid;gap:16px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+
+ <%# Mode picker — three radio cards %>
+ <fieldset style="border:none;padding:0;margin:0;display:grid;gap:10px">
+ <legend style="font-size:12px;text-transform:uppercase;letter-spacing:0.08em;color:var(--fg-muted,#999);margin-bottom:4px;padding:0">How should we call?</legend>
+
+ <label class="call-mode-card" style="display:block;border:1px solid var(--border,#ddd);border-radius:8px;padding:12px 14px;cursor:pointer">
+ <span style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:15px">
+ <input type="radio" name="mode" value="hold" checked> Hold for me
+ </span>
+ <span style="display:block;font-size:13px;line-height:1.5;color:var(--fg-muted,#6a6a6a);margin-top:4px;padding-left:24px">
+ We dial the installer, sit through the IVR and hold music, and call <em>you</em> back the moment a real person picks up.
+ </span>
+ </label>
+
+ <label class="call-mode-card" style="display:block;border:1px solid var(--border,#ddd);border-radius:8px;padding:12px 14px;cursor:pointer">
+ <span style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:15px">
+ <input type="radio" name="mode" value="bridge"> Connect us live
+ </span>
+ <span style="display:block;font-size:13px;line-height:1.5;color:var(--fg-muted,#6a6a6a);margin-top:4px;padding-left:24px">
+ A click-to-call bridge — we ring you and the installer and join you both on one live call.
+ </span>
+ </label>
+
+ <label class="call-mode-card" style="display:block;border:1px solid var(--border,#ddd);border-radius:8px;padding:12px 14px;cursor:pointer">
+ <span style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:15px">
+ <input type="radio" name="mode" value="ai_agent"> Send an AI agent
+ </span>
+ <span style="display:block;font-size:13px;line-height:1.5;color:var(--fg-muted,#6a6a6a);margin-top:4px;padding-left:24px">
+ Our AI agent calls on your behalf, delivers your project brief, asks about availability, and reports back. It identifies itself as an automated assistant.
+ </span>
+ </label>
+ </fieldset>
+
+ <label style="display:block">
+ <span style="font-size:13px;font-weight:600">Your name</span>
+ <input type="text" name="customer_name" maxlength="80" autocomplete="name"
+ style="width:100%;margin-top:4px;padding:9px 10px;border:1px solid var(--border-strong,#ccc);border-radius:6px;background:var(--bg,#fff);color:var(--fg,#000)">
+ </label>
+
+ <label style="display:block">
+ <span style="font-size:13px;font-weight:600">Your phone number <span style="color:#dc2626">*</span></span>
+ <input type="tel" name="customer_phone" required maxlength="40" autocomplete="tel"
+ placeholder="(310) 555-0123"
+ style="width:100%;margin-top:4px;padding:9px 10px;border:1px solid var(--border-strong,#ccc);border-radius:6px;background:var(--bg,#fff);color:var(--fg,#000)">
+ <span style="display:block;font-size:12px;color:var(--fg-muted,#6a6a6a);margin-top:3px">
+ For hold & bridge we call you on this number; for the AI agent we text you the report here.
+ </span>
+ </label>
+
+ <%# Brief — only relevant to ai_agent; shown/hidden by JS %>
+ <label id="call-brief-field" style="display:none">
+ <span style="font-size:13px;font-weight:600">Project brief <span style="color:#dc2626">*</span></span>
+ <textarea name="brief" maxlength="1500" rows="3"
+ placeholder="e.g. ~40 yards of grasscloth in a Beverly Hills dining room, hoping to install next month — what's your availability?"
+ style="width:100%;margin-top:4px;padding:9px 10px;border:1px solid var(--border-strong,#ccc);border-radius:6px;background:var(--bg,#fff);color:var(--fg,#000);font-family:var(--sans,inherit)"></textarea>
+ <span style="display:block;font-size:12px;color:var(--fg-muted,#6a6a6a);margin-top:3px">
+ The AI agent reads this to the installer. Be specific.
+ </span>
+ </label>
+
+ <div id="call-installer-result" role="status" aria-live="polite" style="display:none;font-size:14px;line-height:1.5;padding:11px 13px;border-radius:6px"></div>
+
+ <div style="display:flex;gap:10px;align-items:center">
+ <button type="submit" class="btn btn-primary" data-call-submit
+ style="font-size:15px;padding:10px 18px;border:1px solid var(--accent,#6b1a1a);background:var(--accent,#6b1a1a);color:var(--accent-fg,#f5f1e8);border-radius:6px;cursor:pointer;font-family:var(--sans,inherit)">
+ Place the call →
+ </button>
+ <button type="button" class="btn btn-ghost" data-call-cancel
+ style="font-size:14px;padding:10px 14px;border:1px solid var(--border,#ddd);background:none;color:var(--fg,#000);border-radius:6px;cursor:pointer;font-family:var(--sans,inherit)">
+ Cancel
+ </button>
+ </div>
+ <p style="font-size:11px;color:var(--fg-muted,#6a6a6a);margin:0;line-height:1.5">
+ By placing this call you consent to a call between you and the installer. Calls may be recorded; both parties hear a recording notice. Standard carrier rates apply.
+ </p>
+ </form>
+ </div>
+ </div>
+
+ <script>
+ (function(){
+ var sec = document.getElementById('call-installer-section');
+ if (!sec) return;
+ var modal = document.getElementById('call-installer-modal');
+ var form = document.getElementById('call-installer-form');
+ var openBtn = sec.querySelector('[data-call-open]');
+ var briefField = document.getElementById('call-brief-field');
+ var resultBox = document.getElementById('call-installer-result');
+ var submitBtn = form.querySelector('[data-call-submit]');
+ if (!modal || !form || !openBtn) return;
+
+ function openModal(){
+ modal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+ var first = form.querySelector('input[name="customer_name"]');
+ if (first) first.focus();
+ }
+ function closeModal(){
+ modal.style.display = 'none';
+ document.body.style.overflow = '';
+ }
+ openBtn.addEventListener('click', openModal);
+ sec.querySelectorAll('[data-call-cancel]').forEach(function(b){
+ b.addEventListener('click', closeModal);
+ });
+ modal.addEventListener('click', function(e){ if (e.target === modal) closeModal(); });
+ document.addEventListener('keydown', function(e){
+ if (e.key === 'Escape' && modal.style.display === 'flex') closeModal();
+ });
+
+ // Show the brief field only for the AI-agent mode.
+ function syncBrief(){
+ var mode = (form.querySelector('input[name="mode"]:checked') || {}).value;
+ briefField.style.display = (mode === 'ai_agent') ? 'block' : 'none';
+ }
+ form.querySelectorAll('input[name="mode"]').forEach(function(r){
+ r.addEventListener('change', syncBrief);
+ });
+ syncBrief();
+
+ function showResult(ok, msg){
+ resultBox.style.display = 'block';
+ resultBox.textContent = msg;
+ resultBox.style.background = ok ? 'rgba(34,139,34,0.12)' : 'rgba(220,38,38,0.10)';
+ resultBox.style.border = '1px solid ' + (ok ? 'rgba(34,139,34,0.4)' : 'rgba(220,38,38,0.4)');
+ resultBox.style.color = ok ? '#1a6b1a' : '#b91c1c';
+ }
+
+ form.addEventListener('submit', function(e){
+ e.preventDefault();
+ resultBox.style.display = 'none';
+ submitBtn.disabled = true;
+ var orig = submitBtn.textContent;
+ submitBtn.textContent = 'Placing…';
+
+ var body = new URLSearchParams(new FormData(form));
+ fetch(form.action, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRF-Token': form.querySelector('input[name="_csrf"]').value,
+ 'Accept': 'application/json'
+ },
+ body: body.toString()
+ })
+ .then(function(r){ return r.json().catch(function(){ return { ok:false, message:'Unexpected response.' }; }); })
+ .then(function(j){
+ if (j.ok) {
+ showResult(true, j.message || 'Call placed.');
+ form.querySelectorAll('input,textarea,button').forEach(function(el){
+ if (el.type !== 'button') el.disabled = true;
+ });
+ } else {
+ var msg = j.message || (j.errors && j.errors.join(' ')) || 'The call could not be placed.';
+ showResult(false, msg);
+ submitBtn.disabled = false;
+ submitBtn.textContent = orig;
+ }
+ })
+ .catch(function(){
+ showResult(false, 'Network error — please try again.');
+ submitBtn.disabled = false;
+ submitBtn.textContent = orig;
+ });
+ });
+ })();
+ </script>
+ </section>
+<% } %>
diff --git a/views/public/installer-tpl-bilingue.ejs b/views/public/installer-tpl-bilingue.ejs
index a83f760..252af6f 100644
--- a/views/public/installer-tpl-bilingue.ejs
+++ b/views/public/installer-tpl-bilingue.ejs
@@ -113,4 +113,5 @@
</script>
<%- include('../partials/paper-contributions') %>
<%- include('../partials/coi-request') %>
+<%- include('../partials/call-installer') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-concierge.ejs b/views/public/installer-tpl-concierge.ejs
index cf93798..b02d428 100644
--- a/views/public/installer-tpl-concierge.ejs
+++ b/views/public/installer-tpl-concierge.ejs
@@ -65,4 +65,5 @@
</article>
<%- include('../partials/paper-contributions') %>
<%- include('../partials/coi-request') %>
+<%- include('../partials/call-installer') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-editorial.ejs b/views/public/installer-tpl-editorial.ejs
index 0907d67..62f136b 100644
--- a/views/public/installer-tpl-editorial.ejs
+++ b/views/public/installer-tpl-editorial.ejs
@@ -87,4 +87,5 @@
</article>
<%- include('../partials/paper-contributions') %>
<%- include('../partials/coi-request') %>
+<%- include('../partials/call-installer') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-heritage.ejs b/views/public/installer-tpl-heritage.ejs
index 442c581..5fffdab 100644
--- a/views/public/installer-tpl-heritage.ejs
+++ b/views/public/installer-tpl-heritage.ejs
@@ -93,4 +93,5 @@
</article>
<%- include('../partials/paper-contributions') %>
<%- include('../partials/coi-request') %>
+<%- include('../partials/call-installer') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-studio.ejs b/views/public/installer-tpl-studio.ejs
index 7f72ea3..9269b21 100644
--- a/views/public/installer-tpl-studio.ejs
+++ b/views/public/installer-tpl-studio.ejs
@@ -95,4 +95,5 @@
</article>
<%- include('../partials/paper-contributions') %>
<%- include('../partials/coi-request') %>
+<%- include('../partials/call-installer') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-trade-pro.ejs b/views/public/installer-tpl-trade-pro.ejs
index 4410f34..df95b5c 100644
--- a/views/public/installer-tpl-trade-pro.ejs
+++ b/views/public/installer-tpl-trade-pro.ejs
@@ -97,4 +97,5 @@
</article>
<%- include('../partials/paper-contributions') %>
<%- include('../partials/coi-request') %>
+<%- include('../partials/call-installer') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer.ejs b/views/public/installer.ejs
index e1b9680..fb57f29 100644
--- a/views/public/installer.ejs
+++ b/views/public/installer.ejs
@@ -177,6 +177,8 @@
<%- include('../partials/coi-request') %>
+ <%- include('../partials/call-installer') %>
+
<% if (portfolio.length > 0) { %>
<section class="profile-portfolio">
<h2 class="section-title">Selected projects</h2>
← d08c846 feat: add WebXR AR + gyro-clinometer wall measurement to /bo
·
back to NationalPaperHangers
·
Hold back ai_agent call mode at launch (DTD verdict 2026-05- ec8d505 →