[object Object]

← back to Philipperomano

Shopify fetch + cache + categorizeTags + safeBodyHtml + memoMailto from kamatera (hunk 3)

00974f4aeb9753d15fc99eea15eaf3318a2a49dd · 2026-05-21 18:56:34 -0700 · Steve Abrams

Files touched

Diff

commit 00974f4aeb9753d15fc99eea15eaf3318a2a49dd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 21 18:56:34 2026 -0700

    Shopify fetch + cache + categorizeTags + safeBodyHtml + memoMailto from kamatera (hunk 3)
---
 server.js | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 121 insertions(+)

diff --git a/server.js b/server.js
index d3e6d53..39b500a 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@
 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');
@@ -287,6 +288,126 @@ function memoSampleUrl(handle) {
   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 layout = (title, body, { q='', type='' } = {}) => `<!doctype html>
 <html lang="en">
 <head>

← 30d1a86 color buckets + colorway-hex + ENRICHED + junk-filter from k  ·  back to Philipperomano  ·  header.top flex + .contact CSS from kamatera (hunk 4) 8fdcf2f →