[object Object]

← 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

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)">&times;</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 &amp; 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 →