← back to Hollywood Wallcoverings

customcreator.js

215 lines

'use strict';
/**
 * Hollywood Wallcoverings — Custom Creator (made-to-measure murals), mounted at /CustomCreator.
 *
 * CONTENT = the original AI design library (the published, seam/settlement-clean snapshot — filtered
 * to is_published && !user_removed, which is exactly "published + settlement_verdict='OK' + seam-pass"
 * since the design store only flips is_published after the settlement gate and seam checks pass).
 * (NOT the general Rebel Walls Shopify murals — those were deliberately dropped.)
 * hollywoodwallcoverings.com is the sole home of this content.
 *
 * Design images are PROXIED through this origin (/CustomCreator/img/<file>) so the customer never
 * sees the upstream image host. Each design is sold as a made-to-measure mural at a flat $83.38/m²,
 * recomputed SERVER-SIDE from clamped dims so the browser can never set the price.
 *
 * Image origin: served local-cache-first; the remote fallback is wallpapersback.com — the LIVE
 * AI-design storefront, which serves the identical /designs/img/<file>.png paths (verified
 * byte-identical 2026-06-30). The retired wallco.ai host (HTTP 410 Gone) is no longer referenced.
 *
 * Checkout uses the SHOPIFY_DRAFT_TOKEN already present on the Hollywood prod process env
 * (write_draft_orders) — no new secrets, no nginx change.
 */
const fs = require('fs');
const path = require('path');

const CC_DIR = path.join(__dirname, 'customcreator');
// Remote image origin for the design library. Local cache is always tried first (CACHE_DIR);
// this is only the write-through fallback for a cold-cache id. wallco.ai is RETIRED (HTTP 410) —
// the live AI-design storefront wallpapersback.com serves the identical /designs/img/<file> paths.
// Override via env only if the design host ever moves again.
const IMG_ORIGIN = (process.env.CC_IMG_ORIGIN || 'https://wallpapersback.com').replace(/\/+$/, '');
const STORE = (process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com').replace(/^https?:\/\//, '');
const EP = 'https://' + STORE + '/admin/api/2024-10/graphql.json';
const DRAFT_TOKEN = process.env.SHOPIFY_DRAFT_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN;

const RATE_SQM = 83.38, SAMPLE = 4.25, CM_PER_IN = 2.54;
const MIN_CM = 50, MAX_CM = 1200;
const HOUSE = 'Made to Measure';            // customer-facing vendor label (never the upstream host)
const GALLERY_LIMIT = 120;                  // page size for the browse grid (catalog is ~12k, paginated)

function ftIn(inch) { let f = Math.floor(inch / 12), i = Math.round(inch - f * 12); if (i === 12) { f++; i = 0; } return `${f}′ ${i}″`; }
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
const ID_RE = /^\d+$/;
const CACHE_DIR = path.join(CC_DIR, 'designs-img');   // local write-through cache (Kamatera); not in git/rsync
try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) {}

// Eligible designs (prebuilt: is_published && !user_removed, newest-first).
let DESIGNS = [], BY_KEY = new Map(), SRC = new Map();
try {
  DESIGNS = JSON.parse(fs.readFileSync(path.join(CC_DIR, 'wallco-designs.json'), 'utf8'));
  for (const d of DESIGNS) { BY_KEY.set(String(d.handle).toLowerCase(), d); BY_KEY.set(String(d.id), d); SRC.set(String(d.id), d.src); }
} catch (e) { console.log('[CustomCreator] designs load:', e.message); }

const imgUrl = id => '/CustomCreator/img/' + id;   // id-keyed; the route serves local cache, else fetches IMG_ORIGIN

// Category facets (sorted by count) for the browse dropdown.
const CATS = (() => {
  const m = new Map();
  for (const d of DESIGNS) { const c = (d.category || '').trim(); if (c) m.set(c, (m.get(c) || 0) + 1); }
  return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([category, count]) => ({ category, count }));
})();

// Server-side sort (full-catalog, not just a page). Cached per mode so 12k isn't re-sorted each request.
function colorKey(hex) {
  if (!hex) return 500;
  const m = String(hex).replace('#', '').match(/.{2}/g); if (!m || m.length < 3) return 500;
  const [r, g, b] = m.map(x => parseInt(x, 16));
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 510, d = mx - mn;
  if (d < 18) return l > 0.8 ? 0 : (l < 0.18 ? 900 : 400 + Math.round((1 - l) * 80));
  let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; if (h < 0) h += 360;
  return 50 + Math.round(h / 5);
}
const _sortCache = new Map();
function sorted(mode) {
  if (_sortCache.has(mode)) return _sortCache.get(mode);
  const a = DESIGNS.slice(); // DESIGNS is already newest-first
  if (mode === 'title') a.sort((x, y) => String(x.title).localeCompare(String(y.title)));
  else if (mode === 'style') a.sort((x, y) => String(x.style || '~').localeCompare(String(y.style || '~')) || String(x.title).localeCompare(String(y.title)));
  else if (mode === 'sku') a.sort((x, y) => String(x.handle).localeCompare(String(y.handle)));
  else if (mode === 'color') a.sort((x, y) => colorKey(x.hex) - colorKey(y.hex) || String(x.title).localeCompare(String(y.title)));
  // 'newest' = DESIGNS order (no sort)
  _sortCache.set(mode, a);
  return a;
}

function toCard(d) {
  return { handle: d.handle, sku: d.handle, title: d.title, image: imgUrl(d.id), createdAt: d.created_at || '', style: d.style || 'Original', colorHex: d.hex || '', colorName: '', vendor: HOUSE };
}

function toProduct(d) {
  return {
    sku: d.handle, title: d.title, pattern: d.title, vendor: HOUSE,
    descriptionHtml: `A made-to-measure <strong>${d.title}</strong> — a Hollywood Wallcoverings original, printed to your exact wall size on premium non-woven, paste-the-wall material. Seamless repeat, fade-resistant pigment inks.`,
    images: [{ url: imgUrl(d.id), alt: d.title }], flatImage: imgUrl(d.id),
    specs: [
      { label: 'Type', value: 'Original Design' },
      { label: 'Material', value: 'Non-woven, paste-the-wall' },
      { label: 'Print', value: 'Made to measure · seamless repeat' },
      d.style ? { label: 'Style', value: d.style } : null,
    ].filter(Boolean),
    colors: d.hex ? [{ name: '', hex: d.hex, pct: null }] : [],
    rate: RATE_SQM, sample: SAMPLE, samplePrice: String(SAMPLE),
  };
}

async function gql(query, variables, token) {
  const r = await fetch(EP, { method: 'POST', headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
  return r.json();
}
const CREATE = `mutation($i:DraftOrderInput!){ draftOrderCreate(input:$i){ draftOrder{ id name invoiceUrl totalPriceSet{shopMoney{amount}} } userErrors{field message} } }`;

async function makeDraft(body) {
  if (!DRAFT_TOKEN) return { error: 'checkout unavailable' };
  const mode = body.mode === 'sample' ? 'sample' : 'mural';
  const sku = String(body.sku || '').slice(0, 40), pattern = String(body.pattern || 'Custom Mural').slice(0, 80);
  let line;
  if (mode === 'sample') {
    line = { title: `${pattern} — Sample`, originalUnitPrice: SAMPLE.toFixed(2), quantity: 1, requiresShipping: true, taxable: true, customAttributes: [{ key: 'Design', value: sku }, { key: 'Type', value: 'Sample' }] };
  } else {
    const f = body.unit === 'cm' ? 1 : CM_PER_IN;
    const rawW = parseFloat(body.width), rawH = parseFloat(body.height);
    if (!(rawW > 0) || !(rawH > 0)) return { error: 'Please enter a valid width and height.' };
    const w_cm = clamp(rawW * f, MIN_CM, MAX_CM);
    const h_cm = clamp(rawH * f, MIN_CM, MAX_CM);
    const area = (w_cm / 100) * (h_cm / 100);
    if (!(area > 0)) return { error: 'invalid dimensions' }; // defensive; unreachable post-clamp
    const price = +(RATE_SQM * area).toFixed(2);
    const wDim = ftIn(w_cm / CM_PER_IN), hDim = ftIn(h_cm / CM_PER_IN);
    line = { title: `${pattern} — Custom Mural ${wDim} × ${hDim}`, originalUnitPrice: price.toFixed(2), quantity: 1, requiresShipping: true, taxable: true,
      customAttributes: [{ key: 'Design', value: sku }, { key: 'Width', value: wDim }, { key: 'Height', value: hDim }, { key: 'Area', value: `${(area * 10.7639).toFixed(1)} sq ft` }, { key: 'Rate', value: `$${RATE_SQM}/m²` }] };
  }
  const j = await gql(CREATE, { i: { lineItems: [line], tags: ['custom-mural', 'hollywood-custom-creator', 'original-design'] } }, DRAFT_TOKEN);
  const ue = j.data && j.data.draftOrderCreate && j.data.draftOrderCreate.userErrors;
  if (ue && ue.length) return { error: ue.map(e => e.message).join('; ') };
  if (j.errors) return { error: (j.errors[0] && j.errors[0].message) || 'graphql error', scopeHint: /access denied|write_draft_orders/i.test(JSON.stringify(j.errors)) };
  const d = j.data.draftOrderCreate.draftOrder;
  return { invoiceUrl: d.invoiceUrl, name: d.name, total: d.totalPriceSet.shopMoney.amount };
}

function mount(app, express) {
  const sendHtml = file => (req, res) => { res.type('html'); res.send(fs.readFileSync(path.join(CC_DIR, file))); };

  // Room imagery (local static). Design imagery is proxied (route below).
  app.use('/CustomCreator/rooms', express.static(path.join(CC_DIR, 'rooms'), { maxAge: '1d' }));

  // Image route — id-keyed write-through cache. Serves the locally cached file if present;
  // otherwise fetches the design's source path from IMG_ORIGIN (wallpapersback.com — the live
  // AI-design storefront, which serves the identical /designs/img/<file> paths), streams it to the
  // client, and writes it into the local cache. Once the cache is fully warmed the remote is never
  // hit. The retired wallco.ai host is no longer referenced; the upstream host is never exposed to
  // the customer. If the remote fetch fails, fail gracefully — no dead-origin retry.
  app.get('/CustomCreator/img/:id', async (req, res) => {
    const id = req.params.id;
    if (!ID_RE.test(id)) return res.status(400).type('text/plain').send('bad request');
    const local = path.join(CACHE_DIR, id + '.png');
    if (fs.existsSync(local)) { res.set('Cache-Control', 'public, max-age=604800'); return res.sendFile(local); }
    const src = SRC.get(id);
    if (!src) return res.status(404).type('text/plain').send('not found');
    try {
      const r = await fetch(`${IMG_ORIGIN}${src}`);
      if (!r.ok) return res.status(r.status === 410 ? 404 : r.status).type('text/plain').send('not found');
      const buf = Buffer.from(await r.arrayBuffer());
      res.set('Content-Type', r.headers.get('content-type') || 'image/png');
      res.set('Cache-Control', 'public, max-age=604800');
      res.send(buf);
      fs.writeFile(local, buf, () => {});   // write-through cache (best-effort, after responding)
    } catch (e) { res.status(502).type('text/plain').send('upstream error'); }
  });

  // Pages.
  app.get(['/CustomCreator', '/CustomCreator/'], sendHtml('gallery.html'));
  app.get('/CustomCreator/configure', sendHtml('configure.html'));

  // Category facets for the browse dropdown.
  app.get('/CustomCreator/api/categories', (req, res) => res.json({ total: DESIGNS.length, categories: CATS }));

  // Browse grid — full-catalog search/filter/sort with pagination.
  // ?q= (title contains) · ?category= · ?sort=newest|title|style|sku|color · ?offset= · ?limit=
  app.get('/CustomCreator/api/collection', (req, res) => {
    const q = String(req.query.q || '').trim().toLowerCase();
    const cat = String(req.query.category || '').trim().toLowerCase();
    const sort = ['newest', 'title', 'style', 'sku', 'color'].includes(req.query.sort) ? req.query.sort : 'newest';
    const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
    const limit = Math.min(Math.max(1, parseInt(req.query.limit, 10) || GALLERY_LIMIT), 600);
    let rows = sorted(sort);
    if (cat) rows = rows.filter(d => String(d.category).toLowerCase() === cat);
    if (q) rows = rows.filter(d => String(d.title).toLowerCase().includes(q) || String(d.handle).toLowerCase().includes(q) || String(d.category).toLowerCase().includes(q) || String(d.style).toLowerCase().includes(q));
    const total = rows.length;
    res.json({ items: rows.slice(offset, offset + limit).map(toCard), total, offset, limit });
  });

  // Single design (by handle or numeric id).
  app.get('/CustomCreator/api/product', (req, res) => {
    const key = String(req.query.handle || req.query.sku || '').toLowerCase();
    const d = BY_KEY.get(key) || BY_KEY.get(key.replace(/^wallco-/, ''));
    if (!d) return res.status(404).json({ error: 'not found' });
    res.json(toProduct(d));
  });

  // Lightweight list (used by the configurator's "more patterns" strip).
  app.get('/CustomCreator/api/products', (req, res) => {
    res.json(DESIGNS.slice(0, 24).map(d => ({ handle: d.handle, title: d.title, image: imgUrl(d.id) })));
  });

  // Priced / sample checkout via Shopify draft order (server-side price).
  app.post('/CustomCreator/api/mural-cart', express.json({ limit: '8kb' }), (req, res) => {
    makeDraft(req.body || {})
      .then(out => res.status(out.error ? (out.scopeHint ? 403 : 400) : 200).json(out))
      .catch(e => res.status(500).json({ error: e.message }));
  });

  console.log(`[CustomCreator] mounted at /CustomCreator (designs: ${DESIGNS.length} eligible · img-origin:${IMG_ORIGIN} · draft:${DRAFT_TOKEN ? 'ok' : 'MISSING'})`);
}

module.exports = { mount };