← 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
A lib/clients.jsM routes/public.jsA views/public/clients.ejsA views/public/dwclient.ejs
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,'"') %>" 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 →