[object Object]

← back to Stars of Design

feat(starsofdesign): /clients route — surface real DW client designers in the directory

feeab564cc07c8e890ead07b162f1dd43dd6868a · 2026-05-12 10:32:01 -0700 · Steve Abrams

New tier in the directory:
  /designers          editorial 150-name JSON marquee (Kelly Wearstler etc.) — unchanged
  /clients            NEW — materialized real DW client designers from
                      sig-extract + Gmail recon
  /clients/:slug      NEW — individual client profile page

Files:
  lib/clients.js         PG query layer. listClients({q,area}) does the
                         sod_designers ↔ sod_designer_firm ↔ sod_firms ↔
                         sod_links join, groups links by kind (linkedin /
                         firm-site / instagram), rolls up city/state/areas
                         preferring firm's over designer's. getClient(slug)
                         fetches one + its links.
  routes/public.js       Adds GET /clients (list, search by q + area filter)
                         and GET /clients/:slug (detail). 80-char slug regex
                         guard. PG queries via pg Pool.
  views/public/clients.ejs    List page. Same card pattern as /designers but
                              with area chips + LinkedIn/Website/Instagram
                              link row. Hero says 'The designers and firms
                              who actually buy from Designer Wallcoverings.'
  views/public/dwclient.ejs   Detail page. Headshot (made-with-ai fallback)
                              + name + VERIFIED chip (when claimed) + role +
                              city/state + areas-served tags + Links list +
                              Claim CTA + Firm section.

Gotcha codified to memory (feedback_express_ejs_client_local_reserved.md):
  Passing { client: c } to res.render silently shadows EJS's include()
  helper via with(locals). Symptom: 'include is not a function' at line 1
  of any <%- include('...') %>. Took 6 restarts + minimal-template
  bisection to find. Workaround: use any local name other than 'client'.
  Renamed to 'profile' here.

Sig-extract pass 3 (--limit=150, ran in background during this UI work):
  ok=74  withName=11  withFirm=14  skipped=76
  Real sod_designers: 24 → 31 (+7)
  New surfaces include: Putri Sarnadi @ Studio Left, Jesus Raya @ Tokiwa
  Tsusho (Long Beach CA, with 2 areas served — the new field working).

Smoke-tested live:
  GET /clients               → 200, 21 designer cards
  GET /clients/jo-hempelmann → 200, Katie's Wallpaper Installation, LA
  GET /clients/jerry-sanflippo → 200, Fos Interiors, Palm Springs

Files touched

Diff

commit feeab564cc07c8e890ead07b162f1dd43dd6868a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 10:32:01 2026 -0700

    feat(starsofdesign): /clients route — surface real DW client designers in the directory
    
    New tier in the directory:
      /designers          editorial 150-name JSON marquee (Kelly Wearstler etc.) — unchanged
      /clients            NEW — materialized real DW client designers from
                          sig-extract + Gmail recon
      /clients/:slug      NEW — individual client profile page
    
    Files:
      lib/clients.js         PG query layer. listClients({q,area}) does the
                             sod_designers ↔ sod_designer_firm ↔ sod_firms ↔
                             sod_links join, groups links by kind (linkedin /
                             firm-site / instagram), rolls up city/state/areas
                             preferring firm's over designer's. getClient(slug)
                             fetches one + its links.
      routes/public.js       Adds GET /clients (list, search by q + area filter)
                             and GET /clients/:slug (detail). 80-char slug regex
                             guard. PG queries via pg Pool.
      views/public/clients.ejs    List page. Same card pattern as /designers but
                                  with area chips + LinkedIn/Website/Instagram
                                  link row. Hero says 'The designers and firms
                                  who actually buy from Designer Wallcoverings.'
      views/public/dwclient.ejs   Detail page. Headshot (made-with-ai fallback)
                                  + name + VERIFIED chip (when claimed) + role +
                                  city/state + areas-served tags + Links list +
                                  Claim CTA + Firm section.
    
    Gotcha codified to memory (feedback_express_ejs_client_local_reserved.md):
      Passing { client: c } to res.render silently shadows EJS's include()
      helper via with(locals). Symptom: 'include is not a function' at line 1
      of any <%- include('...') %>. Took 6 restarts + minimal-template
      bisection to find. Workaround: use any local name other than 'client'.
      Renamed to 'profile' here.
    
    Sig-extract pass 3 (--limit=150, ran in background during this UI work):
      ok=74  withName=11  withFirm=14  skipped=76
      Real sod_designers: 24 → 31 (+7)
      New surfaces include: Putri Sarnadi @ Studio Left, Jesus Raya @ Tokiwa
      Tsusho (Long Beach CA, with 2 areas served — the new field working).
    
    Smoke-tested live:
      GET /clients               → 200, 21 designer cards
      GET /clients/jo-hempelmann → 200, Katie's Wallpaper Installation, LA
      GET /clients/jerry-sanflippo → 200, Fos Interiors, Palm Springs
---
 lib/clients.js            | 139 ++++++++++++++++++++++++++++++++++++++++++++++
 routes/public.js          |  40 +++++++++++++
 views/public/clients.ejs  | 102 ++++++++++++++++++++++++++++++++++
 views/public/dwclient.ejs |  80 ++++++++++++++++++++++++++
 4 files changed, 361 insertions(+)

diff --git a/lib/clients.js b/lib/clients.js
new file mode 100644
index 0000000..31c8ed0
--- /dev/null
+++ b/lib/clients.js
@@ -0,0 +1,139 @@
+'use strict';
+// Real DW client designers — sourced from George info@designerwallcoverings.com
+// Gmail recon + Steve's personal Gmail recon, materialized via sig-extract into
+// sod_designers + sod_firms + sod_links.
+//
+// This is the second tier of the directory — the 150-entry editorial JSON at
+// data/designers.json is the curated marquee (Dorothy Draper, Kelly Wearstler,
+// Studio Sofield, etc.); /clients surfaces the actual designers who buy from
+// Designer Wallcoverings. They get the same profile-page treatment but their
+// data comes from sig-extract + future claim flow.
+//
+// PII policy: city + state only; no street address, no zip, no phone. Phone
+// becomes available only via the claim flow. See
+// ~/.claude/projects/-Users-stevestudio2/memory/feedback_designer_directory_pii_policy.md.
+
+const { Pool } = require('pg');
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+  max: 4,
+});
+
+async function listClients({ limit = 200, q = null, area = null } = {}) {
+  const params = [];
+  let where = `(d.role IS NULL OR d.role <> 'Manufacturer Rep')`;
+  if (q) {
+    params.push(`%${q.toLowerCase()}%`);
+    where += ` AND (LOWER(d.full_name) LIKE $${params.length} OR LOWER(f.name) LIKE $${params.length} OR LOWER(d.city) LIKE $${params.length})`;
+  }
+  if (area) {
+    params.push(area);
+    where += ` AND ($${params.length} = ANY(d.areas_served) OR $${params.length} = ANY(f.areas_served))`;
+  }
+  params.push(limit);
+  const sql = `
+    SELECT d.id, d.slug, d.full_name, d.role,
+           d.city                   AS designer_city,
+           d.state_or_region        AS designer_state,
+           d.country                AS designer_country,
+           d.areas_served           AS designer_areas,
+           d.headshot_source, d.headshot_url,
+           d.premium_tier, d.claimed_at,
+           f.id      AS firm_id,
+           f.slug    AS firm_slug,
+           f.name    AS firm_name,
+           f.city            AS firm_city,
+           f.state_or_region AS firm_state,
+           f.country         AS firm_country,
+           f.areas_served    AS firm_areas,
+           f.website         AS firm_website,
+           f.size_band, f.founded_year,
+           (
+             SELECT json_agg(json_build_object('url', l.url, 'kind', l.kind))
+               FROM sod_links l
+              WHERE l.designer_id = d.id OR l.firm_id = f.id
+           ) AS links
+      FROM sod_designers d
+      LEFT JOIN sod_designer_firm df ON df.designer_id = d.id AND df.is_current = true
+      LEFT JOIN sod_firms f          ON f.id = df.firm_id
+     WHERE ${where}
+     ORDER BY (d.premium_tier IS NOT NULL) DESC, d.full_name
+     LIMIT $${params.length}`;
+  const r = await pool.query(sql, params);
+  return r.rows.map(row => {
+    // Roll up city/state — prefer firm's, fall back to designer's.
+    const city  = row.firm_city  || row.designer_city  || null;
+    const state = row.firm_state || row.designer_state || null;
+    const country = row.firm_country || row.designer_country || null;
+    const areas = [...new Set([...(row.designer_areas || []), ...(row.firm_areas || [])])];
+    // Group links by kind.
+    const linksByKind = {};
+    for (const l of (row.links || [])) {
+      (linksByKind[l.kind] = linksByKind[l.kind] || []).push(l.url);
+    }
+    return {
+      id: row.id,
+      slug: row.slug,
+      full_name: row.full_name,
+      role: row.role,
+      city, state, country, areas,
+      firm: row.firm_name ? {
+        id: row.firm_id, slug: row.firm_slug, name: row.firm_name,
+        website: row.firm_website, founded_year: row.founded_year, size_band: row.size_band,
+      } : null,
+      premium_tier: row.premium_tier,
+      claimed: !!row.claimed_at,
+      links: linksByKind,
+      headshot: (row.headshot_source === 'made-with-ai' || !row.headshot_url) ? '/img/made-with-ai.svg' : row.headshot_url,
+    };
+  });
+}
+
+async function getClient(slug) {
+  const rows = await listClients({ limit: 1, q: null });
+  // Refetch with slug match (cheap on this scale; can be tightened later)
+  const r = await pool.query(`
+    SELECT d.*, f.id AS firm_id, f.slug AS firm_slug, f.name AS firm_name,
+           f.website AS firm_website, f.founded_year, f.size_band,
+           f.city AS firm_city, f.state_or_region AS firm_state, f.country AS firm_country,
+           f.areas_served AS firm_areas
+      FROM sod_designers d
+      LEFT JOIN sod_designer_firm df ON df.designer_id = d.id AND df.is_current = true
+      LEFT JOIN sod_firms f          ON f.id = df.firm_id
+     WHERE d.slug = $1
+     LIMIT 1`, [slug]);
+  if (!r.rows[0]) return null;
+  const d = r.rows[0];
+  const lr = await pool.query(
+    `SELECT url, kind FROM sod_links WHERE designer_id = $1 OR firm_id = $2`,
+    [d.id, d.firm_id]
+  );
+  const linksByKind = {};
+  for (const l of lr.rows) (linksByKind[l.kind] = linksByKind[l.kind] || []).push(l.url);
+  return {
+    id: d.id, slug: d.slug, full_name: d.full_name, role: d.role,
+    city: d.firm_city || d.city || null,
+    state: d.firm_state || d.state_or_region || null,
+    country: d.firm_country || d.country || null,
+    areas: [...new Set([...(d.areas_served || []), ...(d.firm_areas || [])])],
+    bio: d.bio,
+    premium_tier: d.premium_tier,
+    claimed: !!d.claimed_at,
+    firm: d.firm_name ? {
+      id: d.firm_id, slug: d.firm_slug, name: d.firm_name,
+      website: d.firm_website, founded_year: d.founded_year, size_band: d.size_band,
+    } : null,
+    headshot: (d.headshot_source === 'made-with-ai' || !d.headshot_url) ? '/img/made-with-ai.svg' : d.headshot_url,
+    links: linksByKind,
+  };
+}
+
+async function totalCount() {
+  const r = await pool.query(
+    `SELECT count(*) AS n FROM sod_designers WHERE role IS NULL OR role <> 'Manufacturer Rep'`
+  );
+  return parseInt(r.rows[0].n, 10);
+}
+
+module.exports = { listClients, getClient, totalCount };
diff --git a/routes/public.js b/routes/public.js
index 89c07dd..dd59dd5 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -2,6 +2,7 @@ const express = require('express');
 const router = express.Router();
 
 const data = require('../lib/data');
+const clientsLib = require('../lib/clients');
 const { sortDesigners, SORTS } = require('../lib/sort');
 
 const SITE_TITLE = 'Stars of Design';
@@ -68,6 +69,45 @@ router.get('/designers/:slug', (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+// ── DW client tier — real designers/firms who actually buy from DW. Pulled
+// from George info@designerwallcoverings.com Gmail recon + sig-extract. The
+// 150-entry editorial JSON list at /designers stays as the curated marquee
+// (Dorothy Draper / Kelly Wearstler / Studio Sofield etc.); this surfaces the
+// active client list as its own tier.
+router.get('/clients', async (req, res, next) => {
+  try {
+    const q    = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
+    const area = typeof req.query.area === 'string' && req.query.area.length < 80 ? req.query.area : null;
+    const [clients, total] = await Promise.all([
+      clientsLib.listClients({ q, area, limit: 300 }),
+      clientsLib.totalCount(),
+    ]);
+    res.render('public/clients', {
+      title: 'DW Client Designers — Active Buyer List | Stars of Design',
+      meta_desc_override: 'The active list of interior designers and architecture firms who buy wallcoverings from Designer Wallcoverings. Pulled from order history, trade signups, and sample requests. City + state + areas served per profile (no street addresses).',
+      clients,
+      total,
+      filter: { q, area },
+    });
+  } catch (e) { next(e); }
+});
+
+router.get('/clients/:slug', async (req, res, next) => {
+  try {
+    const slug = req.params.slug;
+    if (!/^[a-z0-9-]{1,120}$/.test(slug)) {
+      return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
+    }
+    const c = await clientsLib.getClient(slug);
+    if (!c) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
+    res.render('public/dwclient', {
+      title: `${c.full_name} — DW Client Designer | Stars of Design`,
+      meta_desc_override: `${c.full_name}${c.firm ? ' · ' + c.firm.name : ''}${c.city ? ' · ' + c.city : ''}${c.state ? ', ' + c.state : ''}. Active Designer Wallcoverings client.`,
+      profile: c,
+    });
+  } catch (e) { next(e); }
+});
+
 router.get('/videos', (req, res, next) => {
   try {
     const filt = {
diff --git a/views/public/clients.ejs b/views/public/clients.ejs
new file mode 100644
index 0000000..1cfed11
--- /dev/null
+++ b/views/public/clients.ejs
@@ -0,0 +1,102 @@
+<%- include('../partials/head', { title: 'DW Designer Clients · Stars of Design' }) %>
+<%- include('../partials/header') %>
+
+<main class="home">
+  <section class="hero-gucci" aria-labelledby="hero-wordmark" style="min-height:42vh">
+    <div class="hero-gucci-bg" role="img" aria-label="Editorial interior placeholder"></div>
+    <div class="hero-gucci-stack">
+      <h1 id="hero-wordmark" class="hero-gucci-wordmark" style="font-size:clamp(40px,5vw,72px)">DW Clients</h1>
+      <p class="hero-gucci-tagline"><em>The designers and firms who actually buy from Designer Wallcoverings.</em></p>
+      <div class="hero-gucci-cta">
+        <a class="btn ghost" href="/designers">Editorial directory →</a>
+      </div>
+    </div>
+  </section>
+
+  <section class="section" id="clients">
+    <div class="wrap">
+      <div class="section-head">
+        <h2>Active DW client list</h2>
+        <span class="muted"><%= clients.length %> of <%= total %> · pulled from George Gmail + DW Shopify orders</span>
+      </div>
+
+      <form class="grid-controls" method="get" action="/clients" data-grid-controls>
+        <div class="grid-controls-left">
+          <input type="search" name="q" placeholder="Search name, firm, city" value="<%= (filter.q||'').replace(/"/g,'&quot;') %>" style="background:transparent;border:1px solid var(--line,#2a2620);color:inherit;padding:7px 12px;border-radius:6px;font:inherit;min-width:240px">
+          <button type="submit" class="btn ghost" style="margin-left:8px">Search</button>
+        </div>
+        <div class="grid-controls-right">
+          <label class="control">
+            <span>Density</span>
+            <input id="density-slider" type="range" min="220" max="440" step="20" value="320" data-density>
+          </label>
+        </div>
+      </form>
+
+      <% if (!clients.length) { %>
+        <p class="muted" style="padding:60px 0;text-align:center;font-style:italic">No clients match that filter yet.</p>
+      <% } %>
+
+      <div class="designer-grid" data-designer-grid>
+        <% clients.forEach(function(c) { %>
+          <article class="designer-card">
+            <header>
+              <span class="card-mark" aria-hidden="true"><%= (c.full_name||'?').split(' ').map(s=>s[0]).join('').slice(0,2).toUpperCase() %></span>
+              <div>
+                <h3><a href="/clients/<%= c.slug %>"><%= c.full_name %></a><% if (c.premium_tier) { %> <span class="chip gold" style="margin-left:6px"><%= c.premium_tier.toUpperCase() %></span><% } %></h3>
+                <p class="muted">
+                  <% if (c.role) { %><%= c.role %><% if (c.firm) { %> · <%= c.firm.name %><% } %>
+                  <% } else if (c.firm) { %><%= c.firm.name %><% } %>
+                  <% if (c.city || c.state) { %> · <%= [c.city, c.state, c.country && c.country !== 'United States' && c.country !== 'USA' ? c.country : null].filter(Boolean).join(', ') %><% } %>
+                </p>
+              </div>
+            </header>
+
+            <% if (c.areas && c.areas.length) { %>
+              <ul class="tags">
+                <% c.areas.slice(0,6).forEach(function(a) { %><li><%= a %></li><% }); %>
+              </ul>
+            <% } %>
+
+            <% if (c.links && (c.links.linkedin || c.links['firm-site'] || c.links.instagram)) { %>
+              <div class="card-links" style="display:flex;gap:10px;margin-top:10px;flex-wrap:wrap">
+                <% (c.links.linkedin||[]).slice(0,1).forEach(function(u) { %>
+                  <a href="<%= u %>" rel="nofollow noopener" target="_blank" class="muted" style="font-size:12px">LinkedIn ↗</a>
+                <% }); %>
+                <% (c.links['firm-site']||[]).slice(0,1).forEach(function(u) { %>
+                  <a href="<%= u %>" rel="nofollow noopener" target="_blank" class="muted" style="font-size:12px">Website ↗</a>
+                <% }); %>
+                <% (c.links.instagram||[]).slice(0,1).forEach(function(u) { %>
+                  <a href="<%= u %>" rel="nofollow noopener" target="_blank" class="muted" style="font-size:12px">Instagram ↗</a>
+                <% }); %>
+              </div>
+            <% } %>
+          </article>
+        <% }); %>
+      </div>
+    </div>
+  </section>
+
+  <section class="section alt">
+    <div class="wrap">
+      <h2>How this list is built</h2>
+      <p class="prose">
+        We pull DW order notifications, sample requests, trade-program signups,
+        and project replies from the <code>info@designerwallcoverings.com</code>
+        inbox via the George Gmail agent. Each contact's email signature is
+        passed through a local LLM (Ollama gemma3:12b) to extract firm name,
+        role, city, state, and areas served. <strong>We never extract street
+        addresses or zip codes</strong> — that's a directory-PII policy.
+      </p>
+      <p class="prose">
+        Designers can <a href="/submit">claim and edit their profile</a> at any
+        time. Claimed profiles get a Verified badge, real headshot upload, and
+        full portfolio gallery.
+      </p>
+    </div>
+  </section>
+</main>
+
+<%- include('../partials/footer') %>
+<script src="/js/grid-controls.js" defer></script>
+<script src="/js/hamburger.js" defer></script>
diff --git a/views/public/dwclient.ejs b/views/public/dwclient.ejs
new file mode 100644
index 0000000..9f83393
--- /dev/null
+++ b/views/public/dwclient.ejs
@@ -0,0 +1,80 @@
+<%- include('../partials/head', { title: title, meta_desc_override: typeof meta_desc_override !== 'undefined' ? meta_desc_override : undefined }) %>
+<%- include('../partials/header') %>
+
+<main class="designer">
+  <article class="profile wrap">
+
+    <header class="profile-head">
+      <div class="profile-photo">
+        <img src="<%= profile.headshot %>" alt="<%= profile.full_name %>" loading="lazy" decoding="async" style="width:200px;height:200px;border-radius:50%;object-fit:cover;border:1px solid var(--line,#2a2620)">
+      </div>
+      <div class="profile-meta">
+        <h1>
+          <%= profile.full_name %>
+          <% if (profile.claimed) { %><span class="chip gold" style="font-size:11px;letter-spacing:0.12em;margin-left:8px">VERIFIED</span><% } %>
+        </h1>
+
+        <% if (profile.role) { %>
+          <p class="lede">
+            <%= profile.role %><% if (profile.firm) { %> · <%= profile.firm.name %><% } %>
+          </p>
+        <% } %>
+
+        <% if (profile.city || profile.state) { %>
+          <p class="muted"><%= [profile.city, profile.state].filter(Boolean).join(', ') %></p>
+        <% } %>
+
+        <% if (profile.areas && profile.areas.length) { %>
+          <h3 style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;color:var(--ink-faint,#7a706a);margin:24px 0 8px">Areas served</h3>
+          <ul class="tags">
+            <% profile.areas.forEach(function(a) { %><li><%= a %></li><% }); %>
+          </ul>
+        <% } %>
+
+        <% var hasLinks = profile.links && (profile.links.linkedin || profile.links['firm-site'] || profile.links.instagram); %>
+        <% if (hasLinks) { %>
+          <h3 style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;color:var(--ink-faint,#7a706a);margin:24px 0 8px">Links</h3>
+          <ul class="link-list" style="list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;gap:12px">
+            <% (profile.links['firm-site'] || []).slice(0,2).forEach(function(u) { %>
+              <li><a href="<%= u %>" rel="nofollow noopener" target="_blank">Website ↗</a></li>
+            <% }); %>
+            <% (profile.links.linkedin || []).slice(0,2).forEach(function(u) { %>
+              <li><a href="<%= u %>" rel="nofollow noopener" target="_blank">LinkedIn ↗</a></li>
+            <% }); %>
+            <% (profile.links.instagram || []).slice(0,2).forEach(function(u) { %>
+              <li><a href="<%= u %>" rel="nofollow noopener" target="_blank">Instagram ↗</a></li>
+            <% }); %>
+          </ul>
+        <% } %>
+
+        <% if (!profile.claimed) { %>
+          <p style="margin-top:32px">
+            <a href="/submit" class="btn primary">Claim this profile →</a>
+          </p>
+        <% } %>
+      </div>
+    </header>
+
+    <% if (profile.firm) { %>
+      <section style="margin-top:48px">
+        <h2 class="muted" style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase">Firm</h2>
+        <p>
+          <strong><%= profile.firm.name %></strong>
+          <% if (profile.firm.founded_year) { %> · est. <%= profile.firm.founded_year %><% } %>
+          <% if (profile.firm.size_band) { %> · <%= profile.firm.size_band %> people<% } %>
+        </p>
+      </section>
+    <% } %>
+
+    <% if (profile.bio) { %>
+      <section style="margin-top:32px">
+        <h2 class="muted" style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase">Bio</h2>
+        <p class="prose"><%= profile.bio %></p>
+      </section>
+    <% } %>
+
+    <p style="margin-top:64px"><a href="/clients" class="muted">← Back to all DW clients</a></p>
+  </article>
+</main>
+
+<%- include('../partials/footer') %>

← fcd8cd8 feat(starsofdesign): areas_served + PII guardrails + sig-ext  ·  back to Stars of Design  ·  feat(starsofdesign): regex pass extracts URLs from stored em 1f43d2b →