[object Object]

← back to Fentucci

initial scaffold: Fentucci DW-spec storefront (358 ACTIVE SKUs, port 9880)

7c3fc1be5f939f1fa7accbea2552d15ae05115ba · 2026-06-24 08:40:01 -0700 · Steve Abrams

Files touched

Diff

commit 7c3fc1be5f939f1fa7accbea2552d15ae05115ba
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 08:40:01 2026 -0700

    initial scaffold: Fentucci DW-spec storefront (358 ACTIVE SKUs, port 9880)
---
 .gitignore                 |  10 +
 deploy-kamatera.sh         |  67 +++++
 ecosystem.config.js        |  28 +++
 package.json               |  18 ++
 public/css/site.css        | 606 +++++++++++++++++++++++++++++++++++++++++++++
 public/index.html          | 172 +++++++++++++
 public/js/app.js           | 319 ++++++++++++++++++++++++
 public/robots.txt          |   5 +
 scripts/refresh-catalog.sh |  30 +++
 server.js                  | 197 +++++++++++++++
 10 files changed, 1452 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3979237
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.*
+*.log
+logs/
+tmp/
+.DS_Store
+dist/
+build/
+.next/
diff --git a/deploy-kamatera.sh b/deploy-kamatera.sh
new file mode 100755
index 0000000..71345c4
--- /dev/null
+++ b/deploy-kamatera.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+# deploy-kamatera.sh — Fentucci → Kamatera deploy
+# Usage: ./deploy-kamatera.sh [--dry-run] [--health-only]
+
+set -euo pipefail
+
+KAMATERA="root@45.61.58.125"
+LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
+REMOTE_DIR="/root/Projects/Designer-Wallcoverings/DW-Websites/Fentucci"
+PM2_NAME="fentucci-website"
+DOMAIN="fentucci.com"
+PORT="9880"
+
+DRY_RUN=false
+HEALTH_ONLY=false
+case "${1:-}" in
+  --dry-run)     DRY_RUN=true ;;
+  --health-only) HEALTH_ONLY=true ;;
+esac
+
+run() { $DRY_RUN && echo "[DRY-RUN] $*" || eval "$@"; }
+
+health_check() {
+  echo "=== health check ==="
+  echo -n "  origin direct (HTTP :$PORT):  "
+  curl -sI -o /dev/null -w "%{http_code}\n" -m 10 "http://45.61.58.125:$PORT/" || echo FAIL
+  echo -n "  HTTPS (direct IP):            "
+  curl -sI -o /dev/null -w "%{http_code}\n" -m 10 --resolve "fentucci.com:443:45.61.58.125" "https://$DOMAIN/" || echo FAIL
+  echo -n "  www subdomain:                "
+  curl -sI -o /dev/null -w "%{http_code}\n" -m 10 "https://www.$DOMAIN/" || echo FAIL
+  echo -n "  /health endpoint:             "
+  curl -s -m 10 "http://45.61.58.125:$PORT/health" | python3 -c "import json,sys; d=json.load(sys.stdin); print('OK —', d.get('products','?'), 'products')" 2>/dev/null || echo FAIL
+}
+
+$HEALTH_ONLY && { health_check; exit 0; }
+
+echo "=== SSH check ==="
+run "ssh -o ConnectTimeout=8 $KAMATERA 'hostname && uptime'"
+
+echo ""
+echo "=== rsync → $REMOTE_DIR ==="
+run "ssh $KAMATERA 'mkdir -p $REMOTE_DIR/data $REMOTE_DIR/logs $REMOTE_DIR/scripts'"
+run "rsync -az --delete \
+  --exclude=node_modules --exclude=.git --exclude='*.log' --exclude='.env' \
+  --exclude='logs/' --exclude='data/products.json' \
+  '$LOCAL_DIR/' '$KAMATERA:$REMOTE_DIR/'"
+
+echo ""
+echo "=== npm install ==="
+run "ssh $KAMATERA 'cd $REMOTE_DIR && npm install --omit=dev --no-fund --no-audit'"
+
+echo ""
+echo "=== catalog refresh ==="
+run "ssh $KAMATERA 'bash $REMOTE_DIR/scripts/refresh-catalog.sh'"
+
+echo ""
+echo "=== pm2 ==="
+run "ssh $KAMATERA 'cd $REMOTE_DIR && pm2 startOrReload ecosystem.config.js && pm2 save'"
+
+echo ""
+sleep 3
+health_check
+
+echo ""
+echo "=== deploy complete ==="
+echo "  Site:  https://$DOMAIN/"
+echo "  pm2:   ssh $KAMATERA 'pm2 logs $PM2_NAME --lines 30'"
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..0c9cf6d
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,28 @@
+module.exports = {
+  apps: [{
+    name: 'fentucci-website',
+    script: 'server.js',
+    cwd: '/root/Projects/Designer-Wallcoverings/DW-Websites/Fentucci',
+    env: {
+      PORT: 9880,
+      BIND: '127.0.0.1',
+      MAIL_TO: 'info@fentucci.com',
+      MAIL_CC: 'info@designerwallcoverings.com',
+      NODE_ENV: 'production'
+    },
+    instances: 1,
+    autorestart: true,
+    watch: false,
+    max_memory_restart: '512M',
+    error_file: './logs/err.log',
+    out_file:   './logs/out.log',
+    log_file:   './logs/combined.log',
+    time: true,
+    max_restarts: Infinity,
+    min_uptime: '5s',
+    restart_delay: 1000,
+    exp_backoff_restart_delay: 100,
+    cron_restart: '0 4 * * *',
+    kill_timeout: 5000
+  }]
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..15ac5c8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "fentucci",
+  "version": "1.0.0",
+  "description": "Fentucci — Natural fiber & grasscloth wallcoverings, a Designer Wallcoverings line",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "PORT=9880 node server.js"
+  },
+  "dependencies": {
+    "express": "^4.18.2"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "author": "Designer Wallcoverings",
+  "license": "UNLICENSED"
+}
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..eff9cbc
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,606 @@
+/* ============================================================
+   Fentucci — fentucci.com
+   Natural fiber wallcovering storefront
+   DW-family design system: earthy naturals palette
+   ============================================================ */
+
+/* ── Design tokens ───────────────────────────────────────────── */
+:root {
+  /* Palette — earthy naturals / grasscloth warmth */
+  --bg:          #faf7f2;
+  --ink:         #1c1a17;
+  --ink-soft:    #5a5448;
+  --ink-faint:   #8a8075;
+  --line:        rgba(28, 26, 23, 0.10);
+  --card-bg:     #ffffff;
+  --header-bg:   rgba(250, 247, 242, 0.94);
+
+  /* Brand accent — warm khaki/earth */
+  --accent:      #8B6F47;
+  --accent-dark: #6b5235;
+  --accent-bg:   #f4ede4;
+
+  /* Functional */
+  --hover-bg:    #f0ebe3;
+  --shadow-sm:   0 1px 3px rgba(0,0,0,.07);
+  --shadow-md:   0 4px 16px rgba(0,0,0,.10);
+  --radius-sm:   4px;
+  --radius-md:   8px;
+
+  /* Layout */
+  --container:   1280px;
+  --cols:        4;
+}
+
+/* ── Dark mode overrides ──────────────────────────────────────── */
+[data-theme="dark"] {
+  --bg:          #141210;
+  --ink:         #f0ece3;
+  --ink-soft:    #b6ad9a;
+  --ink-faint:   #7a7365;
+  --line:        rgba(240, 236, 227, 0.12);
+  --card-bg:     #1d1a16;
+  --header-bg:   rgba(20, 18, 16, 0.94);
+  --hover-bg:    #221e18;
+  --accent-bg:   #2a2219;
+}
+
+/* accent + status stays constant across themes */
+
+/* ── Reset + base ─────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { background: var(--bg); color-scheme: light dark; scroll-behavior: smooth; }
+html[data-theme="dark"] { color-scheme: dark; }
+body {
+  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  background: var(--bg);
+  color: var(--ink);
+  line-height: 1.55;
+  font-size: 16px;
+}
+a { color: var(--ink); text-decoration: none; }
+a:hover { color: var(--accent); }
+img { display: block; max-width: 100%; }
+button { cursor: pointer; font: inherit; border: none; background: none; }
+
+/* ── Container ────────────────────────────────────────────────── */
+.container {
+  max-width: var(--container);
+  margin: 0 auto;
+  padding: 0 2rem;
+}
+
+/* ── Header ───────────────────────────────────────────────────── */
+.site-header {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background: var(--header-bg);
+  backdrop-filter: blur(8px);
+  border-bottom: 1px solid var(--line);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.9rem 2rem;
+}
+
+.brand {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+}
+.brand-name {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 1.65rem;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  color: var(--ink);
+  line-height: 1;
+}
+.brand-sub {
+  font-size: 0.65rem;
+  font-weight: 500;
+  letter-spacing: 0.18em;
+  text-transform: uppercase;
+  color: var(--ink-soft);
+}
+
+.site-nav {
+  display: flex;
+  align-items: center;
+  gap: 1.75rem;
+}
+.site-nav a {
+  font-size: 0.85rem;
+  font-weight: 500;
+  letter-spacing: 0.02em;
+  color: var(--ink-soft);
+  transition: color 0.15s;
+}
+.site-nav a:hover { color: var(--accent); }
+
+/* ── Dark/light toggle ────────────────────────────────────────── */
+.theme-toggle {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2rem;
+  height: 2rem;
+  border-radius: 50%;
+  color: var(--ink-soft);
+  transition: color 0.15s, background 0.15s;
+}
+.theme-toggle:hover { background: var(--hover-bg); color: var(--ink); }
+/* Show sun in dark, moon in light */
+html:not([data-theme="dark"]) .icon-sun { display: none; }
+html[data-theme="dark"]       .icon-moon { display: none; }
+
+/* ── Buttons ──────────────────────────────────────────────────── */
+.btn {
+  display: inline-block;
+  padding: 0.65rem 1.5rem;
+  border-radius: var(--radius-sm);
+  font-size: 0.85rem;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+.btn-primary {
+  background: var(--accent);
+  color: #fff;
+  border: 1px solid var(--accent);
+}
+.btn-primary:hover {
+  background: var(--accent-dark);
+  border-color: var(--accent-dark);
+  color: #fff;
+}
+.btn-ghost {
+  background: transparent;
+  color: var(--ink);
+  border: 1px solid var(--line);
+}
+.btn-ghost:hover { background: var(--hover-bg); }
+
+/* ── Hero ─────────────────────────────────────────────────────── */
+.hero {
+  position: relative;
+  min-height: 80vh;
+  display: flex;
+  align-items: flex-end;
+  overflow: hidden;
+}
+.hero-bg {
+  position: absolute;
+  inset: 0;
+  background: linear-gradient(135deg, #3d2f1e 0%, #6b5235 40%, #8B6F47 70%, #b8956a 100%);
+  z-index: 0;
+}
+/* Product images rotate in via JS */
+.hero-bg.has-images { background: none; }
+.hero-bg img {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  opacity: 0;
+  transition: opacity 1.2s ease;
+}
+.hero-bg img.active { opacity: 1; }
+
+.hero-content {
+  position: relative;
+  z-index: 2;
+  width: 100%;
+  padding: 5rem 2rem 4rem;
+  background: linear-gradient(to top, rgba(20,18,14,0.72) 0%, rgba(20,18,14,0.28) 60%, transparent 100%);
+}
+.hero-eyebrow {
+  font-size: 0.72rem;
+  font-weight: 500;
+  letter-spacing: 0.2em;
+  text-transform: uppercase;
+  color: rgba(255,255,255,0.72);
+  margin-bottom: 0.6rem;
+}
+.hero-title {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: clamp(3.5rem, 8vw, 7rem);
+  font-weight: 600;
+  color: #fff;
+  line-height: 1;
+  letter-spacing: -0.01em;
+  margin-bottom: 0.5rem;
+}
+.hero-tagline {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: clamp(1.1rem, 2vw, 1.5rem);
+  color: rgba(255,255,255,0.88);
+  margin-bottom: 2rem;
+}
+.hero-ctas {
+  display: flex;
+  gap: 1rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.5rem;
+}
+.hero-service {
+  font-size: 0.8rem;
+  color: rgba(255,255,255,0.62);
+  letter-spacing: 0.03em;
+}
+
+/* ── Brand statement ──────────────────────────────────────────── */
+.brand-statement {
+  padding: 4rem 0;
+  text-align: center;
+  border-bottom: 1px solid var(--line);
+}
+.brand-statement h2 {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: clamp(1.8rem, 3vw, 2.5rem);
+  font-weight: 400;
+  margin-bottom: 1rem;
+}
+.brand-statement p {
+  max-width: 640px;
+  margin: 0 auto;
+  color: var(--ink-soft);
+  font-size: 1.05rem;
+  line-height: 1.7;
+}
+
+/* ── Catalog section ──────────────────────────────────────────── */
+.catalog-section {
+  padding: 3rem 0 5rem;
+}
+
+/* Controls bar */
+.controls-bar {
+  display: flex;
+  align-items: center;
+  gap: 1.25rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.5rem;
+  padding-bottom: 1rem;
+  border-bottom: 1px solid var(--line);
+}
+.search-wrap { flex: 1; min-width: 200px; }
+.search-input {
+  width: 100%;
+  padding: 0.55rem 0.9rem;
+  border: 1px solid var(--line);
+  border-radius: var(--radius-md);
+  background: var(--card-bg);
+  color: var(--ink);
+  font: inherit;
+  font-size: 0.9rem;
+  transition: border-color 0.15s;
+  outline: none;
+}
+.search-input:focus { border-color: var(--accent); }
+.search-input::placeholder { color: var(--ink-faint); }
+
+.sort-density-wrap {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  flex-shrink: 0;
+}
+.ctrl-label {
+  font-size: 0.78rem;
+  font-weight: 500;
+  color: var(--ink-soft);
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+}
+.ctrl-select {
+  padding: 0.45rem 0.75rem;
+  border: 1px solid var(--line);
+  border-radius: var(--radius-sm);
+  background: var(--card-bg);
+  color: var(--ink);
+  font: inherit;
+  font-size: 0.85rem;
+  cursor: pointer;
+  outline: none;
+}
+.ctrl-select:focus { border-color: var(--accent); }
+
+.density-slider {
+  width: 90px;
+  accent-color: var(--accent);
+  cursor: pointer;
+}
+
+/* Results meta */
+.results-meta {
+  display: flex;
+  justify-content: space-between;
+  font-size: 0.82rem;
+  color: var(--ink-faint);
+  margin-bottom: 1.25rem;
+}
+
+/* ── Product grid ─────────────────────────────────────────────── */
+.product-grid {
+  display: grid;
+  grid-template-columns: repeat(var(--cols), 1fr);
+  gap: 1.25rem;
+}
+
+/* Grid loading */
+.grid-loading {
+  grid-column: 1 / -1;
+  text-align: center;
+  padding: 4rem;
+  color: var(--ink-soft);
+}
+.spinner {
+  width: 36px; height: 36px;
+  border: 3px solid var(--line);
+  border-top-color: var(--accent);
+  border-radius: 50%;
+  animation: spin 0.7s linear infinite;
+  margin: 0 auto 1rem;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* Product card */
+.product-card {
+  background: var(--card-bg);
+  border-radius: var(--radius-md);
+  overflow: hidden;
+  box-shadow: var(--shadow-sm);
+  display: flex;
+  flex-direction: column;
+  transition: box-shadow 0.18s, transform 0.18s;
+}
+.product-card:hover {
+  box-shadow: var(--shadow-md);
+  transform: translateY(-2px);
+}
+.card-image-wrap {
+  position: relative;
+  aspect-ratio: 3/4;
+  background: var(--accent-bg);
+  overflow: hidden;
+}
+.card-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  transition: transform 0.3s ease;
+}
+.product-card:hover .card-img { transform: scale(1.04); }
+.card-img-placeholder {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--ink-faint);
+  font-size: 0.75rem;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+.card-body {
+  padding: 0.85rem 0.9rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.3rem;
+  flex: 1;
+}
+.card-title {
+  font-size: 0.88rem;
+  font-weight: 600;
+  line-height: 1.3;
+  color: var(--ink);
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+.card-sku {
+  font-size: 0.72rem;
+  color: var(--ink-faint);
+  font-family: 'Inter', monospace;
+  letter-spacing: 0.04em;
+}
+.card-price {
+  font-size: 0.82rem;
+  color: var(--ink-soft);
+  margin-top: auto;
+  padding-top: 0.4rem;
+}
+.card-price .sample-price { color: var(--accent); font-weight: 600; }
+.card-actions {
+  padding: 0 0.9rem 0.85rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+.card-btn {
+  display: block;
+  text-align: center;
+  padding: 0.5rem;
+  border-radius: var(--radius-sm);
+  font-size: 0.8rem;
+  font-weight: 600;
+  letter-spacing: 0.03em;
+  transition: background 0.15s, color 0.15s;
+}
+.card-btn-sample {
+  background: var(--accent-bg);
+  color: var(--accent-dark);
+  border: 1px solid var(--accent);
+}
+.card-btn-sample:hover { background: var(--accent); color: #fff; }
+.card-btn-shop {
+  background: var(--hover-bg);
+  color: var(--ink-soft);
+  border: 1px solid var(--line);
+}
+.card-btn-shop:hover { background: var(--hover-bg); color: var(--ink); }
+
+/* Empty / no results */
+.grid-empty {
+  grid-column: 1 / -1;
+  text-align: center;
+  padding: 4rem;
+  color: var(--ink-soft);
+}
+.grid-empty h3 { margin-bottom: 0.5rem; font-weight: 500; }
+
+/* ── Pagination ───────────────────────────────────────────────── */
+.pagination {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 0.4rem;
+  margin-top: 2.5rem;
+  flex-wrap: wrap;
+}
+.page-btn {
+  padding: 0.45rem 0.85rem;
+  border-radius: var(--radius-sm);
+  font-size: 0.85rem;
+  border: 1px solid var(--line);
+  background: var(--card-bg);
+  color: var(--ink);
+  transition: background 0.12s, border-color 0.12s;
+}
+.page-btn:hover { background: var(--hover-bg); border-color: var(--accent); }
+.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+.page-btn:disabled { opacity: 0.4; pointer-events: none; }
+
+/* ── Footer ───────────────────────────────────────────────────── */
+.site-footer {
+  border-top: 1px solid var(--line);
+  padding: 2.5rem 0;
+  margin-top: 2rem;
+}
+.footer-inner {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 1rem;
+  text-align: center;
+}
+.footer-name {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 1.2rem;
+  font-weight: 600;
+  display: block;
+  letter-spacing: 0.05em;
+}
+.footer-tagline {
+  font-size: 0.72rem;
+  letter-spacing: 0.15em;
+  text-transform: uppercase;
+  color: var(--ink-faint);
+}
+.footer-links {
+  display: flex;
+  gap: 1.5rem;
+  flex-wrap: wrap;
+  justify-content: center;
+}
+.footer-links a { font-size: 0.85rem; color: var(--ink-soft); }
+.footer-links a:hover { color: var(--accent); }
+.footer-copy { font-size: 0.78rem; color: var(--ink-faint); }
+
+/* ── Modal ────────────────────────────────────────────────────── */
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,0.55);
+  backdrop-filter: blur(4px);
+  z-index: 500;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1rem;
+}
+.modal-overlay[hidden] { display: none; }
+.modal-box {
+  background: var(--card-bg);
+  border-radius: var(--radius-md);
+  padding: 2rem;
+  max-width: 520px;
+  width: 100%;
+  max-height: 90vh;
+  overflow-y: auto;
+  position: relative;
+  box-shadow: var(--shadow-md);
+}
+.modal-close {
+  position: absolute;
+  top: 1rem;
+  right: 1rem;
+  width: 2rem;
+  height: 2rem;
+  border-radius: 50%;
+  font-size: 1.25rem;
+  color: var(--ink-soft);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.modal-close:hover { background: var(--hover-bg); }
+.modal-box h2 {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 1.5rem;
+  margin-bottom: 0.4rem;
+}
+.modal-intro { font-size: 0.9rem; color: var(--ink-soft); margin-bottom: 1.5rem; }
+
+.form-row { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; }
+.form-row label { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft); letter-spacing: 0.03em; }
+.form-row input, .form-row textarea {
+  padding: 0.5rem 0.75rem;
+  border: 1px solid var(--line);
+  border-radius: var(--radius-sm);
+  background: var(--bg);
+  color: var(--ink);
+  font: inherit;
+  font-size: 0.9rem;
+  transition: border-color 0.15s;
+  outline: none;
+}
+.form-row input:focus, .form-row textarea:focus { border-color: var(--accent); }
+.form-row textarea { resize: vertical; min-height: 70px; }
+.form-row-inline { flex-direction: row; gap: 0.75rem; }
+.form-row-inline > div { flex: 1; display: flex; flex-direction: column; gap: 0.3rem; }
+.form-row-inline > div:first-child { flex: 2; }
+.form-row-inline label { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft); }
+.form-row-inline input { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--bg); color: var(--ink); font: inherit; font-size: 0.9rem; outline: none; }
+.form-row-inline input:focus { border-color: var(--accent); }
+.form-submit { width: 100%; margin-top: 0.5rem; }
+.form-status { font-size: 0.85rem; color: var(--ink-soft); margin-top: 0.75rem; min-height: 1.2em; text-align: center; }
+.form-status.success { color: #1f6f3e; }
+.form-status.error   { color: #b94a48; }
+
+/* ── Responsive ───────────────────────────────────────────────── */
+@media (max-width: 1100px) { :root { --cols: 3; } }
+@media (max-width: 780px) {
+  :root { --cols: 2; }
+  .site-header { padding: 0.75rem 1rem; }
+  .container { padding: 0 1rem; }
+  .controls-bar { flex-direction: column; align-items: stretch; }
+  .sort-density-wrap { flex-wrap: wrap; gap: 0.4rem; }
+  .hero-content { padding: 3rem 1rem 2.5rem; }
+}
+@media (max-width: 480px) {
+  :root { --cols: 1; }
+  .hero-ctas { flex-direction: column; }
+  .footer-links { flex-direction: column; gap: 0.6rem; }
+}
+
+/* ── Utility ──────────────────────────────────────────────────── */
+.visually-hidden {
+  position: absolute; width: 1px; height: 1px; padding: 0;
+  margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..06e1636
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,172 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <!-- Anti-flash dark/light inline script — must be first -->
+  <script>(function(){try{var t=localStorage.getItem('fentucci-theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
+  <title>Fentucci Natural Wallcoverings — Grasscloth &amp; Natural Fiber | Designer Wallcoverings</title>
+  <meta name="description" content="Explore Fentucci's collection of natural fiber and grasscloth wallcoverings, available through Designer Wallcoverings. Free memo samples. Trade service. Expert hand-holding from selection to install.">
+  <meta property="og:title" content="Fentucci Natural Wallcoverings — Designer Wallcoverings">
+  <meta property="og:description" content="Natural fiber, grasscloth, and woven wallcoverings from Fentucci. Shop with free memo samples and DW trade service.">
+  <meta name="theme-color" content="#faf7f2" media="(prefers-color-scheme: light)">
+  <meta name="theme-color" content="#141210" media="(prefers-color-scheme: dark)">
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400;1,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="/css/site.css">
+  <!-- GA4 placeholder — inject measurement ID when provisioned -->
+  <!-- <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> -->
+</head>
+<body>
+
+<!-- ── Header ──────────────────────────────────────────────────────────────── -->
+<header class="site-header">
+  <a href="/" class="brand">
+    <span class="brand-name">Fentucci</span>
+    <span class="brand-sub">Natural Wallcoverings</span>
+  </a>
+  <nav class="site-nav">
+    <a href="/#catalog">Shop</a>
+    <a href="https://designerwallcoverings.com" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
+    <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark / light">
+      <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
+      <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
+    </button>
+  </nav>
+</header>
+
+<!-- ── Hero ───────────────────────────────────────────────────────────────── -->
+<section class="hero" id="hero" aria-label="Fentucci wallcoverings hero">
+  <div class="hero-bg" id="hero-bg"></div>
+  <div class="hero-content">
+    <p class="hero-eyebrow">Designer Wallcoverings presents</p>
+    <h1 class="hero-title">Fentucci</h1>
+    <p class="hero-tagline"><em>Natural fiber. Woven texture. Enduring warmth.</em></p>
+    <div class="hero-ctas">
+      <a href="/#catalog" class="btn btn-primary">Explore the Collection</a>
+      <a href="https://designerwallcoverings.com/products/search?q=fentucci" target="_blank" rel="noopener noreferrer" class="btn btn-ghost">Shop on DW</a>
+    </div>
+    <p class="hero-service">Free memo samples &bull; Trade pricing &bull; Expert guidance</p>
+  </div>
+</section>
+
+<!-- ── Brand Statement ────────────────────────────────────────────────────── -->
+<section class="brand-statement">
+  <div class="container">
+    <h2>Where Nature Meets the Wall</h2>
+    <p>Fentucci's collection celebrates the quiet luxury of natural materials — grasscloth, jute, sisal, seagrass, and woven fibers that bring authentic texture and warmth to any space. Each pattern is available through Designer Wallcoverings with free memo samples and hands-on trade service.</p>
+  </div>
+</section>
+
+<!-- ── Catalog ────────────────────────────────────────────────────────────── -->
+<main id="catalog" class="catalog-section">
+  <div class="container">
+    <!-- Controls bar -->
+    <div class="controls-bar" role="search">
+      <div class="search-wrap">
+        <input type="search" id="search-input" class="search-input" placeholder="Search by name, colorway, SKU…" aria-label="Search products">
+      </div>
+      <div class="sort-density-wrap">
+        <label class="ctrl-label" for="sort-select">Sort</label>
+        <select id="sort-select" class="ctrl-select" aria-label="Sort products">
+          <option value="newest">Newest</option>
+          <option value="color">By Color</option>
+          <option value="style">By Style</option>
+          <option value="title">A → Z</option>
+          <option value="title_desc">Z → A</option>
+          <option value="price_asc">Price ↑</option>
+          <option value="price_desc">Price ↓</option>
+          <option value="sku">SKU A → Z</option>
+        </select>
+        <label class="ctrl-label" for="density-slider">Density</label>
+        <input type="range" id="density-slider" class="density-slider" min="2" max="6" value="4" step="1" aria-label="Grid column count">
+      </div>
+    </div>
+
+    <!-- Results meta -->
+    <div class="results-meta" aria-live="polite">
+      <span id="results-count"></span>
+      <span id="page-info"></span>
+    </div>
+
+    <!-- Product grid -->
+    <div class="product-grid" id="product-grid" role="list">
+      <div class="grid-loading" id="grid-loading" aria-label="Loading products">
+        <div class="spinner"></div>
+        <p>Loading collection…</p>
+      </div>
+    </div>
+
+    <!-- Pagination -->
+    <nav class="pagination" id="pagination" aria-label="Product pages"></nav>
+  </div>
+</main>
+
+<!-- ── Footer ─────────────────────────────────────────────────────────────── -->
+<footer class="site-footer">
+  <div class="container footer-inner">
+    <div class="footer-brand">
+      <span class="footer-name">Fentucci</span>
+      <span class="footer-tagline">Natural Wallcoverings</span>
+    </div>
+    <div class="footer-links">
+      <a href="https://designerwallcoverings.com" target="_blank" rel="noopener noreferrer">Designer Wallcoverings</a>
+      <a href="https://designerwallcoverings.com/pages/contact" target="_blank" rel="noopener noreferrer">Contact &amp; Trade</a>
+      <a href="https://designerwallcoverings.com/pages/trade-program" target="_blank" rel="noopener noreferrer">Trade Program</a>
+    </div>
+    <p class="footer-copy">&copy; <span id="year"></span> Designer Wallcoverings. All rights reserved.</p>
+  </div>
+</footer>
+
+<!-- ── Sample request modal ───────────────────────────────────────────────── -->
+<div id="sample-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="modal-title" hidden>
+  <div class="modal-box">
+    <button class="modal-close" id="modal-close" aria-label="Close sample request form">&times;</button>
+    <h2 id="modal-title">Order a Free Memo Sample</h2>
+    <p class="modal-intro">Fill out the form and we'll send a complimentary memo sample directly to you. No charge, no commitment.</p>
+    <input type="hidden" id="modal-product-title">
+    <form id="sample-form" novalidate>
+      <div class="form-row">
+        <label for="f-name">Full Name <span aria-hidden="true">*</span></label>
+        <input type="text" id="f-name" name="name" required autocomplete="name" placeholder="Jane Smith">
+      </div>
+      <div class="form-row">
+        <label for="f-email">Email <span aria-hidden="true">*</span></label>
+        <input type="email" id="f-email" name="email" required autocomplete="email" placeholder="jane@firm.com">
+      </div>
+      <div class="form-row">
+        <label for="f-company">Company / Firm</label>
+        <input type="text" id="f-company" name="company" autocomplete="organization" placeholder="Optional">
+      </div>
+      <div class="form-row">
+        <label for="f-address">Shipping Address</label>
+        <input type="text" id="f-address" name="address" autocomplete="street-address" placeholder="123 Main St">
+      </div>
+      <div class="form-row form-row-inline">
+        <div>
+          <label for="f-city">City</label>
+          <input type="text" id="f-city" name="city" autocomplete="address-level2" placeholder="Los Angeles">
+        </div>
+        <div>
+          <label for="f-state">State</label>
+          <input type="text" id="f-state" name="state" maxlength="2" autocomplete="address-level1" placeholder="CA">
+        </div>
+        <div>
+          <label for="f-zip">ZIP</label>
+          <input type="text" id="f-zip" name="zip" maxlength="10" autocomplete="postal-code" placeholder="90210">
+        </div>
+      </div>
+      <div class="form-row">
+        <label for="f-message">Project Details</label>
+        <textarea id="f-message" name="message" rows="3" placeholder="Room type, yardage estimate, timeline…"></textarea>
+      </div>
+      <button type="submit" class="btn btn-primary form-submit" id="form-submit">Send Sample Request</button>
+      <p class="form-status" id="form-status" aria-live="polite"></p>
+    </form>
+  </div>
+</div>
+
+<script src="/js/app.js"></script>
+</body>
+</html>
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..dbe6abe
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,319 @@
+/* fentucci.com — storefront app */
+'use strict';
+
+// ── State ──────────────────────────────────────────────────────
+const state = {
+  page: 1,
+  sort: localStorage.getItem('fentucci-sort') || 'newest',
+  density: parseInt(localStorage.getItem('fentucci-density') || '4', 10),
+  q: '',
+  loading: false
+};
+
+// ── DOM refs ───────────────────────────────────────────────────
+const grid       = document.getElementById('product-grid');
+const pagination = document.getElementById('pagination');
+const countEl    = document.getElementById('results-count');
+const pageInfoEl = document.getElementById('page-info');
+const searchEl   = document.getElementById('search-input');
+const sortEl     = document.getElementById('sort-select');
+const densityEl  = document.getElementById('density-slider');
+
+// ── Dark/light toggle ──────────────────────────────────────────
+(function () {
+  const btn = document.getElementById('theme-toggle');
+  if (!btn) return;
+  function sync() {
+    const t = document.documentElement.getAttribute('data-theme');
+    btn.setAttribute('aria-pressed', t === 'dark' ? 'true' : 'false');
+  }
+  sync();
+  btn.addEventListener('click', function () {
+    const cur = document.documentElement.getAttribute('data-theme') || 'light';
+    const nxt = cur === 'dark' ? 'light' : 'dark';
+    document.documentElement.setAttribute('data-theme', nxt);
+    try { localStorage.setItem('fentucci-theme', nxt); } catch (e) {}
+    sync();
+  });
+})();
+
+// ── Year ────────────────────────────────────────────────────────
+const yearEl = document.getElementById('year');
+if (yearEl) yearEl.textContent = new Date().getFullYear();
+
+// ── Hero rotation ───────────────────────────────────────────────
+(function () {
+  const heroBg = document.getElementById('hero-bg');
+  if (!heroBg) return;
+  // Fetch a handful of products to populate the hero
+  fetch('/api/products?per_page=12&sort=newest')
+    .then(r => r.json())
+    .then(data => {
+      const imgs = (data.products || [])
+        .filter(p => p.image_url)
+        .slice(0, 7);
+      if (!imgs.length) return;
+      heroBg.classList.add('has-images');
+      imgs.forEach((p, i) => {
+        const el = document.createElement('img');
+        el.src = p.image_url;
+        el.alt = p.title || '';
+        el.loading = 'lazy';
+        if (i === 0) el.classList.add('active');
+        heroBg.appendChild(el);
+      });
+      let cur = 0;
+      setInterval(() => {
+        const all = heroBg.querySelectorAll('img');
+        all[cur].classList.remove('active');
+        cur = (cur + 1) % all.length;
+        all[cur].classList.add('active');
+      }, 4500);
+    })
+    .catch(() => {/* gradient fallback already in CSS */});
+})();
+
+// ── Controls init ───────────────────────────────────────────────
+if (sortEl) {
+  sortEl.value = state.sort;
+  sortEl.addEventListener('change', () => {
+    state.sort = sortEl.value;
+    state.page = 1;
+    localStorage.setItem('fentucci-sort', state.sort);
+    loadProducts();
+  });
+}
+if (densityEl) {
+  densityEl.value = state.density;
+  applyDensity(state.density);
+  densityEl.addEventListener('input', () => {
+    state.density = parseInt(densityEl.value, 10);
+    localStorage.setItem('fentucci-density', state.density);
+    applyDensity(state.density);
+  });
+}
+function applyDensity(n) {
+  document.documentElement.style.setProperty('--cols', Math.max(1, Math.min(8, n)));
+}
+
+// ── Search ──────────────────────────────────────────────────────
+let searchTimer;
+if (searchEl) {
+  searchEl.addEventListener('input', () => {
+    clearTimeout(searchTimer);
+    searchTimer = setTimeout(() => {
+      state.q = searchEl.value.trim();
+      state.page = 1;
+      loadProducts();
+    }, 280);
+  });
+}
+
+// ── Fetch + render ──────────────────────────────────────────────
+function loadProducts() {
+  if (state.loading) return;
+  state.loading = true;
+
+  grid.innerHTML = '<div class="grid-loading" aria-label="Loading products"><div class="spinner"></div><p>Loading…</p></div>';
+  pagination.innerHTML = '';
+  if (countEl) countEl.textContent = '';
+  if (pageInfoEl) pageInfoEl.textContent = '';
+
+  const params = new URLSearchParams({
+    sort: state.sort,
+    page: state.page,
+    per_page: 60,
+    q: state.q
+  });
+
+  fetch('/api/products?' + params)
+    .then(r => r.json())
+    .then(data => {
+      state.loading = false;
+      renderGrid(data);
+    })
+    .catch(err => {
+      state.loading = false;
+      grid.innerHTML = '<div class="grid-empty"><h3>Error loading catalog</h3><p>Please refresh the page.</p></div>';
+      console.error('[app] fetch error:', err);
+    });
+}
+
+function renderGrid(data) {
+  const { total, page, pages, products } = data;
+
+  // meta
+  if (countEl) countEl.textContent = `${total.toLocaleString()} product${total !== 1 ? 's' : ''}`;
+  if (pageInfoEl && pages > 1) pageInfoEl.textContent = `Page ${page} of ${pages}`;
+
+  // empty
+  if (!products || !products.length) {
+    grid.innerHTML = '<div class="grid-empty"><h3>No products found</h3><p>Try a different search or sort.</p></div>';
+    return;
+  }
+
+  // cards
+  grid.innerHTML = products.map(p => cardHTML(p)).join('');
+
+  // pagination
+  renderPagination(page, pages);
+
+  // scroll to catalog (only on page change, not initial load)
+  if (state.page > 1) {
+    document.getElementById('catalog').scrollIntoView({ behavior: 'smooth', block: 'start' });
+  }
+}
+
+function cardHTML(p) {
+  const dw_handle = p.handle || '';
+  const shopUrl = `https://designerwallcoverings.com/products/${dw_handle}`;
+  const sampleUrl = `${shopUrl}#sample`;
+  const price = parseFloat(p.price);
+  const priceStr = price && price > 0
+    ? (price === 4.25 ? '<span class="sample-price">$4.25 sample</span>' : `From $${price.toFixed(2)}`)
+    : '';
+
+  const img = p.image_url
+    ? `<img class="card-img" src="${esc(p.image_url)}" alt="${esc(p.title || '')}" loading="lazy" width="400" height="533">`
+    : `<div class="card-img-placeholder">No image</div>`;
+
+  const sku = p.dw_sku || p.mfr_sku || '';
+
+  return `<article class="product-card" role="listitem">
+  <a href="${esc(shopUrl)}" target="_blank" rel="noopener noreferrer" class="card-image-wrap" tabindex="-1" aria-label="${esc(p.title || 'Product')}">
+    ${img}
+  </a>
+  <div class="card-body">
+    <a href="${esc(shopUrl)}" target="_blank" rel="noopener noreferrer" class="card-title">${esc(p.title || '')}</a>
+    ${sku ? `<span class="card-sku">${esc(sku)}</span>` : ''}
+    ${priceStr ? `<div class="card-price">${priceStr}</div>` : ''}
+  </div>
+  <div class="card-actions">
+    <a href="${esc(sampleUrl)}" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-sample" title="Order a free memo sample of ${esc(p.title || 'this product')}">Order Memo Sample (free)</a>
+    <a href="${esc(shopUrl)}" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-shop">Shop on DW</a>
+  </div>
+</article>`;
+}
+
+function esc(str) {
+  return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function renderPagination(page, pages) {
+  if (pages <= 1) { pagination.innerHTML = ''; return; }
+
+  const maxVisible = 7;
+  let html = '';
+
+  html += `<button class="page-btn" ${page===1?'disabled':''} data-page="${page-1}" aria-label="Previous page">&larr; Prev</button>`;
+
+  // page numbers
+  const half = Math.floor(maxVisible / 2);
+  let start = Math.max(1, page - half);
+  let end   = Math.min(pages, start + maxVisible - 1);
+  if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
+
+  if (start > 1) {
+    html += `<button class="page-btn" data-page="1">1</button>`;
+    if (start > 2) html += `<span class="page-btn" style="pointer-events:none;opacity:.4">…</span>`;
+  }
+  for (let i = start; i <= end; i++) {
+    html += `<button class="page-btn${i===page?' active':''}" data-page="${i}"${i===page?' aria-current="page"':''}>${i}</button>`;
+  }
+  if (end < pages) {
+    if (end < pages - 1) html += `<span class="page-btn" style="pointer-events:none;opacity:.4">…</span>`;
+    html += `<button class="page-btn" data-page="${pages}">${pages}</button>`;
+  }
+
+  html += `<button class="page-btn" ${page===pages?'disabled':''} data-page="${page+1}" aria-label="Next page">Next &rarr;</button>`;
+
+  pagination.innerHTML = html;
+  pagination.querySelectorAll('[data-page]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      state.page = parseInt(btn.dataset.page, 10);
+      loadProducts();
+    });
+  });
+}
+
+// ── Sample request modal ────────────────────────────────────────
+(function () {
+  const modal     = document.getElementById('sample-modal');
+  const closeBtn  = document.getElementById('modal-close');
+  const form      = document.getElementById('sample-form');
+  const statusEl  = document.getElementById('form-status');
+  const submitBtn = document.getElementById('form-submit');
+  const titleHid  = document.getElementById('modal-product-title');
+
+  if (!modal) return;
+
+  function openModal(productTitle) {
+    if (titleHid) titleHid.value = productTitle || '';
+    const h2 = modal.querySelector('#modal-title');
+    if (h2) h2.textContent = productTitle ? `Order a Free Memo Sample — ${productTitle}` : 'Order a Free Memo Sample';
+    modal.removeAttribute('hidden');
+    document.body.style.overflow = 'hidden';
+    modal.querySelector('input[name="name"]')?.focus();
+    if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status'; }
+  }
+  function closeModal() {
+    modal.setAttribute('hidden', '');
+    document.body.style.overflow = '';
+  }
+
+  if (closeBtn) closeBtn.addEventListener('click', closeModal);
+  modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
+  document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modal.hasAttribute('hidden')) closeModal(); });
+
+  // expose for card clicks
+  window.__openSampleModal = openModal;
+
+  if (form) {
+    form.addEventListener('submit', e => {
+      e.preventDefault();
+      const name  = form.querySelector('[name="name"]').value.trim();
+      const email = form.querySelector('[name="email"]').value.trim();
+      if (!name || !email) {
+        if (statusEl) { statusEl.textContent = 'Please fill in your name and email.'; statusEl.className = 'form-status error'; }
+        return;
+      }
+      if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Sending…'; }
+      if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status'; }
+
+      const payload = {
+        name, email,
+        company:  form.querySelector('[name="company"]')?.value.trim() || '',
+        address:  form.querySelector('[name="address"]')?.value.trim() || '',
+        city:     form.querySelector('[name="city"]')?.value.trim() || '',
+        state:    form.querySelector('[name="state"]')?.value.trim() || '',
+        zip:      form.querySelector('[name="zip"]')?.value.trim() || '',
+        message:  form.querySelector('[name="message"]')?.value.trim() || '',
+        product:  titleHid?.value || ''
+      };
+
+      fetch('/api/send-sample-request', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload)
+      })
+        .then(r => r.json())
+        .then(data => {
+          if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send Sample Request'; }
+          if (data.success) {
+            if (statusEl) { statusEl.textContent = 'Your sample request has been sent. We\'ll follow up shortly.'; statusEl.className = 'form-status success'; }
+            form.reset();
+            setTimeout(closeModal, 3500);
+          } else {
+            if (statusEl) { statusEl.textContent = data.error || 'Send failed — please try again.'; statusEl.className = 'form-status error'; }
+          }
+        })
+        .catch(() => {
+          if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send Sample Request'; }
+          if (statusEl) { statusEl.textContent = 'Network error — please try again.'; statusEl.className = 'form-status error'; }
+        });
+    });
+  }
+})();
+
+// ── Boot ────────────────────────────────────────────────────────
+loadProducts();
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..c818b74
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,5 @@
+User-agent: *
+Allow: /
+Disallow: /api/
+
+Sitemap: https://fentucci.com/sitemap.xml
diff --git a/scripts/refresh-catalog.sh b/scripts/refresh-catalog.sh
new file mode 100755
index 0000000..9ac30b6
--- /dev/null
+++ b/scripts/refresh-catalog.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# refresh-catalog.sh — pull a fresh products.json snapshot from dw_unified
+# Run on Kamatera: bash /root/Projects/Designer-Wallcoverings/DW-Websites/Fentucci/scripts/refresh-catalog.sh
+
+set -euo pipefail
+
+DIR="$(cd "$(dirname "$0")/.." && pwd)"
+OUT="$DIR/data/products.json"
+
+echo "[catalog] refreshing from dw_unified → $OUT"
+
+sudo -u postgres psql -d dw_unified -c "
+COPY (
+  SELECT json_agg(row_to_json(p)) FROM (
+    SELECT
+      id, title, vendor, handle, dw_sku, mfr_sku,
+      price::numeric as price, image_url, tags,
+      pattern_name, product_type
+    FROM shopify_products
+    WHERE vendor = 'Fentucci' AND status = 'ACTIVE'
+    ORDER BY title
+  ) p
+) TO STDOUT;
+" > "$OUT"
+
+COUNT=$(python3 -c "import json,sys; d=json.load(open('$OUT')); print(len(d) if isinstance(d,list) else 0)" 2>/dev/null || echo "?")
+echo "[catalog] wrote $COUNT products to $OUT"
+
+# Reload pm2 to pick up new catalog
+pm2 reload fentucci-website 2>/dev/null && echo "[pm2] reloaded" || echo "[pm2] not running — start manually"
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..533130f
--- /dev/null
+++ b/server.js
@@ -0,0 +1,197 @@
+/**
+ * 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() }));
+
+// ── 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]`);
+});

(oldest)  ·  back to Fentucci  ·  add Python catalog refresh script + updated shell wrapper 82ff46b →