← 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
  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]`);
});