[object Object]

← back to NationalPaperHangers

v0.6: notify-when-live capture form on unclaimed /book

f339c343c5e4bf701cf3d51310bfe5f410c5fce8 · 2026-05-06 15:03:17 -0700 · SteveStudio2

Turns the dead-end /book page on unclaimed installers into a capture funnel.

- db/migrations/016_installer_interest.sql: new table (installer_id, email,
  source, zip, notes, ip_hash, created_at, notified_at, notify_msg_id) with
  UNIQUE(installer_id, email) for per-studio dedup. Partial index on un-notified
  rows for fast queue scans.
- routes/public.js: POST /installer/:slug/notify-when-live — basic email shape
  validation, ip_hash via SESSION_SECRET-salted sha256, ON CONFLICT DO NOTHING
  on the dedup constraint. Silent no-op when slug is already claimed (don't
  leak that someone else's interest worked). Renders book-notify-result.
- views/public/book-notify-result.ejs: success/error landing page.
- views/public/book.ejs: capture form on the unclaimed branch only — email +
  ZIP fields, CSRF token, micro-copy promising one email when they're live.
- server.js: claimLimiter applied to the new route (5 req/hour/IP).

Live test on prod (demo-unclaimed-ridgeway-paper, 2026-05-06): form renders,
POST returns 'Got it', row lands in installer_interest with the correct
email/zip/source. Auto-email-on-claim is v0.7.

Files touched

Diff

commit f339c343c5e4bf701cf3d51310bfe5f410c5fce8
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 15:03:17 2026 -0700

    v0.6: notify-when-live capture form on unclaimed /book
    
    Turns the dead-end /book page on unclaimed installers into a capture funnel.
    
    - db/migrations/016_installer_interest.sql: new table (installer_id, email,
      source, zip, notes, ip_hash, created_at, notified_at, notify_msg_id) with
      UNIQUE(installer_id, email) for per-studio dedup. Partial index on un-notified
      rows for fast queue scans.
    - routes/public.js: POST /installer/:slug/notify-when-live — basic email shape
      validation, ip_hash via SESSION_SECRET-salted sha256, ON CONFLICT DO NOTHING
      on the dedup constraint. Silent no-op when slug is already claimed (don't
      leak that someone else's interest worked). Renders book-notify-result.
    - views/public/book-notify-result.ejs: success/error landing page.
    - views/public/book.ejs: capture form on the unclaimed branch only — email +
      ZIP fields, CSRF token, micro-copy promising one email when they're live.
    - server.js: claimLimiter applied to the new route (5 req/hour/IP).
    
    Live test on prod (demo-unclaimed-ridgeway-paper, 2026-05-06): form renders,
    POST returns 'Got it', row lands in installer_interest with the correct
    email/zip/source. Auto-email-on-claim is v0.7.
---
 db/migrations/016_installer_interest.sql | 29 ++++++++++++++++++
 routes/public.js                         | 51 ++++++++++++++++++++++++++++++++
 server.js                                |  1 +
 views/public/book-notify-result.ejs      | 21 +++++++++++++
 views/public/book.ejs                    | 15 ++++++++++
 5 files changed, 117 insertions(+)

diff --git a/db/migrations/016_installer_interest.sql b/db/migrations/016_installer_interest.sql
new file mode 100644
index 0000000..0694071
--- /dev/null
+++ b/db/migrations/016_installer_interest.sql
@@ -0,0 +1,29 @@
+-- Capture queue for "notify me when this studio is on NPH" submissions
+-- collected from the /book page when an installer is unclaimed. When the
+-- studio claims their listing + configures availability, every email in
+-- this table for that installer_id gets a one-shot notification email.
+--
+-- Why a real table: a queue beats firing emails inline because (a) the
+-- claimer's first action might be over hours/days/weeks after the request,
+-- and (b) compliance scrub (suppression list) needs to run at send-time,
+-- not capture-time, since users can opt out in between.
+
+CREATE TABLE IF NOT EXISTS installer_interest (
+  id            BIGSERIAL PRIMARY KEY,
+  installer_id  BIGINT NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+  email         TEXT NOT NULL,  -- always stored lower-cased; route handler normalizes
+  source        TEXT NOT NULL DEFAULT 'book_page',
+  zip           TEXT,
+  notes         TEXT,
+  ip_hash       TEXT,
+  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
+  notified_at   TIMESTAMPTZ,
+  notify_msg_id TEXT,
+  UNIQUE (installer_id, email)
+);
+
+CREATE INDEX IF NOT EXISTS installer_interest_installer_id_idx
+  ON installer_interest (installer_id) WHERE notified_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS installer_interest_email_idx
+  ON installer_interest (email);
diff --git a/routes/public.js b/routes/public.js
index 5a553cb..092def2 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -270,6 +270,57 @@ router.get('/installer/:slug/book', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// POST /installer/:slug/notify-when-live — capture interest from visitors who
+// hit /book on an unclaimed studio. When the studio claims + configures
+// availability, every email here gets a one-shot notification (separate
+// scheduled job; not fired inline). Per-installer dedup via UNIQUE constraint.
+router.post('/installer/:slug/notify-when-live', express.urlencoded({ extended: false }), async (req, res, next) => {
+  try {
+    const installer = await db.one(
+      `SELECT id, slug, business_name, claim_status FROM installers WHERE slug = $1`,
+      [req.params.slug]
+    );
+    if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+
+    // Only meaningful for unclaimed listings — silently treat already-claimed
+    // as a no-op (avoid leaking that a different visitor's "interest" worked).
+    const email = String(req.body.email || '').trim().toLowerCase();
+    const zip = String(req.body.zip || '').trim().slice(0, 10) || null;
+    const notes = String(req.body.notes || '').trim().slice(0, 500) || null;
+
+    // Basic email shape check; defer real validation to send-time.
+    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+      return res.status(400).render('public/book-notify-result', {
+        title: 'Email needed',
+        installer,
+        ok: false,
+        error: 'Please enter a valid email address.'
+      });
+    }
+
+    if (installer.claim_status === 'unclaimed') {
+      const ipHash = require('crypto')
+        .createHash('sha256')
+        .update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
+        .digest('hex')
+        .slice(0, 16);
+      await db.query(
+        `INSERT INTO installer_interest (installer_id, email, source, zip, notes, ip_hash)
+         VALUES ($1, $2, 'book_page', $3, $4, $5)
+         ON CONFLICT (installer_id, email) DO NOTHING`,
+        [installer.id, email, zip, notes, ipHash]
+      );
+    }
+
+    res.render('public/book-notify-result', {
+      title: 'Got it',
+      installer,
+      ok: true,
+      email
+    });
+  } catch (err) { next(err); }
+});
+
 // /bookings/:uuid — booking detail page with PII (customer email, phone,
 // address). Access is gated three ways:
 //   (a) ?t=<HMAC sig>  — the link emailed in the booking confirmation
diff --git a/server.js b/server.js
index dd21b81..26c625f 100644
--- a/server.js
+++ b/server.js
@@ -179,6 +179,7 @@ app.use('/signup', loginLimiter);
 app.use(['/installer/:slug/claim', '/installer/:slug/claim/complete'], claimLimiter);
 app.use('/api/installers/:slug/book', bookLimiter);
 app.use('/api/installers.geo', geoLimiter);
+app.use('/installer/:slug/notify-when-live', claimLimiter);
 
 app.use('/', publicRoutes);
 app.use('/', authRoutes);
diff --git a/views/public/book-notify-result.ejs b/views/public/book-notify-result.ejs
new file mode 100644
index 0000000..e549ff5
--- /dev/null
+++ b/views/public/book-notify-result.ejs
@@ -0,0 +1,21 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="book-page" style="max-width:680px;margin:0 auto;padding:48px 24px">
+  <% if (ok) { %>
+    <div class="callout" style="text-align:center">
+      <h1 class="display-sm" style="margin:0 0 12px">You're on the list</h1>
+      <p style="font-size:16px;line-height:1.55">We'll email <strong><%= email %></strong> the moment <strong><%= installer.business_name %></strong> claims their listing and turns on bookings on National Paper Hangers.</p>
+      <p class="muted" style="margin-top:20px;font-size:13px">In the meantime, <a href="/find">browse studios with live calendars</a> if your project is on a deadline.</p>
+      <a href="/installer/<%= installer.slug %>" class="btn btn-ghost btn-sm" style="margin-top:24px">← Back to <%= installer.business_name %>'s profile</a>
+    </div>
+  <% } else { %>
+    <div class="callout" style="border-color:#dc2626">
+      <h1 class="display-sm" style="margin:0 0 12px">Something needs fixing</h1>
+      <p style="font-size:15px;line-height:1.55"><%= error %></p>
+      <a href="/installer/<%= installer.slug %>/book" class="btn btn-primary" style="margin-top:16px">← Try again</a>
+    </div>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/book.ejs b/views/public/book.ejs
index 8419d76..426b2b3 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -100,6 +100,21 @@
         <% if (installer.phone) { %><a href="tel:<%= installer.phone %>" class="btn btn-ghost">Call <%= installer.phone %></a><% } %>
       </div>
       <% if (_reason === 'unclaimed') { %>
+        <form method="post" action="/installer/<%= installer.slug %>/notify-when-live" style="margin:24px 0;padding:20px;border-top:1px solid var(--border);background:var(--bg-alt,#f8f7f2);border-radius:10px">
+          <input type="hidden" name="_csrf" value="<%= typeof csrfToken !== 'undefined' ? csrfToken : '' %>">
+          <h3 style="margin:0 0 8px;font-size:18px">Want a heads-up when they're on?</h3>
+          <p class="muted" style="margin:0 0 12px;font-size:13px">We'll email you the moment <%= installer.business_name %> claims their listing and turns on bookings — usually within a week of outreach.</p>
+          <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
+            <label style="flex:2;min-width:220px">Email
+              <input type="email" name="email" required placeholder="you@example.com" style="width:100%;box-sizing:border-box">
+            </label>
+            <label style="flex:1;min-width:80px">ZIP
+              <input type="text" name="zip" maxlength="10" placeholder="optional" style="width:100%;box-sizing:border-box">
+            </label>
+            <button type="submit" class="btn btn-primary">Notify me</button>
+          </div>
+          <p class="muted" style="margin:10px 0 0;font-size:11px">One email when they're live, then nothing. We don't share emails — you can unsubscribe from the notification with one click.</p>
+        </form>
         <p class="muted" style="margin-top:16px;font-size:13px">Are you the owner of <%= installer.business_name %>? <a href="/installer/<%= installer.slug %>#claim">Claim this listing →</a> and configure your calendar to start receiving bookings directly through NPH.</p>
       <% } else { %>
         <p class="muted">Want to book directly into a calendar instead? <a href="/find">Browse studios with live calendars →</a></p>

← 196af63 v0.5: NPH email through Purelymail SMTP + tighter /book cale  ·  back to NationalPaperHangers  ·  v0.7 #31: auto-email worker for installer_interest queue f21468c →