← 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
A .gitignoreA deploy-kamatera.shA ecosystem.config.jsA package.jsonA public/css/site.cssA public/index.htmlA public/js/app.jsA public/robots.txtA scripts/refresh-catalog.shA server.js
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 & 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 • Trade pricing • 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 & Trade</a>
+ <a href="https://designerwallcoverings.com/pages/trade-program" target="_blank" rel="noopener noreferrer">Trade Program</a>
+ </div>
+ <p class="footer-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">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+}
+
+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">← 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 →</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 →