← back to Ffepurchasing
snapshot: 5 file(s) changed, +3 new, ~2 modified
24cf53670fc2253d8df5d1cbcea0ad45c9346886 · 2026-05-13 08:57:53 -0700 · Steve
Files touched
A data/trade-inquiries.jsonlM public/index.htmlA public/index.html.bak.ns-canonicalA public/trade-account.htmlM server.js
Diff
commit 24cf53670fc2253d8df5d1cbcea0ad45c9346886
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 13 08:57:53 2026 -0700
snapshot: 5 file(s) changed, +3 new, ~2 modified
---
data/trade-inquiries.jsonl | 1 +
public/index.html | 339 +++++------
public/index.html.bak.ns-canonical | 1118 ++++++++++++++++++++++++++++++++++++
public/trade-account.html | 141 +++++
server.js | 28 +
5 files changed, 1465 insertions(+), 162 deletions(-)
diff --git a/data/trade-inquiries.jsonl b/data/trade-inquiries.jsonl
new file mode 100644
index 0000000..16d71f4
--- /dev/null
+++ b/data/trade-inquiries.jsonl
@@ -0,0 +1 @@
+{"ts":"2026-05-10T07:37:15.959Z","ip":"127.0.0.1","email":"test@example.com","company":"Test FF&E Co","name":"Test User","role":"Hospitality Buyer","ua":"curl/8.7.1"}
diff --git a/public/index.html b/public/index.html
index 816451d..fd8353b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -147,6 +147,45 @@ footer { padding:64px 32px 32px; border-top:1px solid var(--line); margin-top:32
.theme-toggle { background:transparent; border:1px solid var(--line); width:32px; height:32px; cursor:pointer; color:var(--paper); display:inline-flex; align-items:center; justify-content:center; font-size:14px; line-height:1; transition:all .15s; padding:0 }
.theme-toggle:hover { border-color:var(--accent); color:var(--accent) }
+/* ===== B2B Hero pitch (FFE-specific copy + CTAs) ===== */
+.hero-pitch { position:absolute; left:32px; right:32px; top:50%; transform:translateY(-50%); max-width:780px; z-index:6; color:#fff }
+.hero-h1 { font-family:'Inter Tight','Inter',sans-serif; font-weight:600; font-size:clamp(36px,6vw,72px); line-height:1.02; letter-spacing:-0.02em; color:#fff; margin:0 0 18px; text-shadow:0 2px 12px rgba(0,0,0,0.55) }
+.hero-sub { font-family:'Inter',sans-serif; font-weight:400; font-size:clamp(15px,1.5vw,19px); line-height:1.5; color:#fff; opacity:0.92; max-width:62ch; margin:0 0 28px; text-shadow:0 1px 6px rgba(0,0,0,0.45) }
+.hero-cta-row { display:flex; flex-wrap:wrap; gap:12px; align-items:center }
+.hero-cta-primary { display:inline-flex; align-items:center; gap:10px; padding:16px 26px; background:var(--accent); color:#101010; border:1px solid var(--accent); font-family:'Inter Tight',sans-serif; font-size:11px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; text-decoration:none; transition:transform .15s, opacity .15s }
+.hero-cta-primary:hover { opacity:0.92; transform:translateX(2px) }
+.hero-cta-secondary { display:inline-flex; align-items:center; gap:10px; padding:16px 22px; background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.55); font-family:'Inter Tight',sans-serif; font-size:11px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; text-decoration:none; transition:all .15s }
+.hero-cta-secondary:hover { background:rgba(255,255,255,0.12); border-color:#fff }
+@media (max-width:720px) {
+ .hero-pitch { left:20px; right:20px; top:38%; transform:none }
+ .hero-cta-primary, .hero-cta-secondary { padding:13px 18px; font-size:10px; letter-spacing:0.24em }
+}
+
+/* ===== Trade specs on product cards (SKU + MOQ + NET30) ===== */
+.card .trade { display:flex; flex-direction:column; gap:3px; margin-top:8px; padding-top:8px; border-top:1px solid rgba(255,255,255,0.18) }
+.card .trade .row { display:flex; justify-content:space-between; gap:8px; font-size:10px; color:#fff; letter-spacing:0.04em }
+.card .trade .row .lbl { opacity:0.62; text-transform:uppercase; letter-spacing:0.18em; font-weight:600 }
+.card .trade .row .val { font-family:'JetBrains Mono','SF Mono',ui-monospace,Menlo,monospace; font-weight:500; font-feature-settings:"tnum" 1 }
+.card .trade .moq-soft { font-size:10px; color:#fff; opacity:0.7; letter-spacing:0.04em; font-style:italic }
+
+/* ===== View toggle (Grid / Table) ===== */
+.view-toggle { display:inline-flex; border:1px solid var(--line); margin-right:14px }
+.view-toggle button { background:transparent; border:0; color:var(--paper); font-family:inherit; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; padding:6px 12px; cursor:pointer }
+.view-toggle button.active { background:var(--paper); color:var(--bg) }
+
+/* ===== Table view ===== */
+.tbl-wrap { overflow-x:auto; border:1px solid var(--line); border-bottom:0 }
+table.products { width:100%; border-collapse:collapse; font-size:13px; background:var(--bg) }
+table.products thead th { position:sticky; top:0; background:var(--bg-soft); border-bottom:1px solid var(--line); padding:12px 14px; text-align:left; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; color:var(--muted); white-space:nowrap }
+table.products tbody td { border-bottom:1px solid var(--line); padding:10px 14px; vertical-align:middle }
+table.products tbody tr:hover { background:var(--bg-soft) }
+table.products .timg { width:44px; height:44px; object-fit:cover; display:block; background:var(--bg-soft) }
+table.products .tsku, table.products .twidth, table.products .trepeat, table.products .tprice, table.products .tmoq { font-family:'JetBrains Mono','SF Mono',ui-monospace,Menlo,monospace; font-feature-settings:"tnum" 1; font-size:12px; white-space:nowrap }
+table.products .tsku { color:var(--muted) }
+table.products .tven { font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); margin-top:2px }
+table.products .tact { padding:6px 12px; background:var(--paper); color:var(--bg); border:0; font-family:inherit; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; cursor:pointer }
+table.products .tact:hover { background:var(--accent); color:#fff }
+
/* ===== WCAG 2.4.7 — keyboard focus indicators ===== */
/* Strictly :focus-visible so mouse clicks do NOT show the ring */
a:focus-visible,
@@ -181,163 +220,36 @@ textarea:focus-visible,
<style id="ns-header-modal-styles">
.ns-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: 18px 36px; background: rgba(10,10,10,0.92) !important; backdrop-filter: blur(8px); border-bottom: 1px solid rgba(245,241,232,0.06); }
.ns-brand { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; }
- .ns-dot { width: 28px; height: 28px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #a98b5b 0%, #5d4c32 60%, #332a1b 100%); box-shadow: 0 0 0 1px rgba(245,241,232,0.10), 0 2px 12px rgba(180,90,60,0.30); flex-shrink: 0; }
+ .ns-dot { width: 28px; height: 28px; border-radius: 50%; background: var(--ns-dot-bg, radial-gradient(circle at 35% 35%, #b66a4d 0%, #8a4a30 60%, #5a2d1c 100%)); box-shadow: 0 0 0 1px rgba(245,241,232,0.10), 0 2px 12px rgba(180,90,60,0.30); flex-shrink: 0; }
.ns-name { font-family: -apple-system, "SF Pro Display", "Helvetica Neue", system-ui, sans-serif; font-size: 18px !important; font-weight: 300 !important; letter-spacing: 0.02em !important; color: #f5f1e8 !important; text-transform: none !important; margin: 0 !important; }
.ns-name sup { font-size: 0.55em; font-weight: 300; margin-left: 1px; }
- .ns-nav { display: flex; gap: 32px; align-items: center; list-style: none; padding: 0; margin: 0; }
- .ns-nav a, .ns-nav button { font-family: -apple-system, "SF Pro Text", system-ui, sans-serif; font-size: 15px !important; font-weight: 400 !important; color: #f5f1e8 !important; text-decoration: none !important; background: none !important; border: 0 !important; cursor: pointer; padding: 0 !important; transition: color 200ms; letter-spacing: 0 !important; text-transform: none !important; }
+ .ns-nav a, .ns-nav button { font-family: -apple-system, "SF Pro Text", system-ui, sans-serif; font-size: 15px !important; font-weight: 400 !important; color: #f5f1e8 !important; text-decoration: none !important; background: none !important; border: 0 !important; cursor: pointer; transition: color 200ms; letter-spacing: 0 !important; text-transform: none !important; }
.ns-nav a:hover, .ns-nav button:hover { color: #ffffff !important; }
- @media (max-width: 820px) { .ns-header { padding: 14px 18px; } .ns-nav { gap: 16px; } .ns-nav a, .ns-nav button { font-size: 13px !important; } }
- @media (max-width: 760px) {
- .ns-header { flex-wrap: wrap; }
- .ns-nav { display: none !important; flex-basis: 100%; flex-direction: column; align-items: flex-start; gap: 6px; padding: 14px 0 4px; }
- .ns-nav.open { display: flex !important; }
- .ns-nav a, .ns-nav button { padding: 10px 0 !important; border-bottom: 1px solid rgba(245,241,232,0.08); width: 100%; text-align: left; }
- .ns-nav a:nth-child(n+5), .ns-nav button:nth-child(n+5) { display: block !important; }
- .ns-header .mobile-toggle { display: flex !important; }
- }
- @media (min-width: 761px) {
- .ns-header .mobile-toggle { display: none !important; }
- }
- .ns-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.78); z-index: 200; align-items: center; justify-content: center; padding: 24px; backdrop-filter: blur(6px); }
- .ns-modal.show { display: flex; }
- .ns-modal-card { max-width: 460px; width: 100%; background: #141414 !important; border: 1px solid rgba(245,241,232,0.14); border-radius: 6px; padding: 28px 30px 30px; position: relative; }
- .ns-modal-card .close-x { position: absolute; top: 12px; right: 14px; width: 30px; height: 30px; background: none; border: 1px solid rgba(245,241,232,0.18); color: rgba(245,241,232,0.6) !important; border-radius: 50%; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; }
- .ns-modal-card .close-x:hover { color: #ffffff !important; border-color: #ffffff; }
- .ns-modal-card h3 { font-family: -apple-system, "SF Pro Display", system-ui; font-weight: 300 !important; font-size: 22px !important; margin: 0 0 6px !important; color: #f5f1e8 !important; }
- .ns-modal-card .sub { color: rgba(245,241,232,0.65) !important; font-size: 13px; margin: 0 0 22px; }
- .ns-actions { display: flex; flex-direction: column; gap: 10px; }
- .ns-action { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: rgba(245,241,232,0.04) !important; border: 1px solid rgba(245,241,232,0.12) !important; color: #f5f1e8 !important; text-decoration: none !important; cursor: pointer; transition: all 200ms; font: inherit; text-align: left; }
- .ns-action:hover { background: rgba(245,241,232,0.08) !important; border-color: #d4b683 !important; }
- .ns-action .ico { width: 32px; height: 32px; border-radius: 50%; background: rgba(212,182,131,0.12); display: flex; align-items: center; justify-content: center; font-size: 15px; flex-shrink: 0; }
- .ns-action .txt { flex: 1; }
- .ns-action .txt b { display: block; font-weight: 500; font-size: 14px; color: #f5f1e8 !important; }
- .ns-action .txt span { font-size: 12px; color: rgba(245,241,232,0.55) !important; }
-
- /* ─── FINAL CONTRAST PASS — kill remaining gold-solid + white-island defects ─── */
- /* Replace all gold-fill accents with off-white (Gucci-aesthetic, no DW gold solids) */
- .new-badge, .new-arrivals-pill,
- .color-filter-btn.active,
- .drapery-btn, .sample-btn-chip, .cb-cta {
- background: #f5f1e8 !important;
- color: #0a0a0a !important;
- border-color: #f5f1e8 !important;
- }
- .new-badge:hover, .new-arrivals-pill:hover,
- .color-filter-btn.active:hover,
- .drapery-btn:hover, .sample-btn-chip:hover, .cb-cta:hover {
- background: #fff !important;
- color: #0a0a0a !important;
- }
- /* Gold accents stay only as edge color (links, glyphs) — not fills */
- a { color: #d4b683; }
- a:hover { color: #f5d97a; }
- /* Authorized-dealer footer line — small caps tracked, muted ink, no gold pill */
- .authorized, .authorized-dealer p {
- background: transparent !important;
- color: rgba(245,241,232,0.5) !important;
- font-size: 10px !important;
- letter-spacing: 0.22em !important;
- text-transform: uppercase !important;
- padding: 0 !important;
- border: none !important;
- margin-top: 14px !important;
- }
- /* Kill any straggler white bg in info-card / sample-related cards */
- .info-card, .info-card-link, .care-highlight, .feature-card, .spec-item {
- background-color: rgba(245,241,232,0.04) !important;
- border-color: rgba(245,241,232,0.12) !important;
- }
- /* Ensure modal content stays readable — Steve's earlier sweep made it #141414 with white text */
- .modal-content { background: #141414 !important; color: #f5f1e8 !important; }
- .modal-content * { color: #f5f1e8 !important; }
- .modal-content input, .modal-content textarea, .modal-content select {
- background: rgba(245,241,232,0.05) !important;
- color: #f5f1e8 !important;
- border-color: rgba(245,241,232,0.2) !important;
- }
- .modal-content label { color: rgba(245,241,232,0.6) !important; }
- /* Force the wide-tracked NOVASUEDE wordmark to stay white */
- .ns-wordmark, .ns-wordmark sup { color: #f5f1e8 !important; }
-
- /* ─── Modal-internal contrast (post-QA defects) ─── */
- /* Backdrop covers full viewport */
- .modal { width: 100vw !important; height: 100vh !important; left: 0 !important; top: 0 !important; background: rgba(0, 0, 0, 0.78) !important; backdrop-filter: blur(6px); }
- /* Chip — flip from white-on-white island to dark card */
- .request-chip {
- background: rgba(245, 241, 232, 0.06) !important;
- color: #f5f1e8 !important;
- border: 1px solid rgba(245, 241, 232, 0.18) !important;
- border-radius: 4px !important;
- }
- .request-chip * { color: #f5f1e8 !important; }
- .request-chip .chip-swatch {
- /* swatch dot ALWAYS displays the inline-style hex from JS — fight any sweep */
- border: 1px solid rgba(0, 0, 0, 0.4) !important;
- }
- /* + Add to request — hairline white-border on transparent (no gold) */
- .add-to-request-btn {
- background: transparent !important;
- color: #f5f1e8 !important;
- border: 1px solid rgba(245, 241, 232, 0.6) !important;
- border-radius: 0 !important;
- padding: 12px 22px !important;
- font-size: 11px !important;
- letter-spacing: 0.32em !important;
- text-transform: uppercase !important;
- font-weight: 400 !important;
- }
- .add-to-request-btn:hover {
- background: #f5f1e8 !important;
- color: #0a0a0a !important;
- border-color: #f5f1e8 !important;
- }
- /* Hex caption visible */
- .preview-hex, #colorPreviewHex {
- display: block !important;
- color: rgba(245, 241, 232, 0.55) !important;
- font-family: ui-monospace, "SF Mono", Menlo, monospace !important;
- font-size: 11px !important;
- margin-top: 4px !important;
- letter-spacing: 0.04em !important;
- text-transform: lowercase !important;
- }
- /* Color preview name — stays single, prominent */
- #colorPreviewName {
- color: #f5f1e8 !important;
- font-size: 11px !important;
- letter-spacing: 0.32em !important;
- text-transform: uppercase !important;
- font-weight: 500 !important;
- display: block !important;
- margin-top: 12px !important;
- }
-
- /* ─── Mobile hamburger nav (replaces the overflow at <760px) ─── */
- @media (max-width: 760px) {
- header .nav-container { flex-wrap: wrap; }
- header nav { display: none; flex-basis: 100%; flex-direction: column; gap: 6px; padding: 16px 0 6px; }
- header nav.open { display: flex; }
- header nav a { padding: 10px 0; border-bottom: 1px solid rgba(245,241,232,0.1); }
- header .mobile-toggle {
- display: flex !important;
- flex-direction: column;
- gap: 5px;
- background: transparent;
- border: 0;
- cursor: pointer;
- padding: 8px;
- }
- header .mobile-toggle span {
- width: 22px; height: 1.5px;
- background: #f5f1e8;
- display: block;
- }
+ /* All widths: hide nav by default, hamburger always visible on right, nav opens as right-anchored dropdown panel */
+ .ns-nav {
+ display: none !important;
+ position: absolute; top: 100%; right: 18px;
+ flex-direction: column; align-items: stretch; gap: 0;
+ background: rgba(10,10,10,0.96); backdrop-filter: blur(10px);
+ border: 1px solid rgba(245,241,232,0.10); border-top: 0;
+ min-width: 220px; padding: 10px 0; margin: 0;
+ box-shadow: 0 16px 40px rgba(0,0,0,0.55);
+ z-index: 150;
}
- @media (min-width: 761px) {
- header .mobile-toggle { display: none !important; }
+ .ns-nav.open { display: flex !important; }
+ .ns-nav a, .ns-nav button {
+ padding: 12px 22px !important;
+ border-bottom: 1px solid rgba(245,241,232,0.06);
+ width: 100%; text-align: left;
}
-
+ .ns-nav a:last-child, .ns-nav button:last-child { border-bottom: 0; }
+ .ns-nav a:hover, .ns-nav button:hover { background: rgba(245,241,232,0.06) !important; color: #ffffff !important; }
+ @media (max-width: 480px) { .ns-nav { right: 12px; min-width: 200px; } }
+ .ns-header .mobile-toggle { display: flex !important; align-items: center; justify-content: center; width: 36px; height: 36px; background: transparent; border: 0; cursor: pointer; padding: 0; gap: 4px; flex-direction: column; }
+ .ns-header .mobile-toggle span { display: block; width: 22px; height: 1.5px; background: #f5f1e8; transition: transform 200ms, opacity 200ms; }
+ .ns-header .mobile-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(5px) rotate(45deg); }
+ .ns-header .mobile-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
+ .ns-header .mobile-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-5px) rotate(-45deg); }
</style>
<style id="ns-gold-kill">
/* novasuede: kill gold-solid fills, off-white instead. Edge color stays gold. */
@@ -378,7 +290,7 @@ textarea:focus-visible,
<div class="ns-actions">
<a class="ns-action" href="#contact" onclick="nsContactClose()"><span class="ico">📋</span><div class="txt"><b>Get a Quote</b><span>Project specs → priced estimate</span></div></a>
<a class="ns-action" href="#contact" onclick="nsContactClose();openModal();"><span class="ico">📦</span><div class="txt"><b>Order a Sample</b><span>Free swatch to your shop</span></div></a>
- <a class="ns-action" href="mailto:info@ffepurchasing.com?subject=Heritage%20Atelier%20inquiry"><span class="ico">✉️</span><div class="txt"><b>Email Us</b><span>info@ffepurchasing.com</span></div></a>
+ <a class="ns-action" href="mailto:info@ffepurchasing.com?subject=Trade%20Account%20Inquiry"><span class="ico">✉️</span><div class="txt"><b>Email Us</b><span>info@ffepurchasing.com</span></div></a>
<a class="ns-action" href="tel:888-373-4564"><span class="ico">📞</span><div class="txt"><b>Call Us</b><span>(888) 373-4564 · M–F, 9–5 PT</span></div></a>
</div>
</div>
@@ -413,7 +325,14 @@ textarea:focus-visible,
<section class="cinema">
<div class="cinema-bg" id="heroBg"></div>
<div class="corner-mark">Trade-Grade</div>
- <div class="center-mark">FFE Purchasing</div>
+ <div class="hero-pitch">
+ <h1 class="hero-h1">FF&E procurement<br>for hospitality buyers.</h1>
+ <p class="hero-sub">Wallcoverings, fixtures, and finishing materials with trade pricing, NET30 accounts, MOQ-aware ordering, and tear-sheet downloads.</p>
+ <div class="hero-cta-row">
+ <a class="hero-cta-primary" href="/trade-account">Open trade account →</a>
+ <a class="hero-cta-secondary" href="#shop">Browse the catalog</a>
+ </div>
+ </div>
<div class="meta-line">Hotel · Restaurant · Spa · Class A<span class="num" id="heroNum"></span></div>
<a class="enter" href="#shop">Enter <svg viewBox="0 0 24 12" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M0 6h22M16 1l6 5-6 5"/></svg></a>
</section>
@@ -446,6 +365,10 @@ textarea:focus-visible,
</div>
<div class="density">
+ <div class="view-toggle" role="tablist" aria-label="View mode">
+ <button type="button" id="viewGrid" class="active" role="tab" aria-selected="true">Grid view</button>
+ <button type="button" id="viewTable" role="tab" aria-selected="false">Table view</button>
+ </div>
<label>Sort</label>
<select id="sortSelect" aria-label="Sort products" style="background:transparent;color:inherit;border:1px solid var(--line,rgba(255,255,255,.2));font:inherit;font-size:11px;letter-spacing:.10em;text-transform:uppercase;padding:4px 8px;cursor:pointer;outline:none;margin-right:14px">
<option value="newest">Newest</option>
@@ -461,22 +384,30 @@ textarea:focus-visible,
<span class="ct" id="densityLabel">6 cols</span>
<div class="search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5.5-5.5"/></svg>
- <input type="text" id="searchInput" placeholder="Search by pattern, color…" autocomplete="off">
+ <input type="text" id="searchInput" placeholder="Search by pattern, color, SKU…" autocomplete="off">
</div>
</div>
<div class="stat-line" id="statLine">Loading…</div>
<div class="grid" id="grid"></div>
+ <div class="tbl-wrap" id="tblWrap" style="display:none">
+ <table class="products" id="tbl">
+ <thead><tr>
+ <th></th><th>SKU</th><th>Pattern / Vendor</th><th>Width</th><th>Repeat</th><th>Trade $/yd</th><th>MOQ</th><th></th>
+ </tr></thead>
+ <tbody id="tblBody"></tbody>
+ </table>
+ </div>
<div class="loading" id="loading">Loading more…</div>
<div class="sentinel" id="sentinel"></div>
</section>
<footer>
- <div class="footer-grid">
+ <div class="footer-grid" style="grid-template-columns:1.6fr 1fr 1fr 1fr 1fr">
<div>
<div class="footer-brand">FFE Purchasing</div>
- <p class="footer-text">FFE Purchasing — trade pricing, free spec samples for verified buyers.</p>
- <p class="footer-text" style="margin-top:14px;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;font-weight:600;color:var(--paper);opacity:0.7"><a href="/about" style="color:inherit;text-decoration:none;border-bottom:1px solid var(--accent)">About →</a></p>
+ <p class="footer-text">Hospitality FF&E procurement — trade pricing, NET30 accounts, MOQ-aware ordering, free memo samples for verified buyers.</p>
+ <p class="footer-text" style="margin-top:14px;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;font-weight:600;color:var(--paper);opacity:0.7"><a href="/trade-account" style="color:inherit;text-decoration:none;border-bottom:1px solid var(--accent)">Open trade account →</a></p>
</div>
<div class="footer-col">
<h4>Aesthetic</h4>
@@ -484,11 +415,20 @@ textarea:focus-visible,
</div>
<div class="footer-col">
<h4>Trade</h4>
+ <a href="/trade-account">Open trade account</a>
<button onclick="dwmOpen('Inquiry')">Project Inquiry</button>
<button onclick="dwmOpen('Sample')">Request Sample</button>
<button onclick="dwmOpen('Contact')">Contact</button>
</div>
-
+ <div class="footer-col">
+ <h4>Spec sheets</h4>
+ <a href="/spec-sheets/wallcoverings.pdf">Wallcoverings</a>
+ <a href="/spec-sheets/contract-vinyl.pdf">Contract vinyl</a>
+ <a href="/spec-sheets/grasscloth.pdf">Grasscloth & naturals</a>
+ <a href="/spec-sheets/acoustical.pdf">Acoustical panels</a>
+ <a href="/spec-sheets/fire-rating.pdf">Class A fire ratings</a>
+ <a href="/spec-sheets/install-guide.pdf">Install & care</a>
+ </div>
<div class="footer-col">
<h4>Social</h4>
<a class="fb-link" href="https://www.facebook.com/SteveAbramsDesigns.DesignerWallcoverings" target="_blank" rel="noopener">Facebook</a>
@@ -571,17 +511,60 @@ const LABELS = {"all":"All"};
function escAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }
// Image-URL allowlist: only DW Shopify CDN + designerwallcoverings.com (defends against XSS via crafted products.json)
function safeImg(u) { return /^https:\/\/(?:cdn\.shopify\.com|designerwallcoverings\.com)\//.test(u || '') ? u : '/hero-bg.jpg'; }
+// Trade-spec helpers — derive MOQ / width / repeat / NET30 trade $ from product fields when available,
+// fall back to "Spec sheet on request" so the card never looks DTC.
+function tradeSpec(p) {
+ // MOQ — explicit field, else derived from product_type/tags. Stays empty if nothing real.
+ const moq = (p.moq != null) ? Number(p.moq) :
+ (p.minimum_order || p.min_order || p.min_yards || null);
+ // Trade price — explicit trade_price, else max_price (assumed wholesale on this catalog), else null.
+ const trade = (p.trade_price != null) ? Number(p.trade_price) :
+ (p.max_price != null) ? Number(p.max_price) : null;
+ return {
+ sku: p.sku || p.handle || '',
+ width: p.width || null,
+ repeat: p.repeat || p.vertical_repeat || null,
+ moq: (moq && Number(moq) > 0) ? Number(moq) : null,
+ trade: (trade && Number(trade) > 0) ? Number(trade) : null
+ };
+}
+function fmtTrade(n) { return '$' + (Math.round(n * 100) / 100).toFixed(2) + '/yd'; }
+function fmtMoq(n) { return 'Min: ' + n + ' yd'; }
+
function cardHTML(p) {
const eager = state.page === 1;
+ const t = tradeSpec(p);
+ const sku = t.sku;
+ let trade = '';
+ if (sku) trade += '<div class="row"><span class="lbl">SKU</span><span class="val">' + escAttr(sku) + '</span></div>';
+ if (t.trade) trade += '<div class="row"><span class="lbl">NET30</span><span class="val">' + fmtTrade(t.trade) + '</span></div>';
+ if (t.moq) trade += '<div class="row"><span class="lbl">MOQ</span><span class="val">' + fmtMoq(t.moq) + '</span></div>';
+ else trade += '<div class="moq-soft">Spec sheet on request</div>';
return '<img loading="' + (eager ? 'eager' : 'lazy') + '"' + (eager ? ' fetchpriority="high"' : '') + ' src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
+ '<div class="overlay">'
+ '<div class="pat">' + escAttr(p.pattern_name || p.title) + '</div>'
+ '<div class="ven">' + escAttr((p.vendor || '').replace(/-/g, ' ')) + '</div>'
+ + '<div class="trade">' + trade + '</div>'
+ '<div class="actions">'
+ '<button class="sample-btn" onclick="event.stopPropagation();dwmOpen(\'Sample\',' + JSON.stringify({sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'"') + ')">Sample</button>'
+ '</div></div>';
}
+function rowHTML(p) {
+ const t = tradeSpec(p);
+ const optsAttr = JSON.stringify({sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'"');
+ return '<tr>'
+ + '<td><img class="timg" src="' + escAttr(safeImg(p.image_url)) + '" alt="" loading="lazy"></td>'
+ + '<td class="tsku">' + escAttr(t.sku || '—') + '</td>'
+ + '<td><div>' + escAttr(p.pattern_name || p.title) + '</div><div class="tven">' + escAttr((p.vendor || '').replace(/-/g, ' ')) + '</div></td>'
+ + '<td class="twidth">' + escAttr(t.width || '—') + '</td>'
+ + '<td class="trepeat">' + escAttr(t.repeat || '—') + '</td>'
+ + '<td class="tprice">' + (t.trade ? fmtTrade(t.trade) : '—') + '</td>'
+ + '<td class="tmoq">' + (t.moq ? fmtMoq(t.moq) : 'On request') + '</td>'
+ + '<td><button class="tact" onclick="dwmOpen(\'Sample\',' + optsAttr + ')">Sample</button></td>'
+ + '</tr>';
+}
+
async function loadFacets() {
let f;
try {
@@ -618,6 +601,7 @@ async function loadFacets() {
function resetGrid() {
state.page = 1; state.exhausted = false;
document.getElementById('grid').innerHTML = '';
+ const tbody = document.getElementById('tblBody'); if (tbody) tbody.innerHTML = '';
document.getElementById('loading').textContent = 'Loading…';
loadGridPage();
}
@@ -645,11 +629,13 @@ async function loadGridPage() {
document.getElementById('statLine').textContent = data.total + ' patterns · ' + (state.facet === 'all' ? 'all aesthetics' : (LABELS[state.facet] || state.facet)) + (state.q ? ' · matching "' + state.q + '"' : '');
}
const grid = document.getElementById('grid');
+ const tbody = document.getElementById('tblBody');
for (const p of data.items) {
const a = document.createElement('a');
a.className = 'card'; a.href = '#'; a.onclick = (e) => { e.preventDefault(); dwmOpen('Sample',{sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}); };
a.innerHTML = cardHTML(p);
grid.appendChild(a);
+ if (tbody) tbody.insertAdjacentHTML('beforeend', rowHTML(p));
}
if (state.page >= state.pages || data.items.length === 0) {
state.exhausted = true;
@@ -667,17 +653,46 @@ document.getElementById('searchInput').addEventListener('input', e => {
window._t = setTimeout(resetGrid, 220);
});
-// Density slider
+// Density slider — at the lowest setting (4 cols) auto-flip to table view (B2B trade-portal default).
const slider = document.getElementById('densitySlider');
const dlabel = document.getElementById('densityLabel');
-function setDensity(n) {
+function setDensity(n, opts) {
document.documentElement.style.setProperty('--cols', n);
dlabel.textContent = n + ' cols';
try { localStorage.setItem('hosp_theme_density', n); } catch(e){}
+ // Low-density auto-flip: <=4 cols ⇒ table view, ≥5 cols ⇒ grid view (unless user pinned manually).
+ if (!opts || opts.autoToggle !== false) {
+ const pinned = localStorage.getItem('ffe_view_pinned') === '1';
+ if (!pinned) { setView(n <= 4 ? 'table' : 'grid', { fromSlider:true }); }
+ }
}
slider.addEventListener('input', e => setDensity(parseInt(e.target.value)));
const savedDensity = parseInt(localStorage.getItem('hosp_theme_density') || '6');
-if (savedDensity >= 4 && savedDensity <= 12) { slider.value = savedDensity; setDensity(savedDensity); }
+if (savedDensity >= 4 && savedDensity <= 12) { slider.value = savedDensity; setDensity(savedDensity, { autoToggle:false }); }
+
+// Grid / Table view toggle
+function setView(mode, opts) {
+ const grid = document.getElementById('grid');
+ const tbl = document.getElementById('tblWrap');
+ const bg = document.getElementById('viewGrid');
+ const bt = document.getElementById('viewTable');
+ if (mode === 'table') {
+ grid.style.display = 'none'; tbl.style.display = 'block';
+ bg.classList.remove('active'); bt.classList.add('active');
+ bg.setAttribute('aria-selected','false'); bt.setAttribute('aria-selected','true');
+ } else {
+ grid.style.display = ''; tbl.style.display = 'none';
+ bg.classList.add('active'); bt.classList.remove('active');
+ bg.setAttribute('aria-selected','true'); bt.setAttribute('aria-selected','false');
+ }
+ try { localStorage.setItem('ffe_view', mode); } catch(e){}
+ if (!opts || !opts.fromSlider) { try { localStorage.setItem('ffe_view_pinned','1'); } catch(e){} }
+}
+document.getElementById('viewGrid').addEventListener('click', () => setView('grid'));
+document.getElementById('viewTable').addEventListener('click', () => setView('table'));
+const savedView = localStorage.getItem('ffe_view');
+if (savedView === 'table' || savedView === 'grid') setView(savedView, { fromSlider:true });
+else if (savedDensity <= 4) setView('table', { fromSlider:true });
// Theme toggle
const tb = document.getElementById('theme-toggle');
diff --git a/public/index.html.bak.ns-canonical b/public/index.html.bak.ns-canonical
new file mode 100644
index 0000000..c1c6845
--- /dev/null
+++ b/public/index.html.bak.ns-canonical
@@ -0,0 +1,1118 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+
+<meta charset="utf-8">
+<!-- Google tag (gtag.js) -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=G-WJ8QQQ1YEW"></script>
+<script>
+ window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', 'G-WJ8QQQ1YEW');
+</script>
+
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>FFE Purchasing — Hospitality FF&E Trade Portal · Wallcoverings, Spec-Sheets, NET30</title>
+<meta name="description" content="FFE Purchasing — B2B procurement portal for hospitality buyers. Tear sheets, MOQ pricing, NET30 trade accounts, contract-grade wallcoverings. Open a trade account in 60 seconds.">
+<meta name="theme-color" content="#FFF">
+<link rel="canonical" href="https://ffepurchasing.com/">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
+<link rel="preload" as="image" href="/hero-bg.jpg" fetchpriority="high">
+<script type="application/ld+json">
+{
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ "name": "FFE Purchasing",
+ "url": "https://ffepurchasing.com/",
+ "email": "info@ffepurchasing.com"
+}
+</script>
+<script src="/zd-loader.js" defer></script>
+<style>
+/* FFE PURCHASING — Knoll Trade / DWR Trade tabular B2B palette */
+:root {
+ --bg: #F5F1E8;
+ --paper: #101010;
+ --muted: #5A554C;
+ --line: rgba(16,16,16,0.14);
+ --accent: #E55A2B;
+ --bg-soft: #ECE5D2;
+ --cols: 6;
+}
+html[data-theme="dark"] {
+ --bg: #0F0F0E;
+ --paper: #F5F1E8;
+ --muted: #B0AA9D;
+ --line: rgba(245,241,232,0.14);
+ --accent: #FF7A4A;
+ --bg-soft: #1B1A18;
+}
+* { margin:0; padding:0; box-sizing:border-box }
+html { scroll-behavior:smooth }
+body { font-family:'Inter', sans-serif; color:var(--paper); -webkit-font-smoothing:antialiased; background:var(--bg); min-height:100vh; overflow-x:hidden }
+/* DW-FASHION-TEMPLATE-OVERRIDE */
+/* FFE Purchasing: Knoll-Trade tabular sans + monospace specs */
+.section-title .accent, .footer-brand, .center-mark, h1, h2, h3 { font-family:'Inter Tight', 'Inter', system-ui, sans-serif !important; font-weight:600 !important; letter-spacing:-0.01em !important; }
+.sku, .moq, .price, code, .spec { font-family:'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace !important; font-feature-settings:"tnum" 1; }
+
+/* ===== Auto-hide header ===== */
+header { position:fixed; top:0; left:0; right:0; display:flex; justify-content:space-between; align-items:center; padding:22px 32px; z-index:100; background:rgba(0,0,0,0.55); backdrop-filter:blur(10px); border-bottom:1px solid var(--line); transition:transform .3s }
+.h-link { display:flex; align-items:center; gap:10px; font-size:11px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; color:var(--paper); text-decoration:none; cursor:pointer; background:transparent; border:0 }
+.h-link:hover { opacity:0.7 }
+.h-icon { width:18px; height:18px; stroke:currentColor; fill:none; stroke-width:1.5 }
+
+/* ===== Cinema hero ===== */
+.cinema { position:relative; height:100vh; width:100%; overflow:hidden }
+.cinema-bg { position:absolute; inset:0; background-size:cover; background-position:center; background-image:url('/hero-bg.jpg') }
+.cinema-bg::after { content:""; position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.75) 100%) }
+.corner-mark { position:absolute; top:24px; left:32px; font-size:11px; font-weight:700; letter-spacing:0.32em; text-transform:uppercase; color:#fff; text-shadow:0 1px 3px rgba(0,0,0,0.55), 0 0 1px rgba(0,0,0,0.4); z-index:5 }
+.corner-mark::before { content:"◆ "; margin-right:6px }
+.center-mark { position:absolute; top:clamp(80px,12vh,140px); left:50%; transform:translateX(-50%); font-size:clamp(28px,4vw,56px); font-weight:500; letter-spacing:0.18em; text-transform:uppercase; text-align:center; color:#fff; text-shadow:0 2px 8px rgba(0,0,0,0.65), 0 0 1px rgba(0,0,0,0.5); z-index:5 }
+.center-mark .tm { font-size:14px; vertical-align:super; opacity:0.7; margin-left:0.2em }
+.center-mark .sub { display:block; font-family:'Playfair Display', Georgia, serif; font-style:italic; font-weight:400; font-size:0.4em; letter-spacing:0.04em; text-transform:none; margin-top:14px; opacity:0.85 }
+.meta-line { position:absolute; bottom:32px; left:32px; font-size:10px; letter-spacing:0.28em; text-transform:uppercase; font-weight:700; color:#fff; z-index:5; opacity:0.85 }
+.meta-line .num { font-size:12px; display:block; margin-top:4px; letter-spacing:0.05em }
+.enter { position:absolute; bottom:32px; right:32px; display:flex; align-items:center; gap:14px; font-size:11px; letter-spacing:0.36em; text-transform:uppercase; font-weight:700; color:#fff; text-decoration:none; padding-bottom:6px; border-bottom:1px solid #fff; transition:gap 0.25s; z-index:5 }
+.enter:hover { gap:22px }
+.enter svg { width:24px; height:12px }
+
+/* ===== Section ===== */
+.section { padding:96px 32px }
+.section-header { display:flex; justify-content:space-between; align-items:flex-end; flex-wrap:wrap; gap:24px; margin-bottom:32px; padding-bottom:24px; border-bottom:1px solid var(--line) }
+.section-title { font-size:clamp(36px,5vw,64px); font-weight:300; letter-spacing:-0.02em; line-height:1 }
+.section-title .accent { color: var(--accent); font-family:'Playfair Display', Georgia, serif; font-style:italic; font-weight:400 }
+.section-eyebrow { font-size:10px; letter-spacing:0.4em; text-transform:uppercase; font-weight:700; color:var(--muted); margin-bottom:16px }
+.section-meta { font-size:11px; letter-spacing:0.22em; text-transform:uppercase; font-weight:600; color:var(--muted); text-align:right; line-height:1.6 }
+
+/* ===== Filter bar ===== */
+.filters { display:flex; gap:8px; align-items:center; margin-bottom:18px; flex-wrap:wrap }
+.chip { padding:8px 16px; font-size:11px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; color:var(--paper); background:transparent; border:1px solid var(--line); cursor:pointer; transition:all 0.2s; font-family:inherit }
+.chip:hover { border-color:var(--paper) }
+.chip.active { background:var(--paper); color:var(--bg); border-color:var(--paper) }
+.search { flex:0 1 280px; margin-left:auto; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--line); padding:6px 0 }
+.search input { flex:1; background:transparent; border:0; color:var(--paper); font-family:inherit; font-size:13px; outline:none; letter-spacing:0.04em }
+.search input::placeholder { color:var(--muted) }
+.search svg { width:16px; height:16px; stroke:var(--muted); fill:none; stroke-width:1.5 }
+
+/* ===== Grid-density slider ===== */
+.density { display:flex; align-items:center; gap:14px; padding:6px 0; margin-bottom:24px }
+.density label { font-size:10px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; color:var(--muted) }
+.density input[type=range] { flex:1; max-width:240px; -webkit-appearance:none; appearance:none; height:1px; background:var(--line); outline:none }
+.density input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:14px; height:14px; background:var(--accent); cursor:pointer; border-radius:50% }
+.density input[type=range]::-moz-range-thumb { width:14px; height:14px; background:var(--accent); cursor:pointer; border-radius:50%; border:0 }
+.density .ct { font-size:11px; color:var(--muted); letter-spacing:0.18em; text-transform:uppercase; font-weight:600; min-width:80px }
+
+/* ===== Stats line ===== */
+.stat-line { font-size:10px; letter-spacing:0.32em; text-transform:uppercase; font-weight:600; color:var(--muted); margin-bottom:24px }
+
+/* ===== Product grid (novasuede pattern: flush grid + slide-up overlay) ===== */
+.grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:0 }
+.card { position:relative; aspect-ratio:1/1.15; cursor:pointer; overflow:hidden; border:1px solid var(--bg); transition:transform 0.4s cubic-bezier(0.2,0.8,0.2,1); background:var(--bg-soft) }
+.card:hover { transform:scale(1.04); z-index:5 }
+.card img { width:100%; height:100%; object-fit:cover; display:block; transition:transform 0.4s ease }
+.card .overlay { position:absolute; left:0; right:0; bottom:0; padding:20px 16px 14px; background:linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 100%); transform:translateY(48%); opacity:0.85; transition:transform 0.3s ease, opacity 0.3s ease }
+.card:hover .overlay { transform:translateY(0); opacity:1 }
+.card .pat { font-size:13px; font-weight:600; letter-spacing:0.02em; color:#fff; line-height:1.25; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden }
+.card .ven { font-size:9px; letter-spacing:0.18em; text-transform:uppercase; color:#fff; opacity:0.7; font-weight:500; margin-top:4px }
+.card .actions { margin-top:10px; display:flex; gap:6px }
+.card .sample-btn { flex:1; padding:7px 10px; background:#fff; color:#000; font-family:inherit; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; border:0; cursor:pointer; text-align:center; transition:all 0.2s }
+.card .sample-btn:hover { background:var(--accent); color:#fff }
+
+@media (max-width:1024px) { .grid { grid-template-columns:repeat(min(var(--cols), 4), 1fr) } }
+@media (max-width:720px) { .grid { grid-template-columns:repeat(2, 1fr) } .density { display:none } }
+
+.sentinel { height:1px }
+.loading { text-align:center; color:var(--muted); padding:32px; font-size:10px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700 }
+
+/* ===== Footer ===== */
+footer { padding:64px 32px 32px; border-top:1px solid var(--line); margin-top:32px }
+.footer-grid { max-width:1400px; margin:0 auto; display:grid; grid-template-columns:2fr 1fr 1fr 1fr; gap:48px; margin-bottom:48px }
+.footer-brand { font-size:24px; font-weight:300; letter-spacing:0.32em; text-transform:uppercase; margin-bottom:12px }
+.footer-text { font-size:13px; line-height:1.6; color:var(--muted); max-width:380px }
+.footer-col h4 { font-size:10px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; color:var(--paper); margin-bottom:18px }
+.footer-col a { display:block; font-size:13px; color:var(--muted); text-decoration:none; margin-bottom:8px; transition:color 0.2s; cursor:pointer; background:transparent; border:0; font-family:inherit; padding:0; text-align:left }
+.footer-col a:hover { color:var(--accent) }
+.footer-bottom { max-width:1400px; margin:0 auto; padding-top:24px; border-top:1px solid var(--line); display:flex; justify-content:space-between; flex-wrap:wrap; gap:16px; font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted) }
+
+@media (max-width:720px) {
+ .corner-mark, .meta-line, .enter { font-size:10px }
+ .center-mark { font-size:24px; letter-spacing:0.12em; top:clamp(60px,9vh,90px) }
+ .section, footer { padding-left:20px; padding-right:20px }
+ .footer-grid { grid-template-columns:1fr; gap:32px }
+ .search { margin-left:0; flex:1 1 100% }
+}
+
+.theme-toggle { background:transparent; border:1px solid var(--line); width:32px; height:32px; cursor:pointer; color:var(--paper); display:inline-flex; align-items:center; justify-content:center; font-size:14px; line-height:1; transition:all .15s; padding:0 }
+.theme-toggle:hover { border-color:var(--accent); color:var(--accent) }
+
+/* ===== B2B Hero pitch (FFE-specific copy + CTAs) ===== */
+.hero-pitch { position:absolute; left:32px; right:32px; top:50%; transform:translateY(-50%); max-width:780px; z-index:6; color:#fff }
+.hero-h1 { font-family:'Inter Tight','Inter',sans-serif; font-weight:600; font-size:clamp(36px,6vw,72px); line-height:1.02; letter-spacing:-0.02em; color:#fff; margin:0 0 18px; text-shadow:0 2px 12px rgba(0,0,0,0.55) }
+.hero-sub { font-family:'Inter',sans-serif; font-weight:400; font-size:clamp(15px,1.5vw,19px); line-height:1.5; color:#fff; opacity:0.92; max-width:62ch; margin:0 0 28px; text-shadow:0 1px 6px rgba(0,0,0,0.45) }
+.hero-cta-row { display:flex; flex-wrap:wrap; gap:12px; align-items:center }
+.hero-cta-primary { display:inline-flex; align-items:center; gap:10px; padding:16px 26px; background:var(--accent); color:#101010; border:1px solid var(--accent); font-family:'Inter Tight',sans-serif; font-size:11px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; text-decoration:none; transition:transform .15s, opacity .15s }
+.hero-cta-primary:hover { opacity:0.92; transform:translateX(2px) }
+.hero-cta-secondary { display:inline-flex; align-items:center; gap:10px; padding:16px 22px; background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.55); font-family:'Inter Tight',sans-serif; font-size:11px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; text-decoration:none; transition:all .15s }
+.hero-cta-secondary:hover { background:rgba(255,255,255,0.12); border-color:#fff }
+@media (max-width:720px) {
+ .hero-pitch { left:20px; right:20px; top:38%; transform:none }
+ .hero-cta-primary, .hero-cta-secondary { padding:13px 18px; font-size:10px; letter-spacing:0.24em }
+}
+
+/* ===== Trade specs on product cards (SKU + MOQ + NET30) ===== */
+.card .trade { display:flex; flex-direction:column; gap:3px; margin-top:8px; padding-top:8px; border-top:1px solid rgba(255,255,255,0.18) }
+.card .trade .row { display:flex; justify-content:space-between; gap:8px; font-size:10px; color:#fff; letter-spacing:0.04em }
+.card .trade .row .lbl { opacity:0.62; text-transform:uppercase; letter-spacing:0.18em; font-weight:600 }
+.card .trade .row .val { font-family:'JetBrains Mono','SF Mono',ui-monospace,Menlo,monospace; font-weight:500; font-feature-settings:"tnum" 1 }
+.card .trade .moq-soft { font-size:10px; color:#fff; opacity:0.7; letter-spacing:0.04em; font-style:italic }
+
+/* ===== View toggle (Grid / Table) ===== */
+.view-toggle { display:inline-flex; border:1px solid var(--line); margin-right:14px }
+.view-toggle button { background:transparent; border:0; color:var(--paper); font-family:inherit; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; padding:6px 12px; cursor:pointer }
+.view-toggle button.active { background:var(--paper); color:var(--bg) }
+
+/* ===== Table view ===== */
+.tbl-wrap { overflow-x:auto; border:1px solid var(--line); border-bottom:0 }
+table.products { width:100%; border-collapse:collapse; font-size:13px; background:var(--bg) }
+table.products thead th { position:sticky; top:0; background:var(--bg-soft); border-bottom:1px solid var(--line); padding:12px 14px; text-align:left; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; color:var(--muted); white-space:nowrap }
+table.products tbody td { border-bottom:1px solid var(--line); padding:10px 14px; vertical-align:middle }
+table.products tbody tr:hover { background:var(--bg-soft) }
+table.products .timg { width:44px; height:44px; object-fit:cover; display:block; background:var(--bg-soft) }
+table.products .tsku, table.products .twidth, table.products .trepeat, table.products .tprice, table.products .tmoq { font-family:'JetBrains Mono','SF Mono',ui-monospace,Menlo,monospace; font-feature-settings:"tnum" 1; font-size:12px; white-space:nowrap }
+table.products .tsku { color:var(--muted) }
+table.products .tven { font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); margin-top:2px }
+table.products .tact { padding:6px 12px; background:var(--paper); color:var(--bg); border:0; font-family:inherit; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700; cursor:pointer }
+table.products .tact:hover { background:var(--accent); color:#fff }
+
+/* ===== WCAG 2.4.7 — keyboard focus indicators ===== */
+/* Strictly :focus-visible so mouse clicks do NOT show the ring */
+a:focus-visible,
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible,
+[tabindex]:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+</style>
+<script>
+(function(){ try { var t = localStorage.getItem('hosp_theme') || 'light'; document.documentElement.dataset.theme = t; } catch(e){} })();
+</script>
+
+<meta property="og:type" content="website">
+<meta property="og:url" content="https://ffepurchasing.com/">
+<meta property="og:title" content="FFE Purchasing">
+<meta property="og:description" content="FFE Purchasing — B2B trade portal for hospitality buyers · NET30 · spec sheets · MOQ-aware.">
+<meta property="og:image" content="https://ffepurchasing.com/hero-bg.jpg">
+<meta property="og:site_name" content="FFE Purchasing">
+<meta property="article:publisher" content="https://www.facebook.com/SteveAbramsDesigns.DesignerWallcoverings">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="FFE Purchasing">
+<meta name="twitter:description" content="FFE Purchasing — B2B trade portal for hospitality buyers · NET30 · spec sheets · MOQ-aware.">
+<meta name="twitter:image" content="https://ffepurchasing.com/hero-bg.jpg">
+<!-- graphics-loop pass 13: footer + fb -->
+<script>if((location.search+location.hash).indexOf("figmacapture=")>=0){var s=document.createElement("script");s.src="https://mcp.figma.com/mcp/html-to-design/capture.js";s.async=true;document.head.appendChild(s);}</script>
+</head>
+<body>
+<style id="ns-header-modal-styles">
+ .ns-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: 18px 36px; background: rgba(10,10,10,0.92) !important; backdrop-filter: blur(8px); border-bottom: 1px solid rgba(245,241,232,0.06); }
+ .ns-brand { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; }
+ .ns-dot { width: 28px; height: 28px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #a98b5b 0%, #5d4c32 60%, #332a1b 100%); box-shadow: 0 0 0 1px rgba(245,241,232,0.10), 0 2px 12px rgba(180,90,60,0.30); flex-shrink: 0; }
+ .ns-name { font-family: -apple-system, "SF Pro Display", "Helvetica Neue", system-ui, sans-serif; font-size: 18px !important; font-weight: 300 !important; letter-spacing: 0.02em !important; color: #f5f1e8 !important; text-transform: none !important; margin: 0 !important; }
+ .ns-name sup { font-size: 0.55em; font-weight: 300; margin-left: 1px; }
+ .ns-nav { display: flex; gap: 32px; align-items: center; list-style: none; padding: 0; margin: 0; }
+ .ns-nav a, .ns-nav button { font-family: -apple-system, "SF Pro Text", system-ui, sans-serif; font-size: 15px !important; font-weight: 400 !important; color: #f5f1e8 !important; text-decoration: none !important; background: none !important; border: 0 !important; cursor: pointer; padding: 0 !important; transition: color 200ms; letter-spacing: 0 !important; text-transform: none !important; }
+ .ns-nav a:hover, .ns-nav button:hover { color: #ffffff !important; }
+ @media (max-width: 820px) { .ns-header { padding: 14px 18px; } .ns-nav { gap: 16px; } .ns-nav a, .ns-nav button { font-size: 13px !important; } }
+ @media (max-width: 760px) {
+ .ns-header { flex-wrap: wrap; }
+ .ns-nav { display: none !important; flex-basis: 100%; flex-direction: column; align-items: flex-start; gap: 6px; padding: 14px 0 4px; }
+ .ns-nav.open { display: flex !important; }
+ .ns-nav a, .ns-nav button { padding: 10px 0 !important; border-bottom: 1px solid rgba(245,241,232,0.08); width: 100%; text-align: left; }
+ .ns-nav a:nth-child(n+5), .ns-nav button:nth-child(n+5) { display: block !important; }
+ .ns-header .mobile-toggle { display: flex !important; }
+ }
+ @media (min-width: 761px) {
+ .ns-header .mobile-toggle { display: none !important; }
+ }
+ .ns-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.78); z-index: 200; align-items: center; justify-content: center; padding: 24px; backdrop-filter: blur(6px); }
+ .ns-modal.show { display: flex; }
+ .ns-modal-card { max-width: 460px; width: 100%; background: #141414 !important; border: 1px solid rgba(245,241,232,0.14); border-radius: 6px; padding: 28px 30px 30px; position: relative; }
+ .ns-modal-card .close-x { position: absolute; top: 12px; right: 14px; width: 30px; height: 30px; background: none; border: 1px solid rgba(245,241,232,0.18); color: rgba(245,241,232,0.6) !important; border-radius: 50%; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; }
+ .ns-modal-card .close-x:hover { color: #ffffff !important; border-color: #ffffff; }
+ .ns-modal-card h3 { font-family: -apple-system, "SF Pro Display", system-ui; font-weight: 300 !important; font-size: 22px !important; margin: 0 0 6px !important; color: #f5f1e8 !important; }
+ .ns-modal-card .sub { color: rgba(245,241,232,0.65) !important; font-size: 13px; margin: 0 0 22px; }
+ .ns-actions { display: flex; flex-direction: column; gap: 10px; }
+ .ns-action { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: rgba(245,241,232,0.04) !important; border: 1px solid rgba(245,241,232,0.12) !important; color: #f5f1e8 !important; text-decoration: none !important; cursor: pointer; transition: all 200ms; font: inherit; text-align: left; }
+ .ns-action:hover { background: rgba(245,241,232,0.08) !important; border-color: #d4b683 !important; }
+ .ns-action .ico { width: 32px; height: 32px; border-radius: 50%; background: rgba(212,182,131,0.12); display: flex; align-items: center; justify-content: center; font-size: 15px; flex-shrink: 0; }
+ .ns-action .txt { flex: 1; }
+ .ns-action .txt b { display: block; font-weight: 500; font-size: 14px; color: #f5f1e8 !important; }
+ .ns-action .txt span { font-size: 12px; color: rgba(245,241,232,0.55) !important; }
+
+ /* ─── FINAL CONTRAST PASS — kill remaining gold-solid + white-island defects ─── */
+ /* Replace all gold-fill accents with off-white (Gucci-aesthetic, no DW gold solids) */
+ .new-badge, .new-arrivals-pill,
+ .color-filter-btn.active,
+ .drapery-btn, .sample-btn-chip, .cb-cta {
+ background: #f5f1e8 !important;
+ color: #0a0a0a !important;
+ border-color: #f5f1e8 !important;
+ }
+ .new-badge:hover, .new-arrivals-pill:hover,
+ .color-filter-btn.active:hover,
+ .drapery-btn:hover, .sample-btn-chip:hover, .cb-cta:hover {
+ background: #fff !important;
+ color: #0a0a0a !important;
+ }
+ /* Gold accents stay only as edge color (links, glyphs) — not fills */
+ a { color: #d4b683; }
+ a:hover { color: #f5d97a; }
+ /* Authorized-dealer footer line — small caps tracked, muted ink, no gold pill */
+ .authorized, .authorized-dealer p {
+ background: transparent !important;
+ color: rgba(245,241,232,0.5) !important;
+ font-size: 10px !important;
+ letter-spacing: 0.22em !important;
+ text-transform: uppercase !important;
+ padding: 0 !important;
+ border: none !important;
+ margin-top: 14px !important;
+ }
+ /* Kill any straggler white bg in info-card / sample-related cards */
+ .info-card, .info-card-link, .care-highlight, .feature-card, .spec-item {
+ background-color: rgba(245,241,232,0.04) !important;
+ border-color: rgba(245,241,232,0.12) !important;
+ }
+ /* Ensure modal content stays readable — Steve's earlier sweep made it #141414 with white text */
+ .modal-content { background: #141414 !important; color: #f5f1e8 !important; }
+ .modal-content * { color: #f5f1e8 !important; }
+ .modal-content input, .modal-content textarea, .modal-content select {
+ background: rgba(245,241,232,0.05) !important;
+ color: #f5f1e8 !important;
+ border-color: rgba(245,241,232,0.2) !important;
+ }
+ .modal-content label { color: rgba(245,241,232,0.6) !important; }
+ /* Force the wide-tracked NOVASUEDE wordmark to stay white */
+ .ns-wordmark, .ns-wordmark sup { color: #f5f1e8 !important; }
+
+ /* ─── Modal-internal contrast (post-QA defects) ─── */
+ /* Backdrop covers full viewport */
+ .modal { width: 100vw !important; height: 100vh !important; left: 0 !important; top: 0 !important; background: rgba(0, 0, 0, 0.78) !important; backdrop-filter: blur(6px); }
+ /* Chip — flip from white-on-white island to dark card */
+ .request-chip {
+ background: rgba(245, 241, 232, 0.06) !important;
+ color: #f5f1e8 !important;
+ border: 1px solid rgba(245, 241, 232, 0.18) !important;
+ border-radius: 4px !important;
+ }
+ .request-chip * { color: #f5f1e8 !important; }
+ .request-chip .chip-swatch {
+ /* swatch dot ALWAYS displays the inline-style hex from JS — fight any sweep */
+ border: 1px solid rgba(0, 0, 0, 0.4) !important;
+ }
+ /* + Add to request — hairline white-border on transparent (no gold) */
+ .add-to-request-btn {
+ background: transparent !important;
+ color: #f5f1e8 !important;
+ border: 1px solid rgba(245, 241, 232, 0.6) !important;
+ border-radius: 0 !important;
+ padding: 12px 22px !important;
+ font-size: 11px !important;
+ letter-spacing: 0.32em !important;
+ text-transform: uppercase !important;
+ font-weight: 400 !important;
+ }
+ .add-to-request-btn:hover {
+ background: #f5f1e8 !important;
+ color: #0a0a0a !important;
+ border-color: #f5f1e8 !important;
+ }
+ /* Hex caption visible */
+ .preview-hex, #colorPreviewHex {
+ display: block !important;
+ color: rgba(245, 241, 232, 0.55) !important;
+ font-family: ui-monospace, "SF Mono", Menlo, monospace !important;
+ font-size: 11px !important;
+ margin-top: 4px !important;
+ letter-spacing: 0.04em !important;
+ text-transform: lowercase !important;
+ }
+ /* Color preview name — stays single, prominent */
+ #colorPreviewName {
+ color: #f5f1e8 !important;
+ font-size: 11px !important;
+ letter-spacing: 0.32em !important;
+ text-transform: uppercase !important;
+ font-weight: 500 !important;
+ display: block !important;
+ margin-top: 12px !important;
+ }
+
+ /* ─── Mobile hamburger nav (replaces the overflow at <760px) ─── */
+ @media (max-width: 760px) {
+ header .nav-container { flex-wrap: wrap; }
+ header nav { display: none; flex-basis: 100%; flex-direction: column; gap: 6px; padding: 16px 0 6px; }
+ header nav.open { display: flex; }
+ header nav a { padding: 10px 0; border-bottom: 1px solid rgba(245,241,232,0.1); }
+ header .mobile-toggle {
+ display: flex !important;
+ flex-direction: column;
+ gap: 5px;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ padding: 8px;
+ }
+ header .mobile-toggle span {
+ width: 22px; height: 1.5px;
+ background: #f5f1e8;
+ display: block;
+ }
+ }
+ @media (min-width: 761px) {
+ header .mobile-toggle { display: none !important; }
+ }
+
+ </style>
+<style id="ns-gold-kill">
+ /* novasuede: kill gold-solid fills, off-white instead. Edge color stays gold. */
+ .new-badge, .new-arrivals-pill,
+ .color-filter-btn.active, .drapery-btn,
+ .sample-btn-chip, .cb-cta {
+ background: #f5f1e8 !important;
+ color: #0a0a0a !important;
+ border-color: #f5f1e8 !important;
+ }
+ .new-badge:hover, .new-arrivals-pill:hover,
+ .color-filter-btn.active:hover, .drapery-btn:hover,
+ .sample-btn-chip:hover, .cb-cta:hover {
+ background: #fff !important;
+ color: #0a0a0a !important;
+ }
+ </style>
+<header class="ns-header">
+ <a href="/" class="ns-brand" aria-label="Home">
+ <span class="ns-dot"></span>
+ <span class="ns-name">FFE Purchasing</span>
+ </a>
+ <nav class="ns-nav" id="nsPrimaryNav" aria-label="Primary">
+ <a href="/">Home</a>
+ <a href="/about">About</a>
+ <button type="button" onclick="nsContactOpen()">Contact</button>
+ </nav>
+ <button type="button" class="mobile-toggle" aria-label="Toggle menu" aria-controls="nsPrimaryNav" aria-expanded="false" onclick="nsToggleNav(this)">
+ <span></span><span></span><span></span>
+ </button>
+ </header>
+
+<div class="ns-modal" id="nsContactModal" role="dialog" aria-modal="true" aria-labelledby="nsContactTitle" onclick="if(event.target===this)nsContactClose()">
+ <div class="ns-modal-card">
+ <button type="button" class="close-x" onclick="nsContactClose()" aria-label="Close">×</button>
+ <h3 id="nsContactTitle">How can we help?</h3>
+ <p class="sub">Trade-account replies within one business day.</p>
+ <div class="ns-actions">
+ <a class="ns-action" href="#contact" onclick="nsContactClose()"><span class="ico">📋</span><div class="txt"><b>Get a Quote</b><span>Project specs → priced estimate</span></div></a>
+ <a class="ns-action" href="#contact" onclick="nsContactClose();openModal();"><span class="ico">📦</span><div class="txt"><b>Order a Sample</b><span>Free swatch to your shop</span></div></a>
+ <a class="ns-action" href="mailto:info@ffepurchasing.com?subject=Trade%20Account%20Inquiry"><span class="ico">✉️</span><div class="txt"><b>Email Us</b><span>info@ffepurchasing.com</span></div></a>
+ <a class="ns-action" href="tel:888-373-4564"><span class="ico">📞</span><div class="txt"><b>Call Us</b><span>(888) 373-4564 · M–F, 9–5 PT</span></div></a>
+ </div>
+ </div>
+ </div>
+<script>
+ function nsContactOpen() { var m = document.getElementById('nsContactModal'); if (m) m.classList.add('show'); }
+ function nsContactClose() { var m = document.getElementById('nsContactModal'); if (m) m.classList.remove('show'); }
+ function nsToggleNav(btn) {
+ var nav = document.getElementById('nsPrimaryNav');
+ if (!nav) return;
+ var open = nav.classList.toggle('open');
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ }
+ document.addEventListener('keydown', function(e){ if (e.key === 'Escape') nsContactClose(); });
+</script>
+
+
+
+
+
+
+
+
+
+
+
+<header>
+ <button class="h-link" onclick="dwmOpen('Contact')" aria-label="Contact"><svg class="h-icon" viewBox="0 0 24 24"><path d="M3 7h18M3 12h18M3 17h18"/></svg><span>Contact</span></button>
+ <button class="theme-toggle" id="theme-toggle" aria-label="Theme toggle">☾</button>
+</header>
+
+<section class="cinema">
+ <div class="cinema-bg" id="heroBg"></div>
+ <div class="corner-mark">Trade-Grade</div>
+ <div class="hero-pitch">
+ <h1 class="hero-h1">FF&E procurement<br>for hospitality buyers.</h1>
+ <p class="hero-sub">Wallcoverings, fixtures, and finishing materials with trade pricing, NET30 accounts, MOQ-aware ordering, and tear-sheet downloads.</p>
+ <div class="hero-cta-row">
+ <a class="hero-cta-primary" href="/trade-account">Open trade account →</a>
+ <a class="hero-cta-secondary" href="#shop">Browse the catalog</a>
+ </div>
+ </div>
+ <div class="meta-line">Hotel · Restaurant · Spa · Class A<span class="num" id="heroNum"></span></div>
+ <a class="enter" href="#shop">Enter <svg viewBox="0 0 24 12" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M0 6h22M16 1l6 5-6 5"/></svg></a>
+</section>
+
+
+<section class="section" id="ideas">
+ <div class="rail-block">
+ <div class="rail-eyebrow">Ideas</div>
+ <h2 class="rail-title">New this week</h2>
+ <div class="rail" id="railNewest"></div>
+ </div>
+ <div class="rail-block">
+ <div class="rail-eyebrow">Ideas</div>
+ <h2 class="rail-title">Curated picks</h2>
+ <div class="rail" id="railCurated"></div>
+ </div>
+</section>
+
+<section class="section" id="shop">
+ <div class="section-header">
+ <div>
+ <div class="section-eyebrow">The Collection</div>
+ <h2 class="section-title"><span class="accent" id="totalCount">—</span> Patterns.</h2>
+ </div>
+ <div class="section-meta">Hotel · Restaurant · Spa · Class A</div>
+ </div>
+
+ <div class="filters" id="facets">
+ <button class="chip active" data-facet="all">All</button>
+ </div>
+
+ <div class="density">
+ <div class="view-toggle" role="tablist" aria-label="View mode">
+ <button type="button" id="viewGrid" class="active" role="tab" aria-selected="true">Grid view</button>
+ <button type="button" id="viewTable" role="tab" aria-selected="false">Table view</button>
+ </div>
+ <label>Sort</label>
+ <select id="sortSelect" aria-label="Sort products" style="background:transparent;color:inherit;border:1px solid var(--line,rgba(255,255,255,.2));font:inherit;font-size:11px;letter-spacing:.10em;text-transform:uppercase;padding:4px 8px;cursor:pointer;outline:none;margin-right:14px">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="style">Style</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="title">Title A→Z</option>
+ <option value="price-asc">Price ↑</option>
+ <option value="price-desc">Price ↓</option>
+ </select>
+ <label>Grid</label>
+ <input type="range" id="densitySlider" min="4" max="12" step="1" value="6" aria-label="Grid columns">
+ <span class="ct" id="densityLabel">6 cols</span>
+ <div class="search">
+ <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5.5-5.5"/></svg>
+ <input type="text" id="searchInput" placeholder="Search by pattern, color, SKU…" autocomplete="off">
+ </div>
+ </div>
+
+ <div class="stat-line" id="statLine">Loading…</div>
+ <div class="grid" id="grid"></div>
+ <div class="tbl-wrap" id="tblWrap" style="display:none">
+ <table class="products" id="tbl">
+ <thead><tr>
+ <th></th><th>SKU</th><th>Pattern / Vendor</th><th>Width</th><th>Repeat</th><th>Trade $/yd</th><th>MOQ</th><th></th>
+ </tr></thead>
+ <tbody id="tblBody"></tbody>
+ </table>
+ </div>
+ <div class="loading" id="loading">Loading more…</div>
+ <div class="sentinel" id="sentinel"></div>
+</section>
+
+<footer>
+ <div class="footer-grid" style="grid-template-columns:1.6fr 1fr 1fr 1fr 1fr">
+ <div>
+ <div class="footer-brand">FFE Purchasing</div>
+ <p class="footer-text">Hospitality FF&E procurement — trade pricing, NET30 accounts, MOQ-aware ordering, free memo samples for verified buyers.</p>
+ <p class="footer-text" style="margin-top:14px;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;font-weight:600;color:var(--paper);opacity:0.7"><a href="/trade-account" style="color:inherit;text-decoration:none;border-bottom:1px solid var(--accent)">Open trade account →</a></p>
+ </div>
+ <div class="footer-col">
+ <h4>Aesthetic</h4>
+ <div id="footerFacets"></div>
+ </div>
+ <div class="footer-col">
+ <h4>Trade</h4>
+ <a href="/trade-account">Open trade account</a>
+ <button onclick="dwmOpen('Inquiry')">Project Inquiry</button>
+ <button onclick="dwmOpen('Sample')">Request Sample</button>
+ <button onclick="dwmOpen('Contact')">Contact</button>
+ </div>
+ <div class="footer-col">
+ <h4>Spec sheets</h4>
+ <a href="/spec-sheets/wallcoverings.pdf">Wallcoverings</a>
+ <a href="/spec-sheets/contract-vinyl.pdf">Contract vinyl</a>
+ <a href="/spec-sheets/grasscloth.pdf">Grasscloth & naturals</a>
+ <a href="/spec-sheets/acoustical.pdf">Acoustical panels</a>
+ <a href="/spec-sheets/fire-rating.pdf">Class A fire ratings</a>
+ <a href="/spec-sheets/install-guide.pdf">Install & care</a>
+ </div>
+ <div class="footer-col">
+ <h4>Social</h4>
+ <a class="fb-link" href="https://www.facebook.com/SteveAbramsDesigns.DesignerWallcoverings" target="_blank" rel="noopener">Facebook</a>
+ </div>
+ </div>
+ <div class="footer-bottom">
+ <span>ffepurchasing.com · Hospitality FF&E Trade Portal</span>
+ <span><a href="mailto:info@ffepurchasing.com" style="color:var(--paper);text-decoration:none;border-bottom:1px solid var(--accent);padding-bottom:1px">info@ffepurchasing.com</a></span>
+ <span id="footerStat"></span>
+ </div>
+</footer>
+
+<script>
+// mini-constellation — 43 niche-stars, current site highlighted in gold
+(function(){
+ const SLUG = "ffepurchasing";
+ const NICHES = [
+ ['silkwallpaper','Silk','mat'],['silkwallcoverings','Silk W/C','mat'],['linenwallpaper','Linen','mat'],
+ ['jutewallpaper','Jute','mat'],['raffiawallcoverings','Raffia W/C','mat'],['raffiawalls','Raffia Walls','mat'],
+ ['corkwallcovering','Cork','mat'],['fabricwallpaper','Fabric','mat'],['textilewallpaper','Textile','mat'],
+ ['metallicwallpaper','Metallic','mat'],['silverleafwallpaper','Silver Leaf','mat'],['vinylwallpaper','Vinyl','mat'],
+ ['micawallpaper','Mica','mat'],['madagascarwallpaper','Madagascar','mat'],['mylarwallpaper','Mylar','mat'],
+ ['suedewallpaper','Suede','mat'],
+ ['1800swallpaper','1800s','dec'],['1890swallpaper','1890s','dec'],['1900swallpaper','1900s','dec'],
+ ['1920swallpaper','1920s','dec'],['1930swallpaper','1930s','dec'],['1940swallpaper','1940s','dec'],
+ ['1950swallpaper','1950s','dec'],['1960swallpaper','1960s','dec'],['1970swallpaper','1970s','dec'],
+ ['1980swallpaper','1980s','dec'],['retrowalls','Retro','dec'],['wallpapersback','W. Back','dec'],
+ ['agedwallpaper','Aged','cra'],['handcraftedwallpaper','Handcrafted','cra'],['museumwallpaper','Museum','cra'],
+ ['restorationwallpaper','Restoration','cra'],['pastelwallpaper','Pastel','cra'],['glitterwalls','Glitter','cra'],
+ ['greenwallcoverings','Green','cra'],['naturalwallcoverings','Natural','cra'],['saloonwallpaper','Saloon','cra'],
+ ['contractwallpaper','Contract','use'],['hotelwallcoverings','Hotel','use'],['hospitalitywallpaper','Hospitality','use'],
+ ['healthcarewallpaper','Healthcare','use'],['restaurantwallpaper','Restaurant','use'],['architecturalwallcoverings','Architectural','use']
+ ];
+ const COLOR = { mat:'#c9b687', dec:'#b86a4a', cra:'#6b8e6f', use:'#4a6b8e' };
+ const W = 1080, H = 200, BANDS = ['mat','dec','cra','use'];
+ const groups = { mat:[], dec:[], cra:[], use:[] };
+ for (const n of NICHES) groups[n[2]].push(n);
+ const svg = document.getElementById('miniConst');
+ if (!svg) return;
+ const bandW = W / BANDS.length;
+ let nodes = '';
+ BANDS.forEach((band, bi) => {
+ const list = groups[band];
+ const cx = bi * bandW + bandW / 2;
+ list.forEach((n, i) => {
+ const t = list.length === 1 ? 0.5 : i / (list.length - 1);
+ // deterministic jitter per slug
+ let h = 0; for (let k = 0; k < n[0].length; k++) h = (h * 31 + n[0].charCodeAt(k)) | 0;
+ const jx = ((h & 0xff) / 255 - 0.5) * (bandW * 0.5);
+ const jy = (((h >> 8) & 0xff) / 255 - 0.5) * 6; // tightened from 14 → 6 to prevent same-band collisions (16 mat nodes / 144px = 9px spacing)
+ const x = cx + jx, y = 28 + t * (H - 56) + jy;
+ const isCurrent = n[0] === SLUG;
+ const r = isCurrent ? 8 : 4;
+ const op = isCurrent ? 1 : 0.55;
+ nodes += `<g class="mc-node" data-slug="${n[0]}" data-label="${n[1]}" style="cursor:pointer">
+ <circle cx="${x}" cy="${y}" r="${r}" fill="url(#dna-${band})" fill-opacity="${op}" stroke="${COLOR[band]}" stroke-opacity="${isCurrent?1:0.7}" stroke-width="${isCurrent?2:1}">
+ <title>${n[1]} — ${n[0]}.com</title>
+ </circle>
+ ${isCurrent ? `<circle cx="${x}" cy="${y}" r="${r+5}" fill="none" stroke="${COLOR[band]}" stroke-opacity="0.4" stroke-width="1"><animate attributeName="r" values="${r+5};${r+12};${r+5}" dur="2.4s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.4;0;0.4" dur="2.4s" repeatCount="indefinite"/></circle>` : ''}
+ </g>`;
+ });
+ });
+ // append nodes after defs
+ svg.insertAdjacentHTML('beforeend', nodes);
+ svg.querySelectorAll('.mc-node').forEach(n => {
+ n.addEventListener('mouseenter', () => n.querySelector('circle').setAttribute('fill-opacity', '1'));
+ n.addEventListener('mouseleave', e => {
+ const isCurrent = n.dataset.slug === SLUG;
+ n.querySelector('circle').setAttribute('fill-opacity', isCurrent ? '1' : '0.55');
+ });
+ n.addEventListener('click', () => window.open('https://' + n.dataset.slug + '.com', '_blank'));
+ });
+})();
+</script>
+
+<script>
+const state = { q:'', facet:'all', page:1, pages:1, total:0, loading:false, exhausted:false };
+const LABELS = {"all":"All"};
+
+function escAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }
+// Image-URL allowlist: only DW Shopify CDN + designerwallcoverings.com (defends against XSS via crafted products.json)
+function safeImg(u) { return /^https:\/\/(?:cdn\.shopify\.com|designerwallcoverings\.com)\//.test(u || '') ? u : '/hero-bg.jpg'; }
+// Trade-spec helpers — derive MOQ / width / repeat / NET30 trade $ from product fields when available,
+// fall back to "Spec sheet on request" so the card never looks DTC.
+function tradeSpec(p) {
+ // MOQ — explicit field, else derived from product_type/tags. Stays empty if nothing real.
+ const moq = (p.moq != null) ? Number(p.moq) :
+ (p.minimum_order || p.min_order || p.min_yards || null);
+ // Trade price — explicit trade_price, else max_price (assumed wholesale on this catalog), else null.
+ const trade = (p.trade_price != null) ? Number(p.trade_price) :
+ (p.max_price != null) ? Number(p.max_price) : null;
+ return {
+ sku: p.sku || p.handle || '',
+ width: p.width || null,
+ repeat: p.repeat || p.vertical_repeat || null,
+ moq: (moq && Number(moq) > 0) ? Number(moq) : null,
+ trade: (trade && Number(trade) > 0) ? Number(trade) : null
+ };
+}
+function fmtTrade(n) { return '$' + (Math.round(n * 100) / 100).toFixed(2) + '/yd'; }
+function fmtMoq(n) { return 'Min: ' + n + ' yd'; }
+
+function cardHTML(p) {
+ const eager = state.page === 1;
+ const t = tradeSpec(p);
+ const sku = t.sku;
+ let trade = '';
+ if (sku) trade += '<div class="row"><span class="lbl">SKU</span><span class="val">' + escAttr(sku) + '</span></div>';
+ if (t.trade) trade += '<div class="row"><span class="lbl">NET30</span><span class="val">' + fmtTrade(t.trade) + '</span></div>';
+ if (t.moq) trade += '<div class="row"><span class="lbl">MOQ</span><span class="val">' + fmtMoq(t.moq) + '</span></div>';
+ else trade += '<div class="moq-soft">Spec sheet on request</div>';
+ return '<img loading="' + (eager ? 'eager' : 'lazy') + '"' + (eager ? ' fetchpriority="high"' : '') + ' src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
+ + '<div class="overlay">'
+ + '<div class="pat">' + escAttr(p.pattern_name || p.title) + '</div>'
+ + '<div class="ven">' + escAttr((p.vendor || '').replace(/-/g, ' ')) + '</div>'
+ + '<div class="trade">' + trade + '</div>'
+ + '<div class="actions">'
+ + '<button class="sample-btn" onclick="event.stopPropagation();dwmOpen(\'Sample\',' + JSON.stringify({sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'"') + ')">Sample</button>'
+ + '</div></div>';
+}
+
+function rowHTML(p) {
+ const t = tradeSpec(p);
+ const optsAttr = JSON.stringify({sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'"');
+ return '<tr>'
+ + '<td><img class="timg" src="' + escAttr(safeImg(p.image_url)) + '" alt="" loading="lazy"></td>'
+ + '<td class="tsku">' + escAttr(t.sku || '—') + '</td>'
+ + '<td><div>' + escAttr(p.pattern_name || p.title) + '</div><div class="tven">' + escAttr((p.vendor || '').replace(/-/g, ' ')) + '</div></td>'
+ + '<td class="twidth">' + escAttr(t.width || '—') + '</td>'
+ + '<td class="trepeat">' + escAttr(t.repeat || '—') + '</td>'
+ + '<td class="tprice">' + (t.trade ? fmtTrade(t.trade) : '—') + '</td>'
+ + '<td class="tmoq">' + (t.moq ? fmtMoq(t.moq) : 'On request') + '</td>'
+ + '<td><button class="tact" onclick="dwmOpen(\'Sample\',' + optsAttr + ')">Sample</button></td>'
+ + '</tr>';
+}
+
+async function loadFacets() {
+ let f;
+ try {
+ const r = await fetch('/api/facets');
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ f = await r.json();
+ } catch (e) { console.error('loadFacets failed:', e); return; }
+ const el = document.getElementById('facets');
+ for (const [k, v] of Object.entries(f.aesthetics).sort((a,b) => b[1] - a[1])) {
+ const b = document.createElement('button');
+ b.className = 'chip'; b.dataset.facet = k;
+ b.innerHTML = (LABELS[k] || k) + ' <span style="opacity:.55;font-weight:500;margin-left:4px">' + v + '</span>';
+ el.appendChild(b);
+ }
+ el.addEventListener('click', e => {
+ if (e.target.tagName !== 'BUTTON') return;
+ document.querySelectorAll('#facets button').forEach(b => b.classList.remove('active'));
+ e.target.classList.add('active');
+ state.facet = e.target.dataset.facet;
+ resetGrid();
+ });
+ document.getElementById('totalCount').textContent = f.total;
+ document.getElementById('footerStat').textContent = f.total + ' patterns · live archive';
+ // Footer aesthetic links
+ const footerEl = document.getElementById('footerFacets');
+ for (const [k] of Object.entries(f.aesthetics)) {
+ const b = document.createElement('button');
+ b.textContent = LABELS[k] || k;
+ b.onclick = () => { state.facet = k; resetGrid(); document.querySelectorAll('#facets button').forEach(x => x.classList.toggle('active', x.dataset.facet === k)); document.getElementById('shop').scrollIntoView({behavior:'smooth'}); };
+ footerEl.appendChild(b);
+ }
+}
+
+function resetGrid() {
+ state.page = 1; state.exhausted = false;
+ document.getElementById('grid').innerHTML = '';
+ const tbody = document.getElementById('tblBody'); if (tbody) tbody.innerHTML = '';
+ document.getElementById('loading').textContent = 'Loading…';
+ loadGridPage();
+}
+
+async function loadGridPage() {
+ if (state.loading || state.exhausted) return;
+ state.loading = true;
+ const params = new URLSearchParams({ page:state.page, limit:24 });
+ if (state.q) params.set('q', state.q);
+ if (state.facet !== 'all') params.set('aesthetic', state.facet);
+ let data;
+ try {
+ const r = await fetch('/api/products?' + params);
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ data = await r.json();
+ } catch (e) {
+ console.error('loadGridPage failed:', e);
+ document.getElementById('loading').textContent = '— offline — refresh to retry —';
+ state.loading = false;
+ return;
+ }
+ state.total = data.total; state.pages = data.pages;
+
+ if (state.page === 1) {
+ document.getElementById('statLine').textContent = data.total + ' patterns · ' + (state.facet === 'all' ? 'all aesthetics' : (LABELS[state.facet] || state.facet)) + (state.q ? ' · matching "' + state.q + '"' : '');
+ }
+ const grid = document.getElementById('grid');
+ const tbody = document.getElementById('tblBody');
+ for (const p of data.items) {
+ const a = document.createElement('a');
+ a.className = 'card'; a.href = '#'; a.onclick = (e) => { e.preventDefault(); dwmOpen('Sample',{sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}); };
+ a.innerHTML = cardHTML(p);
+ grid.appendChild(a);
+ if (tbody) tbody.insertAdjacentHTML('beforeend', rowHTML(p));
+ }
+ if (state.page >= state.pages || data.items.length === 0) {
+ state.exhausted = true;
+ document.getElementById('loading').textContent = state.total > 0 ? '— end of archive · ' + state.total + ' patterns —' : '';
+ } else state.page++;
+ state.loading = false;
+}
+
+const io = new IntersectionObserver(es => { for (const e of es) if (e.isIntersecting) loadGridPage(); }, { rootMargin:'600px 0px' });
+io.observe(document.getElementById('sentinel'));
+
+document.getElementById('searchInput').addEventListener('input', e => {
+ state.q = e.target.value.trim();
+ clearTimeout(window._t);
+ window._t = setTimeout(resetGrid, 220);
+});
+
+// Density slider — at the lowest setting (4 cols) auto-flip to table view (B2B trade-portal default).
+const slider = document.getElementById('densitySlider');
+const dlabel = document.getElementById('densityLabel');
+function setDensity(n, opts) {
+ document.documentElement.style.setProperty('--cols', n);
+ dlabel.textContent = n + ' cols';
+ try { localStorage.setItem('hosp_theme_density', n); } catch(e){}
+ // Low-density auto-flip: <=4 cols ⇒ table view, ≥5 cols ⇒ grid view (unless user pinned manually).
+ if (!opts || opts.autoToggle !== false) {
+ const pinned = localStorage.getItem('ffe_view_pinned') === '1';
+ if (!pinned) { setView(n <= 4 ? 'table' : 'grid', { fromSlider:true }); }
+ }
+}
+slider.addEventListener('input', e => setDensity(parseInt(e.target.value)));
+const savedDensity = parseInt(localStorage.getItem('hosp_theme_density') || '6');
+if (savedDensity >= 4 && savedDensity <= 12) { slider.value = savedDensity; setDensity(savedDensity, { autoToggle:false }); }
+
+// Grid / Table view toggle
+function setView(mode, opts) {
+ const grid = document.getElementById('grid');
+ const tbl = document.getElementById('tblWrap');
+ const bg = document.getElementById('viewGrid');
+ const bt = document.getElementById('viewTable');
+ if (mode === 'table') {
+ grid.style.display = 'none'; tbl.style.display = 'block';
+ bg.classList.remove('active'); bt.classList.add('active');
+ bg.setAttribute('aria-selected','false'); bt.setAttribute('aria-selected','true');
+ } else {
+ grid.style.display = ''; tbl.style.display = 'none';
+ bg.classList.add('active'); bt.classList.remove('active');
+ bg.setAttribute('aria-selected','true'); bt.setAttribute('aria-selected','false');
+ }
+ try { localStorage.setItem('ffe_view', mode); } catch(e){}
+ if (!opts || !opts.fromSlider) { try { localStorage.setItem('ffe_view_pinned','1'); } catch(e){} }
+}
+document.getElementById('viewGrid').addEventListener('click', () => setView('grid'));
+document.getElementById('viewTable').addEventListener('click', () => setView('table'));
+const savedView = localStorage.getItem('ffe_view');
+if (savedView === 'table' || savedView === 'grid') setView(savedView, { fromSlider:true });
+else if (savedDensity <= 4) setView('table', { fromSlider:true });
+
+// Theme toggle
+const tb = document.getElementById('theme-toggle');
+function setTheme(t){ document.documentElement.dataset.theme = t; try { localStorage.setItem('hosp_theme', t); } catch(e){} tb.textContent = t === 'dark' ? '☾' : '☀'; }
+setTheme(document.documentElement.dataset.theme || 'light');
+tb.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
+
+loadFacets();
+loadGridPage();
+</script>
+<!-- ============================================================
+ DW UNIVERSAL CONTACT MODULE — fashion-house UX
+ Inject before
+ </body> in every DW-family sister site.
+ Self-contained: CSS + 4 modals + JS submission handlers.
+ Uses host site's CSS vars (--bg, --fg, --gold, --rule).
+ ============================================================ -->
+<style>
+ /* === MODAL BASE === */
+ .dwm{position:fixed;inset:0;background:rgba(0,0,0,0.78);display:none;align-items:center;justify-content:center;z-index:9999;padding:24px;overflow-y:auto}
+ .dwm.open{display:flex}
+ .dwm-box{background:var(--bg);border:1px solid var(--rule);max-width:520px;width:100%;padding:36px 32px;position:relative;max-height:90vh;overflow-y:auto;animation:dwm-in 0.25s ease}
+ @keyframes dwm-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
+ .dwm-box h3{font-family:'Playfair Display',Georgia,serif;font-style:italic;font-weight:400;font-size:32px;line-height:1.05;margin-bottom:6px;color:var(--fg);letter-spacing:-0.01em}
+ .dwm-box .sub{color:var(--muted);font-size:12px;letter-spacing:0.16em;text-transform:uppercase;margin-bottom:24px;font-weight:500}
+ .dwm-close{position:absolute;top:18px;right:18px;background:transparent;border:0;color:var(--muted);font-size:24px;cursor:pointer;width:32px;height:32px;line-height:1;padding:0}
+ .dwm-close:hover{color:var(--gold)}
+ .dwm-preview{display:flex;gap:14px;align-items:center;padding:12px;background:var(--bg-soft);margin-bottom:18px}
+ .dwm-preview img{width:64px;height:64px;object-fit:cover}
+ .dwm-preview .pn{font-family:'Playfair Display',Georgia,serif;font-style:italic;font-size:15px;line-height:1.2;color:var(--fg)}
+ .dwm-preview .pc{font-size:10px;letter-spacing:0.14em;text-transform:uppercase;color:var(--muted);margin-top:4px}
+ .dwm-field{margin-bottom:14px}
+ .dwm-field label{display:block;font-size:10px;letter-spacing:0.18em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:500}
+ .dwm-field input,.dwm-field textarea,.dwm-field select{width:100%;border:0;border-bottom:1px solid var(--rule);background:transparent;padding:8px 4px;font-size:14px;font-family:inherit;color:var(--fg);outline:none;transition:border-color 0.2s}
+ .dwm-field input:focus,.dwm-field textarea:focus{border-bottom-color:var(--gold)}
+ .dwm-field textarea{min-height:60px;resize:vertical}
+ .dwm-submit{background:transparent;color:var(--muted, #c9b89e);border:1px solid var(--muted, #c9b89e);padding:14px 28px;font-family:inherit;font-size:11px;letter-spacing:0.20em;text-transform:uppercase;font-weight:700;cursor:pointer;width:100%;margin-top:8px;transition:background 0.15s, color 0.15s}
+ .dwm-submit:hover{background:var(--muted, #c9b89e);color:var(--bg, #000)}
+
+ .dwm-submit:disabled{opacity:0.5;cursor:default}
+ .dwm-status{margin-top:14px;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;text-align:center;display:none}
+ .dwm-status.ok{color:var(--sage);display:block}
+ .dwm-status.err{color:#e08070;display:block}
+ /* contact-options */
+ .dwm-options{display:flex;flex-direction:column;gap:10px}
+ .dwm-option{display:flex;align-items:center;gap:14px;padding:18px 20px;background:transparent;border:1px solid var(--rule);color:var(--fg);font-family:inherit;font-size:11px;letter-spacing:0.20em;text-transform:uppercase;font-weight:600;cursor:pointer;text-decoration:none;transition:all 0.2s;text-align:left;width:100%}
+ .dwm-option:hover{border-color:var(--gold);color:var(--gold)}
+ .dwm-option svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.5;flex-shrink:0}
+ .dwm-option .lbl{flex:1;display:block}
+ .dwm-option .val{display:block;font-size:10px;letter-spacing:0.14em;text-transform:none;font-weight:500;opacity:0.65;margin-top:4px}
+
+/* graphics-loop pass 11: Ideas rails */
+#ideas { padding: 56px 24px 24px; max-width:1600px; margin:0 auto }
+#ideas .rail-block { margin-bottom:48px }
+#ideas .rail-eyebrow { font-size:11px; font-weight:700; letter-spacing:0.32em; text-transform:uppercase; color:var(--muted, #888); margin-bottom:8px }
+#ideas .rail-title { font-size:clamp(22px,2.6vw,32px); font-weight:500; letter-spacing:-0.01em; margin:0 0 18px; color:var(--ink,#fff) }
+#ideas .rail { display:flex; gap:14px; overflow-x:auto; scroll-snap-type:x mandatory; padding-bottom:8px; scrollbar-width:none }
+#ideas .rail::-webkit-scrollbar { display:none }
+#ideas .rail-card { flex:0 0 240px; scroll-snap-align:start; cursor:pointer; transition:transform 0.2s }
+#ideas .rail-card:hover { transform:translateY(-3px) }
+#ideas .rail-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; background:var(--card,#222) }
+#ideas .rail-card .rc-title { font-size:13px; font-weight:500; color:var(--paper,#fff); padding:8px 0 2px; line-height:1.3 }
+#ideas .rail-card .rc-meta { font-size:11px; color:var(--muted,#888); letter-spacing:0.06em }
+@media (max-width:720px) { #ideas .rail-card { flex:0 0 160px } }
+</style>
+
+<!-- Contact options modal -->
+<div class="dwm" id="dwmContact" onclick="if(event.target===this)dwmClose('Contact')">
+ <div class="dwm-box" style="max-width:440px">
+ <button class="dwm-close" onclick="dwmClose('Contact')" aria-label="Close">×</button>
+ <h3>Contact</h3>
+ <p class="sub" id="dwmContactSub">Three ways to reach us</p>
+ <div class="dwm-options">
+ <button class="dwm-option" type="button" onclick="dwmClose('Contact');dwmOpen('Inquiry')">
+ <svg viewBox="0 0 24 24"><path d="M4 4h16v12H7l-3 3z"/></svg>
+ <span class="lbl">Send an Inquiry<span class="val">Project name · scope · all the details</span></span>
+ </button>
+ <button class="dwm-option" type="button" onclick="dwmClose('Contact');dwmOpen('Sample')">
+ <svg viewBox="0 0 24 24"><rect x="4" y="6" width="16" height="14"/><path d="M4 10h16M9 6V3h6v3"/></svg>
+ <span class="lbl">Request a Sample<span class="val">Memo sample · ships free · 3–5 business days</span></span>
+ </button>
+ <a class="dwm-option" id="dwmContactEmailLink" href="mailto:info@ffepurchasing.com">
+ <svg viewBox="0 0 24 24"><path d="M3 6h18v12H3z"/><path d="M3 6l9 7 9-7"/></svg>
+ <span class="lbl">Email Us<span class="val" id="dwmContactEmailLabel">info@ffepurchasing.com</span></span>
+ </a>
+ </div>
+ </div>
+</div>
+
+<!-- Inquiry modal -->
+<div class="dwm" id="dwmInquiry" onclick="if(event.target===this)dwmClose('Inquiry')">
+ <div class="dwm-box">
+ <button class="dwm-close" onclick="dwmClose('Inquiry')" aria-label="Close">×</button>
+ <h3>Project Inquiry</h3>
+ <p class="sub">We'll respond within one business day</p>
+ <form id="dwmInquiryForm" onsubmit="return dwmSubmit(event,'inquiry')">
+ <div class="dwm-field"><label>Name</label><input type="text" name="name" required></div>
+ <div class="dwm-field"><label>Email</label><input type="email" name="email" required></div>
+ <div class="dwm-field"><label>Phone</label><input type="tel" name="phone"></div>
+ <div class="dwm-field"><label>Company / Trade</label><input type="text" name="company"></div>
+ <div class="dwm-field"><label>Project Name</label><input type="text" name="projectName" required></div>
+ <div class="dwm-field"><label>Project Scope</label><textarea name="projectScope" placeholder="Square footage · room count · timeline" required></textarea></div>
+ <div class="dwm-field"><label>Additional Information</label><textarea name="message" placeholder="Patterns of interest, finish preferences, budget"></textarea></div>
+ <button type="submit" class="dwm-submit">Send Inquiry</button>
+ <div class="dwm-status" id="dwmInquiryStatus"></div>
+ </form>
+ </div>
+</div>
+
+<!-- Sample-request modal -->
+<div class="dwm" id="dwmSample" onclick="if(event.target===this)dwmClose('Sample')">
+ <div class="dwm-box">
+ <button class="dwm-close" onclick="dwmClose('Sample')" aria-label="Close">×</button>
+ <h3>Request a Sample</h3>
+ <p class="sub">Memo sample · ships free · 3–5 business days</p>
+ <div class="dwm-preview" id="dwmSamplePreview" style="display:none">
+ <img id="dwmSampleImg" src="" alt="">
+ <div><div class="pn" id="dwmSampleName"></div><div class="pc" id="dwmSampleSku"></div></div>
+ </div>
+ <form id="dwmSampleForm" onsubmit="return dwmSubmit(event,'sample')">
+ <input type="hidden" name="sku" id="dwmSampleSkuInput">
+ <input type="hidden" name="title" id="dwmSampleTitleInput">
+ <input type="hidden" name="image_url" id="dwmSampleImageInput">
+ <div class="dwm-field"><label>Name</label><input type="text" name="name" required></div>
+ <div class="dwm-field"><label>Email</label><input type="email" name="email" required></div>
+ <div class="dwm-field"><label>Company / Trade</label><input type="text" name="company"></div>
+ <div class="dwm-field"><label>Shipping Address</label><input type="text" name="address" placeholder="Street" required></div>
+ <div class="dwm-field" style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px">
+ <input type="text" name="city" placeholder="City" required>
+ <input type="text" name="state" placeholder="State" required>
+ <input type="text" name="zip" placeholder="ZIP" required>
+ </div>
+ <div class="dwm-field"><label>Notes (optional)</label><textarea name="message" placeholder="Project, sq.ft., timing"></textarea></div>
+ <button type="submit" class="dwm-submit">Send Sample Request</button>
+ <div class="dwm-status" id="dwmSampleStatus"></div>
+ </form>
+ </div>
+</div>
+
+<script>
+ // Universal modal control
+ function dwmOpen(name, opts){
+ document.getElementById('dwm' + name).classList.add('open');
+ document.body.style.overflow = 'hidden';
+ if (name === 'Sample' && opts) {
+ document.getElementById('dwmSamplePreview').style.display = 'flex';
+ document.getElementById('dwmSampleImg').src = opts.image_url || '';
+ document.getElementById('dwmSampleName').textContent = opts.title || '';
+ document.getElementById('dwmSampleSku').textContent = opts.sku || '';
+ document.getElementById('dwmSampleSkuInput').value = opts.sku || '';
+ document.getElementById('dwmSampleTitleInput').value = opts.title || '';
+ document.getElementById('dwmSampleImageInput').value = opts.image_url || '';
+ } else if (name === 'Sample') {
+ document.getElementById('dwmSamplePreview').style.display = 'none';
+ document.getElementById('dwmSampleSkuInput').value = '';
+ document.getElementById('dwmSampleTitleInput').value = '';
+ document.getElementById('dwmSampleImageInput').value = '';
+ }
+ }
+ function dwmClose(name){ document.getElementById('dwm'+name).classList.remove('open'); document.body.style.overflow=''; }
+
+ // Universal form submit — POSTs to /api/send-{kind}
+ async function dwmSubmit(e, kind) {
+ e.preventDefault();
+ const form = e.target;
+ const data = Object.fromEntries(new FormData(form).entries());
+ const btn = form.querySelector('.dwm-submit');
+ const status = form.querySelector('.dwm-status');
+ btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Sending…';
+ status.className = 'dwm-status';
+ try {
+ const r = await fetch('/api/send-' + kind, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
+ if (!r.ok) throw new Error('HTTP '+r.status);
+ const j = await r.json();
+ status.textContent = kind === 'sample' ? 'Sample request sent — ships within 3–5 business days' : 'Inquiry sent — we respond within 1 business day';
+ status.className = 'dwm-status ok';
+ btn.textContent = 'Sent ✓';
+ setTimeout(() => { dwmClose(kind === 'sample' ? 'Sample' : 'Inquiry'); btn.disabled = false; btn.textContent = orig; status.className = 'dwm-status'; form.reset(); }, 2800);
+ } catch (err) {
+ status.textContent = 'Send failed — please email info@designerwallcoverings.com';
+ status.className = 'dwm-status err';
+ btn.disabled = false; btn.textContent = orig;
+ }
+ return false;
+ }
+
+ // Wire up "Memo samples ship free" badge in hero to open sample modal directly
+ document.querySelectorAll('.hero .badge, [data-open-sample]').forEach(el => {
+ el.style.cursor = 'pointer';
+ el.addEventListener('click', () => dwmOpen('Sample'));
+ });
+
+ // Wire nav "Trade" link to inquiry modal
+ document.querySelectorAll('nav a[href*="designerwallcoverings.com"]').forEach(a => {
+ a.removeAttribute('target');
+ a.removeAttribute('href');
+ a.style.cursor = 'pointer';
+ a.addEventListener('click', e => { e.preventDefault(); dwmOpen('Contact'); });
+ });
+
+ // Override product card clicks — open Sample modal pre-filled instead of redirecting to DW
+ document.addEventListener('click', e => {
+ const card = e.target.closest('.card, .rail-card');
+ if (!card) return;
+ if (card.tagName === 'A' && (card.getAttribute('href') || '').startsWith('/sample/')) {
+ e.preventDefault();
+ const img = card.querySelector('img');
+ const pat = card.querySelector('.pat');
+ const sku = card.getAttribute('href').replace('/sample/', '');
+ dwmOpen('Sample', {
+ sku,
+ title: pat ? pat.textContent.trim() : '',
+ image_url: img ? img.src : ''
+ });
+ }
+ }, true);
+// DW STANDARD: hero pulls from products.json[0].image_url so dw-collections-viewer
+// drag-drop / hero-pin updates the live front page on next deploy.
+// Always upgrades to the largest Shopify variant (strips _NxN size suffix) so
+// the full-page hero stays sharp.
+// DW STANDARD: hero rotates through top-20 NEWEST products on each visit
+// (novasuede.com pattern). Fetches /api/products?limit=100, sorts by created_at desc,
+// takes the freshest 20, picks one per visit via localStorage counter.
+// Falls back to static /hero-bg.jpg on any error.
+(async function rotateHero() {
+ try {
+ const r = await fetch('/api/products?limit=100');
+ if (!r.ok) return;
+ const d = await r.json();
+ const items = (d.items || [])
+ .filter(p => p && p.image_url && /^https:\/\/(?:cdn\.shopify\.com|designerwallcoverings\.com)\//.test(p.image_url))
+ .sort((a,b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
+ .slice(0, 20);
+ if (!items.length) return;
+ let visits = 0;
+ try {
+ visits = parseInt(localStorage.getItem('hero_visits') || '0', 10) || 0;
+ localStorage.setItem('hero_visits', String(visits + 1));
+ } catch(e) {}
+ const pick = items[visits % items.length];
+ // Strip _NxN size suffix to get the largest CDN variant
+ const url = pick.image_url.replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4');
+ const bg = document.getElementById('heroBg') || document.getElementById('cinemaBg');
+ if (bg) bg.style.backgroundImage = "url('" + url + "')";
+ } catch (e) {}
+})();
+
+
+// DW STANDARD sort-select wire-up (added by fleet patcher)
+(function(){
+ const sortSel = document.getElementById('sortSelect');
+ if (!sortSel) return;
+ const KEY = location.hostname.replace(/\./g, '_') + '_sort';
+ const saved = localStorage.getItem(KEY);
+ if (saved) { try { sortSel.value = saved; } catch(e){} }
+ if (typeof state !== 'undefined') state.sort = sortSel.value;
+ sortSel.addEventListener('change', e => {
+ const v = e.target.value;
+ try { localStorage.setItem(KEY, v); } catch(e){}
+ if (typeof state !== 'undefined') state.sort = v;
+ if (typeof resetGrid === 'function') resetGrid();
+ else location.reload();
+ });
+})();
+
+// graphics-loop pass 11: hydrate Ideas rails from /api/products
+(async function hydrateIdeas() {
+ try {
+ const r = await fetch('/api/products?limit=80');
+ if (!r.ok) return;
+ const d = await r.json();
+ const items = (d.items || []).filter(p => p && p.image_url);
+ if (!items.length) return;
+ const sorted = [...items].sort((a,b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
+ const newest = sorted.slice(0, 12);
+ // Curated = deterministic offset slice (every 5th item starting at idx 3) for variety
+ const curated = items.filter((_, i) => i % 5 === 3).slice(0, 12);
+ function card(p) {
+ const url = (p.image_url || '').replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4');
+ return '<a class="rail-card" href="/sample/' + encodeURIComponent(p.handle || p.sku) + '">' +
+ '<img src="' + url + '" alt="' + (p.title || '').replace(/"/g, '"') + '" loading="lazy">' +
+ '<div class="rc-title">' + ((p.title || '').split('|')[0] || '').trim() + '</div>' +
+ '<div class="rc-meta">' + (p.vendor || '') + '</div>' +
+ '</a>';
+ }
+ const r1 = document.getElementById('railNewest');
+ const r2 = document.getElementById('railCurated');
+ if (r1) r1.innerHTML = newest.map(card).join('');
+ if (r2) r2.innerHTML = curated.map(card).join('');
+ } catch(e) {}
+})();
+</script>
+
+<script src="/dw-fleet-nav.js" defer></script>
+</body>
+</html>
diff --git a/public/trade-account.html b/public/trade-account.html
new file mode 100644
index 0000000..5eb1691
--- /dev/null
+++ b/public/trade-account.html
@@ -0,0 +1,141 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1.0">
+<title>Open Trade Account — FFE Purchasing</title>
+<meta name="description" content="Open a trade account with FFE Purchasing. NET30 terms, MOQ-aware ordering, and tear-sheet downloads for verified hospitality buyers.">
+<link rel="icon" type="image/svg+xml" href="/favicon.svg">
+<link rel="canonical" href="https://ffepurchasing.com/trade-account">
+<meta name="robots" content="noindex,follow">
+<!-- Google tag (gtag.js) -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=G-WJ8QQQ1YEW"></script>
+<script>
+ window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', 'G-WJ8QQQ1YEW');
+</script>
+<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
+<style>
+ :root { --bg:#F5F1E8; --paper:#101010; --muted:#5A554C; --line:rgba(16,16,16,0.14); --accent:#E55A2B; --bg-soft:#ECE5D2; }
+ * { margin:0; padding:0; box-sizing:border-box }
+ html { scroll-behavior:smooth }
+ body { font-family:'Inter',sans-serif; color:var(--paper); background:var(--bg); -webkit-font-smoothing:antialiased; min-height:100vh }
+ a { color:var(--paper); text-decoration:none; border-bottom:1px solid var(--accent) }
+ a:hover { color:var(--accent) }
+ header { display:flex; justify-content:space-between; align-items:center; padding:22px 32px; border-bottom:1px solid var(--line) }
+ .brand { font-family:'Inter Tight',sans-serif; font-weight:600; font-size:14px; letter-spacing:0.02em; display:inline-flex; align-items:center; gap:10px }
+ .brand .dot { width:14px; height:14px; border-radius:50%; background:var(--accent); display:inline-block }
+ .brand a { border:0; color:var(--paper) }
+ nav { font-size:11px; letter-spacing:0.22em; text-transform:uppercase; font-weight:700 }
+ nav a { border:0; color:var(--paper); margin-left:24px }
+ nav a:hover { color:var(--accent) }
+ .wrap { max-width:980px; margin:0 auto; padding:64px 32px }
+ .grid { display:grid; grid-template-columns:1.2fr 1fr; gap:48px; align-items:start }
+ @media (max-width:760px) { .grid { grid-template-columns:1fr; gap:32px } }
+ .eyebrow { font-size:10px; letter-spacing:0.4em; text-transform:uppercase; font-weight:700; color:var(--muted); margin-bottom:14px }
+ h1 { font-family:'Inter Tight',sans-serif; font-weight:600; font-size:clamp(34px,5vw,52px); line-height:1.05; letter-spacing:-0.02em; margin-bottom:18px }
+ .lede { font-size:16px; line-height:1.55; color:var(--muted); margin-bottom:28px; max-width:50ch }
+ .perk-list { list-style:none; padding:0; margin:0 0 28px }
+ .perk-list li { display:flex; gap:12px; padding:10px 0; border-top:1px solid var(--line); font-size:13px; line-height:1.45 }
+ .perk-list li:last-child { border-bottom:1px solid var(--line) }
+ .perk-list .k { font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:11px; letter-spacing:0.04em; color:var(--accent); flex:0 0 84px; text-transform:uppercase; padding-top:2px }
+ .perk-list .v { color:var(--paper) }
+ form { background:var(--bg-soft); border:1px solid var(--line); padding:28px 28px 24px; border-radius:0 }
+ form h2 { font-family:'Inter Tight',sans-serif; font-weight:600; font-size:18px; margin-bottom:6px }
+ form .sub { font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); margin-bottom:22px; font-weight:600 }
+ .field { margin-bottom:14px }
+ .field label { display:block; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; color:var(--muted); margin-bottom:6px; font-weight:700 }
+ .field input, .field select { width:100%; border:0; border-bottom:1px solid var(--line); background:transparent; padding:10px 4px; font-size:14px; font-family:inherit; color:var(--paper); outline:none; transition:border-color .15s }
+ .field input:focus, .field select:focus { border-bottom-color:var(--accent) }
+ .submit { width:100%; padding:14px 20px; background:var(--accent); color:var(--paper); border:1px solid var(--accent); font-family:'Inter Tight',sans-serif; font-size:11px; letter-spacing:0.32em; text-transform:uppercase; font-weight:700; cursor:pointer; margin-top:10px; transition:opacity .15s }
+ .submit:hover { opacity:0.9 }
+ .submit:disabled { opacity:0.5; cursor:default }
+ .status { margin-top:14px; font-size:11px; letter-spacing:0.16em; text-transform:uppercase; text-align:center; min-height:14px; font-weight:600 }
+ .status.ok { color:#3a6b3a }
+ .status.err { color:#a13a2b }
+ .legal { font-size:11px; line-height:1.5; color:var(--muted); margin-top:16px }
+ footer { border-top:1px solid var(--line); padding:32px; text-align:center; font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted) }
+</style>
+</head>
+<body>
+<header>
+ <div class="brand"><span class="dot"></span><a href="/">FFE Purchasing</a></div>
+ <nav><a href="/">Catalog</a><a href="/#shop">Patterns</a></nav>
+</header>
+
+<div class="wrap">
+ <div class="grid">
+ <div>
+ <div class="eyebrow">Trade Account</div>
+ <h1>Open a trade account.</h1>
+ <p class="lede">For hospitality buyers, designers, and procurement teams. Trade pricing, NET30 terms, MOQ-aware ordering, and downloadable tear sheets — once your account is verified.</p>
+ <ul class="perk-list">
+ <li><span class="k">NET30</span><span class="v">Net-30 invoicing on approved accounts. No cards required at order.</span></li>
+ <li><span class="k">MOQ</span><span class="v">Order to your job spec — minimums shown per SKU before commit.</span></li>
+ <li><span class="k">Spec</span><span class="v">Class-A fire-rated wallcoverings with tear sheets, fire certs, and CSI-format spec downloads.</span></li>
+ <li><span class="k">Memo</span><span class="v">Memo samples ship free to verified accounts within 3–5 business days.</span></li>
+ </ul>
+ <p class="legal">Submitting this form starts an account review. We respond within one business day. No payments are processed at sign-up.</p>
+ </div>
+ <form id="taForm" autocomplete="on">
+ <h2>Account Inquiry</h2>
+ <p class="sub">2-field minimum · Reply within 1 business day</p>
+ <div class="field"><label for="email">Work email</label><input type="email" id="email" name="email" required autocomplete="email"></div>
+ <div class="field"><label for="company">Company / Firm</label><input type="text" id="company" name="company" required autocomplete="organization"></div>
+ <div class="field"><label for="name">Name <span style="opacity:.5;font-size:9px">optional</span></label><input type="text" id="name" name="name" autocomplete="name"></div>
+ <div class="field"><label for="role">Role <span style="opacity:.5;font-size:9px">optional</span></label>
+ <select id="role" name="role">
+ <option value="">— Select —</option>
+ <option>Hospitality Buyer</option>
+ <option>Interior Designer</option>
+ <option>FF&E Purchasing</option>
+ <option>Architect / Specifier</option>
+ <option>General Contractor</option>
+ <option>Other</option>
+ </select>
+ </div>
+ <button type="submit" class="submit">Open trade account →</button>
+ <div class="status" id="taStatus"></div>
+ </form>
+ </div>
+</div>
+
+<footer>
+ <span>ffepurchasing.com · Hospitality FF&E Trade Portal</span>
+</footer>
+
+<script>
+document.getElementById('taForm').addEventListener('submit', async function(e){
+ e.preventDefault();
+ var f = e.target;
+ var btn = f.querySelector('.submit');
+ var st = document.getElementById('taStatus');
+ var orig = btn.textContent;
+ btn.disabled = true; btn.textContent = 'Sending…'; st.textContent=''; st.className='status';
+ var data = {
+ email: f.email.value.trim(),
+ company: f.company.value.trim(),
+ name: f.name.value.trim(),
+ role: f.role.value
+ };
+ try {
+ var r = await fetch('/api/trade-inquiry', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
+ if (!r.ok) throw new Error('HTTP '+r.status);
+ var j = await r.json();
+ if (!j.ok) throw new Error('server rejected');
+ st.textContent = 'Thanks — we will reply within 1 business day.';
+ st.className = 'status ok';
+ btn.textContent = 'Sent ✓';
+ f.reset();
+ if (window.gtag) gtag('event','trade_inquiry_submit',{event_category:'lead'});
+ } catch (err) {
+ st.textContent = 'Send failed — please email info@ffepurchasing.com';
+ st.className = 'status err';
+ btn.disabled = false; btn.textContent = orig;
+ }
+});
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index e2dc8ba..2aed233 100644
--- a/server.js
+++ b/server.js
@@ -132,6 +132,34 @@ app.get('/api/facets', (req, res) => {
app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
+// Trade-account inquiry (stub) — appends to data/trade-inquiries.jsonl. No outbound email.
+app.post('/api/trade-inquiry', (req, res) => {
+ const { email, company, name, role } = req.body || {};
+ if (!email || !company) return res.status(400).json({ ok:false, error:'email and company required' });
+ if (String(email).length > 200 || String(company).length > 200) return res.status(400).json({ ok:false, error:'too long' });
+ const row = {
+ ts: new Date().toISOString(),
+ ip: (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').toString().split(',')[0].trim(),
+ email: String(email).slice(0, 200),
+ company: String(company).slice(0, 200),
+ name: name ? String(name).slice(0, 120) : '',
+ role: role ? String(role).slice(0, 80) : '',
+ ua: (req.headers['user-agent'] || '').toString().slice(0, 200)
+ };
+ try {
+ fs.appendFileSync(path.join(__dirname, 'data', 'trade-inquiries.jsonl'), JSON.stringify(row) + '\n');
+ } catch (e) {
+ console.error(`[${__SITE}] trade-inquiry write failed:`, e.message);
+ return res.status(500).json({ ok:false });
+ }
+ res.json({ ok:true });
+});
+
+// Trade-account stub page
+app.get('/trade-account', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'trade-account.html'));
+});
+
app.get('/sample/:handle', (req, res) => {
const p = PRODUCTS_NICHE.find(x => x.handle === req.params.handle || x.sku === req.params.handle);
if (!p) return res.status(404).send('Not found');
← 137bcca add gated figma capture loader (only fires on ?figmacapture=
·
back to Ffepurchasing
·
remove stray contact modal markup that was rendering inline 0102b55 →