← back to Sample Box

server.js

248 lines

// Sample Box — paid curated swatch-box funnel for Designer Wallcoverings
// Zero-dep Node http. Extends the web-dev accelerator template.
// PORT=3903 node server.js  |  BASIC_AUTH=admin:DW2024! gates /admin
const http   = require('http');
const fs     = require('fs');
const path   = require('path');
const crypto = require('crypto');

const PORT        = parseInt(process.env.PORT || '3903', 10);
const BASIC_AUTH  = process.env.BASIC_AUTH || ''; // "user:pass" gates admin
const DIR         = __dirname;
const DATA_DIR    = path.join(DIR, 'data');
const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
const BOX_PRICE   = 15.00; // $15 sample box, fully credited to first roll order

// ── Shopify catalog (read-only static file — no token in server) ──────────────
const SHOPIFY_CATALOG_FILE = path.join(DATA_DIR, 'shopify-catalog.json');
let _shopifyCatalog = null;
function loadShopifyCatalog() {
  if (_shopifyCatalog) return _shopifyCatalog;
  try { _shopifyCatalog = JSON.parse(fs.readFileSync(SHOPIFY_CATALOG_FILE, 'utf8')); }
  catch { _shopifyCatalog = { products: [], checkout_domain: '', sample_variant_id: '', count: 0 }; }
  return _shopifyCatalog;
}

// ── Data helpers ──────────────────────────────────────────────────────────────
function loadProducts() {
  try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'products.json'), 'utf8')); }
  catch { return []; }
}
function loadOrders() {
  try { return JSON.parse(fs.readFileSync(ORDERS_FILE, 'utf8')); }
  catch { return []; }
}
function saveOrder(order) {
  const orders = loadOrders();
  orders.push(order);
  fs.writeFileSync(ORDERS_FILE, JSON.stringify(orders, null, 2));
}

// ── Sort ──────────────────────────────────────────────────────────────────────
function sortProducts(items, mode) {
  const a = items.slice();
  switch (mode) {
    case 'title':      return a.sort((x, y) => (x.title||'').localeCompare(y.title||''));
    case 'sku':        return a.sort((x, y) => (x.sku||'').localeCompare(y.sku||''));
    case 'price-asc':  return a.sort((x, y) => (x.price||0) - (y.price||0));
    case 'price-desc': return a.sort((x, y) => (y.price||0) - (x.price||0));
    default:           return a; // newest = natural order
  }
}
function sortOrders(items, mode) {
  const a = items.slice();
  if (mode === 'oldest') return a.sort((x,y) => new Date(x.created_at) - new Date(y.created_at));
  return a.sort((x,y) => new Date(y.created_at) - new Date(x.created_at));
}

// ── Auth (admin only) ─────────────────────────────────────────────────────────
function isAdmin(req) {
  if (!BASIC_AUTH) return true;
  const m = (req.headers.authorization||'').match(/^Basic\s+(.+)$/i);
  if (!m) return false;
  try { return Buffer.from(m[1],'base64').toString() === BASIC_AUTH; } catch { return false; }
}

// ── Body parser ───────────────────────────────────────────────────────────────
function readBody(req) {
  return new Promise((resolve, reject) => {
    let d = '';
    req.on('data', c => { d += c; if (d.length > 1e6) req.destroy(); });
    req.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); }});
    req.on('error', reject);
  });
}

// ── MIME ──────────────────────────────────────────────────────────────────────
const MIME = { '.html':'text/html; charset=utf-8', '.js':'application/javascript',
  '.css':'text/css', '.json':'application/json', '.png':'image/png',
  '.jpg':'image/jpeg', '.webp':'image/webp', '.svg':'image/svg+xml', '.ico':'image/x-icon' };

function serveFile(res, fp) {
  if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('not found'); }
  const ext = path.extname(fp);
  res.writeHead(200, { 'Content-Type': MIME[ext] || 'text/plain' });
  res.end(fs.readFileSync(fp));
}

// ── Server ────────────────────────────────────────────────────────────────────
http.createServer(async (req, res) => {
  const u  = new URL(req.url, `http://localhost:${PORT}`);
  const mt = req.method.toUpperCase();

  res.setHeader('Access-Control-Allow-Origin', '*');
  if (mt === 'OPTIONS') { res.writeHead(204); return res.end(); }

  // ── Public API ──────────────────────────────────────────────────────────────

  // GET /api/products?sort=newest&vibe=modern
  if (u.pathname === '/api/products' && mt === 'GET') {
    let products = loadProducts();
    const vibe = u.searchParams.get('vibe');
    if (vibe && vibe !== 'all') {
      products = products.filter(p => Array.isArray(p.vibes) && p.vibes.includes(vibe));
    }
    products = sortProducts(products, u.searchParams.get('sort') || 'newest');
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ count: products.length, products }));
  }

  // GET /api/vibes
  if (u.pathname === '/api/vibes' && mt === 'GET') {
    const products = loadProducts();
    const vibeSet  = new Set();
    products.forEach(p => (p.vibes||[]).forEach(v => vibeSet.add(v)));
    const ALL_VIBES = [
      { id:'modern',      label:'Modern & Minimal',    emoji:'⬜' },
      { id:'traditional', label:'Traditional',          emoji:'🏛' },
      { id:'floral',      label:'Floral & Botanical',   emoji:'🌿' },
      { id:'grasscloth',  label:'Grasscloth & Natural', emoji:'🌾' },
      { id:'bold-color',  label:'Bold Color',           emoji:'🎨' },
      { id:'geometric',   label:'Geometric',            emoji:'🔷' },
    ];
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ vibes: ALL_VIBES.filter(v => vibeSet.has(v.id)) }));
  }

  // GET /api/catalog-meta — returns checkout_domain + sample_variant_id (public, no token)
  if (u.pathname === '/api/catalog-meta' && mt === 'GET') {
    const cat = loadShopifyCatalog();
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({
      checkout_domain:   cat.checkout_domain   || '',
      sample_variant_id: cat.sample_variant_id || '',
      pulled_at:         cat.pulled_at         || '',
      shop_domain:       cat.shop_domain        || '',
    }));
  }

  // GET /api/shopify-products?sort=newest&q= — real catalog from shopify-catalog.json
  if (u.pathname === '/api/shopify-products' && mt === 'GET') {
    const cat = loadShopifyCatalog();
    let products = (cat.products || []).slice();
    const q = (u.searchParams.get('q') || '').toLowerCase().trim();
    if (q) {
      products = products.filter(p =>
        (p.title||'').toLowerCase().includes(q) ||
        (p.vendor||'').toLowerCase().includes(q) ||
        (p.sku||'').toLowerCase().includes(q) ||
        (p.type||'').toLowerCase().includes(q)
      );
    }
    const sortMode = u.searchParams.get('sort') || 'newest';
    switch (sortMode) {
      case 'title':      products.sort((a,b) => (a.title||'').localeCompare(b.title||'')); break;
      case 'sku':        products.sort((a,b) => (a.sku||'').localeCompare(b.sku||'')); break;
      case 'price-asc':  products.sort((a,b) => (a.price||0) - (b.price||0)); break;
      case 'price-desc': products.sort((a,b) => (b.price||0) - (a.price||0)); break;
      case 'vendor':     products.sort((a,b) => (a.vendor||'').localeCompare(b.vendor||'')); break;
      default: break; // newest = natural catalog order
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ count: products.length, products }));
  }

  // POST /api/orders — place a TEST order (no real charge)
  if (u.pathname === '/api/orders' && mt === 'POST') {
    const body = await readBody(req);
    const { vibe, swatches, shipping, email, payment_stub } = body;

    if (!email || !shipping || !Array.isArray(swatches) || !swatches.length) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: 'Missing: email, shipping, swatches' }));
    }

    const orderId = 'SB-' + Date.now().toString(36).toUpperCase();
    // Build Shopify cart permalink using sample_variant_id × swatch count
    const cat = loadShopifyCatalog();
    const checkoutDomain   = cat.checkout_domain   || 'www.designerwallcoverings.com';
    const sampleVariantId  = cat.sample_variant_id || '';
    const swatchQty        = swatches.length;
    const shopifyPermalink = sampleVariantId
      ? `https://${checkoutDomain}/cart/${sampleVariantId}:${swatchQty}`
      : null;
    const order   = {
      id:               orderId,
      vibe:             vibe || 'all',
      swatches,                         // [{sku,title,image,price,variant_id}]
      swatch_count:     swatches.length,
      box_price:        BOX_PRICE,
      credit_note:      `$${BOX_PRICE.toFixed(2)} credited toward your first roll order (ref: ${orderId})`,
      email,
      shipping,                         // {name,address1,address2,city,state,zip,country}
      payment_method:   'SHOPIFY_HOSTED',
      payment_status:   'pending_shopify',
      shopify_permalink: shopifyPermalink,
      status:           'pending_fulfillment',
      created_at:       new Date().toISOString(),
    };

    saveOrder(order);
    res.writeHead(201, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ success: true, order_id: orderId, order, shopify_permalink: shopifyPermalink }));
  }

  // ── Admin (gated) ───────────────────────────────────────────────────────────

  if (u.pathname.startsWith('/api/admin') || u.pathname === '/admin' || u.pathname === '/admin/') {
    if (!isAdmin(req)) {
      res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Sample Box Admin"' });
      return res.end('Admin auth required');
    }
  }

  // GET /api/admin/orders?sort=newest
  if (u.pathname === '/api/admin/orders' && mt === 'GET') {
    let orders = loadOrders();
    orders = sortOrders(orders, u.searchParams.get('sort') || 'newest');
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ count: orders.length, orders }));
  }

  // GET /api/admin/stats
  if (u.pathname === '/api/admin/stats' && mt === 'GET') {
    const orders  = loadOrders();
    const revenue = orders.reduce((s,o) => s + (o.box_price||0), 0);
    const swatches = orders.reduce((s,o) => s + (o.swatch_count||0), 0);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ total_orders: orders.length, total_revenue: revenue, total_swatches: swatches }));
  }

  // ── Static ──────────────────────────────────────────────────────────────────

  if (u.pathname === '/admin' || u.pathname === '/admin/') {
    return serveFile(res, path.join(DIR, 'public', 'admin.html'));
  }

  const fname = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
  const fp    = path.join(DIR, 'public', path.basename(fname));
  if (fs.existsSync(fp)) return serveFile(res, fp);

  res.writeHead(404); res.end('not found');

}).listen(PORT, '127.0.0.1', function () {
  console.log(`[sample-box] http://localhost:${this.address().port}`);
  console.log(`[sample-box] /admin — set BASIC_AUTH=user:pass to gate`);
  console.log(`[sample-box] BOX PRICE $${BOX_PRICE.toFixed(2)} — TEST MODE, no real charges`);
});