← back to Fentucci
server.js
226 lines
/**
* fentucci.com — Express server
* Serves the Fentucci natural-fiber wallcoverings storefront.
* Catalog sourced from dw_unified at build time → data/products.json
*/
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const http = require('http');
const app = express();
const PORT = parseInt(process.env.PORT || '9880', 10);
const BIND = process.env.BIND || '127.0.0.1';
// ── George email agent ──────────────────────────────────────────────────────
const GEORGE_PORT = 9850;
const GEORGE_AUTH = process.env.GEORGE_AUTH ||
('Basic ' + Buffer.from('admin:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO').toString('base64'));
app.use(express.json());
// ── Load catalog ────────────────────────────────────────────────────────────
const CATALOG_PATH = path.join(__dirname, 'data', 'products.json');
let catalog = [];
try {
catalog = JSON.parse(fs.readFileSync(CATALOG_PATH, 'utf8'));
console.log(`[catalog] loaded ${catalog.length} products`);
} catch (e) {
console.error('[catalog] WARN — could not load products.json:', e.message);
}
// ── API: /api/products ───────────────────────────────────────────────────────
// Supports: ?q=<search>, ?sort=<newest|title|sku|price_asc|price_desc|color|style>,
// ?page=<n>, ?per_page=<n> (default 60)
app.get('/api/products', (req, res) => {
const { q = '', sort = 'newest', page = '1', per_page = '60' } = req.query;
const pageN = Math.max(1, parseInt(page, 10) || 1);
const perN = Math.min(120, Math.max(12, parseInt(per_page, 10) || 60));
let results = catalog.slice();
// search
if (q.trim()) {
const lq = q.trim().toLowerCase();
results = results.filter(p =>
(p.title || '').toLowerCase().includes(lq) ||
(p.dw_sku || '').toLowerCase().includes(lq) ||
(p.mfr_sku || '').toLowerCase().includes(lq) ||
(p.pattern_name || '').toLowerCase().includes(lq) ||
(p.tags || '').toLowerCase().includes(lq)
);
}
// sort
const colorBucket = t => {
const lo = (t || '').toLowerCase();
if (/\b(black|ebony|charcoal|ink|onyx|graphite)\b/.test(lo)) return '1-dark';
if (/\b(white|ivory|cream|pearl|alabaster|snow)\b/.test(lo)) return '2-white';
if (/\b(warm|sand|tan|oat|wheat|camel|beige|taupe|natural)\b/.test(lo)) return '3-warm';
if (/\b(green|sage|olive|moss|forest|teal|jade)\b/.test(lo)) return '4-green';
if (/\b(blue|navy|indigo|sky|cerulean|cobalt)\b/.test(lo)) return '5-blue';
if (/\b(red|crimson|ruby|coral|terracotta|rust)\b/.test(lo)) return '6-red';
if (/\b(purple|violet|lilac|plum|mauve)\b/.test(lo)) return '7-purple';
if (/\b(gold|yellow|amber|brass|honey)\b/.test(lo)) return '8-gold';
if (/\b(pink|blush|rose|petal|mauve)\b/.test(lo)) return '9-pink';
return 'z-other';
};
const styleBucket = t => {
const lo = (t || '').toLowerCase();
if (/grasscloth|natural|fiber|jute|sisal|seagrass|woven/.test(lo)) return '0-natural';
if (/geometric|grid|stripe|chevron|diamond|lattice/.test(lo)) return '1-geometric';
if (/floral|botanical|leaf|foliage|garden|vine|nature/.test(lo)) return '2-floral';
if (/abstract|mod|contemporary|modern/.test(lo)) return '3-abstract';
if (/damask|toile|traditional|classical/.test(lo)) return '4-traditional';
if (/texture|solid|plain|linen|linen-look/.test(lo)) return '5-texture';
return 'z-other';
};
switch (sort) {
case 'title': results.sort((a,b)=>(a.title||'').localeCompare(b.title||'')); break;
case 'title_desc': results.sort((a,b)=>(b.title||'').localeCompare(a.title||'')); break;
case 'sku': results.sort((a,b)=>(a.dw_sku||'').localeCompare(b.dw_sku||'')); break;
case 'price_asc': results.sort((a,b)=>(parseFloat(a.price)||0)-(parseFloat(b.price)||0)); break;
case 'price_desc': results.sort((a,b)=>(parseFloat(b.price)||0)-(parseFloat(a.price)||0)); break;
case 'color': results.sort((a,b)=>colorBucket(a.tags).localeCompare(colorBucket(b.tags))); break;
case 'style': results.sort((a,b)=>styleBucket(a.tags).localeCompare(styleBucket(b.tags))); break;
default: /* newest — DB insertion order preserved */; break;
}
const total = results.length;
const start = (pageN - 1) * perN;
const page_results = results.slice(start, start + perN);
res.json({
total,
page: pageN,
per_page: perN,
pages: Math.ceil(total / perN),
products: page_results.map(p => ({
id: p.id,
title: p.title,
handle: p.handle,
dw_sku: p.dw_sku,
price: p.price,
image_url: p.image_url,
pattern_name: p.pattern_name,
tags: p.tags,
}))
});
});
// ── API: /api/product/:handle ────────────────────────────────────────────────
app.get('/api/product/:handle', (req, res) => {
const p = catalog.find(x => x.handle === req.params.handle);
if (!p) return res.status(404).json({ error: 'not found' });
res.json(p);
});
// ── API: /api/facets ─────────────────────────────────────────────────────────
app.get('/api/facets', (req, res) => {
const tagSet = new Set();
catalog.forEach(p => {
if (!p.tags) return;
p.tags.split(',').map(t => t.trim()).filter(Boolean).forEach(t => tagSet.add(t));
});
res.json({ total: catalog.length, tag_count: tagSet.size });
});
// ── API: health ───────────────────────────────────────────────────────────────
app.get('/health', (req, res) => res.json({ ok: true, products: catalog.length, ts: new Date().toISOString() }));
// ── SEO: sitemap.xml ────────────────────────────────────────────────────────
// Homepage + a deep-link per catalog product (search anchor on the DW store,
// where the products actually live). Built once from the loaded catalog.
const SITE_ORIGIN = process.env.SITE_ORIGIN || 'https://fentucci.com';
const DW_ORIGIN = 'https://designerwallcoverings.com';
let _sitemapCache = null;
function buildSitemap() {
const today = new Date().toISOString().slice(0, 10);
const xmlEsc = s => String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
const urls = [];
// Homepage (highest priority)
urls.push(` <url><loc>${SITE_ORIGIN}/</loc><lastmod>${today}</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url>`);
// One entry per product → its real DW product page
const seen = new Set();
catalog.forEach(p => {
if (!p.handle || seen.has(p.handle)) return;
seen.add(p.handle);
const loc = `${DW_ORIGIN}/products/${xmlEsc(p.handle)}`;
urls.push(` <url><loc>${loc}</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>`);
});
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
}
app.get('/sitemap.xml', (req, res) => {
if (!_sitemapCache) _sitemapCache = buildSitemap();
res.set('Content-Type', 'application/xml; charset=utf-8');
res.send(_sitemapCache);
});
// ── API: sample request email ─────────────────────────────────────────────────
app.post('/api/send-sample-request', (req, res) => {
const { name, email, company, address, city, state, zip, message } = req.body;
const requestedColorsRaw = req.body.requested_colors || req.body.requestedColors || '';
const requestedColors = String(requestedColorsRaw).split(',').map(s => s.trim()).filter(Boolean);
const product = req.body.product || '';
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required.' });
}
const subject = `Fentucci Sample Request${product ? ` — ${product}` : ''} — ${name}`;
const body = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333; border-bottom: 2px solid #8B6F47; padding-bottom: 10px;">
Fentucci Sample Request
</h2>
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
${product ? `<tr style="background:#faf8f5;"><td style="padding:10px;font-weight:bold;border:1px solid #ddd;width:140px;">Product</td><td style="padding:10px;border:1px solid #ddd;">${product}</td></tr>` : ''}
${requestedColors.length ? `<tr><td style="padding:10px;font-weight:bold;border:1px solid #ddd;">Colorways</td><td style="padding:10px;border:1px solid #ddd;">${requestedColors.join(', ')}</td></tr>` : ''}
<tr style="background:#faf8f5;"><td style="padding:10px;font-weight:bold;border:1px solid #ddd;">Name</td><td style="padding:10px;border:1px solid #ddd;">${name}</td></tr>
<tr><td style="padding:10px;font-weight:bold;border:1px solid #ddd;">Email</td><td style="padding:10px;border:1px solid #ddd;"><a href="mailto:${email}">${email}</a></td></tr>
<tr style="background:#faf8f5;"><td style="padding:10px;font-weight:bold;border:1px solid #ddd;">Company</td><td style="padding:10px;border:1px solid #ddd;">${company || 'Not provided'}</td></tr>
<tr><td style="padding:10px;font-weight:bold;border:1px solid #ddd;">Address</td><td style="padding:10px;border:1px solid #ddd;">${[address, city, state, zip].filter(Boolean).join(', ') || 'Not provided'}</td></tr>
<tr style="background:#faf8f5;"><td style="padding:10px;font-weight:bold;border:1px solid #ddd;">Message</td><td style="padding:10px;border:1px solid #ddd;">${(message || '').replace(/\n/g, '<br>') || 'None'}</td></tr>
</table>
<p style="color:#888;font-size:12px;margin-top:20px;">Sent from fentucci.com</p>
</div>`;
const payload = JSON.stringify({
to: process.env.MAIL_TO || 'info@fentucci.com',
cc: process.env.MAIL_CC || 'info@designerwallcoverings.com',
subject, body
});
const georgeReq = http.request({
hostname: '127.0.0.1', port: GEORGE_PORT, path: '/api/send', method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': GEORGE_AUTH, 'Content-Length': Buffer.byteLength(payload) },
timeout: 15000
}, georgeRes => {
let data = '';
georgeRes.on('data', c => data += c);
georgeRes.on('end', () => {
try {
const r = JSON.parse(data);
if (r.success) { res.json({ success: true }); }
else { res.status(500).json({ error: 'Email send failed.' }); }
} catch { res.status(500).json({ error: 'Email parse error.' }); }
});
});
georgeReq.on('error', e => { console.error('[form] George error:', e.message); res.status(500).json({ error: 'Email unavailable.' }); });
georgeReq.on('timeout', () => { georgeReq.destroy(); res.status(500).json({ error: 'Email timed out.' }); });
georgeReq.write(payload);
georgeReq.end();
});
// ── Static + SPA fallback ────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '7d', etag: true }));
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.listen(PORT, BIND, () => {
console.log(`fentucci.com server → http://${BIND}:${PORT} [${catalog.length} products]`);
});