← 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 };