← back to Philipperomano
server.js
929 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();
app.get('/ads.txt', (req, res) => res.type('text/plain').send('google.com, pub-5278231299883833, DIRECT, f08c47fec0942fa0'));
// 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>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Google tag (gtag.js) — GA4 G-8WGHZ5FTV0 -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-8WGHZ5FTV0"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-8WGHZ5FTV0');</script>
<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('/privacy', (req, res) => {
res.type('html').send(`<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy Policy — philipperomano.com</title>
<meta name="description" content="Privacy policy for philipperomano.com, including how we use cookies and third-party advertising (Google AdSense).">
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
<style>
:root{--bg:#0e0e0f;--paper:#f4f1ea;--muted:#a8a29a;--line:#2a2a2c;--accent:#b8935f}
body{margin:0;background:var(--bg);color:var(--paper);font:16px/1.7 'EB Garamond',Georgia,serif;padding:0}
.wrap{max-width:820px;margin:0 auto;padding:64px 24px 96px}
h1{font-weight:500;letter-spacing:.02em;font-size:34px;margin:0 0 6px}
.sub{color:var(--muted);font-size:13px;letter-spacing:.18em;text-transform:uppercase;margin-bottom:36px}
h2{font-weight:500;font-size:20px;margin:34px 0 8px;border-top:1px solid var(--line);padding-top:26px}
a{color:var(--accent)}p{color:#ddd8ce}
.back{display:inline-block;margin-bottom:28px;color:var(--muted);text-decoration:none;font-size:13px;letter-spacing:.16em;text-transform:uppercase}
</style></head><body><div class="wrap">
<a class="back" href="/">← philipperomano.com</a>
<h1>Privacy Policy</h1>
<div class="sub">philipperomano.com · Last updated September 2026</div>
<p>philipperomano.com ("we", "us") respects your privacy. This page explains what information is collected when you visit this website and how it is used. By using this site you consent to this policy.</p>
<h2>Information we collect</h2>
<p>We collect standard, non-personally-identifying information that browsers and servers make available, such as browser type, language preference, referring site, and the date and time of each request. We may also collect information you voluntarily provide through inquiry or sample-request forms (such as your name, email, and message) solely to respond to you.</p>
<h2>Cookies</h2>
<p>This site uses cookies to help operate the site and improve your experience. A cookie is a small string of information stored by your browser. You can configure your browser to refuse cookies, though some site features may not function as intended.</p>
<h2>Third-party advertising & Google AdSense</h2>
<p>We use third-party advertising companies, including Google, to serve ads when you visit this website. Google's use of advertising cookies (including the DoubleClick DART cookie) enables it and its partners to serve ads to you based on your visit to this and other sites on the Internet.</p>
<p>You may opt out of personalized advertising by visiting <a href="https://www.google.com/settings/ads" target="_blank" rel="noopener">Google Ads Settings</a>, or opt out of a third-party vendor's use of cookies for personalized advertising at <a href="https://www.aboutads.info/choices/" target="_blank" rel="noopener">aboutads.info/choices</a>. For more on how Google uses data, see <a href="https://policies.google.com/technologies/partner-sites" target="_blank" rel="noopener">Google's Privacy & Terms</a>.</p>
<h2>Analytics</h2>
<p>We may use Google Analytics to understand how visitors use the site in aggregate. This data is not used to identify individual visitors.</p>
<h2>Data retention & your choices</h2>
<p>Information submitted through a form is retained only as long as needed to respond to your request. You may request access to, correction of, or deletion of information you have submitted by contacting us at the email below.</p>
<h2>Children's privacy</h2>
<p>This site is not directed to children under 13 and we do not knowingly collect information from them.</p>
<h2>Changes</h2>
<p>We may update this policy from time to time; the "last updated" date above reflects the latest revision.</p>
<h2>Contact</h2>
<p>Questions about this policy? Email <a href="mailto:info@philipperomano.com">info@philipperomano.com</a>.</p>
</div></body></html>
`);
});
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 }));
});
// TK-11304 — Type II contract-wallcovering detector. Tags-first (array or CSV
// string) per the standing rule, falling back to title/product_type since the
// microsite's static dataset predates a tags column. Accepts "Type II" or "Type 2".
function isTypeII(p) {
const hay = [];
if (Array.isArray(p.tags)) hay.push(p.tags.join(','));
else if (typeof p.tags === 'string') hay.push(p.tags);
hay.push(p.title || '', p.product_type || '');
return /type\s*(?:ii|2)\b/i.test(hay.join(' | '));
}
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>'));
// Contract goods (Type II) are sold BY THE YARD — render the product's unit
// when present, never hardcode a single-roll unit. (unit_of_measure metafield
// is corrected upstream; default '/yd' for this per-yard line until it lands.)
const unit = p.unit_of_measure ? esc(p.unit_of_measure) : '/yd';
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>
${isTypeII(p) ? `<div class="type2-label" style="font-weight:700;font-size:15px;color:var(--fg,#1a1714);letter-spacing:.01em;margin:0 0 18px">Type II Commercial Wallcovering</div>` : ''}
<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.unit_of_measure ? `<div class="row"><div class="k">Sold By</div><div>${esc(p.unit_of_measure)}</div></div>` : ''}
${p.retail_price ? `<div class="row"><div class="k">Retail</div><div>$${parseFloat(p.retail_price).toFixed(2)} ${unit}</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 || '',
}));
});
// ── Ungated public feed for the all.designerwallcoverings.com microsite crawler ──
// The fleet aggregator's crawler reads each microsite's /api/products to merge its line
// into the LIVE microsite directory. It seeds phillipe-romano via its localhost feedBase
// (config/known-subdomains.json), so this route is what surfaces Phillipe Romano's cork +
// wallcovering line in the directory. Shape is deliberately DW-family general:
// { count, products:[{ title, handle, vendor, type, image, url }] }
// which the crawler's feedProducts() DW branch (j.products) consumes directly.
// PUBLIC-SAFE ONLY: title/handle/vendor/type/image/store-url — NEVER price/net/cost, and
// the vendor is FORCED to "Phillipe Romano" (the customer-facing brand). The real upstream
// mill "Greenland" (and other private-label mills) must NEVER appear — scrubbed defensively.
const FEED_BANNED = /greenland|command\s*54|wallquest|chesapeake|nextwall|seabrook|rigo|justin\s*david/i;
const FEED_ROWS = ENRICHED
.filter(p => {
const blob = `${p.title || ''} ${p.handle || ''} ${p.product_type || ''}`;
return !FEED_BANNED.test(blob);
})
.map(p => ({
title: p.title || '',
handle: p.handle || null,
vendor: 'Phillipe Romano', // customer-facing brand — never the upstream mill
type: p.product_type || null,
image: p.image_url || null,
url: p.handle ? `${DW_SHOPIFY}/products/${p.handle}` : `${DW_SHOPIFY}`,
}));
// The aggregator was missing the CORK line specifically (greenland.dw/phillipe-romano-*.dw are
// gated/dead, so no ungated feed carried it). The crawler samples the first N rows into the merged
// directory feed, so surface cork FIRST here — otherwise the newest wallcoverings crowd it out of
// the sample and the cork line stays invisible in the directory. Stable within each group.
FEED_ROWS.sort((a, b) => {
const ac = /cork/i.test(`${a.title} ${a.type}`) ? 0 : 1;
const bc = /cork/i.test(`${b.title} ${b.type}`) ? 0 : 1;
return ac - bc;
});
app.get('/api/products', (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || FEED_ROWS.length, FEED_ROWS.length);
res.set('Cache-Control', 'public, max-age=600, s-maxage=600');
res.json({ count: FEED_ROWS.length, products: FEED_ROWS.slice(0, limit) });
});
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`));