← back to Philipperomano
server.js
827 lines
/**
* philipperomano.com — Phillipe Romano exclusive storefront.
* Reads products.json (8,227 active SKUs), exposes search/filter, links
* memo-sample CTA back to the DW Shopify product page (?variant=sample).
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const https = require('https');
const PORT = process.env.PORT || 9831;
const DATA_FILE = path.join(__dirname, 'data', 'products.json');
let DATA;
try {
DATA = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
if (!Array.isArray(DATA)) throw new Error('products.json is not a JSON array');
} catch (err) {
console.error(`FATAL: could not load product catalog from ${DATA_FILE}\n ${err.message}`);
process.exit(1);
}
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const DW_PHONE = '888-373-4564';
const DW_PHONE_TEL = '+18883734564';
const PR_EMAIL = 'info@philipperomano.com';
const app = express();
// Minimal security headers (no helmet dep — keep this storefront tiny).
// Customer-facing storefront → at minimum: clickjacking guard, MIME-sniff
// guard, and a tight referrer policy. CSP would need 'unsafe-inline' for
// the inline <style> block, so deferred until styles are extracted.
app.use((_req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Frame-Options', 'SAMEORIGIN');
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Snapshot-driven catalog → 1h edge cache (Cloudflare in front). Refresh
// only when products.json is regenerated from dw_unified.
res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
next();
});
const NORM_TYPE = (t) => {
if (!t) return 'Other';
const x = t.replace(/s$/i, '').trim();
if (/wallcovering/i.test(x)) return 'Wallcovering';
if (/natural/i.test(x)) return 'Natural';
if (/drapery/i.test(x)) return 'Drapery';
if (/fabric/i.test(x)) return 'Fabric';
return x;
};
const TYPES = [...new Set(DATA.map(p => NORM_TYPE(p.product_type)))].sort();
// ─── Color buckets ────────────────────────────────────────────────────────
// Extract the colorway from titles like "Pattern - Colorway Wallcovering | Phillipe Romano"
// then map keywords to one of the buckets below.
const COLOR_BUCKETS = [
{ id: 'black', label: 'Black', hex: '#1a1a1a' },
{ id: 'white', label: 'White', hex: '#f5f1ea' },
{ id: 'cream', label: 'Cream', hex: '#e8d9c0' },
{ id: 'beige', label: 'Beige', hex: '#cdb792' },
{ id: 'brown', label: 'Brown', hex: '#7a4a23' },
{ id: 'gray', label: 'Gray', hex: '#7c7c7c' },
{ id: 'silver', label: 'Silver', hex: '#b8b8b8' },
{ id: 'gold', label: 'Gold', hex: '#c9a14a' },
{ id: 'red', label: 'Red', hex: '#a8242b' },
{ id: 'pink', label: 'Pink', hex: '#d98ca7' },
{ id: 'orange', label: 'Orange', hex: '#cc6b3a' },
{ id: 'yellow', label: 'Yellow', hex: '#d8b94a' },
{ id: 'green', label: 'Green', hex: '#5a7a4a' },
{ id: 'blue', label: 'Blue', hex: '#3e5d80' },
{ id: 'purple', label: 'Purple', hex: '#6e4a7a' },
{ id: 'multi', label: 'Multi', hex: 'linear-gradient(135deg,#d98ca7,#c9a14a,#5a7a4a,#3e5d80)' },
];
const COLOR_KEYWORDS = {
black: ['black','onyx','ebony','jet','noir','ink','coal','obsidian','raven'],
white: ['white','ivory','snow','chalk','alabaster','porcelain','linen','milk','bone','swan','frost','vanilla'],
cream: ['cream','ecru','natural','parchment','oat','oatmeal','wheat','flax','straw','buff','champagne','butter'],
beige: ['beige','sand','tan','khaki','camel','taupe','stone','shell','desert','almond','sahara','dune','clay'],
brown: ['brown','chocolate','espresso','mocha','java','walnut','cocoa','bronze','umber','sepia','chestnut','rust','cinnamon','copper','caramel','toast','hazel'],
gray: ['gray','grey','slate','smoke','charcoal','ash','pewter','graphite','dove','fog','mist','steel','flint'],
silver: ['silver','platinum','sterling','chrome','metallic','mercury','aluminum'],
gold: ['gold','golden','brass','honey','amber','ochre','mustard','saffron','antique gold'],
red: ['red','crimson','cherry','scarlet','burgundy','wine','garnet','ruby','brick','cardinal','poppy','vermillion','blood','sangria'],
pink: ['pink','rose','blush','coral','salmon','peach','mauve','dusty rose','fuchsia','magenta','flamingo'],
orange: ['orange','tangerine','apricot','marigold','sunset','persimmon','spice','paprika','terracotta'],
yellow: ['yellow','citron','canary','dijon','primrose','lemon','sun','daffodil','goldenrod'],
green: ['green','sage','olive','mint','seafoam','jade','emerald','forest','moss','celery','pistachio','fern','kelly','hunter','celadon','laurel','basil','sea','aqua green','grass','leaf','spring','meadow','willow','vert'],
blue: ['blue','navy','indigo','sapphire','azure','cobalt','denim','peacock','turquoise','teal','aqua','sky','cerulean','royal','ocean','marine','cornflower','periwinkle','baltic','arctic','glacier','lagoon'],
purple: ['purple','violet','lavender','plum','aubergine','eggplant','orchid','lilac','iris','heather','wisteria','grape','amethyst','byzantine'],
};
const KW_TO_BUCKET = {};
for (const [bucket, kws] of Object.entries(COLOR_KEYWORDS)) {
for (const kw of kws) KW_TO_BUCKET[kw] = bucket;
}
// Sort keys longest-first so multi-word matches win ("dusty rose" before "rose")
const KW_ORDERED = Object.keys(KW_TO_BUCKET).sort((a, b) => b.length - a.length);
function extractColorway(title) {
if (!title) return '';
// Strip trailing brand suffix
let t = title.replace(/\s*\|\s*Phillipe Romano.*$/i, '').trim();
// Pattern: "Pattern - Colorway Type"
const m = t.match(/-\s*(.+?)\s+(Wallcovering|Wallpaper|Fabric|Drapery|Natural|Velvet|Linen|Silk|Cotton|Sheer)/i);
if (m) return m[1].trim();
// Fallback: anything after the last hyphen
const idx = t.lastIndexOf(' - ');
if (idx > -1) return t.slice(idx + 3).trim();
return '';
}
function colorBucket(title) {
const cw = extractColorway(title).toLowerCase();
if (!cw) return null;
for (const kw of KW_ORDERED) {
if (cw.includes(kw)) return KW_TO_BUCKET[kw];
}
return null;
}
// Granular colorway-name → real hex lookup. Each entry maps a colorway keyword
// (extracted from the title) to its actual hex code so the per-product dot
// shows the product's own color, not the bucket's average.
const COLORWAY_HEX = {
// Black / dark
black:'#1a1a1a', onyx:'#0f0f0f', ebony:'#181210', jet:'#0a0a0a', noir:'#161616',
ink:'#1a1d24', coal:'#2b2b2b', obsidian:'#0d0d0d', raven:'#1d1d1d',
// White / cream
white:'#f5f1ea', ivory:'#f1ead8', snow:'#fafafa', chalk:'#ece7d7',
alabaster:'#efe9d9', porcelain:'#f0e9d8', linen:'#e9dfc8', milk:'#efeae0',
bone:'#dccfb4', swan:'#f0ebde', frost:'#eaece9', vanilla:'#f3e7c4',
// Cream / oat
cream:'#e8d9c0', ecru:'#d9c8a3', natural:'#d6c39a', parchment:'#e1cd9c',
oat:'#d5c19a', oatmeal:'#cfbb96', wheat:'#d6b87b', flax:'#cdb789',
straw:'#d6b87b', buff:'#cdb287', champagne:'#e6cfa5', butter:'#e7d496',
// Beige / tan
beige:'#cdb792', sand:'#c8b48a', tan:'#b89460', khaki:'#a78d63',
camel:'#a87d4f', taupe:'#9c8470', stone:'#a89682', shell:'#dcc7a7',
desert:'#cdaa7d', almond:'#d2b48c', sahara:'#c8a877', dune:'#c5b18a',
clay:'#a96a4a',
// Brown
brown:'#7a4a23', chocolate:'#3d220e', espresso:'#3a2418', mocha:'#6f4e2a',
java:'#3b2618', walnut:'#5b3a1f', cocoa:'#4a3122', bronze:'#7a522d',
umber:'#5e3a1f', sepia:'#704d23', chestnut:'#714028', rust:'#9b4a1a',
cinnamon:'#8a4a22', copper:'#b35a23', caramel:'#a06a35', toast:'#9a6a44',
hazel:'#8c6238',
// Gray
gray:'#7c7c7c', grey:'#7c7c7c', slate:'#535e69', smoke:'#7a797a',
charcoal:'#36383b', ash:'#92918f', pewter:'#7d7e80', graphite:'#3b3d3f',
dove:'#a09e98', fog:'#8b8b8a', mist:'#c2c1bd', steel:'#5b6770', flint:'#5d6065',
// Silver
silver:'#b8b8b8', platinum:'#c8c8c5', sterling:'#bcbab5', chrome:'#d4d4d4',
metallic:'#a9a9aa', mercury:'#bfbebb', aluminum:'#c5c4c0',
// Gold / yellow-warm
gold:'#c9a14a', golden:'#c9a14a', brass:'#a78436', honey:'#cf9f3a',
amber:'#c5862e', ochre:'#a87523', mustard:'#c69a36', saffron:'#d09b25',
// Red
red:'#a8242b', crimson:'#7e1923', cherry:'#a8212e', scarlet:'#a01828',
burgundy:'#5c1a22', wine:'#5a1e23', garnet:'#621a22', ruby:'#841a26',
brick:'#8a4035', cardinal:'#94232b', poppy:'#bd2c30', vermillion:'#c0331a',
blood:'#5a1216', sangria:'#6e1d24',
// Pink
pink:'#d98ca7', rose:'#c97189', blush:'#e0bbb3', coral:'#cf6f5e',
salmon:'#dc8b76', peach:'#e3a987', mauve:'#a98091', fuchsia:'#bf2c6e',
magenta:'#a02963', flamingo:'#df6e83',
// Orange
orange:'#cc6b3a', tangerine:'#d8722a', apricot:'#daa177', marigold:'#d2952c',
sunset:'#cc6f3a', persimmon:'#c25827', spice:'#a35430', paprika:'#a8431f',
terracotta:'#a35a3b',
// Yellow
yellow:'#d8b94a', citron:'#c7c247', canary:'#dfc83a', dijon:'#9a7d23',
primrose:'#dac86b', lemon:'#d6c63b', sun:'#d4b22c', daffodil:'#d8b820',
goldenrod:'#b88a25',
// Green
green:'#5a7a4a', sage:'#9aa687', olive:'#7a7035', mint:'#9bc8a6',
seafoam:'#82bda1', jade:'#3f7361', emerald:'#235a3c', forest:'#264a30',
moss:'#5c6c3a', celery:'#a8b274', pistachio:'#9bbf72', fern:'#4a6a3b',
kelly:'#3b6a31', hunter:'#28482e', celadon:'#a4b89a', laurel:'#4f6b3e',
basil:'#566c34', sea:'#5e8e7a', grass:'#5a7e2c', leaf:'#598a3a',
spring:'#7ab83a', meadow:'#618d44', willow:'#869c6e', vert:'#3e6a3a',
// Blue
blue:'#3e5d80', navy:'#1c2a44', indigo:'#202852', sapphire:'#1f3a78',
azure:'#2c5e90', cobalt:'#1c4f8e', denim:'#3d6391', peacock:'#1c5d6c',
turquoise:'#2db1b8', teal:'#226e72', aqua:'#5fb4b6', sky:'#7ea8c8',
cerulean:'#2c5e8a', royal:'#1c358a', ocean:'#1f4d6d', marine:'#1f3d68',
cornflower:'#6a82bb', periwinkle:'#9aa4d2', baltic:'#365a78',
arctic:'#a8c5d2', glacier:'#a4c4cf', lagoon:'#357a86',
// Purple
purple:'#6e4a7a', violet:'#5b3a78', lavender:'#aa9bcb', plum:'#5b2a4a',
aubergine:'#3a1f33', eggplant:'#3d2436', orchid:'#a3679a', lilac:'#b69dca',
iris:'#6b4f99', heather:'#9a8aa8', wisteria:'#a48ac4', grape:'#552864',
amethyst:'#7e4d9c', byzantine:'#7a3784',
};
const COLORWAY_HEX_KEYS = Object.keys(COLORWAY_HEX).sort((a, b) => b.length - a.length);
function colorHex(title) {
const cw = extractColorway(title).toLowerCase();
if (!cw) return null;
for (const kw of COLORWAY_HEX_KEYS) {
if (cw.includes(kw)) return COLORWAY_HEX[kw];
}
return null;
}
// Annotate every product with bucket + dwsku numeric (for sort)
const skuNum = sku => {
const m = String(sku || '').match(/(\d+)/);
return m ? parseInt(m[1], 10) : 0;
};
// Fallback for products where dw_sku is null in JSON: extract DWxx-NNNNNN from the handle.
function effectiveSku(p) {
if (p.dw_sku) return p.dw_sku;
const m = (p.handle || '').match(/(dw[a-z]{2,4}-\d+)$/i);
return m ? m[1].toUpperCase() : '';
}
// Inclusive naturals matcher — catches BOTH real natural-fiber wallcoverings
// (grasscloth, cork, linen, silk, jute, sisal, abaca, raffia, seagrass, bamboo,
// hemp, flax, wool, leather, suede, mica, capiz) AND Type II commercial vinyls
// with natural-look textures (linen-look, grasscloth-look, cork-look, etc.).
// Per Steve 2026-05-25 — "most Type II vinyl textures work".
const NATURALS_RE = /\b(grasscloth|seagrass|sea[- ]grass|jute|sisal|abaca|raffia|cork|linen|silk|silk[- ]?look|bamboo|hemp|flax|wool|leather|suede|mica|capiz|slate|natural|naturals|fiber|woven|sandstone|reed|cane|rattan|paperweave|paper[- ]weave|sandgrass)\b/i;
function isNatural(p) {
// product_type is the strongest signal when present
const pt = (p.product_type || '').toLowerCase();
if (pt.includes('natural')) return true;
// Title-keyword pattern (catches Type II naturals-look vinyls)
const blob = (p.title || '') + ' ' + (p.handle || '');
return NATURALS_RE.test(blob);
}
const ENRICHED = DATA.map(p => {
const sku = effectiveSku(p);
return {
...p,
dw_sku: sku || p.dw_sku,
_color: colorBucket(p.title),
_hex: colorHex(p.title),
_skuNum: skuNum(sku),
_isNatural: isNatural(p),
};
});
// Default order: newest first (highest DWxx sku numeric desc)
ENRICHED.sort((a, b) => b._skuNum - a._skuNum);
// --- Junk-product defense (codified in /dw-site-build) -------------------
// Drop rows that don't belong on a textile-brand storefront even though
// the dw_unified replica returned them under vendor='Phillipe Romano'.
// Causes: variant_id leaked into dw_sku column, mis-tagged product_type,
// cross-vendor catalog corruption.
const JUNK_TITLE_RE = /\b(lamp|tripod|rug|pillow|throw|frame|mirror|vase|candle|sculpture|figurine)\b/i;
const ALLOWED_TYPES = new Set([
'Wallcovering','Wallcoverings','Commercial Wallcovering','Commercial Wallcoverings',
'Natural Wallcovering','Naturals','Fabric','Fabrics','Drapery Fabric','Drapery'
]);
function isJunk(p) {
if (!p.image_url || !String(p.image_url).trim()) return true;
const sku = String(p.dw_sku || '').trim();
if (!sku) return true;
if (/^\d{13,}$/.test(sku)) return true; // variant_id leaked into sku
if (!ALLOWED_TYPES.has(p.product_type || '')) return true;
if (JUNK_TITLE_RE.test(p.title || '')) return true;
return false;
}
const _kept = ENRICHED.filter(p => !isJunk(p));
const _dropped = ENRICHED.length - _kept.length;
console.log(`[junk-filter] kept=${_kept.length} dropped=${_dropped}`);
ENRICHED.length = 0;
ENRICHED.push(..._kept);
// --- end junk-product defense -------------------------------------------
const COLOR_COUNTS = {};
for (const p of ENRICHED) if (p._color) COLOR_COUNTS[p._color] = (COLOR_COUNTS[p._color] || 0) + 1;
const NATURALS_COUNT = ENRICHED.filter(p => p._isNatural).length;
console.log(`[naturals] enriched count: ${NATURALS_COUNT} of ${ENRICHED.length}`);
const esc = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function paginate(arr, page, perPage = 60) {
const total = arr.length;
const pages = Math.max(1, Math.ceil(total / perPage));
const p = Math.min(Math.max(1, page), pages);
return { items: arr.slice((p-1)*perPage, p*perPage), page: p, pages, total };
}
// Server-side product sort. Only modes the snapshot data can actually
// support — products.json carries no tag/color data and price fields are
// near-empty, so Color/Style/Price sorts are intentionally omitted here.
function sortProducts(list, mode) {
const cmpStr = k => (a, b) => String(a[k] || '').localeCompare(String(b[k] || ''));
if (mode === 'sku') return [...list].sort((a,b) => String(a.dw_sku || a.handle || '').localeCompare(String(b.dw_sku || b.handle || '')));
if (mode === 'title') return [...list].sort(cmpStr('title'));
if (mode === 'type') return [...list].sort((a,b) => NORM_TYPE(a.product_type).localeCompare(NORM_TYPE(b.product_type)) || cmpStr('title')(a,b));
return list; // 'newest' / default — snapshot's natural order
}
function shopifyUrl(handle) {
return handle ? `${DW_SHOPIFY}/products/${handle}` : `${DW_SHOPIFY}`;
}
function memoSampleUrl(handle) {
// DW Shopify products have a sample variant via ?variant=sample query/option-passthrough.
// The actual sample-variant ID lives on the product page; this URL hands off to DW
// where the customer finishes adding to cart.
return handle ? `${DW_SHOPIFY}/products/${handle}#sample` : DW_SHOPIFY;
}
// Lazy-fetch + cache the full Shopify product per handle, so the detail page
// can mirror everything that's on the canonical DW Shopify product page
// (description, price, variants, images, tags) without re-fetching on each request.
const productCache = new Map();
const PRODUCT_TTL_MS = 60 * 60 * 1000; // 1h
function fetchShopifyProduct(handle) {
const cached = productCache.get(handle);
if (cached && Date.now() - cached.at < PRODUCT_TTL_MS) return Promise.resolve(cached.data);
return new Promise((resolve) => {
const opts = {
hostname: 'designerwallcoverings.com',
path: `/products/${encodeURIComponent(handle)}.json`,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; PhillipeRomanoSite/1.0; +https://philipperomano.com)',
'Accept': 'application/json'
}
};
https.get(opts, (r) => {
let body = '';
r.on('data', c => body += c);
r.on('end', () => {
try {
const data = JSON.parse(body)?.product || null;
if (data) productCache.set(handle, { data, at: Date.now() });
resolve(data);
} catch { resolve(null); }
});
}).on('error', () => resolve(null));
});
}
function fetchVariantId(handle) {
return fetchShopifyProduct(handle).then(p => p?.variants?.[0]?.id || null);
}
// Group product tags into meaningful sections for the detail page.
// Hides internal/utility tags (lowercase mfr codes, AI-* flags) and groups the rest.
function categorizeTags(tagsStr) {
if (!tagsStr) return {};
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
const ROOMS = /^(Bedroom|Living Room|Dining Room|Hallway|Bathroom|Kitchen|Office|Foyer|Entryway|Powder Room|Nursery|Family Room|Hospitality|Restaurant|Hotel|Lobby|Healthcare|Retail|Spa)$/i;
const STYLES = /^(Contemporary|Traditional|Transitional|Modern|Organic Modern|Mid-Century|Mid Century|Classic|Bohemian|Minimalist|Rustic|Industrial|Coastal|Farmhouse|Glam|Art Deco|Eclectic|Scandinavian|Japandi|Maximalist|Old World)$/i;
const MOODS = /^(Serene|Calming|Dramatic|Bold|Subtle|Elegant|Romantic|Sophisticated|Energetic|Cozy|Luxurious|Playful|Refined|Warm)$/i;
const MATERIALS = /^(Grasscloth|Linen|Velvet|Silk|Cotton|Wool|Vinyl|Performance Vinyl|Faux Leather|Faux-Leather|Leather|Paper|Mylar|Mica|Cork|Hemp|Jute|Sisal|Raffia|Abaca|Beaded|Sequin|Glass Bead|Flock|Flocked|Embroidered|Hand-Painted|Acoustic)$/i;
const TEXTURES = /Texture|Weave|Tweed|Boucle|Plaid|Stripe|Damask|Chevron|Geometric|Floral|Botanical|Animal|Trellis|Lattice|Diamond|Houndstooth|Plain|Solid/i;
const APPS = /^(Architectural|Commercial|Hospitality|Residential|Class A Fire Rated|Class A|Class B|Class C|Type II|Type III|Heavy Duty|Acoustic Rated|FR|Fire Rated|Wipeable|Scrubbable|Bleach Cleanable|Mildew Resistant|Stain Resistant)$/i;
const COLLECTIONS = /^(Trending|Best Seller|New Arrival|Limited Edition).*$/i;
const COLOR_PREFIX = /^Color\s*:\s*/i;
const CERTS = /Fire Rated|Class [A-Z]|Type II|Type III|Heavy Duty/i;
// Skip patterns: lowercase-only utility tags, mfr SKUs/handles, AI flags
const SKIP = (t) => (
/^[a-z][a-z0-9-]*$/.test(t) || // all-lowercase tokens (mfr-handles, sku slugs)
/^AI-/i.test(t) ||
/^Phillipe Romano$/i.test(t) ||
/^decor wallcovering$/i.test(t) ||
/^performance-vinyl$/i.test(t) ||
/^vinyl$/i.test(t) && tags.includes('Vinyl')
);
const groups = {
colors: new Set(),
style: new Set(),
mood: new Set(),
rooms: new Set(),
materials: new Set(),
textures: new Set(),
certifications: new Set(),
application: new Set(),
collection: new Set(),
other: new Set()
};
for (const t of tags) {
if (SKIP(t)) continue;
if (COLOR_PREFIX.test(t)) {
groups.colors.add(t.replace(COLOR_PREFIX, '').trim());
continue;
}
if (CERTS.test(t)) { groups.certifications.add(t); continue; }
if (ROOMS.test(t)) { groups.rooms.add(t); continue; }
if (STYLES.test(t)) { groups.style.add(t); continue; }
if (MOODS.test(t)) { groups.mood.add(t); continue; }
if (MATERIALS.test(t)) { groups.materials.add(t); continue; }
if (APPS.test(t)) { groups.application.add(t); continue; }
if (COLLECTIONS.test(t)) { groups.collection.add(t); continue; }
if (TEXTURES.test(t)) { groups.textures.add(t); continue; }
// Title-cased single-word colorways → colors heuristic
if (/^[A-Z][a-z]+$/.test(t) && t.length <= 12) { groups.colors.add(t); continue; }
groups.other.add(t);
}
// To array
const out = {};
for (const [k, v] of Object.entries(groups)) {
const arr = [...v];
if (arr.length) out[k] = arr;
}
return out;
}
// Sanitize body_html: allow paragraphs, lists, line-breaks, basic emphasis. Strip scripts/styles/iframes.
function safeBodyHtml(html) {
if (!html) return '';
return String(html)
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '');
}
function memoMailto(p) {
const sub = encodeURIComponent(`Memo Sample Request — ${p.dw_sku || p.title}`);
const body = encodeURIComponent(
`Hi Phillipe Romano,\n\nI'd like to request a free memo sample of:\n\n` +
` ${p.title}\n SKU: ${p.dw_sku || ''}\n\n` +
`Please ship to:\n Name:\n Company:\n Address:\n City, State, ZIP:\n Phone:\n\nThank you.`
);
return `mailto:${PR_EMAIL}?subject=${sub}&body=${body}`;
}
const PR_ORIGIN = 'https://philipperomano.com';
const PR_DESC = 'Phillipe Romano commercial wallcoverings, naturals, and fabrics. Order memo samples free.';
const layout = (title, body, { q='', type='', cat='', canonical='', ogImage='', ogTitle='', ogDesc='' } = {}) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)} — Phillipe Romano</title>
<meta name="description" content="${esc(PR_DESC)}">
${canonical ? `<link rel="canonical" href="${esc(canonical)}">\n` : ''}<meta property="og:type" content="website">
<meta property="og:site_name" content="Phillipe Romano">
<meta property="og:title" content="${esc(ogTitle || (title + ' — Phillipe Romano'))}">
<meta property="og:description" content="${esc(ogDesc || PR_DESC)}">
${canonical ? `<meta property="og:url" content="${esc(canonical)}">\n` : ''}${ogImage ? `<meta property="og:image" content="${esc(ogImage)}">\n` : ''}<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
<style>
:root {
--bg:#faf7f2; --fg:#1a1714; --muted:#7c736a; --rule:#d8cec0;
--accent:#7d3c00; --accent-soft:#b8865c; --card:#ffffff;
--serif:'Cormorant Garamond', 'Playfair Display', Georgia, serif;
--sans: -apple-system, 'Inter', 'Segoe UI', system-ui, sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{font-family:var(--sans);background:var(--bg);color:var(--fg);line-height:1.5}
a{color:inherit;text-decoration:none}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
header.top{padding:24px 32px;border-bottom:1px solid var(--rule);background:#fff;display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap}
.brand{font-family:var(--serif);font-weight:600;font-size:32px;letter-spacing:.04em;text-transform:uppercase}
.brand small{display:block;font-family:var(--sans);font-size:11px;letter-spacing:.18em;font-weight:500;color:var(--muted);margin-top:4px}
.contact{font-size:13px;color:var(--muted);text-align:right;line-height:1.7;letter-spacing:.04em}
.contact a{color:var(--fg)}
.contact a:hover{color:var(--accent)}
.contact .phone{font-family:var(--serif);font-size:18px;color:var(--fg);font-weight:600;letter-spacing:.06em}
nav.types{display:flex;gap:18px;padding:14px 32px;background:#fff;border-bottom:1px solid var(--rule);flex-wrap:wrap;align-items:center}
nav.types a{font-size:13px;font-weight:500;color:var(--muted);padding:6px 0;border-bottom:2px solid transparent;letter-spacing:.04em;text-transform:uppercase}
nav.types a:hover{color:var(--fg)}
nav.types a.active{color:var(--accent);border-bottom-color:var(--accent)}
form.search{margin-left:auto;display:flex;gap:8px;align-items:center}
form.search input{border:1px solid var(--rule);background:#fff;padding:8px 12px;border-radius:0;min-width:240px;font:inherit;font-size:13px;outline:none;color:var(--fg)}
form.search input:focus{border-color:var(--accent)}
form.search button{border:1px solid var(--accent);background:var(--accent);color:#fff;padding:8px 16px;font:inherit;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:500;cursor:pointer}
main{max-width:1400px;margin:0 auto;padding:32px}
.meta-line{font-size:12px;color:var(--muted);margin-bottom:18px;letter-spacing:.06em;text-transform:uppercase}
.controls{display:flex;gap:24px;align-items:center;flex-wrap:wrap;margin-bottom:24px}
.controls label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:500;display:flex;gap:8px;align-items:center}
.controls select{border:1px solid var(--rule);background:#fff;padding:6px 10px;font:inherit;font-size:13px;color:var(--fg);outline:none;cursor:pointer}
.controls select:focus{border-color:var(--accent)}
.controls input[type=range]{cursor:pointer;accent-color:var(--accent)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardmin,220px),1fr));gap:24px}
.card{background:var(--card);border:1px solid var(--rule);transition:box-shadow .15s, transform .12s}
.card:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)}
.card a.thumb{display:block;aspect-ratio:1/1;background:#f0ece4;overflow:hidden}
.card a.thumb img{width:100%;height:100%;object-fit:cover;display:block}
.card .info{padding:14px 14px 16px}
.card .info .t{font-family:var(--serif);font-size:18px;font-weight:500;line-height:1.25;margin-bottom:4px;color:var(--fg)}
.card .info .sub{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
.card .actions{display:flex;border-top:1px solid var(--rule)}
.card .actions a{flex:1;padding:10px 12px;text-align:center;font-size:11px;text-transform:uppercase;letter-spacing:.08em;font-weight:500;color:var(--muted);transition:all .12s}
.card .actions a + a{border-left:1px solid var(--rule)}
.card .actions a:hover{background:var(--bg);color:var(--fg)}
.card .actions a.cta{color:var(--accent);font-weight:600}
.card .actions a.cta:hover{background:var(--accent);color:#fff}
.pager{display:flex;justify-content:center;gap:8px;margin-top:48px}
.pager a, .pager span{padding:8px 14px;border:1px solid var(--rule);background:#fff;font-size:13px;min-width:44px;text-align:center}
.pager a:hover{border-color:var(--accent);color:var(--accent)}
.pager .cur{background:var(--accent);color:#fff;border-color:var(--accent)}
.empty{padding:80px 20px;text-align:center;color:var(--muted)}
footer{padding:48px 32px;border-top:1px solid var(--rule);background:#fff;color:var(--muted);font-size:12px;letter-spacing:.06em;text-align:center;margin-top:80px}
footer a{color:var(--accent)}
/* Detail page */
.detail{display:grid;grid-template-columns:1.2fr 1fr;gap:48px;padding:48px 32px;max-width:1400px;margin:0 auto}
.detail .hero{background:#f0ece4;aspect-ratio:1/1}
.detail .hero img{width:100%;height:100%;object-fit:cover;display:block}
.detail h1{font-family:var(--serif);font-size:48px;font-weight:500;line-height:1.05;margin-bottom:8px}
.detail .crumb{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:18px}
.detail .pt{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);font-weight:600;margin-bottom:32px}
.detail .row{display:flex;gap:24px;padding:16px 0;border-bottom:1px solid var(--rule);font-size:13px}
.detail .row .k{flex:0 0 140px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:500}
.detail .actions{display:flex;gap:12px;margin-top:32px}
.detail .actions a{padding:14px 28px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;font-weight:600}
.detail .btn-primary{background:var(--accent);color:#fff;border:1px solid var(--accent)}
.detail .btn-primary:hover{background:#5e2d00}
.detail .btn-secondary{border:1px solid var(--fg);color:var(--fg)}
.detail .btn-secondary:hover{background:var(--fg);color:#fff}
@media (max-width:780px){.detail{grid-template-columns:1fr;padding:24px}}
</style>
</head>
<body>
<!-- Theme toggle (theme1 / theme2) — query-param-gated preview surface
(Steve 2026-05-29). Mirror of dw-domain-fleet shared/render.js productPage()
block (commit 34fbe0e). No admin auth → security-by-obscurity: ?theme=2
flips to theme2 and persists in localStorage 'wallco-page-theme'. Toggle UI
hidden until ?theme=N or localStorage opt-in. Shares the wallco-page-theme
localStorage key for cross-site continuity. -->
<script>
(function(){
var qs = new URLSearchParams(location.search);
var fromUrl = qs.get('theme');
var saved = null; try { saved = localStorage.getItem('wallco-page-theme'); } catch(e){}
var theme = null;
if (fromUrl === '2' || fromUrl === 'theme2') theme = 'theme2';
else if (fromUrl === '1' || fromUrl === 'theme1') theme = 'theme1';
else if (saved === 'theme2' || saved === 'theme1') theme = saved;
if (theme) {
document.documentElement.setAttribute('data-page-theme', theme);
if (fromUrl) { try { localStorage.setItem('wallco-page-theme', theme); } catch(e){} }
}
/* No opt-in → no attribute set, toggle stays display:none (gate works). */
})();
</script>
<style id="page-theme-css-fleet">
[data-page-theme="theme2"] body{
--bg:#ffffff !important; --ink:#0a0a0a !important;
--muted:#5a5a5a !important; --line:#e5e5e5 !important;
--card-bg:#fafafa !important; --accent:#c14a2e !important;
background:#fff !important; color:#0a0a0a !important;
}
[data-page-theme="theme2"] h1,
[data-page-theme="theme2"] h2,
[data-page-theme="theme2"] h3{ letter-spacing:-0.01em; font-weight:500; }
[data-page-theme="theme2"] .card{
background:#fafafa !important; border:1px solid #e5e5e5 !important; box-shadow:none !important;
}
#page-theme-toggle{ display:none; }
[data-page-theme] #page-theme-toggle{ display:inline-flex; }
#page-theme-toggle{
position:fixed; top:96px; right:14px; z-index:90;
gap:0; align-items:center;
background:rgba(255,255,255,.92); border:1px solid #d8d2c5;
border-radius:999px; padding:3px; backdrop-filter:blur(6px);
box-shadow:0 2px 10px rgba(0,0,0,.08);
font:600 11px ui-sans-serif,system-ui; letter-spacing:.06em; text-transform:uppercase;
}
#page-theme-toggle button{
border:0; background:transparent; color:#5a5048; cursor:pointer;
padding:6px 14px; border-radius:999px; font:inherit; transition:all .15s;
}
#page-theme-toggle button[aria-pressed="true"]{ background:#1a1714; color:#faf7f2; }
#page-theme-toggle button:hover:not([aria-pressed="true"]){ color:#1a1714; }
</style>
<div id="page-theme-toggle" role="group" aria-label="Page theme">
<button type="button" data-theme="theme1" aria-pressed="false">Theme 1</button>
<button type="button" data-theme="theme2" aria-pressed="false">Theme 2</button>
</div>
<script>
(function(){
var cur = document.documentElement.getAttribute('data-page-theme') || 'theme1';
var btns = document.querySelectorAll('#page-theme-toggle button');
function sync(t){ btns.forEach(function(b){ b.setAttribute('aria-pressed', b.dataset.theme === t ? 'true' : 'false'); }); }
sync(cur);
btns.forEach(function(b){
b.addEventListener('click', function(){
var t = b.dataset.theme;
document.documentElement.setAttribute('data-page-theme', t);
try { localStorage.setItem('wallco-page-theme', t); } catch(e){}
sync(t);
});
});
})();
</script>
<header class="top">
<a href="/" class="brand">Phillipe Romano<small>A Designer Wallcoverings Exclusive</small></a>
</header>
<nav class="types">
<a href="/" ${!type && !cat ? 'class="active"' : ''}>All</a>
<a href="/naturals" ${cat==='naturals' ? 'class="active"' : ''}>Naturals (${NATURALS_COUNT.toLocaleString()})</a>
${TYPES.map(t => `<a href="/?type=${encodeURIComponent(t)}" ${type===t ? 'class="active"' : ''}>${esc(t)}</a>`).join('')}
<form class="search" method="GET" action="/">
<input name="q" value="${esc(q)}" placeholder="Search 8,227 SKUs…">
${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}
<button>Search</button>
</form>
</nav>
${body}
<footer>
Phillipe Romano is an exclusive collection of <a href="${DW_SHOPIFY}">Designer Wallcoverings</a>. Memo samples are always free. © ${new Date().getFullYear()}
</footer>
</body>
</html>`;
// /naturals — friendly shortcut → /?cat=naturals
app.get('/naturals', (req, res) => {
const u = new URL('http://x' + req.url);
u.searchParams.set('cat', 'naturals');
u.pathname = '/';
res.redirect(302, '/?' + u.searchParams.toString());
});
app.get('/', (req, res) => {
const q = (req.query.q || '').toString().toLowerCase().trim();
const type = (req.query.type || '').toString();
const cat = (req.query.cat || '').toString().toLowerCase();
const sort = (req.query.sort || '').toString();
const page = parseInt(req.query.page, 10) || 1;
// Source from ENRICHED (junk-filtered + _isNatural flag set) — not raw DATA
let filtered = ENRICHED;
if (cat === 'naturals') filtered = filtered.filter(p => p._isNatural);
if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
if (q) {
const tokens = q.split(/\s+/);
filtered = filtered.filter(p => {
const hay = `${p.title} ${p.dw_sku||''} ${p.mfr_sku||''}`.toLowerCase();
return tokens.every(t => hay.includes(t));
});
}
filtered = sortProducts(filtered, sort);
const { items, page: cur, pages, total } = paginate(filtered, page);
const cards = items.map(p => `
<article class="card">
<a class="thumb" href="/p/${esc(p.handle||'')}">
${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}
</a>
<div class="info">
<a href="/p/${esc(p.handle||'')}">
<div class="t">${esc(p.title)}</div>
<div class="sub">${esc(p.dw_sku || '')} · ${esc(NORM_TYPE(p.product_type))}</div>
</a>
</div>
<div class="actions">
<a href="/p/${esc(p.handle||'')}">View</a>
<a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo</a>
</div>
</article>
`).join('');
const SORTS = [
['', 'Newest'], ['sku', 'SKU A→Z'], ['title', 'Title A→Z'], ['type', 'Type'],
];
const sortOptions = SORTS.map(([v, lbl]) =>
`<option value="${v}"${sort === v ? ' selected' : ''}>${lbl}</option>`).join('');
const body = `
<main>
<h1 class="sr-only">Phillipe Romano — ${esc(cat === 'naturals' ? 'Naturals' : (type || 'All Products'))}</h1>
<div class="meta-line">
${q || type ? `Showing ${total.toLocaleString()} ${type ? `<strong style="color:var(--fg)">${esc(type)}</strong>` : ''} ${q ? `matching "<strong style="color:var(--fg)">${esc(q)}</strong>"` : ''}` : `${total.toLocaleString()} products available`}
</div>
<div class="controls">
<label>Sort
<select id="sortSelect">${sortOptions}</select>
</label>
<label>Density
<input type="range" id="densityRange" min="160" max="340" step="20" value="220">
</label>
</div>
${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid">${cards}</div>`}
${pages > 1 ? `<div id="scroll-sentinel" style="padding:20px;text-align:center;color:var(--muted);font-size:13px;"><span id="loading-status">Loading more…</span></div>` : ''}
</main>
<script>
(function () {
var sortSel = document.getElementById('sortSelect');
var dens = document.getElementById('densityRange');
var grid = document.querySelector('.grid');
var sentinel = document.getElementById('scroll-sentinel');
// ========== SORT & DENSITY (unchanged) ==========
if (sortSel) {
var savedSort = localStorage.getItem('pr.sort');
var params = new URLSearchParams(location.search);
if (savedSort !== null && !params.has('sort') && savedSort !== '') {
params.set('sort', savedSort);
location.replace(location.pathname + '?' + params.toString());
return;
}
sortSel.addEventListener('change', function () {
localStorage.setItem('pr.sort', sortSel.value);
var p = new URLSearchParams(location.search);
if (sortSel.value) p.set('sort', sortSel.value); else p.delete('sort');
p.delete('page');
location.search = p.toString();
});
}
if (dens) {
var savedDens = localStorage.getItem('pr.density');
if (savedDens) { dens.value = savedDens; }
var apply = function () {
document.documentElement.style.setProperty('--cardmin', dens.value + 'px');
};
apply();
dens.addEventListener('input', function () {
localStorage.setItem('pr.density', dens.value);
apply();
});
}
// ========== INFINITE SCROLL ==========
if (grid && sentinel) {
var currentPage = parseInt(new URLSearchParams(location.search).get('page')) || 1;
var isLoading = false;
var hasMore = true;
var scrollObserver = new IntersectionObserver(function(entries) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting && hasMore && !isLoading) {
loadNextPage();
}
}
}, { rootMargin: '400px 0px' });
scrollObserver.observe(sentinel);
function loadNextPage() {
if (isLoading || !hasMore) return;
isLoading = true;
var nextPage = currentPage + 1;
var url = new URL(location.href);
url.searchParams.set('page', nextPage);
fetch(url.toString())
.then(function(res) { return res.text(); })
.then(function(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var nextGrid = doc.querySelector('.grid');
if (!nextGrid || nextGrid.children.length === 0) {
hasMore = false;
document.getElementById('loading-status').textContent = 'No more products';
sentinel.style.color = '#ccc';
return;
}
Array.from(nextGrid.children).forEach(function(card) {
var clone = card.cloneNode(true);
grid.appendChild(clone);
});
currentPage = nextPage;
// Check if next page exists by looking for sentinel in fetched doc
var nextSentinel = doc.querySelector('#scroll-sentinel');
if (!nextSentinel) {
hasMore = false;
document.getElementById('loading-status').textContent = 'All products loaded';
sentinel.style.color = '#ccc';
} else {
document.getElementById('loading-status').textContent = 'Loading more…';
}
isLoading = false;
})
.catch(function(err) {
console.error('Infinite scroll failed:', err);
document.getElementById('loading-status').textContent = 'Error loading more';
sentinel.style.color = '#c66c4d';
isLoading = false;
});
}
}
})();
</script>
`;
// Canonical (DTD verdict B): keep content filters (cat, type), drop volatile
// view params (sort, page, q) so all sorted/paginated/searched views of a
// listing consolidate to one canonical. og:image = first product on the page.
const canonParams = new URLSearchParams();
if (cat) canonParams.set('cat', cat);
if (type) canonParams.set('type', type);
const canonical = PR_ORIGIN + '/' + (canonParams.toString() ? '?' + canonParams.toString() : '');
const ogImage = (items[0] && items[0].image_url) || '';
res.set('Content-Type','text/html').send(layout(`${cat === 'naturals' ? 'Naturals' : (type || 'All Products')}${q ? ` · ${q}` : ''}`, body, { q, type, cat, canonical, ogImage }));
});
app.get('/p/:handle', (req, res) => {
const p = ENRICHED.find(x => x.handle === req.params.handle);
if (!p) return res.status(404).set('Content-Type','text/html').send(layout('Not found', '<main><div class="empty">Product not found.</div></main>'));
const body = `
<div class="detail">
<div class="hero">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}</div>
<div>
<div class="crumb"><a href="/">Phillipe Romano</a> / ${esc(NORM_TYPE(p.product_type))}</div>
<div class="pt">${esc(NORM_TYPE(p.product_type))}</div>
<h1>${esc(p.title)}</h1>
<div class="row"><div class="k">SKU</div><div>${esc(p.dw_sku || '—')}</div></div>
${p.mfr_sku ? `<div class="row"><div class="k">Mfr SKU</div><div>${esc(p.mfr_sku)}</div></div>` : ''}
<div class="row"><div class="k">Type</div><div>${esc(p.product_type)}</div></div>
${p.retail_price ? `<div class="row"><div class="k">Retail</div><div>$${parseFloat(p.retail_price).toFixed(2)} /yd</div></div>` : ''}
<div class="actions">
<a class="btn-primary" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
<a class="btn-secondary" href="${esc(shopifyUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Shop on DW</a>
</div>
</div>
</div>
`;
res.set('Content-Type','text/html').send(layout(p.title, body, {
canonical: PR_ORIGIN + '/p/' + encodeURIComponent(p.handle || ''),
ogImage: p.image_url || '',
}));
});
app.get('/health', (_req, res) => res.json({ ok: true, products: ENRICHED.length, types: TYPES }));
// Loopback-only on the local Mac. Set BIND=0.0.0.0 in prod env when needed.
const BIND = process.env.BIND || '127.0.0.1';
app.listen(PORT, BIND, () => console.log(`Phillipe Romano on http://${BIND}:${PORT} · ${DATA.length} products`));