[object Object]

← back to Wallco Ai

footer: add visible phone, DW corp address, and © 2026 line

2ac2375d6f74531facd378e35c48a73287c43320 · 2026-05-13 14:54:10 -0700 · Steve Abrams

DW standing rule — every public site's visible footer must surface
info@wallco.ai mailto, 888-373-4564 phone, 15442 Ventura Bl #102 Sherman
Oaks CA 91403 address, and current-year copyright. Address was already
in JSON-LD schema; this lifts it into human-visible HTML.

Files touched

Diff

commit 2ac2375d6f74531facd378e35c48a73287c43320
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 14:54:10 2026 -0700

    footer: add visible phone, DW corp address, and © 2026 line
    
    DW standing rule — every public site's visible footer must surface
    info@wallco.ai mailto, 888-373-4564 phone, 15442 Ventura Bl #102 Sherman
    Oaks CA 91403 address, and current-year copyright. Address was already
    in JSON-LD schema; this lifts it into human-visible HTML.
---
 public/css/site.css |  18 ++++
 server.js           | 295 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 303 insertions(+), 10 deletions(-)

diff --git a/public/css/site.css b/public/css/site.css
index 6031d97..7f8ac2c 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -814,6 +814,24 @@ main { padding-top: 73px; }
   margin: 0 0 6px;
 }
 .site-footer a { color: var(--ink-soft); }
+.site-footer .footer-address {
+  font-size: 13px;
+  font-style: normal;
+  color: var(--ink-faint);
+  margin: 0 0 6px;
+  letter-spacing: 0.02em;
+}
+.site-footer .footer-contact span { padding: 0 8px; color: var(--ink-faint); opacity: .55; }
+.site-footer .footer-legal {
+  margin-top: 14px;
+  padding-top: 14px;
+  border-top: 1px solid var(--line);
+  font-size: 11px;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--ink-faint);
+  display: inline-block;
+}
 
 /* ── EMPTY STATE */
 .empty-state { padding: 60px; text-align: center; color: var(--ink-faint); }
diff --git a/server.js b/server.js
index b713aa7..e0f2ab3 100644
--- a/server.js
+++ b/server.js
@@ -84,6 +84,11 @@ app.use(compression({
   }
 }));
 
+// ── Marketplace early-mount: the Shopify orders/fulfilled webhook MUST register
+// before the global express.json() so the raw request body is preserved for
+// HMAC verification (express.raw runs on that route only).
+try { require('./src/marketplace').mountEarly(app); } catch (e) { console.error('Marketplace early mount failed:', e.message); }
+
 app.use(express.json({ limit: '25mb' }));  // 25mb to accept full-res baked PNGs from /api/design/:id/bake
 app.use(express.urlencoded({ extended: false }));
 
@@ -145,7 +150,7 @@ app.get('/robots.txt', (req, res) => {
   res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
   if (maybe304(req, res, ROBOTS_LAST_MODIFIED)) return;
   res.type('text/plain').send(
-    `User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /_devlogin\n\nSitemap: https://${SITE}/sitemap.xml\n`
+    `User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /_devlogin\n\nSitemap: https://${SITE}/sitemap.xml\nSitemap: https://${SITE}/sitemap-marketplace.xml\n`
   );
 });
 
@@ -2395,7 +2400,15 @@ function htmlHeader(active) {
 const FOOTER = `<footer class="site-footer">
   <p>wallco.ai &mdash; AI-original wallpaper &amp; murals. Every pattern generated, never repeated.</p>
   <p>Part of the <a href="https://designerwallcoverings.com">Designer Wallcoverings</a> family.</p>
-  <p><a href="mailto:info@wallco.ai">info@wallco.ai</a></p>
+  <p class="footer-contact">
+    <a href="mailto:info@wallco.ai">info@wallco.ai</a>
+    <span aria-hidden="true">&middot;</span>
+    <a href="tel:+18883734564">888-373-4564</a>
+  </p>
+  <address class="footer-address">
+    Designer Wallcoverings &middot; 15442 Ventura Bl #102 &middot; Sherman Oaks, CA 91403
+  </address>
+  <p class="footer-legal">&copy; ${new Date().getFullYear()} Designer Wallcoverings. Memo samples are always free.</p>
 </footer>
 <script src="/js/wallco-game.js" defer></script>
 <!-- Mobile tap-target minimums (Apple HIG / WCAG 2.5.5) — applied site-wide.
@@ -7471,8 +7484,13 @@ ${htmlHeader('/designs')}
         <button type="button" class="btn-outline" id="btn-room"        data-id="${design.id}">See in a Room</button>
         <button type="button" class="btn-outline" id="btn-spoon-dry"   data-id="${design.id}">Spoonflower (dry-run)</button>
         <button type="button" class="btn-outline" id="btn-spoon-live"  data-id="${design.id}">Spoonflower — Upload as New</button>
+        <button type="button" class="btn-outline" id="btn-shopify"     data-id="${design.id}">Upload to Shopify</button>
         <button type="button" class="btn-outline" id="btn-narrate"     data-id="${design.id}">Narrate (ElevenLabs)</button>
       </div>
+      <div style="margin-top:8px;font:11px/1.4 var(--sans);color:#666">
+        DW Original Number:
+        <code style="font:12px ui-monospace,Menlo,monospace;background:rgba(0,0,0,.05);padding:2px 6px;border-radius:3px">${design.dig_number || 'DIG_AI_' + String(design.id).padStart(6,'0')}</code>
+      </div>
       <div id="tool-result" style="margin-top:16px;display:none;padding:14px;border-radius:6px;background:rgba(0,0,0,.05);font-size:13px;line-height:1.5"></div>
       <div id="room-result" style="margin-top:14px;display:none">
         <img id="room-img" alt="Room preview" style="max-width:100%;border-radius:8px;border:1px solid rgba(0,0,0,.08)">
@@ -7519,14 +7537,89 @@ ${htmlHeader('/designs')}
 
         document.getElementById('btn-spoon-live').addEventListener('click', async function(){
           if (!confirm('Upload this design to Spoonflower as a NEW product? This is a public publish.')) return;
-          show('Live upload running…', false);
-          this.disabled = true;
-          var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: false });
-          this.disabled = false;
-          if (j.ok) show('Upload kicked off. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank" rel="noopener noreferrer">view log</a>', false);
-          else show('Upload failed: ' + (j.error||'unknown'), true);
+          var btn = this;
+          var designId = this.dataset.id;
+          show('Live upload running… (Spoonflower publish typically takes 60–180s)', false);
+          btn.disabled = true;
+          var j = await post('/api/spoonflower/upload', { design_id: designId, dry: false });
+          if (!j.ok) {
+            btn.disabled = false;
+            show('Upload failed: ' + (j.error||'unknown'), true);
+            return;
+          }
+          var jobId = j.job_id;
+          show('Upload kicked off (job ' + jobId + '). Polling for the new Spoonflower pattern number…', false);
+          // Poll /api/spoonflower/status/:job until the python script
+          // finishes. 5s cadence, hard cap at 6 minutes.
+          var started = Date.now();
+          var poll = setInterval(async function(){
+            try {
+              var r = await fetch('/api/spoonflower/status/' + jobId);
+              var s = await r.json();
+              var st = s && s.job && s.job.status;
+              if (st === 'done' || st === 'error') {
+                clearInterval(poll);
+                btn.disabled = false;
+                if (st === 'error') {
+                  show('Spoonflower upload errored. <a href="/api/spoonflower/status/' + jobId + '" target="_blank" rel="noopener noreferrer">view log</a>', true);
+                  return;
+                }
+                if (s.sf_design_id) {
+                  var link = s.sf_public_url || ('https://www.spoonflower.com/en/shop/wallpaper/' + s.sf_design_id);
+                  show(
+                    '<strong>Spoonflower pattern #' + s.sf_design_id + '</strong> created. ' +
+                    '<a href="' + link + '" target="_blank" rel="noopener noreferrer">View on Spoonflower ↗</a><br>' +
+                    '<span style="font-size:12px;color:#3a3631">Next: click <b>Upload to Shopify</b> below to publish to designer-laboratory-sandbox.</span>',
+                    false
+                  );
+                  var sb = document.getElementById('btn-shopify');
+                  if (sb) { sb.style.borderColor = '#c9a14b'; sb.style.background = '#fff8e6'; }
+                } else {
+                  show('Upload finished but Spoonflower pattern ID could not be parsed. <a href="/api/spoonflower/status/' + jobId + '" target="_blank" rel="noopener noreferrer">view log</a>', true);
+                }
+                return;
+              }
+              if (Date.now() - started > 6 * 60 * 1000) {
+                clearInterval(poll);
+                btn.disabled = false;
+                show('Upload still running after 6 min — check <a href="/api/spoonflower/status/' + jobId + '" target="_blank" rel="noopener noreferrer">job log</a> manually.', true);
+              }
+            } catch (e) { /* swallow transient poll errors */ }
+          }, 5000);
         });
 
+        // Upload to Shopify — admin only (endpoint 404s for non-admins).
+        var shopifyBtn = document.getElementById('btn-shopify');
+        if (shopifyBtn) {
+          shopifyBtn.addEventListener('click', async function(){
+            if (!confirm('Create a new Shopify product on designer-laboratory-sandbox for this design? It will land as DRAFT.')) return;
+            var btn = this;
+            btn.disabled = true;
+            show('Creating Shopify product…', false);
+            try {
+              var r = await fetch('/api/design/' + btn.dataset.id + '/upload-shopify', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({})
+              });
+              var j = await r.json();
+              if (r.ok && j.ok) {
+                show(
+                  '<strong>Shopify product ' + j.shopify_product_id + '</strong> created ' +
+                  (j.already_uploaded ? '(already existed)' : '(DRAFT)') + '. ' +
+                  'SKU <code>' + (j.dig_number || '') + '</code>. ' +
+                  '<a href="' + j.shopify_product_url + '" target="_blank" rel="noopener noreferrer">View on store ↗</a>' +
+                  (j.shopify_admin_url ? ' · <a href="' + j.shopify_admin_url + '" target="_blank" rel="noopener noreferrer">admin ↗</a>' : ''),
+                  false
+                );
+              } else {
+                show('Shopify upload failed: ' + (j.error || (j.shopify_response && JSON.stringify(j.shopify_response).slice(0,300)) || 'unknown'), true);
+              }
+            } catch (e) { show('Shopify upload error: ' + e.message, true); }
+            btn.disabled = false;
+          });
+        }
+
         document.getElementById('btn-narrate').addEventListener('click', async function(){
           show('Generating narration…', false);
           this.disabled = true;
@@ -10585,17 +10678,180 @@ app.post('/api/spoonflower/upload', (req, res) => {
   proc.stderr.pipe(out, { end: false });
   proc.on('exit', code => {
     out.end();
+    // Parse the JSON-line log so the new Spoonflower pattern number + URL
+    // can be persisted onto the design row. The python script emits one
+    // JSON object per line; the final `done` event carries sf_design_id
+    // and sf_public_url on a successful LIVE publish.
+    let sfDesignId = null, sfUrl = null;
     try {
+      const raw = fs.readFileSync(logFile, 'utf8');
+      for (const line of raw.split('\n')) {
+        const s = line.trim();
+        if (!s.startsWith('{')) continue;
+        try {
+          const ev = JSON.parse(s);
+          if (ev && ev.sf_design_id)  sfDesignId = String(ev.sf_design_id);
+          if (ev && ev.sf_public_url) sfUrl      = String(ev.sf_public_url);
+        } catch {}
+      }
       psqlQuery(`UPDATE spoonflower_jobs SET status='${code===0?'done':'error'}',
                  finished_at=now(),
-                 log='${fs.readFileSync(logFile,'utf8').replace(/'/g,"''").slice(-8000)}'
+                 log='${raw.replace(/'/g,"''").slice(-8000)}'
                  WHERE id=${jobId};`);
+      if (code === 0 && !dry && sfDesignId) {
+        const did = parseInt(design_id, 10);
+        psqlQuery(`UPDATE spoon_all_designs SET
+                     spoonflower_design_id   = ${pgEsc(sfDesignId)},
+                     spoonflower_url         = ${pgEsc(sfUrl)},
+                     spoonflower_uploaded_at = NOW()
+                   WHERE id = ${did};`);
+      }
     } catch {}
   });
   proc.unref();
   res.json({ ok: true, job_id: jobId, dry, log_url: `/api/spoonflower/status/${jobId}` });
 });
 
+// ── POST /api/design/:id/upload-shopify — push a wallco.ai design as a NEW Shopify product
+//
+// Admin-only. Reads the design row, picks the DW Original Number
+// (DIG_AI_xxxxxx — auto-assigned at INSERT-time by trg_spoon_all_designs_assign_dig)
+// as the SKU, and creates a draft Shopify product on
+// designer-laboratory-sandbox.myshopify.com with:
+//   • title       = ai_title || title
+//   • vendor      = "Designer Wallcoverings"
+//   • product_type= "Wallpaper"
+//   • tags        = wallco-ai, wallco-original, <category>, <ai_pattern>
+//   • SKU         = dig_number
+//   • image       = base64 of local_path
+//   • metafields  = dwc.dig_number, dwc.spoonflower_url, dwc.wallco_design_id
+//
+// Persists shopify_product_id + shopify_product_url onto the design row so
+// /designs index and /design/:id can show the linkage.
+app.post('/api/design/:id/upload-shopify', requireAdmin, express.json({ limit: '16kb' }), async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+
+  const token = process.env.SHOPIFY_ADMIN_TOKEN;
+  const store = process.env.SHOPIFY_STORE;
+  if (!token || !store) {
+    return res.status(503).json({ error: 'SHOPIFY_ADMIN_TOKEN / SHOPIFY_STORE not configured' });
+  }
+
+  let d;
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (
+      SELECT id, title, ai_title, ai_pattern, ai_color_name, category, tags,
+             dominant_hex, dig_number, local_path, spoonflower_design_id,
+             spoonflower_url, shopify_product_id, shopify_product_url
+      FROM spoon_all_designs WHERE id=${id}) t;`);
+    d = JSON.parse(raw || '{}');
+  } catch (e) { return res.status(500).json({ error: 'db: ' + e.message }); }
+  if (!d || !d.id) return res.status(404).json({ error: 'design not found' });
+
+  if (d.shopify_product_id) {
+    return res.json({
+      ok: true,
+      already_uploaded: true,
+      shopify_product_id: d.shopify_product_id,
+      shopify_product_url: d.shopify_product_url,
+    });
+  }
+
+  if (!d.local_path || !fs.existsSync(d.local_path)) {
+    return res.status(409).json({ error: 'image file missing on disk', local_path: d.local_path });
+  }
+
+  const dig = d.dig_number || `DIG_AI_${String(d.id).padStart(6, '0')}`;
+  const title = d.ai_title || d.title || `wallco.ai #${d.id}`;
+  const tagList = [
+    'wallco-ai', 'wallco-original',
+    d.category, d.ai_pattern, d.ai_color_name,
+  ].filter(Boolean).join(', ');
+
+  const imgB64 = fs.readFileSync(d.local_path).toString('base64');
+  const imgExt = (path.extname(d.local_path) || '.png').replace('.', '');
+
+  const productBody = {
+    product: {
+      title,
+      vendor: 'Designer Wallcoverings',
+      product_type: 'Wallpaper',
+      status: 'draft',
+      tags: tagList,
+      body_html: `<p>An AI-original wallcovering pattern from wallco.ai. DW Original Number ${dig}.</p>`,
+      variants: [{
+        sku: dig,
+        price: '0.00',
+        inventory_management: null,
+        inventory_policy: 'continue',
+      }],
+      images: [{
+        attachment: imgB64,
+        filename: `${dig}.${imgExt}`,
+        alt: title,
+      }],
+      metafields: [
+        { namespace: 'dwc', key: 'dig_number',         value: dig,               type: 'single_line_text_field' },
+        { namespace: 'dwc', key: 'wallco_design_id',   value: String(d.id),      type: 'single_line_text_field' },
+        ...(d.spoonflower_url ? [
+          { namespace: 'dwc', key: 'spoonflower_url',  value: d.spoonflower_url, type: 'url' },
+        ] : []),
+        ...(d.spoonflower_design_id ? [
+          { namespace: 'dwc', key: 'spoonflower_id',   value: String(d.spoonflower_design_id), type: 'single_line_text_field' },
+        ] : []),
+      ],
+    }
+  };
+
+  const url = `https://${store}/admin/api/2026-01/products.json`;
+  let r, body;
+  try {
+    r = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'X-Shopify-Access-Token': token,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify(productBody),
+    });
+    body = await r.json();
+  } catch (e) {
+    return res.status(502).json({ error: 'shopify fetch failed: ' + e.message });
+  }
+
+  if (!r.ok || !body || !body.product || !body.product.id) {
+    return res.status(502).json({
+      error: 'shopify create failed',
+      status: r.status,
+      shopify_response: body,
+    });
+  }
+
+  const sid = String(body.product.id);
+  const handle = body.product.handle || '';
+  const sUrl = `https://${store.replace('.myshopify.com','')}.myshopify.com/admin/products/${sid}`;
+  const publicUrl = handle ? `https://${store}/products/${handle}` : sUrl;
+
+  try {
+    psqlQuery(`UPDATE spoon_all_designs SET
+                 shopify_product_id   = ${pgEsc(sid)},
+                 shopify_product_url  = ${pgEsc(publicUrl)},
+                 shopify_uploaded_at  = NOW()
+               WHERE id = ${id};`);
+  } catch {}
+
+  res.json({
+    ok: true,
+    id,
+    dig_number: dig,
+    shopify_product_id: sid,
+    shopify_product_url: publicUrl,
+    shopify_admin_url: sUrl,
+    shopify_status: body.product.status,
+  });
+});
+
 // ── POST /api/spoonflower/launch-chrome — open dedicated debug Chrome for cookie reuse
 app.post('/api/spoonflower/launch-chrome', (req, res) => {
   try {
@@ -10677,7 +10933,26 @@ app.get('/api/spoonflower/status/:job', (req, res) => {
     const p = path.join(SF_JOBS_DIR, `${f}_${jobId}.log`);
     if (fs.existsSync(p)) { log = fs.readFileSync(p, 'utf8'); break; }
   }
-  res.type('application/json').send(JSON.stringify({ ok:true, job: JSON.parse(row), log_tail: log.slice(-8000) }, null, 2));
+  // Extract the Spoonflower design id + URL from the JSON-line log so the
+  // /design/:id frontend can render "Spoonflower pattern #N → view" after
+  // polling without re-reading the raw log.
+  let sf_design_id = null, sf_public_url = null;
+  for (const line of log.split('\n')) {
+    const s = line.trim();
+    if (!s.startsWith('{')) continue;
+    try {
+      const ev = JSON.parse(s);
+      if (ev.sf_design_id)  sf_design_id  = String(ev.sf_design_id);
+      if (ev.sf_public_url) sf_public_url = String(ev.sf_public_url);
+    } catch {}
+  }
+  res.type('application/json').send(JSON.stringify({
+    ok: true,
+    job: JSON.parse(row),
+    sf_design_id,
+    sf_public_url,
+    log_tail: log.slice(-8000)
+  }, null, 2));
 });
 
 // ── GET /spoonflower — admin page: pulled DW library + per-design publish buttons

← a1c0a71 marketplace ai/generate-colorways: dedicated smoke test  ·  back to Wallco Ai  ·  marketplace SEO: add smoke tests for sitemap + designer/patt e092403 →