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