← back to Dw Signup Fulfillment
Retail free-samples: pivot to verify→tag (Option C), drop gift cards
75d27f5e64e43f11e3f7e0370d94739145644199 · 2026-08-14 09:43:07 -0700 · Steve Abrams
- Double opt-in: /claim or customers/create webhook → branded verify letter → /verify
appends the `verified-sample` customer tag. The store's Regios tag-gated sample
discount (same mechanism as trade memos, scoped to the Sample variant) makes samples
free — roll variant on the same product is never touched.
- New lib/verify.js (stateless HMAC token), retail-webhook.js rewired to send the verify
letter, email.js verify+unlocked templates, config VERIFIED_TAG/VERIFY_SECRET/
VERIFY_TTL_HOURS, server.js /claim + /verify routes + branded pages.
- Gift-card / ledger / mint-cap / shared-coupon paths retired to legacy alternates.
- Verified end-to-end in DRY_RUN (token round-trip, /claim, /verify → single tag write).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M lib/config.jsM lib/email.jsM lib/retail-webhook.jsA lib/verify.jsM server.js
Diff
commit 75d27f5e64e43f11e3f7e0370d94739145644199
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 14 09:43:07 2026 -0700
Retail free-samples: pivot to verify→tag (Option C), drop gift cards
- Double opt-in: /claim or customers/create webhook → branded verify letter → /verify
appends the `verified-sample` customer tag. The store's Regios tag-gated sample
discount (same mechanism as trade memos, scoped to the Sample variant) makes samples
free — roll variant on the same product is never touched.
- New lib/verify.js (stateless HMAC token), retail-webhook.js rewired to send the verify
letter, email.js verify+unlocked templates, config VERIFIED_TAG/VERIFY_SECRET/
VERIFY_TTL_HOURS, server.js /claim + /verify routes + branded pages.
- Gift-card / ledger / mint-cap / shared-coupon paths retired to legacy alternates.
- Verified end-to-end in DRY_RUN (token round-trip, /claim, /verify → single tag write).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
lib/config.js | 17 ++++++++
lib/email.js | 54 ++++++++++++++++++++++++-
lib/retail-webhook.js | 72 +++++++++++++--------------------
lib/verify.js | 95 +++++++++++++++++++++++++++++++++++++++++++
server.js | 110 ++++++++++++++++++++++++++++++++++++++++++++------
5 files changed, 291 insertions(+), 57 deletions(-)
diff --git a/lib/config.js b/lib/config.js
index 3d10f13..4a7ddc3 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -51,6 +51,23 @@ const config = {
FREE_SAMPLE_COUNT: parseInt(process.env.FREE_SAMPLE_COUNT || '3', 10),
CURRENCY: process.env.CURRENCY || 'USD',
+ // --- Retail double-opt-in verify -> tag-gated samples (Option C, DTD 2026-08-14) ---
+ // WIRED retail path (lib/verify.js): a new/claiming customer confirms their email,
+ // and the /verify click appends VERIFIED_TAG to their Shopify customer. The store's
+ // tag-gated sample discount (the SAME Regios mechanism that already makes `trade`
+ // memos free — scoped to the "Sample" variant, so the $80+ roll is never touched)
+ // then shows their samples free at checkout. No gift card, no ledger, no coupon.
+ // GO-LIVE: create the Regios rule "samples free for tag <VERIFIED_TAG>" (clone the
+ // trade-memo rule) and keep this value === that rule's tag.
+ VERIFIED_TAG: process.env.VERIFIED_TAG || 'verified-sample',
+ // Signs the stateless email-verify token. From the secrets master (256-bit random),
+ // NEVER a documented value. Empty in LIVE -> mint/read fail closed (no token issued,
+ // /verify 503) so a missing secret disables the reward rather than trusting a
+ // guessable key. In DRY_RUN a dev fallback is used so local testing round-trips.
+ VERIFY_SECRET: firstEnv('DW_SIGNUP_VERIFY_SECRET', SECRETS_ENVS),
+ // Verify-link lifetime in hours (default 7 days).
+ VERIFY_TTL_HOURS: parseInt(process.env.VERIFY_TTL_HOURS || '168', 10),
+
// WIRED retail path (lib/retail-code.js): the SHARED discount code created once in
// Shopify admin against the "DW Free Samples" function, limited to one-use-per-
// customer. The service just emails this code to every new customer. Must match the
diff --git a/lib/email.js b/lib/email.js
index 1ff9ace..af32684 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -151,6 +151,58 @@ function designerWelcomeEmail({ firstName }) {
return { subject, html };
}
+// WIRED retail template (Option C, 2026-08-14): double opt-in "confirm your email to
+// unlock N free samples" letter. The button carries a signed verify token; clicking it
+// tags the customer so samples show free at checkout (no code to type, no gift card).
+function verifyEmail({ firstName, url, count }) {
+ const fn = firstName ? String(firstName).trim() : '';
+ const greet = fn ? esc(fn.charAt(0).toUpperCase() + fn.slice(1)) : 'there';
+ const subject = `Confirm your email to unlock ${count} free samples 🎁`;
+ const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto">
+ <div style="border:1px solid #e2ddd4;border-radius:10px;overflow:hidden">
+ <div style="background:#1a1a1a;color:#fff;text-align:center;padding:26px 20px">
+ <div style="font-size:22px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+ <div style="font-size:11px;letter-spacing:2px;color:#b8afa2;margin-top:4px">FINE WALLCOVERINGS & FABRICS</div>
+ </div>
+ <div style="padding:26px 28px;font-size:15px;line-height:1.7;color:#2a2a2a">
+ <p style="margin:0 0 14px">Dear ${greet},</p>
+ <p style="margin:0 0 14px">Welcome to Designer Wallcoverings — home to the world's most beautiful wallcoverings and fabrics from over 200 of the finest design houses, all in one place.</p>
+ <p style="margin:0 0 8px">You're one click from <b>${count} free samples</b>. Confirm your email and we'll switch them on:</p>
+ <div style="text-align:center;margin:22px 0 8px">
+ <a href="${esc(url)}" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:13px 34px;border-radius:30px;font-size:14px;letter-spacing:1px;display:inline-block">Unlock my ${count} free samples</a>
+ </div>
+ <p style="margin:14px 0 0;color:#555;font-size:13px">Once confirmed, your samples show free at checkout — just add your swatches, see them in your space, and fall in love before you commit. This link is just for you and expires in a few days. If you didn't create a Designer Wallcoverings account, you can ignore this email.</p>
+ <p style="margin:20px 0 0;color:#2a2a2a">Warmly,<br><b>The Designer Wallcoverings Team</b></p>
+ </div>
+ </div>
+</div>`;
+ return { subject, html };
+}
+
+// Confirmation letter sent after a successful /verify — the reward is now live.
+function samplesUnlockedEmail({ firstName, count }) {
+ const fn = firstName ? String(firstName).trim() : '';
+ const greet = fn ? esc(fn.charAt(0).toUpperCase() + fn.slice(1)) : 'there';
+ const subject = `You're all set — ${count} free samples unlocked`;
+ const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto">
+ <div style="border:1px solid #e2ddd4;border-radius:10px;overflow:hidden">
+ <div style="background:#1a1a1a;color:#fff;text-align:center;padding:26px 20px">
+ <div style="font-size:22px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+ <div style="font-size:11px;letter-spacing:2px;color:#b8afa2;margin-top:4px">FINE WALLCOVERINGS & FABRICS</div>
+ </div>
+ <div style="padding:26px 28px;font-size:15px;line-height:1.7;color:#2a2a2a">
+ <p style="margin:0 0 14px">Dear ${greet},</p>
+ <p style="margin:0 0 14px">Your email is confirmed and your <b>${count} free samples</b> are ready. Add any sample swatches to your cart — they'll show free at checkout, no code needed.</p>
+ <div style="text-align:center;margin:22px 0 6px">
+ <a href="https://designerwallcoverings.com" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:13px 34px;border-radius:30px;font-size:14px;letter-spacing:1px;display:inline-block">Explore the Collections</a>
+ </div>
+ <p style="margin:20px 0 0;color:#2a2a2a">Warmly,<br><b>The Designer Wallcoverings Team</b></p>
+ </div>
+ </div>
+</div>`;
+ return { subject, html };
+}
+
// PRIMARY retail template: a unique function-backed sample code (NOT a gift card).
// Copy is careful NOT to imply instant free product — it hands them a CODE to use
// at checkout on sample swatches.
@@ -235,4 +287,4 @@ function tradeApplicationEmail({ app, approveUrl, rejectUrl, adminUrl }) {
function money(v) { return `$${Number(v).toFixed(2)}`; }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
-module.exports = { sendEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
+module.exports = { sendEmail, verifyEmail, samplesUnlockedEmail, retailCodeEmail, retailGiftEmail, designerWelcomeEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, tradeApplicationEmail, money, esc };
diff --git a/lib/retail-webhook.js b/lib/retail-webhook.js
index 8cfd30a..f7f775f 100644
--- a/lib/retail-webhook.js
+++ b/lib/retail-webhook.js
@@ -1,24 +1,25 @@
'use strict';
-// Secret-less webhook authentication (Steve's decision, TK-10006 — "verify without
-// the secret"). Instead of HMAC (which needs the Full Access app's API secret), each
-// customers/create event is authenticated by RE-FETCHING the customer from Shopify by
-// id, and everything downstream uses Shopify's REAL on-file data — never the webhook
-// payload's fields.
+// customers/create webhook handler — WIRED to the double-opt-in verify flow
+// (Option C, DTD 2026-08-14). It no longer mints a gift card; it sends the customer a
+// branded "confirm your email to unlock N free samples" letter (lib/verify.js). The
+// reward (the VERIFIED_TAG that makes samples free at checkout) is applied only when
+// the customer CLICKS the verify link — so this endpoint hands out no value, just an
+// email, and the double opt-in also keeps junk/bot signups off the reward.
//
-// Why this is safe against a forged/replayed POST to the public endpoint:
-// 1) AUTH — a forged/unknown customer id is not found in Shopify → rejected.
-// 2) NO REDIRECT — the gift card is created for the real customer id and emailed to
-// the customer's REAL on-file email (from the re-fetch), so a forged event can
-// only ever deliver the code to the legitimate customer's own account. An
-// attacker gains nothing (they can't point it at themselves).
-// 3) IDEMPOTENCY — a custom.welcome_gift_issued metafield guarantees exactly ONE
-// gift card per customer, ever. Replays / duplicate deliveries are no-ops.
+// Anti-forgery (unchanged intent): a forged/replayed POST to the public endpoint is
+// authenticated by RE-FETCHING the customer from Shopify by id and using the REAL
+// on-file email — a forged event can only ever mail a legitimate customer's own inbox.
+// 1) AUTH — unknown/forged id is not found in Shopify -> rejected.
+// 2) FRESHNESS — refuse customers older than WEBHOOK_FRESHNESS_MIN (a real
+// customers/create fires within seconds) so the endpoint can't be
+// driven to spam the existing base.
+// 3) IDEMPOTENCY — a custom.sample_verify_sent metafield sends the verify letter at
+// most once per customer; replays/duplicates are no-ops.
const config = require('./config');
const shopify = require('./shopify');
-const giftcard = require('./giftcard');
-const mintLedger = require('./mint-ledger');
+const verify = require('./verify');
-const GIFT_FLAG = { namespace: 'custom', key: 'welcome_gift_issued' };
+const SENT_FLAG = { namespace: 'custom', key: 'sample_verify_sent' };
async function handleCustomerCreate(payload) {
const id = payload && payload.id;
@@ -31,43 +32,28 @@ async function handleCustomerCreate(payload) {
return { ok: false, reason: 'customer_not_found_or_no_email', id };
}
- // 2) FRESHNESS — a real customers/create fires within seconds of signup. Refuse to
- // gift a customer whose Shopify created_at is older than WEBHOOK_FRESHNESS_MIN, so
- // the public endpoint can't be driven to mint gift cards to the EXISTING customer
- // base. (Missing created_at ⇒ not real Shopify data ⇒ treated as stale/rejected.)
+ // 2) FRESHNESS — only a freshly-created customer gets the letter. (Missing created_at
+ // ⇒ not real Shopify data ⇒ treated as stale/rejected.)
const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
}
- // 3) IDEMPOTENCY — skip if this customer already received their welcome gift.
- const flag = await shopify.getCustomerMetafield(id, GIFT_FLAG.namespace, GIFT_FLAG.key);
+ // 3) IDEMPOTENCY — send the verify letter at most once per customer.
+ const flag = await shopify.getCustomerMetafield(id, SENT_FLAG.namespace, SENT_FLAG.key);
if (flag && String(flag).toLowerCase() === 'true') {
- return { ok: true, skipped: 'already_issued', id };
+ return { ok: true, skipped: 'already_sent', id };
}
- // 4) DAILY MINT CAP — money backstop. Beyond the cap, skip + warn (never mint unbounded).
- if (mintLedger.todayCount() >= config.MINT_DAILY_CAP) {
- console.warn(`[retail-webhook] daily mint cap ${config.MINT_DAILY_CAP} reached — skipping id=${id}`);
- return { ok: false, skipped: 'daily_cap_reached', id, cap: config.MINT_DAILY_CAP };
- }
-
- // 5) ISSUE — gift card for the REAL customer (real id + real email; payload ignored).
- const issued = await giftcard.issueRetailGiftCode({ id: real.id, email: real.email, first_name: real.first_name });
+ // 4) SEND — the branded "confirm your email" letter to the REAL on-file email, with
+ // the real customer id baked into the token so the tag lands on the right account.
+ const started = await verify.startVerification({ email: real.email, customerId: real.id, firstName: real.first_name });
- // 6) FLAG in Shopify so it never double-issues (survives restarts; "in Shopify").
- await shopify.setCustomerMetafield(id, { ...GIFT_FLAG, value: 'true', type: 'boolean' });
-
- // 7) LEDGER — record the mint value + surface running daily $ total (Steve's cost rule).
- // Only count real (non-dry-run) mints so DRY_RUN dev/selftest never inflates the cap.
- let ledger = null;
- if (!config.DRY_RUN && !(issued.giftCard && issued.giftCard.dryRun)) {
- ledger = mintLedger.recordMint(issued.value != null ? issued.value : config.SAMPLE_GIFT_VALUE);
- console.log(`[retail-webhook] $${issued.value} gift minted — today ${ledger.count}/${config.MINT_DAILY_CAP} ($${ledger.total} liability)`);
- }
+ // 5) FLAG in Shopify so it never double-sends (survives restarts).
+ await shopify.setCustomerMetafield(id, { ...SENT_FLAG, value: 'true', type: 'boolean' });
- return { ok: true, id, email: real.email, issued, ledger };
+ return { ok: started.ok, id, email: real.email, started };
}
-module.exports = { handleCustomerCreate, GIFT_FLAG };
+module.exports = { handleCustomerCreate, SENT_FLAG };
diff --git a/lib/verify.js b/lib/verify.js
new file mode 100644
index 0000000..95ec96f
--- /dev/null
+++ b/lib/verify.js
@@ -0,0 +1,95 @@
+'use strict';
+// Retail free-samples via DOUBLE OPT-IN + tag-gated pricing (Option C — DTD verdict,
+// 2026-08-14). This REPLACES the gift-card path (giftcard.js / mint-ledger.js) and the
+// shared-coupon path (retail-code.js), which are kept only as reference alternates.
+//
+// A new/claiming customer is NOT given stored value. Instead:
+// 1) startVerification() emails a branded "confirm your email to unlock N free
+// samples" link carrying a SIGNED, STATELESS token (HMAC — no DB, no nonce store).
+// 2) On click, /verify -> completeVerification() appends VERIFIED_TAG to the Shopify
+// customer. The store's tag-gated sample discount (the SAME Regios mechanism that
+// already makes `trade` memos free — scoped to the "Sample" variant, so the $80+
+// roll variant is never touched) then shows their samples free at checkout.
+//
+// Why this beats the old design: no gift-card liability, no ledger, no daily mint cap,
+// no shared coupon that leaks to coupon sites, no Shopify Function / Plus dependency. A
+// customer tag is durable and non-redeemable by a stranger. Re-applying the tag is a
+// no-op, so the whole flow is idempotent and safe to replay.
+//
+// DRY_RUN-safe end to end: shopify.* writes and email.sendEmail are short-circuited by
+// their own modules when config.DRY_RUN is true.
+const crypto = require('crypto');
+const config = require('./config');
+const shopify = require('./shopify');
+const email = require('./email');
+
+// Dev-only fallback secret so tokens round-trip in DRY_RUN without provisioning one.
+// In LIVE a missing VERIFY_SECRET fails CLOSED (mintToken/readToken return no_secret,
+// /verify 503s) — a missing key disables the reward rather than trusting a guessable one.
+const DEV_SECRET = 'dw-signup-verify-dev-secret-DRYRUN-ONLY';
+function secret() { return config.VERIFY_SECRET || (config.DRY_RUN ? DEV_SECRET : ''); }
+
+function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
+function fromB64url(s) { return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
+function sign(payloadB64, sec) { return b64url(crypto.createHmac('sha256', sec).update(payloadB64).digest()); }
+
+// mintToken({email, customerId}) -> "payload.sig" (base64url), or null with no secret (live).
+function mintToken({ email: addr, customerId }) {
+ const sec = secret();
+ if (!sec) return null;
+ const payload = {
+ e: String(addr || '').trim().toLowerCase(),
+ c: customerId ? String(customerId) : '',
+ x: Date.now() + config.VERIFY_TTL_HOURS * 3600 * 1000,
+ };
+ const pB64 = b64url(JSON.stringify(payload));
+ return pB64 + '.' + sign(pB64, sec);
+}
+
+// readToken(token) -> { ok:true, email, customerId } | { ok:false, reason }
+function readToken(token) {
+ const sec = secret();
+ if (!sec) return { ok: false, reason: 'no_secret' };
+ if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return { ok: false, reason: 'malformed' };
+ const [pB64, sig] = token.split('.');
+ const expect = sign(pB64, sec);
+ const a = Buffer.from(String(sig || '')), b = Buffer.from(expect);
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'bad_signature' };
+ let payload;
+ try { payload = JSON.parse(fromB64url(pB64).toString('utf8')); } catch { return { ok: false, reason: 'bad_payload' }; }
+ if (!payload || typeof payload.x !== 'number' || Date.now() > payload.x) return { ok: false, reason: 'expired' };
+ if (!payload.e) return { ok: false, reason: 'no_email' };
+ return { ok: true, email: payload.e, customerId: payload.c || null };
+}
+
+function baseUrl() { return config.PUBLIC_URL || `http://127.0.0.1:${config.PORT}`; }
+
+// Step 1 — email the branded "confirm your email" letter with the verify link.
+async function startVerification({ email: to, customerId, firstName }) {
+ const addr = String(to || '').trim().toLowerCase();
+ if (!addr) return { ok: false, reason: 'no_email' };
+ const token = mintToken({ email: addr, customerId });
+ if (!token) {
+ console.warn('[verify] VERIFY_SECRET unset (live) — cannot mint verify token; skipping send.');
+ return { ok: false, reason: 'no_secret' };
+ }
+ const url = `${baseUrl()}/verify?token=${encodeURIComponent(token)}`;
+ const first = firstName || (addr.includes('@') ? addr.split('@')[0] : '');
+ const tpl = email.verifyEmail({ firstName: first, url, count: config.FREE_SAMPLE_COUNT });
+ const mail = await email.sendEmail({ to: addr, subject: tpl.subject, html: tpl.html, source: 'retail-verify' });
+ // verifyUrl carries the bearer token — callers must redact it before logging.
+ return { ok: mail.ok !== false, sent: { to: addr, subject: tpl.subject, dryRun: mail.dryRun || false }, verifyUrl: url };
+}
+
+// Step 2 — apply the tag-gated sample entitlement after a valid click. Idempotent.
+async function completeVerification({ email: addr, customerId }) {
+ let custId = customerId || null;
+ if (!custId) custId = await shopify.findCustomerByEmail(addr);
+ if (!custId) return { ok: false, reason: 'customer_not_found', email: addr };
+ const tagRes = await shopify.addTags(custId, [config.VERIFIED_TAG]);
+ // Best-effort audit flag; never fatal (the tag is what actually gates the discount).
+ try { await shopify.setCustomerMetafield(custId, { namespace: 'custom', key: 'sample_verified', value: 'true', type: 'boolean' }); } catch (e) { /* non-fatal */ }
+ return { ok: tagRes.ok !== false, customerId: custId, tag: config.VERIFIED_TAG, dryRun: tagRes.dryRun || false };
+}
+
+module.exports = { mintToken, readToken, startVerification, completeVerification, baseUrl };
diff --git a/server.js b/server.js
index 391f7b8..051185d 100644
--- a/server.js
+++ b/server.js
@@ -10,10 +10,12 @@
// no real emails, no webhook registration until Steve flips DRY_RUN=0 at go-live.
const express = require('express');
const config = require('./lib/config');
-const retailWebhook = require('./lib/retail-webhook'); // WIRED: secret-less re-fetch auth + idempotent gift card
-const giftcard = require('./lib/giftcard'); // WIRED default (FINAL — Steve, memo §2): unique gift-card code per signup
-const retailCode = require('./lib/retail-code'); // alternate (shared function code — needs a DW Free Samples admin discount)
-const giftcodeDiscount = require('./lib/giftcode-discount'); // alternate (not wired)
+const retailWebhook = require('./lib/retail-webhook'); // WIRED: re-fetch auth + idempotent verify-letter send
+const verify = require('./lib/verify'); // WIRED default (Option C, DTD 2026-08-14): double opt-in -> VERIFIED_TAG -> tag-gated free samples
+const shopify = require('./lib/shopify'); // used by /claim to resolve a customer id from email
+const giftcard = require('./lib/giftcard'); // legacy alternate — stored-value gift card (retired path)
+const retailCode = require('./lib/retail-code'); // legacy alternate — shared function code
+const giftcodeDiscount = require('./lib/giftcode-discount'); // legacy alternate — collection-scoped code (unsafe: samples share a product with the roll)
const trade = require('./lib/trade');
const reps = require('./lib/reps');
const email = require('./lib/email');
@@ -32,10 +34,12 @@ app.get('/', (_req, res) => {
<style>body{font:15px/1.6 -apple-system,system-ui,sans-serif;margin:40px;color:#1a1a1a;background:#faf9f7}code{background:#eee;padding:1px 5px;border-radius:4px}.p{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}</style>
</head><body>
<h1>DW Signup Fulfillment <span class="p">DRY_RUN: ${config.DRY_RUN ? 'ON' : 'OFF (LIVE)'}</span></h1>
- <p>Service is running. It emails new customers the sample-locked code for 3 free samples and handles trade applications.</p>
+ <p>Service is running. New customers confirm their email (double opt-in) to unlock 3 free samples — the verify click tags them so samples show free at checkout. Also handles trade applications.</p>
<ul>
<li><code>GET /healthz</code> — liveness (open)</li>
- <li><code>POST /webhooks/customers/create/<token></code> — Shopify webhook (URL-token auth + rate-limit)</li>
+ <li><code>POST /webhooks/customers/create/<token></code> — Shopify webhook → sends the verify letter (URL-token auth + rate-limit)</li>
+ <li><code>POST /claim</code> — retail sample claim (email in → verify letter out)</li>
+ <li><code>GET /verify?token=…</code> — confirm email → apply the free-samples tag</li>
<li><code>POST /trade/apply</code> — trade application intake</li>
<li><code>GET /admin/trade</code> — trade review (basic-auth)</li>
</ul>
@@ -80,9 +84,10 @@ async function webhookHandler(req, res) {
try {
console.log(`[webhook] customers/create id=${customer.id}`);
const result = await retailWebhook.handleCustomerCreate(customer);
- // Redact the live gift-card code before logging — a leaked code can be spent.
- const safe = result && result.issued && result.issued.giftCard
- ? { ...result, issued: { ...result.issued, giftCard: { ...result.issued.giftCard, code: '***' } } }
+ // Redact the verify URL before logging — it carries a bearer token that would let a
+ // log reader self-apply the sample tag.
+ const safe = result && result.started && result.started.verifyUrl
+ ? { ...result, started: { ...result.started, verifyUrl: '***' } }
: result;
console.log('[webhook] retail result:', JSON.stringify(safe));
} catch (e) {
@@ -133,6 +138,46 @@ app.post('/trade/apply', tradeCors, (req, res) => {
res.json({ ok: true, id: created.id, status: created.status, created_at: created.created_at });
});
+// --- Retail sample claim (public, double opt-in) — email in, verify letter out. ---
+// CORS-shared with /trade/apply so the storefront can POST it from its own origin.
+app.options('/claim', tradeCors);
+app.post('/claim', tradeCors, async (req, res) => {
+ const emailAddr = String((req.body || {}).email || '').trim().toLowerCase();
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailAddr)) return res.status(400).json({ ok: false, error: 'valid email required' });
+ // Best-effort: resolve an existing customer id so the tag lands on the right account.
+ let custId = null;
+ try { custId = await shopify.findCustomerByEmail(emailAddr); } catch (e) { /* best-effort only */ }
+ const started = await verify.startVerification({ email: emailAddr, customerId: custId, firstName: (req.body || {}).first_name });
+ // Never reveal whether the email exists — always answer the same.
+ res.json({ ok: started.ok !== false, message: 'Check your inbox to confirm and unlock your free samples.' });
+});
+
+// Tiny branded claim form (handy for testing / embedding on a landing page).
+app.get('/claim', (_req, res) => res.type('html').send(claimPage()));
+
+// --- Verify click — validate the signed token, apply the free-samples tag. Idempotent. ---
+app.get('/verify', async (req, res) => {
+ const parsed = verify.readToken(req.query.token);
+ if (!parsed.ok) {
+ const msg = parsed.reason === 'expired'
+ ? "This confirmation link has expired. Request a new one and we'll send a fresh link."
+ : 'This confirmation link is invalid.';
+ return res.status(parsed.reason === 'no_secret' ? 503 : 400).type('html').send(verifyPage(msg, false));
+ }
+ const done = await verify.completeVerification({ email: parsed.email, customerId: parsed.customerId });
+ if (!done.ok) {
+ return res.status(200).type('html').send(verifyPage("We couldn't attach the samples to your account. Please make sure you're signed in with this email and try again — or reply to our email and we'll sort it out.", false));
+ }
+ // Confirmation letter (fire-and-forget, DRY_RUN-safe).
+ (async () => {
+ const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: config.FREE_SAMPLE_COUNT });
+ const r = await email.sendEmail({ to: parsed.email, subject: t.subject, html: t.html, source: 'retail-verified' });
+ if (r && r.ok === false) console.error(`[verify] unlocked-email send FAILED for ${parsed.email}: ${r.error || r.status}`);
+ })().catch(e => console.error('[verify] unlocked-email error:', e.message));
+ console.log(`[verify] tagged customer ${done.customerId} '${done.tag}'${done.dryRun ? ' (DRY_RUN)' : ''}`);
+ res.type('html').send(verifyPage(`Your ${config.FREE_SAMPLE_COUNT} free samples are unlocked. They'll show free at checkout — just add your swatches.`, true));
+});
+
// Public base the emailed Approve/Reject buttons point at (Kamatera host at go-live).
function baseUrl() { return config.PUBLIC_URL || `http://127.0.0.1:${config.PORT}`; }
@@ -173,6 +218,43 @@ function resultPage(msg, ok) {
`<p style="color:#6b7280"><a href="/admin/trade">Open the trade admin panel</a></p></div></body>`;
}
+// Branded retail claim form (email input) that POSTs to /claim.
+function claimPage() {
+ return `<!doctype html><meta charset="utf-8"><title>DW — 3 Free Samples</title>
+ <body style="font:16px/1.6 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
+ <div style="max-width:420px;width:100%;box-sizing:border-box;padding:32px;border:1px solid #e5e2dd;border-radius:12px;background:#fff;text-align:center">
+ <div style="font-size:16px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
+ <h1 style="font-weight:600;font-size:22px;margin:14px 0 6px">3 free samples, on us</h1>
+ <p style="color:#6b7280;margin:0 0 18px">Enter your email and we'll send a link to unlock them.</p>
+ <input id="e" type="email" placeholder="you@example.com" style="width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d6d1c8;border-radius:8px;font-size:15px">
+ <button onclick="go()" style="margin-top:12px;width:100%;padding:12px;border:0;background:#1a1a1a;color:#fff;border-radius:30px;font-size:15px;cursor:pointer">Send my link</button>
+ <p id="m" style="margin:14px 0 0;min-height:20px"></p>
+ </div>
+ <script>
+ async function go(){
+ const email=document.getElementById('e').value.trim();
+ const m=document.getElementById('m');
+ m.style.color='#6b7280'; m.textContent='Sending…';
+ try{ const r=await fetch('/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})});
+ const j=await r.json(); m.style.color=j.ok?'#16a34a':'#b91c1c'; m.textContent=j.ok?j.message:(j.error||'Something went wrong'); }
+ catch(e){ m.style.color='#b91c1c'; m.textContent='Network error — try again.'; }
+ }
+ </script></body>`;
+}
+
+// Branded verify result page (success or failure).
+function verifyPage(msg, ok) {
+ return `<!doctype html><meta charset="utf-8"><title>Designer Wallcoverings</title>` +
+ `<body style="font:16px/1.6 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">` +
+ `<div style="max-width:440px;text-align:center;padding:34px;border:1px solid #e5e2dd;border-radius:12px;background:#fff">` +
+ `<div style="font-size:16px;letter-spacing:3px;font-weight:600;color:#1a1a1a">DESIGNER WALLCOVERINGS</div>` +
+ `<div style="font-size:44px;margin:14px 0 6px">${ok ? '🎁' : '⚠️'}</div>` +
+ `<h1 style="font-weight:600;font-size:20px;margin:6px 0 10px">${ok ? "You're all set" : 'Hmm.'}</h1>` +
+ `<p style="color:#4b5563;margin:0 0 18px">${esc(msg)}</p>` +
+ `<a href="https://designerwallcoverings.com" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:12px 30px;border-radius:30px;font-size:14px;display:inline-block">Explore the Collections</a>` +
+ `</div></body>`;
+}
+
// --- Basic-auth guard for /admin/* ---
function adminAuth(req, res, next) {
const hdr = req.get('Authorization') || '';
@@ -234,11 +316,12 @@ app.get('/reps/next', adminAuth, (_req, res) => res.json({ assigned: reps.houseA
app.post('/admin/retail/issue', adminAuth, async (req, res) => {
const customer = req.body || {};
if (!customer.email) return res.status(400).json({ ok: false, error: 'email required' });
- let mode = req.query.mode || 'giftcard';
+ let mode = req.query.mode || 'verify';
let result;
- if (mode === 'sharedcode') result = await retailCode.issueRetailCode(customer); // alternate (needs admin discount)
- else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // alternate
- else { mode = 'giftcard'; result = await giftcard.issueRetailGiftCode(customer); } // WIRED default
+ if (mode === 'giftcard') result = await giftcard.issueRetailGiftCode(customer); // legacy alternate
+ else if (mode === 'sharedcode') result = await retailCode.issueRetailCode(customer); // legacy alternate
+ else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // legacy alternate
+ else { mode = 'verify'; result = await verify.startVerification(customer); } // WIRED default (Option C)
res.json({ ok: true, mode, result });
});
@@ -297,6 +380,7 @@ if (require.main === module) {
console.log(` health: http://127.0.0.1:${config.PORT}/healthz`);
console.log(` admin: http://127.0.0.1:${config.PORT}/admin/trade (basic-auth user=${config.ADMIN_USER}, pass in env/config — not logged)`);
console.log(` webhook: POST /webhooks/customers/create/<token> (URL-token auth + rate-limit; token ${config.WEBHOOK_URL_TOKEN ? 'SET' : 'UNSET → 503 when live'})`);
+ console.log(` claim: POST /claim · verify: GET /verify?token=… (tag=${config.VERIFIED_TAG}; secret ${config.VERIFY_SECRET ? 'SET' : (config.DRY_RUN ? 'dev-fallback (DRY_RUN)' : 'UNSET → /verify 503')})`);
if (config.DRY_RUN) console.log(' ** DRY_RUN ON — no live Shopify writes, no real emails, nothing registered. **');
});
}
← 1755b63 chore: session-close quality gate — comment accuracy fix + v
·
back to Dw Signup Fulfillment
·
DEPLOY.md: rewrite runbook for Option C (verify→tag→Regios), 9196a62 →