@blamejs/blamejs-shop 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -0
- package/lib/admin.js +153 -39
- package/lib/checkout.js +7 -0
- package/lib/order.js +24 -3
- package/lib/storefront.js +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.7 (2026-05-25) — **Admin console — persistent navigation and a products screen.** The admin is now a cohesive, navigable web console rather than a set of disconnected pages. A persistent nav (Home, Dashboard, Products, Integrations, Setup) runs across every signed-in page, and Products is the first full management screen: browse the catalog, create a product, and archive or restore one — all from the browser. The JSON API is unchanged: an endpoint serves the HTML console to a signed-in browser and JSON to a bearer-token client, so tooling keeps working exactly as before. **Added:** *Console navigation* — Every signed-in admin page shares a top nav with the current section highlighted, so the dashboard, products, integrations, and setup wizard are one console. The shell is the same brand-matched layout as the dashboard. · *Products management screen* — `/admin/products` renders the catalog as a table (title, slug, status) with Archive / Restore actions and a New-product form, when opened in a signed-in browser. The same path returns the existing JSON list to a bearer-token client; create, archive, and restore content-negotiate the same way (browser form → redirect; bad input re-renders with a notice, never a 500). The product create / archive / restore JSON API is unchanged.
|
|
12
|
+
|
|
13
|
+
- v0.1.6 (2026-05-25) — **Claim past guest orders when a shopper signs in.** A shopper who checked out as a guest and later signs in with a provider-verified email now finds those past orders attached to their account. Checkout records a hash of the buyer's email on the order; on Google sign-in, orders placed under that same email — and not yet owned by anyone — are claimed into the account. Linking happens only on an email the identity provider verified, never on an unverified one, so it can't be used to steal another shopper's order history. **Added:** *Guest-order reconciliation on sign-in* — Orders now carry a `customer_email_hash` (migration 0206), written at checkout from the buyer's email with the same key the customers table uses. On a verified Google sign-in, `order.linkGuestOrdersByEmailHash` claims every ownerless order under that email into the account — so prior guest purchases appear in /account and satisfy customer-scoped checks (e.g. verified-buyer reviews). Only ownerless orders are touched (an order already attached to a customer is never reassigned), and only a provider-verified email triggers a link.
|
|
14
|
+
|
|
11
15
|
- v0.1.5 (2026-05-25) — **Tell operators how to turn each integration on.** Every third-party integration (Stripe card checkout, Apple/Google Pay, Sign in with Google) is off by default and only activates when you supply its credentials. This release documents exactly what to set for each — in the README, in a new .env.example, and in the admin console itself. A signed-in operator opens /admin/integrations to see, at a glance, which integrations are live and the precise environment variables (or one-time action) needed to enable the rest. Nothing is enabled without your keys. **Added:** *Admin integrations status page* — `/admin/integrations` lists each integration with a live Enabled / Not configured status and the exact variables or action to turn it on — Stripe keys, the payment-method-domain registration for wallets, the Google OAuth client + redirect URI. Read-only; secrets are never rendered. Linked from the admin landing. · *Operator setup docs* — A README "Optional integrations" section and a top-level `.env.example` enumerate every environment variable, what capability it unlocks, and the external setup (e.g. the Google OAuth redirect URI, the Stripe webhook path). Both note that Apple sign-in + PayPal are planned and that Shop Pay / "Sign in with Shop" isn't available to a self-hosted store.
|
|
12
16
|
|
|
13
17
|
- v0.1.4 (2026-05-25) — **Sign in with Google.** Customers can sign in with Google alongside passkeys. The account login page gains a Continue with Google button; the OIDC authorization-code flow (PKCE, state, nonce, ID-token verification) runs through the framework's OAuth adapter, and the verified identity becomes a shop session. Accounts are keyed on the provider's stable subject, and an existing account is only ever linked on an email the provider has verified — an unverified email that collides with an existing account is refused rather than linked. A cart built before signing in is adopted into the account, so checkout attaches the order to the customer. **Added:** *Google sign-in* — Mounts `/account/login/google` + `/account/auth/google/callback` when the operator sets `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, and `SHOP_ORIGIN`. The in-flight state (CSRF state + nonce + PKCE verifier) rides a sealed, /account-scoped, SameSite=Lax cookie; the callback verifies the state before exchanging the code. A forged or stale callback is dropped to the login page. On success the guest session cart is adopted into the account (`cart.setCustomer`), matching the passkey path. · *Federated identity model + safe account linking* — `customers.signInWithOIDC` resolves a verified sign-in to a customer: an existing `(provider, subject)` link, else — only on a provider-verified email — an existing account with that email, else a new account. It never links to an existing account on an unverified email (account-takeover defense). New `customer_oauth_identities` table (migration 0205) + `customers.byOAuthIdentity`.
|
package/README.md
CHANGED
|
@@ -93,6 +93,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
93
93
|
- `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
|
|
94
94
|
- `migrations-d1/0023_returns.sql` — return authorizations + lines (RMA lifecycle FSM)
|
|
95
95
|
- `migrations-d1/0205_customer_oauth_identities.sql` — federated sign-in identities (provider + subject, verified-email gating)
|
|
96
|
+
- `migrations-d1/0206_orders_email_hash.sql` — queryable buyer-email hash on orders (guest-order reconciliation key)
|
|
96
97
|
- `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
|
|
97
98
|
- `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
|
|
98
99
|
|
package/lib/admin.js
CHANGED
|
@@ -235,6 +235,24 @@ function mount(router, deps) {
|
|
|
235
235
|
return _wrap(h, { expectedToken: expectedToken });
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
+
// Content-negotiate one endpoint between the JSON API and the HTML
|
|
239
|
+
// console: a bearer token routes to `apiHandler` (the JSON contract,
|
|
240
|
+
// unchanged for tooling); a browser admin-cookie session routes to
|
|
241
|
+
// `htmlHandler` (the rendered console page). Unauthenticated GETs show
|
|
242
|
+
// the sign-in form; other methods bounce to /admin.
|
|
243
|
+
function _pageOrApi(isGet, apiHandler, htmlHandler) {
|
|
244
|
+
return async function (req, res) {
|
|
245
|
+
if (_authOk(_readBearer(req), expectedToken)) return apiHandler(req, res);
|
|
246
|
+
// Mirror _htmlAuthed: a missing vault makes the cookie check throw;
|
|
247
|
+
// treat that as "not authed" rather than 500-ing the route.
|
|
248
|
+
var cookieOk = false;
|
|
249
|
+
try { cookieOk = _adminCookieValid(req); } catch (_e) { cookieOk = false; }
|
|
250
|
+
if (cookieOk) return htmlHandler(req, res);
|
|
251
|
+
if (isGet) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
252
|
+
return _redirect(res, "/admin");
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
238
256
|
function _json(res, status, obj) {
|
|
239
257
|
res.status(status);
|
|
240
258
|
if (res.setHeader) res.setHeader("content-type", "application/json; charset=utf-8");
|
|
@@ -244,11 +262,31 @@ function mount(router, deps) {
|
|
|
244
262
|
|
|
245
263
|
// ---- products -------------------------------------------------------
|
|
246
264
|
|
|
247
|
-
router.post("/admin/products",
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
265
|
+
router.post("/admin/products", _pageOrApi(false,
|
|
266
|
+
W("product.create", async function (req, res) {
|
|
267
|
+
var p = await catalog.products.create(req.body || {});
|
|
268
|
+
_json(res, 201, p);
|
|
269
|
+
return p;
|
|
270
|
+
}),
|
|
271
|
+
async function (req, res) {
|
|
272
|
+
// Browser form submit — create, then redirect (PRG). Bad input
|
|
273
|
+
// re-renders the products page with a notice, never a 500.
|
|
274
|
+
try {
|
|
275
|
+
await catalog.products.create(req.body || {});
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e instanceof TypeError || e.code === "CATALOG_DUPLICATE" || /slug|exists|duplicate/i.test(e.message || "")) {
|
|
278
|
+
var page = await catalog.products.list({ limit: 100 });
|
|
279
|
+
return _sendHtml(res, 400, renderAdminProducts({
|
|
280
|
+
shop_name: deps.shop_name, products: page.rows || [],
|
|
281
|
+
notice: (e && e.message) || "Couldn't create that product.",
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
throw e;
|
|
285
|
+
}
|
|
286
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.create", outcome: "success", metadata: {} });
|
|
287
|
+
_redirect(res, "/admin/products?created=1");
|
|
288
|
+
},
|
|
289
|
+
));
|
|
252
290
|
|
|
253
291
|
router.get("/admin/products/search", R(async function (req, res) {
|
|
254
292
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -269,15 +307,23 @@ function mount(router, deps) {
|
|
|
269
307
|
_json(res, 200, page);
|
|
270
308
|
}));
|
|
271
309
|
|
|
272
|
-
router.get("/admin/products",
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
310
|
+
router.get("/admin/products", _pageOrApi(true,
|
|
311
|
+
R(async function (req, res) {
|
|
312
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
313
|
+
var status = url && url.searchParams.get("status");
|
|
314
|
+
var cursor = url && url.searchParams.get("cursor");
|
|
315
|
+
var limitS = url && url.searchParams.get("limit");
|
|
316
|
+
var limit = limitS == null ? 50 : parseInt(limitS, 10);
|
|
317
|
+
var page = await catalog.products.list({ status: status || undefined, cursor: cursor || undefined, limit: limit });
|
|
318
|
+
_json(res, 200, page);
|
|
319
|
+
}),
|
|
320
|
+
async function (req, res) {
|
|
321
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
322
|
+
var created = !!(url && url.searchParams.get("created"));
|
|
323
|
+
var page = await catalog.products.list({ limit: 100 });
|
|
324
|
+
_sendHtml(res, 200, renderAdminProducts({ shop_name: deps.shop_name, products: page.rows || [], created: created }));
|
|
325
|
+
},
|
|
326
|
+
));
|
|
281
327
|
|
|
282
328
|
router.get("/admin/products/:id", R(async function (req, res) {
|
|
283
329
|
var p = await catalog.products.get(req.params.id);
|
|
@@ -292,19 +338,26 @@ function mount(router, deps) {
|
|
|
292
338
|
return p;
|
|
293
339
|
}));
|
|
294
340
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
341
|
+
function _productStateAction(verb, op, audit) {
|
|
342
|
+
return _pageOrApi(false,
|
|
343
|
+
W(audit, async function (req, res) {
|
|
344
|
+
var p = await op(req.params.id);
|
|
345
|
+
if (!p) return _problem(res, 404, "product-not-found");
|
|
346
|
+
_json(res, 200, p);
|
|
347
|
+
return p;
|
|
348
|
+
}),
|
|
349
|
+
async function (req, res) {
|
|
350
|
+
// A bad/missing id is a no-op (fall through to the list); a real
|
|
351
|
+
// failure must NOT be reported as success — let it surface.
|
|
352
|
+
try { await op(req.params.id); }
|
|
353
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
354
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: req.params.id } });
|
|
355
|
+
_redirect(res, "/admin/products");
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
router.post("/admin/products/:id/archive", _productStateAction("archive", function (id) { return catalog.products.archive(id); }, "product.archive"));
|
|
360
|
+
router.post("/admin/products/:id/restore", _productStateAction("restore", function (id) { return catalog.products.restore(id); }, "product.restore"));
|
|
308
361
|
|
|
309
362
|
// ---- variants -------------------------------------------------------
|
|
310
363
|
|
|
@@ -1189,6 +1242,11 @@ var DASHBOARD_LAYOUT =
|
|
|
1189
1242
|
" .banner--ok { background:#e9f5ec; border:1px solid #bfe1c9; color:#1f6b3a; }\n" +
|
|
1190
1243
|
" .banner--err { background:#fff1eb; border:1px solid #f6c5af; color:var(--accent-d); }\n" +
|
|
1191
1244
|
" .actions-row { display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin-top:1.5rem; }\n" +
|
|
1245
|
+
" .admin-nav { background:var(--paper); border-bottom:1px solid var(--hair); }\n" +
|
|
1246
|
+
" .admin-nav__inner { max-width:80rem; margin:0 auto; padding:0 1.5rem; display:flex; gap:.1rem; flex-wrap:wrap; }\n" +
|
|
1247
|
+
" .admin-nav a { display:inline-block; padding:.85rem .9rem; color:var(--ink-2); text-decoration:none; font-size:.84rem; font-weight:600; border-bottom:2px solid transparent; }\n" +
|
|
1248
|
+
" .admin-nav a:hover { color:var(--ink); }\n" +
|
|
1249
|
+
" .admin-nav a.active { color:var(--accent); border-bottom-color:var(--accent); }\n" +
|
|
1192
1250
|
" </style>\n" +
|
|
1193
1251
|
"</head>\n" +
|
|
1194
1252
|
"<body>\n" +
|
|
@@ -1198,6 +1256,7 @@ var DASHBOARD_LAYOUT =
|
|
|
1198
1256
|
" <span style=\"font-size:.8rem; color:var(--mute);\">{{window_label}}</span>\n" +
|
|
1199
1257
|
" </div>\n" +
|
|
1200
1258
|
" </header>\n" +
|
|
1259
|
+
" {{nav}}\n" +
|
|
1201
1260
|
" <main>{{body}}</main>\n" +
|
|
1202
1261
|
"</body>\n" +
|
|
1203
1262
|
"</html>\n";
|
|
@@ -1326,12 +1385,12 @@ function renderDashboard(opts) {
|
|
|
1326
1385
|
|
|
1327
1386
|
var body = statsBlock + otherCurrencies + spark + twoCol;
|
|
1328
1387
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
body
|
|
1333
|
-
|
|
1334
|
-
|
|
1388
|
+
return _renderAdminShell(
|
|
1389
|
+
opts.shop_name,
|
|
1390
|
+
"Window: last 30 days (operator-tunable via ?since=&until=)",
|
|
1391
|
+
body,
|
|
1392
|
+
"dashboard",
|
|
1393
|
+
);
|
|
1335
1394
|
}
|
|
1336
1395
|
|
|
1337
1396
|
function _statCard(label, value, accent) {
|
|
@@ -1341,12 +1400,32 @@ function _statCard(label, value, accent) {
|
|
|
1341
1400
|
|
|
1342
1401
|
// ---- admin web pages (login / landing / setup wizard) -------------------
|
|
1343
1402
|
|
|
1344
|
-
|
|
1403
|
+
// Console nav — one entry per HTML console screen. `active` highlights
|
|
1404
|
+
// the current page; `null`/`false` (unauthenticated pages like the
|
|
1405
|
+
// sign-in form) renders no nav at all.
|
|
1406
|
+
var ADMIN_NAV_ITEMS = [
|
|
1407
|
+
{ key: "home", href: "/admin", label: "Home" },
|
|
1408
|
+
{ key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
|
|
1409
|
+
{ key: "products", href: "/admin/products", label: "Products" },
|
|
1410
|
+
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
1411
|
+
{ key: "setup", href: "/admin/setup", label: "Setup" },
|
|
1412
|
+
];
|
|
1413
|
+
function _adminNav(active) {
|
|
1414
|
+
if (active === null || active === undefined || active === false) return "";
|
|
1415
|
+
var links = ADMIN_NAV_ITEMS.map(function (it) {
|
|
1416
|
+
return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\"" : "") + ">" +
|
|
1417
|
+
_htmlEscape(it.label) + "</a>";
|
|
1418
|
+
}).join("");
|
|
1419
|
+
return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function _renderAdminShell(shopName, subtitle, bodyHtml, active) {
|
|
1345
1423
|
return _renderTemplate(DASHBOARD_LAYOUT, {
|
|
1346
1424
|
shop_name: shopName || "blamejs.shop",
|
|
1347
1425
|
window_label: subtitle || "",
|
|
1426
|
+
nav: "RAW_NAV",
|
|
1348
1427
|
body: "RAW_BODY",
|
|
1349
|
-
}).replace("RAW_BODY", bodyHtml);
|
|
1428
|
+
}).replace("RAW_NAV", _adminNav(active)).replace("RAW_BODY", bodyHtml);
|
|
1350
1429
|
}
|
|
1351
1430
|
|
|
1352
1431
|
function renderAdminLogin(opts) {
|
|
@@ -1365,7 +1444,7 @@ function renderAdminLogin(opts) {
|
|
|
1365
1444
|
"<button type=\"submit\" class=\"btn\">Sign in</button>" +
|
|
1366
1445
|
"</form>" +
|
|
1367
1446
|
"</section>";
|
|
1368
|
-
return _renderAdminShell(opts.shop_name, "Sign in", body);
|
|
1447
|
+
return _renderAdminShell(opts.shop_name, "Sign in", body, null);
|
|
1369
1448
|
}
|
|
1370
1449
|
|
|
1371
1450
|
function renderAdminLanding(opts) {
|
|
@@ -1383,7 +1462,7 @@ function renderAdminLanding(opts) {
|
|
|
1383
1462
|
"</div>" +
|
|
1384
1463
|
"<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
|
|
1385
1464
|
"</section>";
|
|
1386
|
-
return _renderAdminShell(opts.shop_name, "", body);
|
|
1465
|
+
return _renderAdminShell(opts.shop_name, "", body, "home");
|
|
1387
1466
|
}
|
|
1388
1467
|
|
|
1389
1468
|
function _setupField(label, name, value, type, hint, extra) {
|
|
@@ -1412,7 +1491,7 @@ function renderAdminSetup(opts) {
|
|
|
1412
1491
|
"<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
1413
1492
|
"</form>" +
|
|
1414
1493
|
"</section>";
|
|
1415
|
-
return _renderAdminShell(opts.shop_name, "Setup", body);
|
|
1494
|
+
return _renderAdminShell(opts.shop_name, "Setup", body, "setup");
|
|
1416
1495
|
}
|
|
1417
1496
|
|
|
1418
1497
|
// Each integration is off until the operator supplies its credentials.
|
|
@@ -1464,7 +1543,41 @@ function renderAdminIntegrations(opts) {
|
|
|
1464
1543
|
"<p class=\"meta\" style=\"margin-top:1.25rem;\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
|
|
1465
1544
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
1466
1545
|
"</section>";
|
|
1467
|
-
return _renderAdminShell(opts.shop_name, "Integrations", body);
|
|
1546
|
+
return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations");
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function renderAdminProducts(opts) {
|
|
1550
|
+
opts = opts || {};
|
|
1551
|
+
var products = opts.products || [];
|
|
1552
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Product created.</div>" : "";
|
|
1553
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1554
|
+
var rows = products.map(function (p) {
|
|
1555
|
+
var cls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
|
|
1556
|
+
var action = p.status === "archived"
|
|
1557
|
+
? "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/restore\"><button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
|
|
1558
|
+
: "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/archive\"><button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
|
|
1559
|
+
return "<tr><td><strong>" + _htmlEscape(p.title) + "</strong></td>" +
|
|
1560
|
+
"<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
|
|
1561
|
+
"<td><span class=\"status-pill " + cls + "\">" + _htmlEscape(p.status) + "</span></td>" +
|
|
1562
|
+
"<td>" + action + "</td></tr>";
|
|
1563
|
+
}).join("");
|
|
1564
|
+
var table = products.length
|
|
1565
|
+
? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th><th>Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
1566
|
+
: "<p class=\"empty\">No products yet — create your first one below.</p>";
|
|
1567
|
+
var body =
|
|
1568
|
+
"<section><h2>Products</h2>" + created + notice + table +
|
|
1569
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
|
|
1570
|
+
"<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">New product</h3>" +
|
|
1571
|
+
"<form method=\"post\" action=\"/admin/products\">" +
|
|
1572
|
+
_setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
|
|
1573
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
|
|
1574
|
+
"<label class=\"form-field\"><span>Status</span><select name=\"status\"><option value=\"draft\">Draft</option><option value=\"active\">Active</option></select></label>" +
|
|
1575
|
+
_setupField("Description", "description", "", "text", "", " maxlength=\"2000\"") +
|
|
1576
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create product</button></div>" +
|
|
1577
|
+
"</form>" +
|
|
1578
|
+
"</div>" +
|
|
1579
|
+
"</section>";
|
|
1580
|
+
return _renderAdminShell(opts.shop_name, "Products", body, "products");
|
|
1468
1581
|
}
|
|
1469
1582
|
|
|
1470
1583
|
module.exports = {
|
|
@@ -1475,4 +1588,5 @@ module.exports = {
|
|
|
1475
1588
|
renderAdminLanding: renderAdminLanding,
|
|
1476
1589
|
renderAdminSetup: renderAdminSetup,
|
|
1477
1590
|
renderAdminIntegrations: renderAdminIntegrations,
|
|
1591
|
+
renderAdminProducts: renderAdminProducts,
|
|
1478
1592
|
};
|
package/lib/checkout.js
CHANGED
|
@@ -96,6 +96,10 @@ function create(deps) {
|
|
|
96
96
|
var payment = deps.payment;
|
|
97
97
|
var order = deps.order;
|
|
98
98
|
var subscriptions = deps.subscriptions || null;
|
|
99
|
+
// Optional — when wired, the buyer email is hashed (via the same
|
|
100
|
+
// customers.hashEmail keying the customers table) and stored on the
|
|
101
|
+
// order so a later verified-email sign-in can claim the guest order.
|
|
102
|
+
var customers = deps.customers || null;
|
|
99
103
|
|
|
100
104
|
// Compose a quote from a cart + ship-to + (optional) selected
|
|
101
105
|
// shipping service. Pure read — no DB writes.
|
|
@@ -219,6 +223,9 @@ function create(deps) {
|
|
|
219
223
|
grand_total_minor: quote.totals.grand_total_minor,
|
|
220
224
|
payment_intent_id: pi.id,
|
|
221
225
|
ship_to: input.ship_to,
|
|
226
|
+
// Hash of the buyer email (same key as the customers table) so a
|
|
227
|
+
// later verified-email sign-in can claim this guest order.
|
|
228
|
+
customer_email_hash: customers ? customers.hashEmail(email) : null,
|
|
222
229
|
lines: quote.lines.map(function (l) {
|
|
223
230
|
return {
|
|
224
231
|
variant_id: l.variant_id,
|
package/lib/order.js
CHANGED
|
@@ -174,13 +174,14 @@ function create(opts) {
|
|
|
174
174
|
await query(
|
|
175
175
|
"INSERT INTO orders (id, cart_id, customer_id, session_id, status, currency, " +
|
|
176
176
|
"subtotal_minor, discount_minor, tax_minor, shipping_minor, grand_total_minor, " +
|
|
177
|
-
"payment_intent_id, ship_to_json, created_at, updated_at) " +
|
|
178
|
-
"VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?
|
|
177
|
+
"payment_intent_id, ship_to_json, customer_email_hash, created_at, updated_at) " +
|
|
178
|
+
"VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14)",
|
|
179
179
|
[
|
|
180
180
|
id, input.cart_id, input.customer_id || null, input.session_id,
|
|
181
181
|
input.currency, input.subtotal_minor, input.discount_minor,
|
|
182
182
|
input.tax_minor, input.shipping_minor, input.grand_total_minor,
|
|
183
|
-
input.payment_intent_id || null, JSON.stringify(input.ship_to),
|
|
183
|
+
input.payment_intent_id || null, JSON.stringify(input.ship_to),
|
|
184
|
+
input.customer_email_hash || null, ts,
|
|
184
185
|
],
|
|
185
186
|
);
|
|
186
187
|
for (var i = 0; i < input.lines.length; i += 1) {
|
|
@@ -369,6 +370,26 @@ function create(opts) {
|
|
|
369
370
|
return await this.get(orderId);
|
|
370
371
|
},
|
|
371
372
|
|
|
373
|
+
// Claim guest orders into a customer account by matching the
|
|
374
|
+
// recorded buyer-email hash. The CALLER must only pass a hash for an
|
|
375
|
+
// email the identity provider VERIFIED — this method does not (and
|
|
376
|
+
// cannot) re-verify; linking an unverified email would be account
|
|
377
|
+
// takeover. Only touches orders with no owner yet (customer_id IS
|
|
378
|
+
// NULL), so it never re-assigns another customer's order. Returns
|
|
379
|
+
// the count linked.
|
|
380
|
+
linkGuestOrdersByEmailHash: async function (customerId, emailHash) {
|
|
381
|
+
_uuid(customerId, "customer id");
|
|
382
|
+
if (typeof emailHash !== "string" || !emailHash.length) {
|
|
383
|
+
throw new TypeError("order.linkGuestOrdersByEmailHash: emailHash must be a non-empty string");
|
|
384
|
+
}
|
|
385
|
+
var r = await query(
|
|
386
|
+
"UPDATE orders SET customer_id = ?1, updated_at = ?2 " +
|
|
387
|
+
"WHERE customer_id IS NULL AND customer_email_hash = ?3",
|
|
388
|
+
[customerId, _now(), emailHash],
|
|
389
|
+
);
|
|
390
|
+
return Number(r.rowCount || 0);
|
|
391
|
+
},
|
|
392
|
+
|
|
372
393
|
// Has this customer purchased this product? True iff an order
|
|
373
394
|
// line for any variant of the product sits in an order owned by
|
|
374
395
|
// the customer whose status is a real purchase — anything except
|
package/lib/storefront.js
CHANGED
|
@@ -3294,6 +3294,15 @@ function mount(router, deps) {
|
|
|
3294
3294
|
if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer.id);
|
|
3295
3295
|
} catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
|
|
3296
3296
|
}
|
|
3297
|
+
// Claim prior guest orders placed under this email — ONLY because
|
|
3298
|
+
// the provider verified it (claims.email_verified). Links orders
|
|
3299
|
+
// with no owner yet whose recorded email hash matches; best-effort.
|
|
3300
|
+
if (claims.email_verified === true && claims.email && deps.order &&
|
|
3301
|
+
typeof deps.order.linkGuestOrdersByEmailHash === "function") {
|
|
3302
|
+
try {
|
|
3303
|
+
await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
|
|
3304
|
+
} catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
|
|
3305
|
+
}
|
|
3297
3306
|
_setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + _b().constants.TIME.days(14) });
|
|
3298
3307
|
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
3299
3308
|
return res.end ? res.end() : res.send("");
|
package/package.json
CHANGED