@blamejs/blamejs-shop 0.1.31 → 0.1.32
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 +2 -0
- package/README.md +2 -0
- package/lib/asset-manifest.json +2 -2
- package/lib/bundles.js +28 -0
- package/lib/checkout.js +48 -2
- package/lib/storefront.js +466 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.32 (2026-05-26) — **Bundles and quantity discounts — sell products together at a set price, and reward buying more.** Two merchandising surfaces go live on the product page and in the cart, both priced authoritatively on the server. A product that belongs to a bundle shows a "Bundle & save" offer with its member products and the bundle price against the sum of the parts; one action adds the whole bundle to the cart at that price, all-or-nothing, and the discount is split across the member lines so the cart subtotal equals the quoted bundle price. Separately, a product with quantity breaks shows its price tiers ("1–2 / 3–5 / 6+") on the page, and the cart applies the right unit price for each line's quantity — recomputed on every cart render and again at checkout, so the line, cart, and order totals all reflect the break. The client never sends a price: it sends a bundle code or a quantity, and the server prices both from the live catalog. A bundle whose member is archived or out of stock is shown unavailable rather than adding a broken line, and a missing offer simply renders nothing. **Added:** *Bundles on the product page* — A product that is part of one or more bundles shows a "Bundle & save" offer listing the bundle's member products, the bundle price, and the saving against buying the parts separately. `POST /cart/bundle` adds the whole bundle to the cart in one action: the bundle price is recomputed from the live catalog and allocated across the member lines (proportional to each member's list price, with the rounding remainder on the last line) so the cart subtotal equals the quoted price. The add is atomic — if any member is archived or out of stock the bundle is shown unavailable and the action adds nothing rather than a partial set. A product in no bundle shows no offer; an unpriceable bundle (a missing member price or a currency mismatch) is shown unavailable, never a server error. The offer renders identically from the edge worker and the container. · *Quantity discounts on the product page and in the cart* — A product with quantity breaks shows its price tiers on the page (for example 1–2, 3–5, 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — applied server-side on every cart render so changing the quantity re-prices the line, and applied again at checkout so the per-line price and the order total written onto the order reflect the break, not the list price. A quantity below the first tier falls back to the base price; a quantity above the top tier takes the top tier. Stacks with the rest of the cart math. The tier table renders identically from the edge worker and the container. **Fixed:** *Themed product template carries the compare control* — The file-backed default product template now includes the Add-to-compare slot, matching the built-in render path so the comparison control appears on the product page whichever template is in use.
|
|
12
|
+
|
|
11
13
|
- v0.1.31 (2026-05-26) — **Product compare — a side-by-side comparison table any shopper can build from the product pages.** Shoppers can now line products up against each other. Every product page gains an "Add to compare" control; a "Compare" link in the footer opens a side-by-side table of the chosen products — image, price, availability, and each product's attributes in aligned rows. No sign-in is required: the comparison basket rides the visitor's existing sealed session cookie, and a signed-in shopper's customer id is carried alongside. The basket holds up to four products (a fifth add is refused with a notice rather than quietly dropping one), the toggle is idempotent, and a product that goes archived or out of stock resolves out of the table with a remove control instead of breaking the page. The control and table render identically from the edge and the container, and the comparison page reuses the catalog grid styling, so no new stylesheet ships. **Added:** *Add to compare on the product page* — Each product page shows an "Add to compare" / "Remove from compare" control reflecting whether the product is already in the basket. `POST /compare/toggle` adds or removes it and redirects back to where the shopper was (the redirect target is validated, so a crafted `return_to` can't bounce the visitor off-site). Comparing needs no account — the basket is keyed on the visitor's existing sealed session cookie; a signed-in shopper's customer id is captured alongside. The basket is capped at four products: a fifth add is refused with a notice and the basket is left unchanged. · *The comparison table* — `GET /compare` renders the selected products side by side — image, title, price, availability, and the per-product attributes resolved through the catalog, in aligned rows. Each column has a remove control, and `POST /compare/clear` empties the whole basket. A product that has since been archived or sold out shows in a gone/out-of-stock state with a remove control rather than failing the render. An empty basket shows a friendly empty state and mints no cookie. A "Compare" link sits in the footer on every page.
|
|
12
14
|
|
|
13
15
|
- v0.1.30 (2026-05-26) — **Product Q&A — customer questions and seller answers on the product page.** Products gain a question-and-answer thread alongside reviews. A signed-in shopper asks a question from the product page; it lands pending and surfaces only after an operator approves it. Operators moderate at `/admin/questions`: a status-filtered queue of every question across the catalog, and a per-question detail page where they approve or reject the question, post the authoritative seller answer, approve / reject individual answers, and pin one answer as the definitive 'top answer'. Approved questions and their approved answers render on the product page in both the edge and container render paths, reusing the reviews section's styling so no new CSS ships. As with reviews, a missing Q&A table degrades the product page to an empty Q&A state rather than failing the render. **Added:** *Ask a question on the product page* — `GET /products/:slug/question` renders an auth-gated question form; `POST /products/:slug/question` submits it. Asking requires a signed-in customer — a logged-out shopper is redirected to sign in. The question is stored pending and is not shown on the product page until an operator approves it. A malformed submission re-renders the form with the reason and a 400; an unknown product is a 404. · *Published Q&A on the product page* — The product page shows approved questions with their approved answers below the reviews section. Each answer carries a source badge — 'Answered by the seller', 'Customer answer', or 'Automated answer' — and the operator-pinned answer is flagged 'Top answer'. The empty state invites the first question. Rendered identically by the edge worker and the container so the page is the same whichever serves it; the read degrades to the empty state when the Q&A tables aren't present. · *Q&A moderation console* — `/admin/questions` is the question queue across all products, with pending / approved / rejected status-filter chips. Each question opens a detail page showing its full answer thread (every status), an approve / reject control for the question, a form to post the seller's answer, and per-answer approve / reject / pin controls. Posting an answer leaves it pending; approving it publishes it on the product page. Pinning floats one operator answer to the top of the thread. Every path content-negotiates: a bearer-token client gets a JSON contract, a signed-in browser gets the HTML console. An unknown id or a refused transition redirects back with a notice, never a 500. · *productQA read APIs for the storefront and console* — `answersForQuestion` returns the approved answers under a question (pinned first) for the product-page thread; `listAnswersForQuestion` returns every answer regardless of status for the moderation detail; `listQuestionsByStatus` and `listAnswersByStatus` return the cross-product moderation queues. **Changed:** *Console nav gains Q&A; product page wires the Q&A primitive* — The signed-in admin nav includes Q&A, shown only when the Q&A primitive is wired so the link never points at an unmounted route. The storefront and admin share one Q&A instance, wired with the customer primitive so an authenticated questioner's id is verified before the row is stored.
|
package/README.md
CHANGED
|
@@ -72,6 +72,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
72
72
|
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
73
73
|
| **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
|
|
74
74
|
| **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
|
|
75
|
+
| **`lib/bundles.js`** | Sell products together at a set price. A product page shows the bundles it belongs to as a "Bundle & save" offer — the member products, the bundle price, and the saving against the parts. `POST /cart/bundle` adds the whole bundle in one action: the price is recomputed from the live catalog and allocated across the member lines (proportional to list price, remainder on the last line) so the cart subtotal matches the quoted price. The add is atomic — an archived or out-of-stock member shows the bundle unavailable and adds nothing rather than a partial set; an unpriceable bundle (missing member price / currency mismatch) is shown unavailable, never a 500. The client sends only the bundle code; the server prices it. The offer renders identically from the edge worker and the container. |
|
|
76
|
+
| **`lib/quantity-discounts.js`** | Reward buying more. A product with quantity breaks shows its price tiers on the page (e.g. 1–2 / 3–5 / 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — reapplied on every cart render so changing the quantity re-prices, and again at checkout so the per-line price and order total written onto the order reflect the break, not the list price. A quantity under the first tier falls back to the base price; over the top tier takes the top tier. The client sends only a quantity; the server prices it. The tier table renders identically from the edge worker and the container. |
|
|
75
77
|
| **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
|
|
76
78
|
| **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
|
|
77
79
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.32",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
|
|
5
|
-
"css/main.css": "sha384-
|
|
5
|
+
"css/main.css": "sha384-A2m9cYLHIlIrFvTluTOaZz9r8F90yWIKd3SHOlyNwQwtaOBrzlsn/NDR1vso6dSF",
|
|
6
6
|
"js/passkey-login.js": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
|
|
7
7
|
"js/passkey-register.js": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY"
|
|
8
8
|
}
|
package/lib/bundles.js
CHANGED
|
@@ -333,6 +333,33 @@ function create(opts) {
|
|
|
333
333
|
return { rows: r.rows, next_cursor: next };
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
// ---- bundlesForComponent ----------------------------------------------
|
|
337
|
+
|
|
338
|
+
// Every bundle that lists `sku` as a direct component, newest-edited
|
|
339
|
+
// first. Backs the storefront "Bundle & save" rail — a product page
|
|
340
|
+
// shows the bundles its variant SKUs belong to. Returns the same
|
|
341
|
+
// shape as getBundle (definition + ordered components) so the caller
|
|
342
|
+
// can price + render each without a second round trip per bundle.
|
|
343
|
+
// Only direct membership is resolved; a bundle that contains `sku`
|
|
344
|
+
// transitively (via a nested child bundle) is reachable by walking
|
|
345
|
+
// its own bundlesForComponent, which the storefront doesn't surface
|
|
346
|
+
// on a leaf PDP.
|
|
347
|
+
async function bundlesForComponent(sku) {
|
|
348
|
+
_sku(sku, "sku");
|
|
349
|
+
var r = await query(
|
|
350
|
+
"SELECT DISTINCT bc.bundle_sku FROM bundle_components bc " +
|
|
351
|
+
"JOIN bundles b ON b.bundle_sku = bc.bundle_sku " +
|
|
352
|
+
"WHERE bc.sku = ?1 ORDER BY b.updated_at DESC, b.bundle_sku DESC",
|
|
353
|
+
[sku],
|
|
354
|
+
);
|
|
355
|
+
var out = [];
|
|
356
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
357
|
+
var full = await getBundle(r.rows[i].bundle_sku);
|
|
358
|
+
if (full) out.push(full);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
336
363
|
// ---- expand ------------------------------------------------------------
|
|
337
364
|
|
|
338
365
|
// Flatten a bundle into its leaf components with multipliers applied.
|
|
@@ -569,6 +596,7 @@ function create(opts) {
|
|
|
569
596
|
defineBundle: defineBundle,
|
|
570
597
|
getBundle: getBundle,
|
|
571
598
|
listBundles: listBundles,
|
|
599
|
+
bundlesForComponent: bundlesForComponent,
|
|
572
600
|
expand: expand,
|
|
573
601
|
priceBundle: priceBundle,
|
|
574
602
|
updateBundle: updateBundle,
|
package/lib/checkout.js
CHANGED
|
@@ -121,6 +121,47 @@ function create(deps) {
|
|
|
121
121
|
// checkout never re-derives the conversion constant. Disabled when
|
|
122
122
|
// the handle is absent — the rest of the flow is unchanged.
|
|
123
123
|
var loyalty = deps.loyalty || null;
|
|
124
|
+
// Optional quantity-discount engine. When wired, each cart line is
|
|
125
|
+
// repriced at confirm/quote time by reapplying the active per-line
|
|
126
|
+
// quantity break at the line's current quantity, so the order total
|
|
127
|
+
// (and the per-line price written onto the order) reflects the break
|
|
128
|
+
// — never the bare add-time snapshot. Disabled when absent: lines
|
|
129
|
+
// pass through at their snapshot price. The repricing is the same
|
|
130
|
+
// server-authoritative computation the cart page renders, so the
|
|
131
|
+
// "review your order" subtotal and the charged amount agree.
|
|
132
|
+
var quantityDiscounts = deps.quantityDiscounts || null;
|
|
133
|
+
|
|
134
|
+
// Reprice a list of cart lines through the quantity-discount engine.
|
|
135
|
+
// Returns a shallow copy with `unit_amount_minor` overwritten by the
|
|
136
|
+
// discounted unit for each line's SKU at its quantity. A line whose
|
|
137
|
+
// SKU has no active tier (or any apply failure) keeps its snapshot.
|
|
138
|
+
// No-op pass-through when the engine isn't wired.
|
|
139
|
+
async function _repriceLines(c, lines) {
|
|
140
|
+
if (!quantityDiscounts) return lines;
|
|
141
|
+
var out = [];
|
|
142
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
143
|
+
var l = lines[i];
|
|
144
|
+
var unit = l.unit_amount_minor;
|
|
145
|
+
try {
|
|
146
|
+
var v = await catalog.variants.get(l.variant_id);
|
|
147
|
+
var applied = await quantityDiscounts.applyToLine({
|
|
148
|
+
line: {
|
|
149
|
+
sku: l.sku,
|
|
150
|
+
quantity: l.qty,
|
|
151
|
+
unit_price_minor: l.unit_amount_minor,
|
|
152
|
+
product_id: v ? v.product_id : undefined,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
unit = applied.discounted_unit_minor;
|
|
156
|
+
} catch (_e) {
|
|
157
|
+
// Unpriceable line keeps its snapshot — the quantity-break is
|
|
158
|
+
// an adjustment, never a hard gate on the buy path.
|
|
159
|
+
unit = l.unit_amount_minor;
|
|
160
|
+
}
|
|
161
|
+
out.push(Object.assign({}, l, { unit_amount_minor: unit }));
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
124
165
|
|
|
125
166
|
// Validate a gift-card code against a priced quote: the card exists,
|
|
126
167
|
// is active, not expired, and matches the order currency. Returns
|
|
@@ -289,8 +330,13 @@ function create(deps) {
|
|
|
289
330
|
var c = await cart.get(input.cart_id);
|
|
290
331
|
if (!c) throw new TypeError("checkout: cart " + input.cart_id + " not found");
|
|
291
332
|
if (c.status !== "active") throw new TypeError("checkout: cart status is " + c.status + ", cannot quote");
|
|
292
|
-
var
|
|
293
|
-
if (
|
|
333
|
+
var rawLines = await cart.listLines(c.id);
|
|
334
|
+
if (rawLines.length === 0) throw new TypeError("checkout: cart is empty");
|
|
335
|
+
// Reapply the active quantity-break for each line at confirm time —
|
|
336
|
+
// the order total + the per-line price written onto the order
|
|
337
|
+
// reflect the break, not the bare add-time snapshot. Pass-through
|
|
338
|
+
// when the engine isn't wired.
|
|
339
|
+
var lines = await _repriceLines(c, rawLines);
|
|
294
340
|
|
|
295
341
|
// Variants for shipping weight + requires_shipping flag.
|
|
296
342
|
var enrichedLines = [];
|
package/lib/storefront.js
CHANGED
|
@@ -745,10 +745,12 @@ var PRODUCT_PAGE =
|
|
|
745
745
|
" </table>\n" +
|
|
746
746
|
" </div>\n" +
|
|
747
747
|
" </div>\n" +
|
|
748
|
+
" RAW_QTYBREAK_PLACEHOLDER\n" +
|
|
748
749
|
" RAW_WISHLIST_PLACEHOLDER\n" +
|
|
749
750
|
" RAW_COMPARE_PLACEHOLDER\n" +
|
|
750
751
|
" </div>\n" +
|
|
751
752
|
" </div>\n" +
|
|
753
|
+
" RAW_BUNDLES_PLACEHOLDER\n" +
|
|
752
754
|
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
753
755
|
" RAW_QA_PLACEHOLDER\n" +
|
|
754
756
|
"</section>\n";
|
|
@@ -890,6 +892,109 @@ function _buildReviews(summary, reviews, ctaHtml) {
|
|
|
890
892
|
"</section>";
|
|
891
893
|
}
|
|
892
894
|
|
|
895
|
+
// Builds the PDP "Bundle & save" rail from the offers the route
|
|
896
|
+
// resolved. Each offer carries its component list (with display
|
|
897
|
+
// titles + per-member quantity), the sum-of-parts list price, the
|
|
898
|
+
// bundle price, and the saving — every figure already formatted to a
|
|
899
|
+
// currency string by the route (price math stays server-side; the
|
|
900
|
+
// builder is pure string assembly so the container + edge renderers
|
|
901
|
+
// emit byte-identical markup). An unavailable offer (a member is
|
|
902
|
+
// archived / out of stock) renders disabled with a reason instead of
|
|
903
|
+
// the add form, so a customer never hits a broken atomic add. Returns
|
|
904
|
+
// "" when there are no offers so the PDP shows no empty rail. Mirrors
|
|
905
|
+
// the edge renderer (`worker/render/product.js#_buildBundles`).
|
|
906
|
+
function _buildBundles(offers) {
|
|
907
|
+
return _renderBundles(offers, b.template.escapeHtml);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Shared pure-string assembler. `esc` is the caller's HTML-escaper
|
|
911
|
+
// (b.template.escapeHtml in the container, escapeHtml at the edge) —
|
|
912
|
+
// both produce the same output, so the markup is identical across
|
|
913
|
+
// render paths.
|
|
914
|
+
function _renderBundles(offers, esc) {
|
|
915
|
+
offers = offers || [];
|
|
916
|
+
if (offers.length === 0) return "";
|
|
917
|
+
var cards = "";
|
|
918
|
+
for (var i = 0; i < offers.length; i += 1) {
|
|
919
|
+
var o = offers[i];
|
|
920
|
+
var members = "";
|
|
921
|
+
for (var j = 0; j < o.components.length; j += 1) {
|
|
922
|
+
var c = o.components[j];
|
|
923
|
+
members +=
|
|
924
|
+
"<li class=\"bundle-card__member\">" +
|
|
925
|
+
"<span class=\"bundle-card__member-qty\">" + esc(String(c.quantity)) + "×</span> " +
|
|
926
|
+
"<span class=\"bundle-card__member-title\">" + esc(String(c.title)) + "</span> " +
|
|
927
|
+
"<code class=\"bundle-card__member-sku\">" + esc(String(c.sku)) + "</code>" +
|
|
928
|
+
"</li>";
|
|
929
|
+
}
|
|
930
|
+
var pricing =
|
|
931
|
+
"<div class=\"bundle-card__pricing\">" +
|
|
932
|
+
"<span class=\"bundle-card__list\">Buy separately " + esc(o.list_total_str) + "</span>" +
|
|
933
|
+
"<span class=\"bundle-card__price price\">Bundle price " + esc(o.amount_str) + "</span>" +
|
|
934
|
+
(o.discount_str ? "<span class=\"bundle-card__save\">You save " + esc(o.discount_str) + "</span>" : "") +
|
|
935
|
+
"</div>";
|
|
936
|
+
var action;
|
|
937
|
+
if (o.available) {
|
|
938
|
+
action =
|
|
939
|
+
"<form method=\"post\" action=\"/cart/bundle\" class=\"bundle-card__form\">" +
|
|
940
|
+
"<input type=\"hidden\" name=\"bundle_sku\" value=\"" + esc(o.bundle_sku) + "\">" +
|
|
941
|
+
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add bundle to cart</button>" +
|
|
942
|
+
"</form>";
|
|
943
|
+
} else {
|
|
944
|
+
action =
|
|
945
|
+
"<p class=\"bundle-card__unavailable\">" +
|
|
946
|
+
esc(o.unavailable_reason || "This bundle is currently unavailable.") +
|
|
947
|
+
"</p>";
|
|
948
|
+
}
|
|
949
|
+
cards +=
|
|
950
|
+
"<article class=\"bundle-card" + (o.available ? "" : " bundle-card--unavailable") + "\">" +
|
|
951
|
+
"<h3 class=\"bundle-card__title\">" + esc(String(o.title)) + "</h3>" +
|
|
952
|
+
"<ul class=\"bundle-card__members\">" + members + "</ul>" +
|
|
953
|
+
pricing +
|
|
954
|
+
action +
|
|
955
|
+
"</article>";
|
|
956
|
+
}
|
|
957
|
+
return "<section class=\"bundles\" aria-labelledby=\"bundles-title\">" +
|
|
958
|
+
"<h2 id=\"bundles-title\" class=\"bundles__heading\">Bundle & save</h2>" +
|
|
959
|
+
"<div class=\"bundles__grid\">" + cards + "</div>" +
|
|
960
|
+
"</section>";
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Builds the PDP quantity-break table for the displayed variant. Each
|
|
964
|
+
// row is a (range label, unit price) pair the route already resolved +
|
|
965
|
+
// formatted (the unit price comes from the quantity-discount
|
|
966
|
+
// primitive's tierBreakdown against the variant's list price — the math
|
|
967
|
+
// is server-side; the builder only lays out strings). Returns "" when
|
|
968
|
+
// the product has no active breaks so the PDP shows no empty table.
|
|
969
|
+
// Mirrors the edge renderer (`worker/render/product.js#_buildQtyBreaks`).
|
|
970
|
+
function _buildQtyBreaks(breaks) {
|
|
971
|
+
return _renderQtyBreaks(breaks, b.template.escapeHtml);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function _renderQtyBreaks(breaks, esc) {
|
|
975
|
+
breaks = breaks || [];
|
|
976
|
+
if (breaks.length === 0) return "";
|
|
977
|
+
var rows = "";
|
|
978
|
+
for (var i = 0; i < breaks.length; i += 1) {
|
|
979
|
+
var br = breaks[i];
|
|
980
|
+
rows +=
|
|
981
|
+
"<tr>" +
|
|
982
|
+
"<td class=\"qty-break__range\">" + esc(String(br.label)) + "</td>" +
|
|
983
|
+
"<td class=\"qty-break__unit price\">" + esc(String(br.unit_str)) + "</td>" +
|
|
984
|
+
"</tr>";
|
|
985
|
+
}
|
|
986
|
+
return "<div class=\"qty-breaks\">" +
|
|
987
|
+
"<h2 class=\"qty-breaks__title\">Buy more, save more</h2>" +
|
|
988
|
+
"<div class=\"table-scroll\">" +
|
|
989
|
+
"<table class=\"qty-break-table\">" +
|
|
990
|
+
"<thead><tr><th>Quantity</th><th>Price each</th></tr></thead>" +
|
|
991
|
+
"<tbody>" + rows + "</tbody>" +
|
|
992
|
+
"</table>" +
|
|
993
|
+
"</div>" +
|
|
994
|
+
"<p class=\"qty-breaks__note\">Discount applies automatically in your cart.</p>" +
|
|
995
|
+
"</div>";
|
|
996
|
+
}
|
|
997
|
+
|
|
893
998
|
var REVIEW_FORM_PAGE =
|
|
894
999
|
"<section class=\"review-form-page\">\n" +
|
|
895
1000
|
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
@@ -2321,6 +2426,8 @@ function renderProduct(opts) {
|
|
|
2321
2426
|
// include the slot; a custom theme opts in by adding it.
|
|
2322
2427
|
reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
|
|
2323
2428
|
qa_html: _buildProductQa(opts.qa_questions, opts.qa_cta),
|
|
2429
|
+
bundles_html: _buildBundles(opts.bundle_offers),
|
|
2430
|
+
qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
|
|
2324
2431
|
wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
|
|
2325
2432
|
compare_html: _buildCompare(opts.product.id),
|
|
2326
2433
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
@@ -2333,6 +2440,8 @@ function renderProduct(opts) {
|
|
|
2333
2440
|
var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
|
|
2334
2441
|
var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
|
|
2335
2442
|
var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
|
|
2443
|
+
var bundlesHtml = _buildBundles(opts.bundle_offers);
|
|
2444
|
+
var qtyBreaksHtml = _buildQtyBreaks(opts.qty_breaks);
|
|
2336
2445
|
var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
|
|
2337
2446
|
var compareHtml = _buildCompare(opts.product.id);
|
|
2338
2447
|
var body = _render(PRODUCT_PAGE, {
|
|
@@ -2342,8 +2451,10 @@ function renderProduct(opts) {
|
|
|
2342
2451
|
})
|
|
2343
2452
|
.replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
|
|
2344
2453
|
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
2454
|
+
.replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
|
|
2345
2455
|
.replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
|
|
2346
2456
|
.replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
|
|
2457
|
+
.replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
|
|
2347
2458
|
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
|
|
2348
2459
|
.replace("RAW_QA_PLACEHOLDER", qaHtml);
|
|
2349
2460
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
@@ -3610,6 +3721,204 @@ function mount(router, deps) {
|
|
|
3610
3721
|
return { sid: sid, cart: created };
|
|
3611
3722
|
}
|
|
3612
3723
|
|
|
3724
|
+
// ---- bundle + quantity-discount pricing (server-authoritative) --------
|
|
3725
|
+
//
|
|
3726
|
+
// Every price below is recomputed from the catalog + the bundle /
|
|
3727
|
+
// quantity-discount primitives on each render. The client never
|
|
3728
|
+
// sends a price; the cart line's stored snapshot is the add-time
|
|
3729
|
+
// catalog price, and the quantity-break adjustment is reapplied each
|
|
3730
|
+
// time the cart renders (idempotent — the same line quantity always
|
|
3731
|
+
// yields the same adjusted unit), so a stale or forged client value
|
|
3732
|
+
// can't survive a round trip.
|
|
3733
|
+
|
|
3734
|
+
// A pricing.priceFor(sku) adapter over the catalog, for
|
|
3735
|
+
// bundles.priceBundle. Returns the current price row in `currency`
|
|
3736
|
+
// (or null when a member SKU has no price configured, which the
|
|
3737
|
+
// caller treats as "bundle unavailable"). Memoized per call site so
|
|
3738
|
+
// a bundle that repeats a SKU only hits the catalog once.
|
|
3739
|
+
function _skuPricer(currency) {
|
|
3740
|
+
var cache = Object.create(null);
|
|
3741
|
+
return {
|
|
3742
|
+
priceFor: async function (sku) {
|
|
3743
|
+
if (cache[sku] !== undefined) return cache[sku];
|
|
3744
|
+
var variant = await deps.catalog.variants.bySku(sku);
|
|
3745
|
+
if (!variant) { cache[sku] = null; return null; }
|
|
3746
|
+
var price = await deps.catalog.prices.current(variant.id, currency);
|
|
3747
|
+
cache[sku] = price
|
|
3748
|
+
? { amount_minor: price.amount_minor, currency: price.currency }
|
|
3749
|
+
: null;
|
|
3750
|
+
return cache[sku];
|
|
3751
|
+
},
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
// Is a SKU buyable right now — a real, in-stock catalog variant?
|
|
3756
|
+
// A bundle with any unbuyable member is shown as unavailable so the
|
|
3757
|
+
// atomic add never half-completes. Inventory is optional: a SKU with
|
|
3758
|
+
// no inventory row is treated as available (the operator hasn't opted
|
|
3759
|
+
// into stock tracking for it), matching the rest of the storefront's
|
|
3760
|
+
// never-block-on-missing-inventory stance.
|
|
3761
|
+
async function _skuBuyable(sku) {
|
|
3762
|
+
var variant = await deps.catalog.variants.bySku(sku);
|
|
3763
|
+
if (!variant) return null;
|
|
3764
|
+
var inv = await deps.catalog.inventory.get(sku);
|
|
3765
|
+
if (inv && (inv.stock_on_hand - inv.stock_held) <= 0) return false;
|
|
3766
|
+
return variant;
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
// Resolve the "Bundle & save" offers for a product's variant SKUs.
|
|
3770
|
+
// For each bundle a SKU belongs to: price it (sum-of-parts vs bundle
|
|
3771
|
+
// price via the primitive's stored discount), check every member is
|
|
3772
|
+
// buyable, and shape the display offer the renderer consumes. An
|
|
3773
|
+
// unpriceable or unbuyable-member bundle is surfaced as unavailable
|
|
3774
|
+
// (never hidden silently — the customer sees why). Dedupes bundles
|
|
3775
|
+
// reachable via multiple variant SKUs. Drop-silent on a primitive
|
|
3776
|
+
// error so a bundles read failure can't 500 the PDP — the rail just
|
|
3777
|
+
// doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
|
|
3778
|
+
async function _resolveBundleOffers(variantSkus, currency) {
|
|
3779
|
+
if (!deps.bundles) return [];
|
|
3780
|
+
var seen = Object.create(null);
|
|
3781
|
+
var offers = [];
|
|
3782
|
+
try {
|
|
3783
|
+
for (var s = 0; s < variantSkus.length; s += 1) {
|
|
3784
|
+
var bundleList = await deps.bundles.bundlesForComponent(variantSkus[s]);
|
|
3785
|
+
for (var i = 0; i < bundleList.length; i += 1) {
|
|
3786
|
+
var bundle = bundleList[i];
|
|
3787
|
+
if (seen[bundle.bundle_sku]) continue;
|
|
3788
|
+
seen[bundle.bundle_sku] = true;
|
|
3789
|
+
|
|
3790
|
+
// Decorate each member with a display title; flag unbuyable.
|
|
3791
|
+
var componentsOut = [];
|
|
3792
|
+
var allBuyable = true;
|
|
3793
|
+
for (var j = 0; j < bundle.components.length; j += 1) {
|
|
3794
|
+
var comp = bundle.components[j];
|
|
3795
|
+
var buyable = await _skuBuyable(comp.sku);
|
|
3796
|
+
if (!buyable) allBuyable = false;
|
|
3797
|
+
var memberVariant = buyable || (await deps.catalog.variants.bySku(comp.sku));
|
|
3798
|
+
var memberTitle = comp.sku;
|
|
3799
|
+
if (memberVariant) {
|
|
3800
|
+
var memberProduct = await deps.catalog.products.get(memberVariant.product_id);
|
|
3801
|
+
memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
|
|
3802
|
+
}
|
|
3803
|
+
componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
var priced = null;
|
|
3807
|
+
try {
|
|
3808
|
+
priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
|
|
3809
|
+
} catch (_e) {
|
|
3810
|
+
// A missing member price / mixed currency makes the bundle
|
|
3811
|
+
// unpriceable — surface it as unavailable rather than 500.
|
|
3812
|
+
priced = null;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
var available = allBuyable && priced != null;
|
|
3816
|
+
var listMinor = priced ? priced.list_total_minor : 0;
|
|
3817
|
+
var amountMinor = priced ? priced.amount_minor : 0;
|
|
3818
|
+
var discountMinor = priced ? priced.discount_minor : 0;
|
|
3819
|
+
var cur = (priced && priced.currency) || currency;
|
|
3820
|
+
offers.push({
|
|
3821
|
+
bundle_sku: bundle.bundle_sku,
|
|
3822
|
+
title: bundle.title,
|
|
3823
|
+
components: componentsOut,
|
|
3824
|
+
list_total_str: pricing.format(listMinor, cur),
|
|
3825
|
+
amount_str: pricing.format(amountMinor, cur),
|
|
3826
|
+
discount_str: discountMinor > 0 ? pricing.format(discountMinor, cur) : null,
|
|
3827
|
+
available: available,
|
|
3828
|
+
unavailable_reason: available
|
|
3829
|
+
? null
|
|
3830
|
+
: (priced == null
|
|
3831
|
+
? "Pricing for this bundle isn't available right now."
|
|
3832
|
+
: "One or more items in this bundle are out of stock."),
|
|
3833
|
+
});
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
} catch (_e) {
|
|
3837
|
+
// Bundles table not migrated / read failure — degrade to no rail.
|
|
3838
|
+
return [];
|
|
3839
|
+
}
|
|
3840
|
+
return offers;
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// Resolve the quantity-break table rows for a variant's SKU, against
|
|
3844
|
+
// its list price. Composes the quantity-discount primitive's
|
|
3845
|
+
// tierBreakdown with a sample unit = the variant's current price, so
|
|
3846
|
+
// the displayed "price each" matches what the cart will charge at
|
|
3847
|
+
// that quantity. Rows read as ascending ranges ("1–4", "5–9",
|
|
3848
|
+
// "10+"). Returns [] when no active sku-scoped tier set exists.
|
|
3849
|
+
// Drop-silent on a read failure so the PDP still renders.
|
|
3850
|
+
async function _resolveQtyBreaks(sku, unitMinor, currency) {
|
|
3851
|
+
if (!deps.quantityDiscounts || unitMinor == null) return [];
|
|
3852
|
+
var breakdown;
|
|
3853
|
+
try {
|
|
3854
|
+
breakdown = await deps.quantityDiscounts.tierBreakdown({
|
|
3855
|
+
scope: "sku",
|
|
3856
|
+
scope_id: sku,
|
|
3857
|
+
sample_unit_price_minor: unitMinor,
|
|
3858
|
+
});
|
|
3859
|
+
} catch (_e) {
|
|
3860
|
+
return [];
|
|
3861
|
+
}
|
|
3862
|
+
var rows = breakdown.rows || [];
|
|
3863
|
+
if (rows.length === 0) return [];
|
|
3864
|
+
// Order by min_quantity ascending; build the implicit range each
|
|
3865
|
+
// tier covers (this tier's min up to the next tier's min minus 1,
|
|
3866
|
+
// open-ended on the last). The base price (below the first tier)
|
|
3867
|
+
// rides as the leading "1–(min-1)" row so the table shows the full
|
|
3868
|
+
// ladder including the undiscounted band.
|
|
3869
|
+
var sorted = rows.slice().sort(function (a, b2) { return a.min_quantity - b2.min_quantity; });
|
|
3870
|
+
var out = [];
|
|
3871
|
+
if (sorted[0].min_quantity > 1) {
|
|
3872
|
+
out.push({
|
|
3873
|
+
label: "1–" + (sorted[0].min_quantity - 1),
|
|
3874
|
+
unit_str: pricing.format(unitMinor, currency),
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
for (var i = 0; i < sorted.length; i += 1) {
|
|
3878
|
+
var t = sorted[i];
|
|
3879
|
+
var next = sorted[i + 1];
|
|
3880
|
+
var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
|
|
3881
|
+
var unit = t.sample_discounted_unit_minor != null ? t.sample_discounted_unit_minor : unitMinor;
|
|
3882
|
+
out.push({ label: label, unit_str: pricing.format(unit, currency) });
|
|
3883
|
+
}
|
|
3884
|
+
return out;
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
// Reprice cart lines for display + totals by reapplying the active
|
|
3888
|
+
// quantity-break for each line's SKU at its current quantity. Returns
|
|
3889
|
+
// a shallow copy of each line with `unit_amount_minor` overwritten by
|
|
3890
|
+
// the discounted unit (the stored snapshot is never mutated — the
|
|
3891
|
+
// adjustment is recomputed every render, so it stays correct as the
|
|
3892
|
+
// quantity changes and as the operator edits tier schedules). When
|
|
3893
|
+
// the quantity-discount primitive isn't wired, the lines pass through
|
|
3894
|
+
// unchanged. Drop-silent per line so one unpriceable SKU can't break
|
|
3895
|
+
// the cart total.
|
|
3896
|
+
async function _repriceCartLines(lines) {
|
|
3897
|
+
if (!deps.quantityDiscounts) return lines;
|
|
3898
|
+
var out = [];
|
|
3899
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
3900
|
+
var l = lines[i];
|
|
3901
|
+
var unit = l.unit_amount_minor;
|
|
3902
|
+
try {
|
|
3903
|
+
var variant = await deps.catalog.variants.get(l.variant_id);
|
|
3904
|
+
var applied = await deps.quantityDiscounts.applyToLine({
|
|
3905
|
+
line: {
|
|
3906
|
+
sku: l.sku,
|
|
3907
|
+
quantity: l.qty,
|
|
3908
|
+
unit_price_minor: l.unit_amount_minor,
|
|
3909
|
+
product_id: variant ? variant.product_id : undefined,
|
|
3910
|
+
},
|
|
3911
|
+
});
|
|
3912
|
+
unit = applied.discounted_unit_minor;
|
|
3913
|
+
} catch (_e) {
|
|
3914
|
+
// Unpriceable line (no tier / bad shape) keeps its snapshot.
|
|
3915
|
+
unit = l.unit_amount_minor;
|
|
3916
|
+
}
|
|
3917
|
+
out.push(Object.assign({}, l, { unit_amount_minor: unit }));
|
|
3918
|
+
}
|
|
3919
|
+
return out;
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3613
3922
|
router.get("/", async function (_req, res) {
|
|
3614
3923
|
var page = await deps.catalog.products.list({ status: "active", limit: 24 });
|
|
3615
3924
|
// Best-effort "starting price" + "hero media" lookup. Each
|
|
@@ -3768,6 +4077,19 @@ function mount(router, deps) {
|
|
|
3768
4077
|
catch (_e) { /* drop-silent — recently-viewed is supplementary to the buy path */ }
|
|
3769
4078
|
}
|
|
3770
4079
|
}
|
|
4080
|
+
// Bundle offers + quantity-break table. Both compose the
|
|
4081
|
+
// server-authoritative pricing primitives (the customer never sends
|
|
4082
|
+
// a price); both degrade to empty on a missing-table / read failure
|
|
4083
|
+
// so the buy path renders regardless. The quantity-break table is
|
|
4084
|
+
// built against the first variant's list price — the band a shopper
|
|
4085
|
+
// sees on the PDP matches what the cart charges at that quantity.
|
|
4086
|
+
var variantSkus = variants.map(function (v) { return v.sku; });
|
|
4087
|
+
var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
|
|
4088
|
+
var firstVariant = variants[0] || null;
|
|
4089
|
+
var firstPrice = firstVariant ? prices[firstVariant.id] : null;
|
|
4090
|
+
var qtyBreaks = firstVariant && firstPrice
|
|
4091
|
+
? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
|
|
4092
|
+
: [];
|
|
3771
4093
|
var html = renderProduct({
|
|
3772
4094
|
product: product,
|
|
3773
4095
|
variants: variants,
|
|
@@ -3778,6 +4100,8 @@ function mount(router, deps) {
|
|
|
3778
4100
|
review_cta: reviewCta,
|
|
3779
4101
|
qa_questions: qaQuestions,
|
|
3780
4102
|
qa_cta: qaCta,
|
|
4103
|
+
bundle_offers: bundleOffers,
|
|
4104
|
+
qty_breaks: qtyBreaks,
|
|
3781
4105
|
wishlist_count: wishlistCount,
|
|
3782
4106
|
shop_name: shopName,
|
|
3783
4107
|
cart_count: cartCount,
|
|
@@ -4111,7 +4435,13 @@ function mount(router, deps) {
|
|
|
4111
4435
|
shop_name: shopName, theme: theme,
|
|
4112
4436
|
}));
|
|
4113
4437
|
}
|
|
4114
|
-
var
|
|
4438
|
+
var rawLines = await deps.cart.listLines(c.id);
|
|
4439
|
+
// Reapply the active quantity-break for each line at its current
|
|
4440
|
+
// quantity before totals — the unit price the customer sees on the
|
|
4441
|
+
// line, the line total, and the cart subtotal all reflect the break.
|
|
4442
|
+
// Recomputed every render (idempotent); the stored snapshot is never
|
|
4443
|
+
// mutated, so changing a line's quantity re-prices it automatically.
|
|
4444
|
+
var lines = await _repriceCartLines(rawLines);
|
|
4115
4445
|
var totals = pricing.totals(c, lines, {});
|
|
4116
4446
|
// Build the variant_id → { product, hero_media } lookup the
|
|
4117
4447
|
// renderer uses to decorate each line with a thumbnail + title.
|
|
@@ -6142,6 +6472,141 @@ function mount(router, deps) {
|
|
|
6142
6472
|
res.end ? res.end() : res.send("");
|
|
6143
6473
|
});
|
|
6144
6474
|
|
|
6475
|
+
// POST /cart/bundle — add every member of a bundle to the cart at the
|
|
6476
|
+
// bundle price, atomically. Reads `bundle_sku` from the form body.
|
|
6477
|
+
// The price is recomputed server-side from the catalog + the bundle
|
|
6478
|
+
// primitive (the client sends only the SKU, never a price). The
|
|
6479
|
+
// bundle discount is allocated across the member lines proportional
|
|
6480
|
+
// to each member's list contribution, so the cart subtotal equals the
|
|
6481
|
+
// bundle's quoted price; integer-cent remainder lands on the last
|
|
6482
|
+
// line. Mounted only when the bundles primitive is wired.
|
|
6483
|
+
if (deps.bundles) {
|
|
6484
|
+
router.post("/cart/bundle", async function (req, res) {
|
|
6485
|
+
var body = req.body || {};
|
|
6486
|
+
var bundleSku = body.bundle_sku;
|
|
6487
|
+
if (!bundleSku || typeof bundleSku !== "string") {
|
|
6488
|
+
res.status(400);
|
|
6489
|
+
return res.end ? res.end("Invalid request") : res.send("Invalid request");
|
|
6490
|
+
}
|
|
6491
|
+
var currency = "USD";
|
|
6492
|
+
// Resolve + price the bundle. A malformed sku throws TypeError
|
|
6493
|
+
// (→ 400); an unknown sku resolves to no bundle (→ 404).
|
|
6494
|
+
var bundle, leaves, priced;
|
|
6495
|
+
try {
|
|
6496
|
+
bundle = await deps.bundles.getBundle(bundleSku);
|
|
6497
|
+
if (!bundle) {
|
|
6498
|
+
res.status(404);
|
|
6499
|
+
return res.end ? res.end("Bundle not found") : res.send("Bundle not found");
|
|
6500
|
+
}
|
|
6501
|
+
leaves = await deps.bundles.expand({ bundle_sku: bundleSku, quantity: 1 });
|
|
6502
|
+
priced = await deps.bundles.priceBundle({ bundle_sku: bundleSku, pricing: _skuPricer(currency) });
|
|
6503
|
+
} catch (e) {
|
|
6504
|
+
if (e instanceof TypeError) {
|
|
6505
|
+
res.status(400);
|
|
6506
|
+
return res.end ? res.end((e && e.message) || "Invalid bundle") : res.send((e && e.message) || "Invalid bundle");
|
|
6507
|
+
}
|
|
6508
|
+
res.status(500);
|
|
6509
|
+
return res.end ? res.end("Error") : res.send("Error");
|
|
6510
|
+
}
|
|
6511
|
+
|
|
6512
|
+
// Pre-flight: every leaf must resolve to a buyable variant + a
|
|
6513
|
+
// current price. All-or-nothing — if any member is unavailable,
|
|
6514
|
+
// add nothing and bounce back to the PDP with a notice (no
|
|
6515
|
+
// half-filled cart). `leaf.quantity` is the per-bundle demand.
|
|
6516
|
+
var members = [];
|
|
6517
|
+
for (var i = 0; i < leaves.length; i += 1) {
|
|
6518
|
+
var leaf = leaves[i];
|
|
6519
|
+
var variant = await _skuBuyable(leaf.sku);
|
|
6520
|
+
if (!variant) {
|
|
6521
|
+
res.status(303);
|
|
6522
|
+
res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
|
|
6523
|
+
return res.end ? res.end() : res.send("");
|
|
6524
|
+
}
|
|
6525
|
+
var price = await deps.catalog.prices.current(variant.id, currency);
|
|
6526
|
+
if (!price) {
|
|
6527
|
+
res.status(303);
|
|
6528
|
+
res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
|
|
6529
|
+
return res.end ? res.end() : res.send("");
|
|
6530
|
+
}
|
|
6531
|
+
members.push({
|
|
6532
|
+
variant_id: variant.id,
|
|
6533
|
+
qty: leaf.quantity,
|
|
6534
|
+
list_each: price.amount_minor,
|
|
6535
|
+
list_line: price.amount_minor * leaf.quantity,
|
|
6536
|
+
});
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
// Allocate the bundle total across members proportional to each
|
|
6540
|
+
// member's list contribution, then express each share as an integer
|
|
6541
|
+
// per-unit price. The cart stores one integer unit price per line and
|
|
6542
|
+
// charges qty*unit, so flooring a share to a per-unit price loses up
|
|
6543
|
+
// to (qty-1) cents on a multi-unit line. Those cents are added back
|
|
6544
|
+
// below so the cart subtotal equals the bundle price exactly — never
|
|
6545
|
+
// undercharging the advertised bundle.
|
|
6546
|
+
var listTotal = priced.list_total_minor;
|
|
6547
|
+
var bundleTotal = priced.amount_minor;
|
|
6548
|
+
var allocated = 0;
|
|
6549
|
+
for (var m = 0; m < members.length; m += 1) {
|
|
6550
|
+
var mem = members[m];
|
|
6551
|
+
var share = listTotal > 0
|
|
6552
|
+
? Math.floor((bundleTotal * mem.list_line) / listTotal)
|
|
6553
|
+
: Math.floor(bundleTotal / members.length);
|
|
6554
|
+
mem.alloc_line = share;
|
|
6555
|
+
allocated += share;
|
|
6556
|
+
}
|
|
6557
|
+
// The proportional floor leaves a whole-cent remainder; park it on the
|
|
6558
|
+
// last member's line so the per-line allocations sum to the bundle
|
|
6559
|
+
// total exactly.
|
|
6560
|
+
if (members.length > 0) members[members.length - 1].alloc_line += (bundleTotal - allocated);
|
|
6561
|
+
|
|
6562
|
+
// Convert each member's line allocation to a uniform integer per-unit
|
|
6563
|
+
// price (floor) and tally the cents lost to that floor across all
|
|
6564
|
+
// lines. A single-unit line (the usual bundle shape) absorbs the whole
|
|
6565
|
+
// shortfall exactly; failing that, the cents are returned in qty-sized
|
|
6566
|
+
// steps to the smallest-quantity lines, with any final sub-step residual
|
|
6567
|
+
// rounded up onto the smallest line — so the realized subtotal is never
|
|
6568
|
+
// below the quoted bundle price.
|
|
6569
|
+
var lostCents = 0;
|
|
6570
|
+
for (var u = 0; u < members.length; u += 1) {
|
|
6571
|
+
var me = members[u];
|
|
6572
|
+
me.unit = me.qty > 0 ? Math.floor(me.alloc_line / me.qty) : me.alloc_line;
|
|
6573
|
+
lostCents += me.alloc_line - (me.unit * me.qty);
|
|
6574
|
+
}
|
|
6575
|
+
if (lostCents > 0 && members.length > 0) {
|
|
6576
|
+
var byQty = members.slice().sort(function (a, b) { return a.qty - b.qty; });
|
|
6577
|
+
var single = null;
|
|
6578
|
+
for (var s = 0; s < byQty.length; s += 1) { if (byQty[s].qty === 1) { single = byQty[s]; break; } }
|
|
6579
|
+
if (single) {
|
|
6580
|
+
single.unit += lostCents;
|
|
6581
|
+
} else {
|
|
6582
|
+
for (var g = 0; g < byQty.length && lostCents > 0; g += 1) {
|
|
6583
|
+
while (lostCents >= byQty[g].qty) { byQty[g].unit += 1; lostCents -= byQty[g].qty; }
|
|
6584
|
+
}
|
|
6585
|
+
if (lostCents > 0) { byQty[0].unit += 1; lostCents = 0; }
|
|
6586
|
+
}
|
|
6587
|
+
}
|
|
6588
|
+
|
|
6589
|
+
var resolved = await _getOrCreateCart(req, res, currency);
|
|
6590
|
+
try {
|
|
6591
|
+
for (var k = 0; k < members.length; k += 1) {
|
|
6592
|
+
var mk = members[k];
|
|
6593
|
+
await deps.cart.addLine(resolved.cart.id, {
|
|
6594
|
+
variant_id: mk.variant_id,
|
|
6595
|
+
qty: mk.qty,
|
|
6596
|
+
unit_amount_minor: mk.unit,
|
|
6597
|
+
unit_currency: currency,
|
|
6598
|
+
});
|
|
6599
|
+
}
|
|
6600
|
+
} catch (e) {
|
|
6601
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
6602
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
6603
|
+
}
|
|
6604
|
+
res.status(303);
|
|
6605
|
+
res.setHeader && res.setHeader("location", "/cart");
|
|
6606
|
+
res.end ? res.end() : res.send("");
|
|
6607
|
+
});
|
|
6608
|
+
}
|
|
6609
|
+
|
|
6145
6610
|
// POST /cart/lines/:line_id/update — change qty on an existing
|
|
6146
6611
|
// line. Form value `qty` is the new quantity (1..99). HTML forms
|
|
6147
6612
|
// only support GET/POST so the verb is in the path.
|
package/package.json
CHANGED