← back to Dw Pairs Well
server.js
1329 lines
require('dotenv').config();
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const { parseTags, classify, score, scoreSimilar } = require('./lib/classify');
const { hexToLab, deltaE2000, safeParseHexArray } = require('./lib/color');
const { nearestPaintsBatch } = require('./lib/paint');
const { generateCoordinates, renderCustom } = require('./lib/ai-stripes');
const { COLORS } = require('./lib/classify');
const PORT = Number(process.env.PORT || 9799);
const DATABASE_URL = process.env.DATABASE_URL;
const CORS_ORIGINS = (process.env.CORS_ORIGIN || '*')
.split(',').map(s => s.trim()).filter(Boolean);
// CLIP visual-search service (dw-photo-capture/visual-search/search_service.py).
// Used by /api/similar?engine=clip for image-embedding nearest-neighbour. Optional:
// if unreachable OR the source SKU has no stored vector, /api/similar transparently
// falls back to the tag engine, so this is a pure enhancement.
const VISUAL_SEARCH_URL = process.env.VISUAL_SEARCH_URL || 'http://127.0.0.1:9914';
// CLIP blend can be turned ON for ALL /api/pairs callers (not just ?engine=clip) by
// setting PAIRS_CLIP_DEFAULT=1. Defaults OFF so shipping this is a no-op in prod until
// the visual-search service is confirmed reachable from the serving host. ?engine=tag
// is an explicit per-request override that force-disables the blend even when default-on.
const PAIRS_CLIP_DEFAULT = /^(1|true|on)$/i.test(process.env.PAIRS_CLIP_DEFAULT || '');
// Same one-switch for /api/similar: SIMILAR_CLIP_DEFAULT=1 makes the CLIP color+texture
// engine the default for all callers (per-request ?engine=tag still forces tag).
const SIMILAR_CLIP_DEFAULT = /^(1|true|on)$/i.test(process.env.SIMILAR_CLIP_DEFAULT || '');
// Latency guardrail: a short per-call timeout + a circuit-breaker so an unreachable/slow
// visual-search service never adds more than one slow call per cooldown window to customer
// traffic. On any failure we "trip" and skip the service for CLIP_COOLDOWN_MS.
const CLIP_TIMEOUT_MS = Number(process.env.CLIP_TIMEOUT_MS || 1500);
const CLIP_COOLDOWN_MS = Number(process.env.CLIP_COOLDOWN_MS || 30000);
let _clipDownUntil = 0;
function clipCircuitOpen() { return Date.now() < _clipDownUntil; }
function clipTrip() { _clipDownUntil = Date.now() + CLIP_COOLDOWN_MS; }
function clipReset() { _clipDownUntil = 0; }
if (!DATABASE_URL) {
console.error('FATAL: DATABASE_URL not set — copy .env.example to .env');
process.exit(1);
}
const pool = new Pool({ connectionString: DATABASE_URL });
// "Link works on the live store" predicate. Probed at boot: if this DB's
// shopify_products lacks online_store_published (schema drift between the Mac2
// mirror and Kamatera), degrade to status-only filtering and say so loudly —
// a weaker guarantee beats 500ing every suggestion endpoint.
let PUB_SQL = 'AND sp.online_store_published IS TRUE';
pool.query('SELECT online_store_published FROM shopify_products LIMIT 0')
.then(() => console.log('[link-guarantee] online_store_published present — full ACTIVE+published gate on'))
.catch(() => {
PUB_SQL = '';
console.error('[link-guarantee] WARNING: shopify_products.online_store_published MISSING in this DB — degrading to status-only filtering');
});
const app = express();
app.disable('x-powered-by');
app.use((req, res, next) => {
const origin = req.headers.origin;
if (CORS_ORIGINS.includes('*') || (origin && CORS_ORIGINS.includes(origin))) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.use(express.static(path.join(__dirname, 'public')));
// Root → the Design Coordinate preview
app.get('/', (_req, res) => res.redirect(302, '/preview.html'));
app.get('/healthz', async (_req, res) => {
try {
const r = await pool.query('SELECT 1 AS ok');
res.json({ ok: true, db: r.rows[0].ok === 1, ts: new Date().toISOString() });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
const SAFE_KEY = /^[A-Za-z0-9._\-]{1,128}$/;
// pattern_id grouping keys embed "::" segments (vendorSlug::source::root), so they
// need a wider charset than SAFE_KEY (which gates handles/SKUs).
const SAFE_PATTERN_ID = /^[A-Za-z0-9._:∅\- ]{1,200}$/;
const HEX_RE = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;
// Parse a `palette=` query — comma-separated hex codes. Used by external sources
// (e.g. wallco.ai design pages) that aren't in shopify_products but still want
// to get coordinating stripes/plaids matched to a color story.
function parsePaletteParam(raw) {
if (!raw) return [];
return String(raw).split(',')
.map(s => s.trim())
.filter(s => HEX_RE.test(s))
.map(s => s.startsWith('#') ? s : ('#' + s))
.slice(0, 8);
}
const SOURCE_COLS = `
sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name, sp.status,
sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
`;
async function loadOne(col, val) {
const q = `SELECT ${SOURCE_COLS}
FROM shopify_products sp
LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
WHERE sp.${col} = $1
LIMIT 1`;
const r = await pool.query(q, [val]);
return r.rows[0] || null;
}
// Resolve the source product. 2026-06-27 (vp-dw-commerce): the DW theme passes
// data-product-sku = custom.manufacturer_sku as ?dw_sku=, which never matches
// shopify_products.dw_sku (a DWxx key) — so a wrong dw_sku used to short-circuit
// (else-if) the handle path and return "source not found", breaking the whole
// Design-Coordinate panel. Fix: TRY dw_sku, and if it doesn't resolve, FALL BACK
// to handle. A bad sku can no longer block a good handle.
async function loadSource({ dw_sku, handle }) {
let src = null;
if (dw_sku) src = await loadOne('dw_sku', dw_sku);
if (!src && handle) src = await loadOne('handle', handle);
return src;
}
// "Coordinate" patterns in the interior-design sense — what a designer uses to
// COORDINATE a hero pattern. Stripes/plaids/checks/tartans/gingham/ticking only.
// Florals coordinate against florals visually compete; stripes never do.
const COORDINATE_MOTIFS = ['stripe', 'striped', 'plaid', 'check', 'checked', 'checkered', 'tartan', 'gingham', 'ticking'];
async function loadCandidates(source, limit = 600) {
// Hard motif filter: candidate MUST have a stripe/plaid/check tag.
// We don't filter by color here — color overlap is the scoring signal, but
// we want a wide candidate pool so the algorithm can pick the best palette match.
const motifClauses = COORDINATE_MOTIFS.map((_, i) => `lower(sp.tags) LIKE $${i + 3}`).join(' OR ');
const motifPatterns = COORDINATE_MOTIFS.map(m => `%${m}%`);
const params = [source.dw_sku || '', limit, ...motifPatterns];
const q = `
SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
FROM shopify_products AS sp
LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
WHERE sp.status = 'ACTIVE'
${PUB_SQL}
AND sp.image_url IS NOT NULL
AND sp.handle IS NOT NULL
AND sp.dw_sku IS NOT NULL
AND sp.dw_sku <> $1
AND (${motifClauses})
ORDER BY (sce.dominant_hex IS NOT NULL) DESC,
(sce.hex_codes IS NOT NULL) DESC,
sp.synced_at DESC NULLS LAST
LIMIT $2
`;
const r = await pool.query(q, params);
return r.rows;
}
function detectCoordinateMotif(tags) {
if (!tags) return null;
const lower = String(tags).toLowerCase();
for (const m of ['plaid','tartan','gingham','check','ticking','stripe','striped','checked','checkered']) {
if (lower.indexOf(m) !== -1) return m === 'striped' ? 'stripe' : (m === 'checked' || m === 'checkered' ? 'check' : m);
}
return null;
}
const COLOR_NAME = [
// hue-name lookup keyed by approximate Lab — used only for the "Picks up:" explanation
{ name: 'black', L: 10 },
{ name: 'white', L: 95 },
{ name: 'cream', L: 92, a: 3, b: 14 },
{ name: 'beige', L: 80, a: 6, b: 18 },
{ name: 'taupe', L: 60, a: 4, b: 8 },
{ name: 'brown', L: 38, a: 14, b: 22 },
{ name: 'ochre', L: 65, a: 12, b: 50 },
{ name: 'gold', L: 70, a: 8, b: 60 },
{ name: 'mustard', L: 70, a: 10, b: 70 },
{ name: 'olive', L: 50, a: -8, b: 35 },
{ name: 'sage', L: 65, a: -15, b: 12 },
{ name: 'green', L: 50, a: -40, b: 25 },
{ name: 'teal', L: 50, a: -25, b: -10 },
{ name: 'blue', L: 45, a: 0, b: -45 },
{ name: 'navy', L: 20, a: 0, b: -35 },
{ name: 'lavender', L: 70, a: 12, b: -18 },
{ name: 'purple', L: 35, a: 30, b: -35 },
{ name: 'pink', L: 75, a: 25, b: 8 },
{ name: 'rose', L: 60, a: 35, b: 12 },
{ name: 'red', L: 45, a: 60, b: 35 },
{ name: 'rust', L: 45, a: 35, b: 40 },
{ name: 'orange', L: 65, a: 35, b: 60 },
{ name: 'gray', L: 60, a: 0, b: 0 }
];
function nameHex(hex) {
const lab = hexToLab(hex);
if (!lab) return null;
// grayscale shortcut
const sat = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
if (sat < 6) {
if (lab.L > 90) return 'white';
if (lab.L > 70) return 'light gray';
if (lab.L > 35) return 'gray';
if (lab.L > 15) return 'charcoal';
return 'black';
}
let best = null, bestD = Infinity;
for (const c of COLOR_NAME) {
const dL = lab.L - c.L;
const da = (c.a == null ? 0 : lab.a - c.a);
const db = (c.b == null ? 0 : lab.b - c.b);
const d = dL*dL + da*da + db*db;
if (d < bestD) { bestD = d; best = c.name; }
}
return best;
}
function paletteOf(row) {
// Build a clean hex list: dominant first, then bg, then the rest of hex_codes,
// de-duped (case-insensitive), capped at 5.
var out = [];
var seen = new Set();
function push(h) {
if (!h) return;
var key = String(h).toLowerCase();
if (seen.has(key)) return;
seen.add(key); out.push(h);
}
push(row.dominant_hex);
push(row.background_hex);
safeParseHexArray(row.hex_codes).forEach(push);
return out.slice(0, 5);
}
// Palette-vs-palette color overlap score.
// For each source color, find the BEST ΔE2000 match in the candidate palette.
// Award a bonus per color and report which source colors the candidate "picks up".
function paletteOverlap(sourcePalette, candPalette) {
if (!sourcePalette || !candPalette) return { score: 0, pickedUp: [], avgDE: null };
const sourceLabs = sourcePalette.map(h => ({ hex: h, lab: hexToLab(h), name: nameHex(h) }));
const candLabs = candPalette.map(h => ({ hex: h, lab: hexToLab(h) }));
if (!sourceLabs.length || !candLabs.length) return { score: 0, pickedUp: [], avgDE: null };
let total = 0;
const pickedUp = [];
const allDE = [];
for (const s of sourceLabs) {
if (!s.lab) continue;
let bestDE = Infinity;
for (const c of candLabs) {
if (!c.lab) continue;
const dE = deltaE2000(s.lab, c.lab);
if (dE < bestDE) bestDE = dE;
}
if (!Number.isFinite(bestDE)) continue;
allDE.push(bestDE);
// perceptual bands → bonus
let bonus;
if (bestDE <= 10) bonus = 4;
else if (bestDE <= 20) bonus = 2;
else if (bestDE <= 35) bonus = 1;
else bonus = 0;
total += bonus;
if (bonus > 0 && s.name && !pickedUp.includes(s.name)) pickedUp.push(s.name);
}
const avgDE = allDE.length ? (allDE.reduce((a, b) => a + b, 0) / allDE.length) : null;
return { score: total, pickedUp, avgDE };
}
// Batch-load enrichment rows for a set of dw_skus (for CLIP-result UI parity:
// title, palette, vendor, handle). First row per dw_sku wins.
async function loadRowsByDwSkus(skus) {
if (!skus || !skus.length) return [];
// ACTIVE + Online-Store-published only: a CLIP result without a row here is NOT
// sellable/linkable on the live store and gets dropped by clipSimilarTop.
const q = `SELECT ${SOURCE_COLS}
FROM shopify_products sp
LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
WHERE sp.dw_sku = ANY($1)
AND sp.status = 'ACTIVE'
${PUB_SQL}
AND sp.handle IS NOT NULL`;
const r = await pool.query(q, [skus]);
const seen = new Set(), out = [];
for (const row of r.rows) {
if (!row.dw_sku || seen.has(row.dw_sku)) continue;
seen.add(row.dw_sku); out.push(row);
}
return out;
}
// CLIP nearest-neighbour "similar" via the visual-search service. Returns an array
// in the SAME shape pickSimilar() produces (_score/_palette/_dE/_why) so the shared
// palette+paint response tail is reused verbatim. Returns null on any miss (service
// down, source SKU never embedded, empty result) → caller falls back to the tag engine.
async function clipSimilarTop(source, k) {
if (!source.dw_sku) return null;
if (clipCircuitOpen()) return null; // service recently failed → skip, no latency hit
try {
// Over-fetch 4x: results get dropped by BOTH the ACTIVE+published gate and the
// color lock below, and the strip should still fill to k.
const body = { dw_sku: source.dw_sku, hex: source.dominant_hex || '', k: Math.min(k * 4, 50) };
const r = await fetch(`${VISUAL_SEARCH_URL}/similar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(CLIP_TIMEOUT_MS)
});
if (!r.ok) { // 404 = no stored vector for this sku (per-sku miss, NOT an outage)
if (r.status >= 500) clipTrip(); // 5xx = service alive but erroring → trip the breaker
return null;
}
const d = await r.json();
clipReset(); // reachable → close the breaker
if (!d || !d.ok || !Array.isArray(d.results) || d.results.length === 0) return null;
const skus = d.results.map(x => x.dw_sku).filter(Boolean);
const rowByS = new Map((await loadRowsByDwSkus(skus)).map(row => [row.dw_sku, row]));
const sourcePalette = paletteOf(source);
// HARD LINK GUARANTEE: only products verified ACTIVE + Online-Store-published
// (loadRowsByDwSkus filters to exactly those) may be suggested — every /products/
// link the UI renders must resolve on the live store.
//
// COLOR + TEXTURE LOCK (Steve, 2026-07-13): CLIP cosine locks the texture/style;
// the hex palette locks the color. When the source has hex enrichment, a candidate
// must pick up ≥1 source color (ΔE ≤ 35, paletteOverlap ≥ 1) to qualify; survivors
// re-rank by cosine + a small color term so tighter palette matches rise. If the
// lock would empty the strip (rare), pure-cosine order backfills — never blank.
const scored = [];
for (const x of d.results) {
const row = rowByS.get(x.dw_sku);
if (!row || !row.handle) continue;
const candPalette = paletteOf(row);
const overlap = paletteOverlap(sourcePalette, candPalette);
const why = [`Visual match (CLIP · cosine ${Number(x.score).toFixed(3)})`];
if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 3).join(', ')}`);
scored.push({
dw_sku: x.dw_sku,
handle: row.handle,
title: row.title || x.pattern || x.dw_sku,
vendor: row.vendor || x.vendor || null,
image_url: row.image_url || x.image || null,
pattern_name: row.pattern_name || x.pattern || null,
_score: x.score, _palette: candPalette, _dE: overlap.avgDE, _why: why,
_colorLocked: overlap.score >= 1,
_combined: Number(x.score) + 0.02 * Math.min(overlap.score, 8)
});
}
const hasSourcePalette = sourcePalette.length > 0;
const locked = hasSourcePalette ? scored.filter(s => s._colorLocked) : scored;
locked.sort((a, b) => b._combined - a._combined);
const out = locked.slice(0, k);
if (out.length < k) { // backfill from unlocked, best cosine first
const rest = scored.filter(s => !out.includes(s)).sort((a, b) => b._score - a._score);
out.push(...rest.slice(0, k - out.length));
}
return out.length ? out : null;
} catch (e) {
clipTrip(); // unreachable/slow → open the breaker
console.error('clipSimilarTop fallback →', e.message);
return null;
}
}
// CLIP style-world sub-score for PAIRS. A coordinate should share the hero's style
// world WITHOUT visually competing — so mid-similarity is rewarded and near-duplicates
// (same pattern re-colored) are penalized. Neutral (0) when the candidate has no
// vector, so the un-embedded tail is never punished.
const CLIP_NEAR_DUP = 0.88; // ≥ this = visually the same design → penalize
const CLIP_STYLE_HI = 0.55; // sweet spot lower bound → +4
const CLIP_STYLE_LO = 0.45; // weak style kinship → +2
function clipPairBonus(cos) {
if (cos == null || !Number.isFinite(cos)) return 0;
if (cos >= CLIP_NEAR_DUP) return -4;
if (cos >= CLIP_STYLE_HI) return 4;
if (cos >= CLIP_STYLE_LO) return 2;
return 0;
}
// Bulk source-vs-candidates cosine map from the visual-search service.
// Returns {} on any miss (service down / source not embedded) — blend degrades to
// the pure tag+palette scorer, never blocks the response.
async function clipPairwiseMap(source, candidates) {
if (!source.dw_sku) return {};
if (clipCircuitOpen()) return {}; // service recently failed → skip, no latency hit
try {
const skus = candidates.map(c => c.dw_sku).filter(Boolean);
if (!skus.length) return {};
const r = await fetch(`${VISUAL_SEARCH_URL}/pairwise`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dw_sku: source.dw_sku, candidates: skus }),
signal: AbortSignal.timeout(CLIP_TIMEOUT_MS)
});
if (!r.ok) {
if (r.status >= 500) clipTrip(); // 5xx = service alive but erroring → trip the breaker
return {};
}
const d = await r.json();
clipReset(); // reachable → close the breaker
return (d && d.ok && d.cosines) ? d.cosines : {};
} catch (e) {
clipTrip(); // unreachable/slow → open the breaker
console.error('clipPairwiseMap fallback →', e.message);
return {};
}
}
function pickPairs(source, candidates, k = 24, clipMap = null) {
const sCls = classify(parseTags(source.tags));
const sourcePalette = paletteOf(source);
const scored = candidates.map(c => {
const cCls = classify(parseTags(c.tags));
const tagScore = score(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
const candPalette = paletteOf(c);
const motif = detectCoordinateMotif(c.tags);
const overlap = paletteOverlap(sourcePalette, candPalette);
// Strong bias: motif is the whole point of this feature.
const motifBonus = motif ? 6 : 0;
const clipCos = clipMap ? clipMap[c.dw_sku] : null;
const clipBonus = clipPairBonus(clipCos);
const totalScore = tagScore.score + overlap.score + motifBonus + clipBonus;
const why = [];
if (motif) why.push(`Coordinating ${motif}`);
if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 4).join(', ')}`);
if (clipBonus > 0) why.push(`Same style world (CLIP ${Number(clipCos).toFixed(2)})`);
if (overlap.avgDE != null) why.push(`palette ΔE avg ${overlap.avgDE.toFixed(1)}`);
if (tagScore.why.length) why.push(tagScore.why[0]);
return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp, _overlapScore: overlap.score, _clipCos: clipCos };
});
// HARD FILTER per Steve's rule: every coordinate MUST use colors from the source palette.
// If the source has hex enrichment, require palette overlap ≥ 1 (ΔE ≤ 35 to any source color).
// If the source has NO hex data (~9% of catalog — Phillipe Romano / Brunschwig / Thibaut etc.
// not yet Gemini-enriched), fall back to tag-based color matching so the feature still works.
const hasSourcePalette = sourcePalette.length > 0;
const eligible = hasSourcePalette
? scored.filter(x => x._overlapScore >= 1 && x._pickedUp.length >= 1)
: scored.filter(x => x._score >= 5); // motif (+6) is already in; require some tag overlap too
eligible.sort((a, b) => b._score - a._score);
// Diversity: variety of motifs + vendors + colorways. Tuned for 24 cards.
const out = [];
const seenPatterns = new Set();
const motifCount = new Map();
const vendorCount = new Map();
for (const x of eligible) {
if (out.length >= k) break;
const pat = (x.pattern_name || x.handle || '').toLowerCase();
if (pat && seenPatterns.has(pat)) continue;
const vc = vendorCount.get(x.vendor) || 0;
if (vc >= 6) continue; // cap any single vendor at 6/24
const mc = motifCount.get(x._motif) || 0;
if (mc >= 16) continue; // cap any single motif at 16/24
out.push(x);
if (pat) seenPatterns.add(pat);
vendorCount.set(x.vendor, vc + 1);
motifCount.set(x._motif, mc + 1);
}
return out;
}
// ============================================================
// "Similar Designs" — visual ALTERNATIVES to the source (NOT coordinates).
// Candidate pool is anchored on the SOURCE's own motif/material tags so a
// grasscloth surfaces other grasscloths, a damask other damasks. Scoring
// (scoreSimilar) rewards sameness; color overlap is a soft bonus, not a gate.
// ============================================================
async function loadSimilarCandidates(source, limit = 1200) {
const sCls = classify(parseTags(source.tags));
// Anchor on the source's recognized motif + material tags.
const anchors = [...sCls.motifs, ...sCls.materials];
const params = [source.dw_sku || ''];
let filterClause;
if (anchors.length) {
const likeClauses = anchors.map((_, i) => `lower(sp.tags) LIKE $${i + 2}`);
filterClause = `(${likeClauses.join(' OR ')})`;
anchors.forEach(a => params.push(`%${String(a).toLowerCase()}%`));
} else if (source.color_family) {
// No motif/material on source — fall back to same color family so the pool stays relevant.
filterClause = `lower(sp.tags) LIKE $2`;
params.push(`%${String(source.color_family).toLowerCase()}%`);
} else {
filterClause = 'TRUE';
}
params.push(limit);
const limitIdx = params.length;
const q = `
SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
FROM shopify_products AS sp
LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
WHERE sp.status = 'ACTIVE'
${PUB_SQL}
AND sp.image_url IS NOT NULL
AND sp.handle IS NOT NULL
AND sp.dw_sku IS NOT NULL
AND sp.dw_sku <> $1
AND ${filterClause}
ORDER BY (sce.dominant_hex IS NOT NULL) DESC,
(sce.hex_codes IS NOT NULL) DESC,
sp.synced_at DESC NULLS LAST
LIMIT $${limitIdx}
`;
const r = await pool.query(q, params);
return r.rows;
}
function pickSimilar(source, candidates, k = 12) {
const sCls = classify(parseTags(source.tags));
const sourcePalette = paletteOf(source);
const hasAnchors = (sCls.motifs.size + sCls.materials.size) > 0;
const scored = candidates.map(c => {
const cCls = classify(parseTags(c.tags));
const sim = scoreSimilar(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
const candPalette = paletteOf(c);
const overlap = paletteOverlap(sourcePalette, candPalette);
// color overlap is a SOFT bonus for "similar" (capped), never a hard filter
const totalScore = sim.score + Math.min(overlap.score, 6);
const why = sim.why.slice(0, 2);
if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 3).join(', ')}`);
return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _samePattern: sim.samePattern };
});
// Drop this design's own colorways; require at least one real shared signal.
const eligible = scored.filter(x => !x._samePattern && x._score >= (hasAnchors ? 5 : 3));
eligible.sort((a, b) => b._score - a._score);
// Diversity: one card per SIMILAR pattern (no colorway floods), looser vendor cap than pairs.
const out = [];
const seenPatterns = new Set();
const vendorCount = new Map();
for (const x of eligible) {
if (out.length >= k) break;
const pat = (x.pattern_name || x.handle || '').toLowerCase();
if (pat && seenPatterns.has(pat)) continue;
const vc = vendorCount.get(x.vendor) || 0;
if (vc >= 8) continue;
out.push(x);
if (pat) seenPatterns.add(pat);
vendorCount.set(x.vendor, vc + 1);
}
return out;
}
// ============================================================
// WS-3 — COLORWAYS BACKBONE (collapse colorways → one tile per pattern)
//
// In PRODUCTION the grouping key is the Shopify metafield `custom.pattern_id`
// written by tools/pattern-id-backfill.js (GATED). That metafield is NOT in
// the read-only mirror, so for LOCAL exercisability TODAY these endpoints
// derive an INTERIM grouping key with the SAME T2-title-base + series-aware
// mfr_root logic used in pattern-id-backfill.js. When a `pattern_id` column
// later exists in the mirror (or metafields jsonb carries custom.pattern_id),
// groupKeyForRow() reads it first and the interim derivation is the fallback.
//
// Key precedence per row (mirrors pattern-id-backfill.js):
// 1. custom.pattern_id metafield (prod truth) — read from metafields jsonb
// 2. series-aware mfr_root over mirror.mfr_sku → `${vendorSlug}::mfr::${root}`
// 3. T2 normalized title minus color words → `${vendorSlug}::t2::${t2}`
// 4. pattern_name last resort → `${vendorSlug}::pn::${key}`
// ============================================================
const GROUP_NOISE = [
'wallcovering','wallcoverings','wallpaper','wall covering','fabric','fabrics',
'durable vinyl','vinyl','grasscloth','print','commercial','residential',
'type ii','type iii','mural','wall mural','peel and stick','peel-and-stick',
'self adhesive','self-adhesive','by phillipe romano','phillipe romano'
];
const GROUP_COLOR_WORDS = new Set([...COLORS,
'light','dark','deep','pale','soft','warm','cool','muted','bright','antique',
'metallic','natural','neutral','multi','multicolor','multicolour'
]);
function gStripVendorSuffix(title) {
const i = (title || '').indexOf('|');
return (i === -1 ? (title || '') : title.slice(0, i)).trim();
}
function gNormTitle(title) {
let s = gStripVendorSuffix(title).toLowerCase();
for (const n of GROUP_NOISE) s = s.split(n).join(' ');
s = s.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
return s;
}
function gStripColors(normalized) {
const kept = normalized.split(' ').filter(w => w && !GROUP_COLOR_WORDS.has(w));
return kept.join(' ').trim() || normalized;
}
// series-aware mfr_sku ROOT — drop a TRAILING colorway suffix, keep series root.
// TAK-AA04-01 -> TAK-AA04 · FRL5226/01 -> FRL5226 · W7008-01 -> W7008 · AT9604 -> AT9604
function gMfrRoot(raw) {
if (!raw) return null;
let s = String(raw).trim().toUpperCase();
if (!s) return null;
const tokens = s.split(/[-/_.\s]+/).filter(Boolean);
if (tokens.length >= 2) {
const last = tokens[tokens.length - 1];
if (/^\d{1,3}[A-Z]?$/.test(last)) return tokens.slice(0, -1).join('-');
}
return tokens.join('-');
}
function gVendorSlug(v) {
return (v || '∅').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'no-vendor';
}
// Read custom.pattern_id from the metafields jsonb if present (prod truth path).
function patternIdFromMetafields(metafields) {
if (!metafields) return null;
let mf = metafields;
try { if (typeof mf === 'string') mf = JSON.parse(mf); } catch (_) { return null; }
if (!mf || typeof mf !== 'object') return null;
// accept {custom:{pattern_id:...}} or {"custom.pattern_id":...} or [{namespace,key,value}]
if (mf.custom && mf.custom.pattern_id) return String(mf.custom.pattern_id);
if (mf['custom.pattern_id']) return String(mf['custom.pattern_id']);
if (Array.isArray(mf)) {
const hit = mf.find(m => m && m.namespace === 'custom' && m.key === 'pattern_id' && m.value);
if (hit) return String(hit.value);
}
return null;
}
// AUTHORITATIVE pattern_id map (dw_sku -> pid), generated by tools/pattern-id-backfill.js
// WITH the junk-root guard applied (demotes over-merges like ::mfr::CORK / ::mfr::YORK,
// where one pure-alpha mfr root spans 8+ distinct designs). This is the SAME key written
// to Shopify custom.pattern_id, so the live grid/pattern-page group EXACTLY as the
// backfilled metafield does — eliminating the backfill-vs-live divergence that surfaced
// /pages/pattern?pattern_id=…::mfr::CORK as 16 unrelated cork designs under one tile.
// Falls back to the interim derivation for any dw_sku not present in the map.
const AUTH_PID = (() => {
// WS-1 (2026-06-26): prefer the NEW title-first + color-invariant map (TH=30) — the
// same keys re-backfilled to Shopify custom.pattern_id. Falls back to the OLD mfr-first
// map only if the new one is absent, so a deploy before the re-backfill never regresses.
for (const path of ['./tools/pattern-id-titlefirst-by-dwsku.json', './tools/pattern-id-by-dwsku.json']) {
try {
const raw = require(path);
const m = new Map();
for (const k in raw) { const v = raw[k]; if (v && v.pid) m.set(k, v.pid); }
console.log(`[pattern_id] loaded ${m.size} authoritative grouping keys from ${path}`);
return m;
} catch (e) { /* try next */ }
}
console.warn('[pattern_id] authoritative map unavailable, using interim derivation');
return new Map();
})();
// THE canonical grouping key for a product row. Authoritative backfilled key first
// (junk-root-guarded), then prod metafield, else interim derivation.
function groupKeyForRow(row) {
const auth = row.dw_sku && AUTH_PID.get(row.dw_sku);
if (auth) return auth;
const prod = patternIdFromMetafields(row.metafields);
if (prod) return prod;
const vslug = gVendorSlug(row.vendor);
const root = gMfrRoot(row.mfr_sku);
if (root) return `${vslug}::mfr::${root}`;
const t2 = gStripColors(gNormTitle(row.title));
if (t2) return `${vslug}::t2::${t2}`;
const pn = (row.pattern_name || '').toLowerCase().trim();
return `${vslug}::pn::${pn || 'ungrouped'}`;
}
// Extract the REAL (scraped) colorway NAME for a card — never an AI color, never "Unknown".
// DW title shape is `Pattern RealColor[ - AIColorDescriptor Type] | Brand`. The real color
// is the words AFTER the pattern base and BEFORE any " - " AI descriptor. We compute the
// pattern base from the mfr_root group members' shared title prefix is overkill here, so we
// approximate: take the part before " - " (drops the AI descriptor), strip pattern/noise/AI
// words, and what remains is the manufacturer colorway name.
// Sources, in order:
// 1. real colorway phrase recovered from the title's pre-dash segment
// 2. enrichment color_family (tag-grade fallback)
// 3. the bare title base (never "Unknown")
function colorNameForRow(row, patternBaseWords) {
const base = gStripVendorSuffix(row.title); // "Zendo Toffee - Beige Fabric"
const preDash = base.split(' - ')[0].trim(); // "Zendo Toffee"
// normalize the pre-dash segment, then remove pattern words + noise + AI color words
let norm = preDash.toLowerCase();
for (const n of GROUP_NOISE) norm = norm.split(n).join(' ');
norm = norm.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
const patSet = new Set(patternBaseWords || []);
const colorWords = norm.split(' ').filter(w => w && !patSet.has(w) && !GROUP_COLOR_WORDS.has(w));
if (colorWords.length) {
return colorWords.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
// if removing pattern words left nothing real, try the words present before-dash minus pattern only
const colorWords2 = norm.split(' ').filter(w => w && !patSet.has(w));
if (colorWords2.length) {
return colorWords2.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
if (row.color_family) {
const cf = String(row.color_family).trim();
if (cf) return cf.charAt(0).toUpperCase() + cf.slice(1);
}
return base || null;
}
// Guard against degenerate pattern LABELS (display only — never the group key).
// Some ACTIVE products carry malformed titles/pattern_names that are just a vendor
// suffix ("| Osborne & Little Europe") or empty — rendering those verbatim shows the
// VENDOR (or a bare pipe) where the pattern name belongs. A leading "|" is the tell:
// the pattern segment before the vendor suffix was empty. When the computed label is
// empty / pipe-led / vendor-only, fall back to mfr_root → humanized handle → dw_sku
// so a tile never displays "| Vendor". Root cause is upstream catalog data (junk
// title/mfr_sku); this only hardens the widget against it.
function bestPatternLabel(row, computed) {
const titleCase = s => String(s).replace(/\b\w/g, c => c.toUpperCase());
const raw = String(computed || '').trim();
const vendor = String(row.vendor || '').trim().toLowerCase();
const s = raw.replace(/^[\s|·—–-]+/, '').replace(/[\s|·—–-]+$/, '').trim();
const degenerate = !s
|| raw.startsWith('|') // empty pattern base before vendor suffix
|| /^[\s|·—–-]+$/.test(raw)
|| s.toLowerCase() === vendor
|| s.toLowerCase().replace(/\s+(europe|usa|na|studios?)$/, '') === vendor;
if (!degenerate) return titleCase(s);
// DW handles are descriptive ("agapantha-trellis-mural-black") — prefer a humanized
// handle over an ALL-CAPS mfr code, but only when it carries real words (not a bare
// SKU slug like "eur-71203"). Then mfr_root (non-junk), then dw_sku.
const h = String(row.handle || '')
.replace(/-designer-wallcoverings-los-angeles$/i, '')
.replace(/[-_]+/g, ' ').trim();
const hasWords = /[a-z]{3,}/i.test(h.replace(/\b[a-z]{1,2}\d/gi, '')) && h.split(' ').some(w => /^[a-z]{3,}$/i.test(w));
if (h && hasWords) return titleCase(h);
const root = gMfrRoot(row.mfr_sku);
if (root && !/^\d{1,3}$/.test(root)) return titleCase(root.replace(/[-_]+/g, ' '));
if (h) return titleCase(h); // bare SKU-ish handle beats dw_sku
return row.dw_sku || 'Pattern';
}
// Sanitize a product TITLE for recommendation lists (/api/pairs, /api/similar),
// which pass shopify `title` through verbatim. A junk title whose pattern segment
// is empty ("| DW Bespoke Studios") otherwise renders as a bare-pipe tile. ONLY
// degenerate titles are rewritten (via the bestPatternLabel fallback → handle /
// mfr_root / dw_sku); a normal "Name | Vendor" title is returned unchanged.
function safeTitle(row) {
const raw = String(row.title || '').trim();
const degenerate = !raw || raw.startsWith('|') || /^[\s|·—–-]+$/.test(raw);
return degenerate ? bestPatternLabel(row, '') : row.title;
}
// Paint-chip lookup helpers — shared by /api/pairs and /api/similar.
// paintMap is the per-request result of nearestPaintsBatch; passed explicitly so both
// routes can use a single definition without sharing mutable state.
function paintsFor(hex, paintMap) {
const m = paintMap[String(hex || '').toLowerCase()] || null;
if (!m) return null;
// Return all 7 brands; UI picks which to render. Short keys for transport size.
return {
sw: m['sherwin-williams'] || null,
de: m['dunn-edwards'] || null,
bm: m['benjamin-moore'] || null,
behr: m['behr'] || null,
ppg: m['ppg'] || null,
fb: m['farrow-ball'] || null,
ral: m['ral'] || null
};
}
function paletteWithPaints(hexList, paintMap) {
return hexList.map(h => ({ hex: h, paints: paintsFor(h, paintMap) }));
}
// Resolve a single product to its group key (used when caller passes handle/dw_sku).
async function resolveGroupKey({ dw_sku, handle, pattern_id }) {
if (pattern_id) return pattern_id;
const sel = `dw_sku, handle, title, vendor, mfr_sku, pattern_name, metafields`;
let q, params;
if (dw_sku) { q = `SELECT ${sel} FROM shopify_products WHERE dw_sku = $1 LIMIT 1`; params = [dw_sku]; }
else if (handle) { q = `SELECT ${sel} FROM shopify_products WHERE handle = $1 LIMIT 1`; params = [handle]; }
else return null;
const r = await pool.query(q, params);
if (!r.rows[0]) return null;
return groupKeyForRow(r.rows[0]);
}
// GET /api/colorways?pattern_id=… | ?handle=… | ?dw_sku=…
// → { pattern_id, pattern, vendor, count, colorways:[{dw_sku,handle,title,color_name,color_hex,image_url}] }
app.get('/api/colorways', async (req, res) => {
try {
const pattern_id = req.query.pattern_id && SAFE_PATTERN_ID.test(req.query.pattern_id) ? req.query.pattern_id : null;
const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
const handle = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
if (!pattern_id && !dw_sku && !handle) {
return res.status(400).json({ ok: false, error: 'pass ?pattern_id=…, ?handle=…, or ?dw_sku=…' });
}
const key = await resolveGroupKey({ dw_sku, handle, pattern_id });
if (!key) return res.status(404).json({ ok: false, error: 'group not found for that handle/sku' });
// Pull all ACTIVE products in this vendor's space, group in JS by groupKeyForRow,
// and return the members of the matched key. Vendor-scoped narrows the scan since
// the key embeds the vendor slug as its first segment.
const vendorSlug = key.split('::')[0];
const r = await pool.query(`
SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.mfr_sku, sp.pattern_name,
sp.image_url, sp.metafields, sce.color_family, sce.dominant_hex, sce.background_hex
FROM shopify_products sp
LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
WHERE sp.status = 'ACTIVE' ${PUB_SQL}
AND sp.title IS NOT NULL AND sp.handle IS NOT NULL
AND sp.dw_sku IS NOT NULL
AND lower(regexp_replace(coalesce(sp.vendor,'∅'),'[^a-zA-Z0-9]+','-','g')) LIKE $1
`, [vendorSlug.replace(/[%_]/g, '\\$&') + '%']);
const members = r.rows.filter(row => groupKeyForRow(row) === key);
if (!members.length) return res.status(404).json({ ok: false, error: 'group empty' });
// Pattern label = the WORDS COMMON to every member's pre-dash title base (the real
// shared pattern name), Title-Cased. Common-prefix is more robust than one member's T2.
const memberBases = members.map(m => {
let n = gStripVendorSuffix(m.title).split(' - ')[0].toLowerCase();
for (const x of GROUP_NOISE) n = n.split(x).join(' ');
return n.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
});
// intersection of word-sets across members = the shared pattern words
let patternBaseWords = memberBases.length ? memberBases[0].slice() : [];
for (const wb of memberBases.slice(1)) {
const set = new Set(wb);
patternBaseWords = patternBaseWords.filter(w => set.has(w));
}
// if a single-member group, the pattern base = its T2 stripped title
if (members.length === 1) {
patternBaseWords = (gStripColors(gNormTitle(members[0].title)) || '').split(' ').filter(Boolean);
}
const labelBase = patternBaseWords.length
? patternBaseWords.join(' ')
: (gStripColors(gNormTitle(members[0].title)) || members[0].pattern_name || '');
const pattern = bestPatternLabel(members[0], labelBase);
const patSetWords = new Set(patternBaseWords);
const colorways = members
.map(row => ({
dw_sku: row.dw_sku,
handle: row.handle,
title: row.title,
color_name: colorNameForRow(row, patSetWords) || row.dw_sku,
color_hex: row.dominant_hex || row.background_hex || null,
image_url: row.image_url || null,
url: `/products/${row.handle}`
}))
.sort((a, b) => String(a.color_name).localeCompare(String(b.color_name)));
// no-store: this response carries /products/ links whose ACTIVE+published gate is
// checked at GENERATION time — caching would let an archived product serve a dead
// link for the TTL window (contrarian finding, 2026-07-13).
res.set('Cache-Control', 'no-store');
res.json({
ok: true,
pattern_id: key,
pattern,
vendor: members[0].vendor || null,
count: colorways.length,
colorways
});
} catch (e) {
console.error('colorways error', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// Hue angle (0-360) from a hex; grayscale/low-sat or missing → 999 so it sorts last.
function hexHue(hex) {
if (!hex) return 999;
const m = String(hex).replace('#','').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (!m) return 999;
const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
if (d < 0.06) return 999; // grayscale → bucket at the end
let h;
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60; if (h < 0) h += 360;
return h;
}
// Hue family buckets (degrees) for the "Color" drill-down filter; 'neutral' = grayscale (999).
const HUE_BUCKETS = {
red: [[345,360],[0,15]], orange: [[15,45]], yellow: [[45,70]], green: [[70,165]],
teal: [[165,200]], blue: [[200,255]], purple: [[255,290]], pink: [[290,345]],
neutral: [[999,999]]
};
function hueInBucket(h, name) {
const b = HUE_BUCKETS[name]; if (!b) return true;
return b.some(function (r) { return h >= r[0] && h <= r[1]; });
}
// First recognized design-style token from tags → for Style sort; none → 'zzz' (sorts last).
const STYLE_WORDS = ['abstract','animal','botanical','damask','floral','geometric','ikat','moroccan',
'paisley','plaid','scenic','stripe','striped','toile','traditional','trellis','tropical','modern',
'contemporary','art deco','mid-century','victorian','transitional','grasscloth','textured','solid'];
function styleOf(tags) {
if (!tags) return 'zzz';
const low = String(tags).toLowerCase();
for (const w of STYLE_WORDS) if (low.indexOf(w) !== -1) return w;
return 'zzz';
}
// GET /api/collection-grouped?handle=<collection-handle>&page=N&per=48
// The WS-2 grouped-grid feed. Returns ONE tile per pattern_id for the named
// collection, with a colorway count per tile, correct across pagination
// because grouping happens server-side over the WHOLE collection before paging.
//
// NOTE: the read-only mirror has no collection-membership table, so for LOCAL
// testing this accepts ?vendor=<name> (group within a vendor) or ?all=1 (whole
// active catalog) as the collection proxy. In PRODUCTION the theme passes the
// collection's product handles (or this reads a collection_products join).
app.get('/api/collection-grouped', async (req, res) => {
try {
const vendor = req.query.vendor ? String(req.query.vendor).slice(0, 120) : null;
const collHandle = req.query.collection_handle ? String(req.query.collection_handle).slice(0, 120) : null;
const all = req.query.all === '1';
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const per = Math.max(1, Math.min(96, parseInt(req.query.per, 10) || 48));
const sort = String(req.query.sort || 'newest').slice(0, 24);
if (!vendor && !collHandle && !all) {
return res.status(400).json({ ok: false, error: 'pass ?vendor=<name>, ?collection_handle=<handle>, or ?all=1' });
}
// VENDOR-FAMILY scope: a brand collection spans multiple vendor spellings
// ("Designers Guild" / "Designer's Guild Europe" / "Designers Guild Europe").
// Derive an alnum-only family key from the vendor or the collection handle
// (drop trailing wallpaper/wallcovering/fabric), match the normalized vendor.
function familyKey(s) {
if (!s) return null;
let w = String(s).toLowerCase().replace(/[-_]/g, ' ')
.replace(/\b(wallpaper|wallcovering|wallcoverings|fabric|fabrics|collection)\b/g, ' ')
.trim().split(/\s+/).filter(Boolean).slice(0, 2).join('');
return w.replace(/[^a-z0-9]/g, '') || null;
}
const fam = familyKey(vendor) || familyKey(collHandle);
const params = [];
let where = `sp.status = 'ACTIVE' ${PUB_SQL} AND sp.title IS NOT NULL AND sp.handle IS NOT NULL AND sp.dw_sku IS NOT NULL AND sp.image_url IS NOT NULL`;
if (fam && !all) {
params.push(fam + '%');
where += ` AND regexp_replace(lower(sp.vendor), '[^a-z0-9]', '', 'g') LIKE $${params.length}`;
}
const r = await pool.query(`
SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.mfr_sku, sp.pattern_name, sp.tags,
sp.image_url, sp.metafields, sp.synced_at, sce.color_family, sce.dominant_hex
FROM shopify_products sp
LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
WHERE ${where}
ORDER BY sp.synced_at DESC NULLS LAST
${all && !vendor ? 'LIMIT 8000' : ''}
`, params);
// group server-side over the FULL set → counts are correct regardless of paging
const groups = new Map();
let seq = 0;
for (const row of r.rows) {
const key = groupKeyForRow(row);
let g = groups.get(key);
if (!g) {
const labelBase = gStripColors(gNormTitle(row.title)) || (row.pattern_name || '');
g = {
pattern_id: key,
pattern: bestPatternLabel(row, labelBase),
vendor: row.vendor || null,
// representative tile = first (newest) member
rep_handle: row.handle, rep_dw_sku: row.dw_sku, rep_image_url: row.image_url,
count: 0, swatches: [],
_seq: seq++, // newest-first insertion order
_hue: hexHue(row.dominant_hex), // for Color (Hue) sort
_style: styleOf(row.tags) // for Style sort
};
groups.set(key, g);
}
g.count++;
if (g.swatches.length < 6 && (row.dominant_hex)) g.swatches.push(row.dominant_hex);
}
const allGroups = [...groups.values()];
let arr = allGroups;
// FACETS — which Color/Style values actually have tiles in THIS collection, so the
// front-end only renders populated filter chips (no dead tab that returns 0 results).
// Computed over the UNFILTERED set so the chip list stays stable while drilling in.
const styleCounts = {}, hueCounts = {};
for (const g of allGroups) {
if (g._style && g._style !== 'zzz') styleCounts[g._style] = (styleCounts[g._style] || 0) + 1;
for (const hk of Object.keys(HUE_BUCKETS)) {
if (hueInBucket(g._hue, hk)) { hueCounts[hk] = (hueCounts[hk] || 0) + 1; break; }
}
}
const facets = {
color: Object.keys(hueCounts).filter(k => hueCounts[k] > 0),
style: Object.keys(styleCounts).filter(k => styleCounts[k] > 0)
};
// DRILL-DOWN FILTERS — the "Color" / "Style" chips reveal value sub-chips that filter here.
const hueF = String(req.query.hue || '').toLowerCase().slice(0, 16);
const styleF = String(req.query.style || '').toLowerCase().slice(0, 24);
if (styleF) arr = arr.filter(g => g._style === styleF);
if (hueF && HUE_BUCKETS[hueF]) arr = arr.filter(g => hueInBucket(g._hue, hueF));
// SORT the grouped tiles (over the whole set) before paging, so order is stable across pages.
const byPattern = (a, b) => String(a.pattern).localeCompare(String(b.pattern));
const sorters = {
newest: (a, b) => a._seq - b._seq,
colors_desc: (a, b) => b.count - a.count || byPattern(a, b),
pattern_az: byPattern,
vendor_az: (a, b) => String(a.vendor||'').localeCompare(String(b.vendor||'')) || byPattern(a, b),
hue: (a, b) => (a._hue - b._hue) || byPattern(a, b), // grayscale (hue=999) sorts last
style: (a, b) => String(a._style).localeCompare(String(b._style)) || byPattern(a, b)
};
arr.sort(sorters[sort] || sorters.newest);
const total_tiles = arr.length;
const start = (page - 1) * per;
const slice = arr.slice(start, start + per);
// no-store: carries product_url links — see link-guarantee cache note on /api/colorways.
res.set('Cache-Control', 'no-store');
res.json({
ok: true,
collection: vendor ? { vendor } : { all: true },
page, per,
total_tiles,
total_pages: Math.ceil(total_tiles / per),
has_next: start + per < total_tiles,
facets,
tiles: slice.map(g => ({
pattern_id: g.pattern_id,
pattern: g.pattern,
vendor: g.vendor,
count: g.count,
colors_label: g.count > 1 ? `${g.count} Colors` : '1 Color',
image_url: g.rep_image_url,
handle: g.rep_handle,
dw_sku: g.rep_dw_sku,
swatches: g.swatches,
pattern_url: `/pages/pattern?pattern_id=${encodeURIComponent(g.pattern_id)}`,
product_url: `/products/${g.rep_handle}`
}))
});
} catch (e) {
console.error('collection-grouped error', e);
res.status(500).json({ ok: false, error: e.message });
}
});
app.get('/api/similar', async (req, res) => {
try {
const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
const handle = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
if (!dw_sku && !handle) {
return res.status(400).json({ ok: false, error: 'pass ?dw_sku=… or ?handle=…' });
}
const source = await loadSource({ dw_sku, handle });
if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 12));
// null status = unknown, treat as live (not archived) rather than false-flag it
const sourceArchived = source.status != null && source.status !== 'ACTIVE';
// CLIP when explicitly requested (?engine=clip) OR when SIMILAR_CLIP_DEFAULT is on
// and the caller didn't force ?engine=tag. Same one-switch pattern as pairs.
const engine = (req.query.engine === 'clip' || (SIMILAR_CLIP_DEFAULT && req.query.engine !== 'tag'))
? 'clip' : 'tag';
// CLIP engine: color+texture-locked image-embedding nearest-neighbour, no tag dependency.
let top = null, usedEngine = 'tag';
if (engine === 'clip') {
top = await clipSimilarTop(source, requestedK); // null → service down / sku not embedded
if (top) usedEngine = 'clip';
}
// Tag engine (default, and the fallback when CLIP misses). Keys on motif/material/era.
if (!top) {
if (!source.tags || parseTags(source.tags).length === 0) {
return res.json({
ok: true, engine: 'tag',
source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor, archived: sourceArchived },
similar: [],
reason: sourceArchived
? `source is ${source.status} (not a live product) — no similar designs`
: 'no tags on source — cannot compute similar designs'
});
}
const candidates = await loadSimilarCandidates(source, 1200);
top = pickSimilar(source, candidates, requestedK);
}
// Palette dots + paint chips, same shape as /api/pairs for UI parity.
const sourcePalette = paletteOf(source);
const allHexes = sourcePalette.slice();
for (const p of top) for (const h of p._palette) allHexes.push(h);
const paintMap = await nearestPaintsBatch(pool, allHexes);
// no-store: carries /products/ links — see link-guarantee cache note on /api/colorways.
res.set('Cache-Control', 'no-store');
res.json({
ok: true,
engine: usedEngine,
source: {
dw_sku: source.dw_sku, handle: source.handle, title: source.title,
vendor: source.vendor, image_url: source.image_url, archived: sourceArchived,
palette: paletteWithPaints(sourcePalette, paintMap)
},
// an empty strip on an archived/unpublished source is expected, not a fault
...(top.length === 0 && sourceArchived
? { reason: `source is ${source.status} (not a live product) — no similar designs` } : {}),
similar: top.map(p => ({
dw_sku: p.dw_sku, handle: p.handle, title: safeTitle(p), vendor: p.vendor,
image_url: p.image_url, url: `/products/${p.handle}`,
score: p._score, why: p._why, delta_e: p._dE, palette: paletteWithPaints(p._palette, paintMap)
}))
});
} catch (e) {
console.error('similar error', e);
res.status(500).json({ ok: false, error: e.message });
}
});
app.get('/api/pairs', async (req, res) => {
try {
const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
const handle = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
const paletteParam = parsePaletteParam(req.query.palette);
if (!dw_sku && !handle && paletteParam.length === 0) {
return res.status(400).json({ ok: false, error: 'pass ?dw_sku=…, ?handle=…, or ?palette=#hex1,#hex2,…' });
}
// Synthetic source from palette param (used by wallco.ai design pages,
// any external integration where the source isn't in shopify_products).
let source;
if (paletteParam.length) {
source = {
dw_sku: null,
handle: null,
title: String(req.query.title || 'External source').slice(0, 200),
vendor: String(req.query.vendor || '').slice(0, 100),
image_url: String(req.query.image_url || '').slice(0, 1000) || null,
tags: null,
pattern_name: null,
dominant_hex: paletteParam[0],
background_hex: paletteParam[1] || null,
hex_codes: JSON.stringify(paletteParam),
color_family: null
};
} else {
source = await loadSource({ dw_sku, handle });
if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
}
// Bail only if we have NEITHER tags NOR palette — palette alone is enough
// for the palette-overlap path to find coordinates.
const sourceHasTags = source.tags && parseTags(source.tags).length > 0;
const sourceHasPalette = paletteOf(source).length > 0;
if (!sourceHasTags && !sourceHasPalette) {
return res.json({
ok: true,
source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor },
pairs: [],
reason: 'no tags or palette on source — cannot compute pairings'
});
}
// Limit query: caller can request up to 24 via ?limit=
const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
const candidates = await loadCandidates(source, 1200);
// Blend CLIP style-world sub-score when explicitly requested (?engine=clip) OR when
// PAIRS_CLIP_DEFAULT is on for all callers — but ?engine=tag always force-disables.
// clipPairwiseMap returns {} on any miss (service down / circuit open), so the blend
// degrades to tag+palette and the response never depends on the service being up.
let usedEngine = 'tag';
let clipMap = null;
const wantClip = req.query.engine === 'clip'
|| (PAIRS_CLIP_DEFAULT && req.query.engine !== 'tag');
if (wantClip) {
clipMap = await clipPairwiseMap(source, candidates);
if (Object.keys(clipMap).length) usedEngine = 'clip-blend';
else clipMap = null;
}
const top = pickPairs(source, candidates, requestedK, clipMap);
// Collect every hex we need paint matches for: source palette + each pair palette
const sourcePalette = paletteOf(source);
const allHexes = sourcePalette.slice();
for (const p of top) for (const h of p._palette) allHexes.push(h);
const paintMap = await nearestPaintsBatch(pool, allHexes);
// no-store: carries /products/ links — see link-guarantee cache note on /api/colorways.
res.set('Cache-Control', 'no-store');
res.json({
ok: true,
engine: usedEngine,
source: {
dw_sku: source.dw_sku,
handle: source.handle,
title: source.title,
vendor: source.vendor,
image_url: source.image_url,
palette: paletteWithPaints(sourcePalette, paintMap)
},
pairs: top.map(p => ({
dw_sku: p.dw_sku,
handle: p.handle,
title: safeTitle(p),
vendor: p.vendor,
image_url: p.image_url,
url: `/products/${p.handle}`,
score: p._score,
why: p._why,
delta_e: p._dE,
palette: paletteWithPaints(p._palette, paintMap)
}))
});
} catch (e) {
console.error('pairs error', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// ============================================================
// /api/ai-coordinates — AI-generated coordinate designs
// Returns 24 stripe + plaid concepts seeded from the source palette using
// lib/ai-stripes.js. Three width tiers (2″, 6″, 12″) × multiple styles
// (even, pinstripe, cabana, wavy, awning, hairline, block) + 8 plaids
// (buffalo, gingham, tattersall, windowpane, madras, tartan, glen, box).
// Three slots use the lightest+darkest palette pair (Steve's standing rule).
// Seamless tiling is structural — every variant uses SVG <pattern>.
// Client can slide the repeat width via ?tile_in=N (12-48 inches); each
// variant snaps tile_in DOWN to its pitch so the repeat always fits.
// ============================================================
app.get('/api/ai-coordinates', async (req, res) => {
try {
const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
const handle = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
const paletteParam = parsePaletteParam(req.query.palette);
if (!dw_sku && !handle && paletteParam.length === 0) {
return res.status(400).json({ ok: false, error: 'pass dw_sku, handle, or palette' });
}
const tile_in = Math.max(2, Math.min(60, parseFloat(req.query.tile_in) || 24));
// Honor ?limit= the same way /api/pairs does (default 6, hard cap 24) so the
// Auto Generated grid renders exactly what the caller asks for — without this,
// generateCoordinates() always returned its full set and the client showed 24.
const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 6));
let source, palette;
if (paletteParam.length) {
source = { dw_sku: null, handle: null,
title: String(req.query.title || 'External source').slice(0, 200),
image_url: String(req.query.image_url || '').slice(0, 1000) || null };
palette = paletteParam;
} else {
source = await loadSource({ dw_sku, handle });
if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
palette = paletteOf(source);
}
if (!palette.length) return res.json({ ok: true, source: { dw_sku: source.dw_sku, handle: source.handle }, items: [] });
const items = generateCoordinates({ palette, tile_in }).slice(0, requestedK);
res.set('Cache-Control', 'public, max-age=300');
res.json({
ok: true,
tile_in: tile_in,
source: {
dw_sku: source.dw_sku,
handle: source.handle,
title: source.title,
image_url: source.image_url,
palette: palette
},
items: items
});
} catch (e) {
console.error('ai-coordinates error', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// Render ONE pattern from a custom user-edited spec. Used by the Wix-style
// pattern editor in the wallco.ai admin UI. Accepts:
// GET /api/ai-coordinates/render?motif=stripe&style=even&pitch_in=6&colors=#000,#fff&tile_in=24
// POST /api/ai-coordinates/render body: { motif, style, pitch_in, colors:[], tile_in, pinstripe? }
app.use('/api/ai-coordinates/render', express.json({ limit: '8kb' }));
function handleRender(req, res) {
try {
const src = (req.method === 'POST' ? req.body : req.query) || {};
const colors = Array.isArray(src.colors)
? src.colors
: String(src.colors || '').split(',').map(s => s.trim()).filter(Boolean);
const spec = {
motif: String(src.motif || 'stripe').slice(0, 20),
style: String(src.style || 'even').slice(0, 24),
pitch_in: parseFloat(src.pitch_in) || 6,
colors: colors.filter(h => /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(h))
.map(h => h.startsWith('#') ? h : '#' + h)
.slice(0, 5),
pinstripe: src.pinstripe || undefined,
label: src.label ? String(src.label).slice(0, 120) : undefined
};
const tile_in = parseFloat(src.tile_in) || 24;
const rendered = renderCustom(spec, tile_in);
res.set('Cache-Control', 'no-store');
res.json({ ok: true, ...rendered });
} catch (e) {
console.error('ai-coordinates/render error', e);
res.status(500).json({ ok: false, error: e.message });
}
}
app.get('/api/ai-coordinates/render', handleRender);
app.post('/api/ai-coordinates/render', handleRender);
app.listen(PORT, () => {
console.log(`dw-pairs-well listening on :${PORT}`);
});