@nextcommerce/campaigns-os 1.57.0 → 1.59.0

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/campaign-spec/dist/checkout-flow.d.ts +7 -0
  3. package/campaign-spec/dist/checkout-flow.js +17 -1
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  6. package/contracts/commerce-surface-catalog.json +26 -27
  7. package/contracts/effects.v1.json +5 -5
  8. package/contracts/release-ledger.json +432 -0
  9. package/contracts/supported-surface.json +4 -4
  10. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  11. package/docs/build-packet.md +22 -8
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/qa-and-test-orders.md +54 -12
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/package.json +1 -1
  18. package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
  19. package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +6 -6
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/built-site-scope.mjs +91 -0
  31. package/src/cli.mjs +15 -23
  32. package/src/doctor/checks.mjs +73 -3
  33. package/src/doctor/next-step.mjs +3 -14
  34. package/src/polish-node.mjs +5 -4
  35. package/src/qa-browser.mjs +137 -2
  36. package/src/qa-cart-entry.mjs +105 -0
  37. package/src/qa-node.mjs +3 -1
  38. package/src/qa-verdict.mjs +24 -1
  39. package/src/sdk-attribute-index.mjs +8 -7
  40. package/src/source-html-intake.mjs +27 -18
  41. package/src/stage-record.mjs +200 -33
@@ -35,6 +35,10 @@ export const CART_ENTRY_CODES = Object.freeze({
35
35
  ENTRY_CONTROL_MISSING: "cart_entry_control_missing",
36
36
  // The control was clicked but the page never reached the checkout URL.
37
37
  ENTRY_NO_NAVIGATION: "cart_entry_no_navigation",
38
+ // A select page's bundle slot shows an empty variant control (size,
39
+ // colour) with no in-stock option to choose, so its Next would refuse.
40
+ // Fired before the checkout control is clicked (campaigns-os#667).
41
+ ENTRY_VARIANT_UNFILLED: "cart_entry_variant_unfilled",
38
42
  // The SDK cart held zero items at submit time. Fired before the budget
39
43
  // reservation and before the submit click.
40
44
  CART_EMPTY_BEFORE_SUBMIT: "cart_empty_before_submit",
@@ -427,6 +431,107 @@ export function chooseCartEntryControl(controls = [], requested = []) {
427
431
  // returns the store's `totalQuantity` — and the debugger's cart store second
428
432
  // (`window.nextDebug.stores.cart`, present with `?debugger=true`), which is
429
433
  // the only public place the line items and their package ids are readable.
434
+ // The variant controls a select page renders per bundle slot: the
435
+ // campaign-cart SDK injects a native select into each slot's
436
+ // [data-next-variant-selectors] (inside a .next-slot-variant-field). The
437
+ // olympus-mv-two-step starter, and pages built from it, hide that select behind
438
+ // a visible os-dropdown (a toggle button plus os-dropdown-item rows carrying
439
+ // `value`); a row click is what sets the select and dispatches `change`.
440
+ export const SLOT_VARIANT_SELECT_SELECTOR = "[data-next-variant-selectors] select, .next-slot-variant-field select";
441
+ export const SLOT_VARIANT_FIELD_SELECTOR = ".next-slot-variant-field";
442
+ export const SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR = "os-dropdown .os-card__variant-dropdown-toggle";
443
+ export const SLOT_VARIANT_DROPDOWN_ROW_SELECTOR = "os-dropdown os-dropdown-item";
444
+
445
+ export const slotVariantKey = ({ bundle_id: bundleId, slot, variant_code: code } = {}) => `${bundleId ?? ""}|${slot}|${code ?? ""}`;
446
+
447
+ // evaluate() body: the next slot variant action, one per call because a
448
+ // choice can re-render its slot. In document order over the visible slot
449
+ // variant selects (a select counts as visible when it or its field is):
450
+ // - a field showing a dropdown UI whose key is not in `picked` gets a pick:
451
+ // the row matching the select's current value when that row is in stock
452
+ // (so the cart stays what the SDK chose), else the first in-stock row. The
453
+ // caller clicks the toggle and the row like a shopper, because a page may
454
+ // count a field as chosen only when a row was clicked. Pre-filled selects
455
+ // are picked too. Returned as { pick } with the field's index among
456
+ // SLOT_VARIANT_FIELD_SELECTOR matches and the row's index among the
457
+ // field's SLOT_VARIANT_DROPDOWN_ROW_SELECTOR matches;
458
+ // - a select with no dropdown UI is filled here only when it is empty, with
459
+ // its first in-stock option and input + change dispatched ({ filled });
460
+ // a pre-filled one is left alone.
461
+ // In stock: enabled, not hidden, non-empty value; for a row also not
462
+ // [disabled], aria-disabled, .next-oos, .next-variant-unavailable or
463
+ // data-available="false", and its native option not disabled. Nothing to
464
+ // choose returns { unfillable } naming the slot; nothing left returns
465
+ // { done: true }.
466
+ export function slotVariantStepScript() {
467
+ return ({ selector, fieldSelector, toggleSelector, rowSelector, picked = [] }) => {
468
+ const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
469
+ const isVisible = (element) => {
470
+ if (!element) return false;
471
+ const rect = element.getBoundingClientRect();
472
+ const style = getComputedStyle(element);
473
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
474
+ };
475
+ const holderOf = (select) => select.closest(fieldSelector) || select.closest("[data-next-variant-selectors]");
476
+ const visible = Array.from(new Set(document.querySelectorAll(selector)))
477
+ .filter((select) => !select.disabled && (isVisible(select) || isVisible(holderOf(select))));
478
+ const slotRoots = Array.from(new Set(visible.map((select) => select.closest("[data-next-variant-selectors]") || holderOf(select))));
479
+ const fields = Array.from(document.querySelectorAll(fieldSelector));
480
+ const describe = (select) => {
481
+ const field = select.closest(fieldSelector);
482
+ const index = Number.parseInt(field?.getAttribute("data-next-slot-index") ?? "", 10);
483
+ const root = select.closest("[data-next-variant-selectors]") || holderOf(select);
484
+ return {
485
+ bundle_id: clean(field?.getAttribute("data-next-bundle-id")) || null,
486
+ slot: Number.isFinite(index) ? index + 1 : slotRoots.indexOf(root) + 1,
487
+ variant_code: clean(select.getAttribute("data-next-variant-code") || select.getAttribute("data-variant-code") || field?.getAttribute("data-next-variant-code") || select.name) || null,
488
+ };
489
+ };
490
+ const keyOf = (info) => `${info.bundle_id ?? ""}|${info.slot}|${info.variant_code ?? ""}`;
491
+ const optionInStock = (option) => !option.disabled && !option.hidden && clean(option.value) !== "";
492
+ for (const select of visible) {
493
+ const info = describe(select);
494
+ const field = select.closest(fieldSelector);
495
+ const toggle = field ? Array.from(field.querySelectorAll(toggleSelector)).find(isVisible) : null;
496
+ if (toggle) {
497
+ if (picked.includes(keyOf(info))) continue;
498
+ const rows = Array.from(field.querySelectorAll(rowSelector));
499
+ // One in-stock rule: a row is in stock when its own markup says so and
500
+ // its native option (if any) passes optionInStock.
501
+ const optionsByValue = new Map(Array.from(select.options).map((option) => [clean(option.value), option]));
502
+ const rowInStock = (row) => {
503
+ const value = clean(row.getAttribute("value"));
504
+ if (!value || row.hidden || row.hasAttribute("disabled") || row.getAttribute("aria-disabled") === "true") return false;
505
+ if (row.classList.contains("next-oos") || row.classList.contains("next-variant-unavailable") || row.getAttribute("data-available") === "false") return false;
506
+ const option = optionsByValue.get(value);
507
+ return !option || optionInStock(option);
508
+ };
509
+ const current = clean(select.value);
510
+ const row = (current && rows.find((candidate) => clean(candidate.getAttribute("value")) === current && rowInStock(candidate))) || rows.find(rowInStock);
511
+ if (!row) return { unfillable: { ...info, options: rows.length, via: "dropdown" } };
512
+ const fieldIndex = fields.indexOf(field);
513
+ const rowIndex = rows.indexOf(row);
514
+ if (fieldIndex < 0 || rowIndex < 0) {
515
+ return { unfillable: { ...info, options: rows.length, via: "dropdown", detail: `the dropdown ${fieldIndex < 0 ? "field" : "row"} could not be located` } };
516
+ }
517
+ return { pick: { ...info, value: clean(row.getAttribute("value")), field_index: fieldIndex, row_index: rowIndex } };
518
+ }
519
+ if (clean(select.value) !== "") continue;
520
+ const option = Array.from(select.options).find(optionInStock);
521
+ if (!option) return { unfillable: { ...info, options: select.options.length, via: "select" } };
522
+ select.value = option.value;
523
+ select.dispatchEvent(new Event("input", { bubbles: true }));
524
+ select.dispatchEvent(new Event("change", { bubbles: true }));
525
+ return { filled: { ...info, value: option.value } };
526
+ }
527
+ return { done: true };
528
+ };
529
+ }
530
+
531
+ export function slotVariantLabel({ bundle_id: bundleId, slot, variant_code: code } = {}) {
532
+ return `slot ${slot}${bundleId ? ` (bundle "${bundleId}")` : ""}${code ? ` ${code}` : " variant"}`;
533
+ }
534
+
430
535
  // The enriched line list on `getCartData()` is deliberately NOT read: it is
431
536
  // always empty on the shipped SDK (campaign-cart#36; see the cart-state
432
537
  // verification section of docs/qa-and-test-orders.md), so it would call every
package/src/qa-node.mjs CHANGED
@@ -37,7 +37,7 @@ import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
37
37
  import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
38
38
  import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
39
39
  import { policyLinkNotRequestedRows, policyLinkQaAssertion } from "./qa-policy-links.mjs";
40
- import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
40
+ import { createVerdict, formatTestOrderSummary, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
41
41
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
42
42
  import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
43
43
  import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
@@ -3577,6 +3577,8 @@ function output(value, args) {
3577
3577
  console.log("This is a passing proof with exceptions. Report these exceptions to the operator; do not clear or waive them, or change markup just to make them pass.");
3578
3578
  }
3579
3579
  console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
3580
+ const testOrderSummary = formatTestOrderSummary(value.verdict);
3581
+ if (testOrderSummary) console.log(testOrderSummary);
3580
3582
  printCauseLines(value.verdict);
3581
3583
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
3582
3584
  if (value.commercial) {
@@ -256,7 +256,8 @@ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } =
256
256
  return {
257
257
  declared_order_path_depth: optionalString(proofPolicy?.order_path_depth),
258
258
  declared_typed_card_depth: optionalString(proofPolicy?.typed_card_depth),
259
- order_paths_executed: orders.length,
259
+ order_paths_executed: orders.filter((order) => !isTestOrderRetry(order)).length,
260
+ test_order_attempts: orders.length,
260
261
  orders_created: created.length,
261
262
  orders_verified: orders.filter((order) => order.verification?.verified === true).length,
262
263
  // null, not false, when nothing ran: "no order was out of test mode" and
@@ -265,6 +266,28 @@ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } =
265
266
  };
266
267
  }
267
268
 
269
+ // A re-run of a planned path is its own test_orders[] entry with `attempt` 2;
270
+ // it is an attempt, not another path. Entries written before `attempt` existed
271
+ // each count as a path, which is what they were counted as then.
272
+ function isTestOrderRetry(order) {
273
+ const attempt = Number(order?.attempt);
274
+ return Number.isInteger(attempt) && attempt > 1;
275
+ }
276
+
277
+ function plural(count, noun) {
278
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
279
+ }
280
+
281
+ // The human summary line for a run that drove typed-card test orders: planned
282
+ // paths, attempts (a re-run is a second attempt at the same path) and orders
283
+ // the platform created, kept apart so a path that was retried is not read as
284
+ // two paths against the --max-test-orders cap. null when no order path ran.
285
+ export function formatTestOrderSummary(verdict) {
286
+ const summary = summarizePurchaseProof({ verdict });
287
+ if (!summary.test_order_attempts) return null;
288
+ return `Test orders: ${plural(summary.order_paths_executed, "path")}, ${plural(summary.test_order_attempts, "attempt")}, ${plural(summary.orders_created, "order")} created`;
289
+ }
290
+
268
291
  // The browser placeholder-text residue gate (H3.1) emits one assertion per
269
292
  // page as `template-residue:<page_id>:placeholder-text`. Both the emitter
270
293
  // (qa-browser.mjs) and the stage-ledger summary below build the id from this
@@ -2,7 +2,7 @@
2
2
  // checks (#303).
3
3
  //
4
4
  // Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
5
- // v0.4.41 (the SDK pin every certified starter family ships today): every
5
+ // v0.4.42 (the SDK pin every certified starter family ships today): every
6
6
  // data-next-* name the SDK's non-test TypeScript names as a string, plus every
7
7
  // `dataset.next*` property it reads or writes, turned back into its
8
8
  // attribute. Only the data-next-* names are kept: the checks that read this
@@ -15,8 +15,8 @@
15
15
  // whole from the source at a named tag, never edited by hand.
16
16
  //
17
17
  // paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
18
- // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.41 -- "${paths[@]}"
19
- // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.41 -- "${paths[@]}" \
18
+ // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.42 -- "${paths[@]}"
19
+ // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.42 -- "${paths[@]}" \
20
20
  // | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
21
21
  // } | sort -u
22
22
  //
@@ -30,14 +30,15 @@
30
30
  // manifests never did (the address block's, data-next-i18n,
31
31
  // data-next-bump-section, data-next-button-text, the variant option state).
32
32
  // At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
33
- // those and data-next-phone-e164 / data-next-phone-country.
33
+ // those and data-next-phone-e164 / data-next-phone-country. At v0.4.42 it
34
+ // returns the same 149 names as at v0.4.41.
34
35
  //
35
36
  // A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
36
37
  // suffix.
37
38
 
38
39
  import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
39
40
 
40
- export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.41";
41
+ export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.42";
41
42
 
42
43
  export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
43
44
  "data-next-accordion",
@@ -364,8 +365,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
364
365
  });
365
366
 
366
367
  // The SDK versions whose renderer files above were verified unchanged from
367
- // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40 and v0.4.41, for
368
+ // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41 and v0.4.42, for
368
369
  // the eight renderer files cited above plus cart-item-list.enhancer.ts and
369
370
  // order-item-list.enhancer.ts). A page whose loader pins another version
370
371
  // cannot pass the placeholder check.
371
- export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41"]);
372
+ export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42"]);
@@ -4,7 +4,7 @@ import {
4
4
  } from "./source-html-manifest.mjs";
5
5
  // Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
6
6
  // so build-time wiring and spec-time analysis share one edge resolver.
7
- import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage } from "../campaign-spec/dist/index.js";
7
+ import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage, isPrePaymentPage } from "../campaign-spec/dist/index.js";
8
8
  import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
9
9
 
10
10
  const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
@@ -570,11 +570,12 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir,
570
570
  const frontmatter = { page_type: pageType };
571
571
  if (permalinkRequired) frontmatter.permalink = publicRoute;
572
572
 
573
- const nextUrl = nextUrlForPage(page, pageById, publicRouteSlug);
574
- if (nextUrl) frontmatter.next_url = nextUrl;
573
+ const forwardUrl = nextUrlForPage(page, pageById, publicRouteSlug);
574
+ const checkoutFlow = checkoutFlowFrontmatter(page, forwardUrl, { specPages, pageById, publicRouteSlug });
575
+ if (checkoutFlow) Object.assign(frontmatter, checkoutFlow);
576
+ else if (forwardUrl) frontmatter.next_url = forwardUrl;
575
577
  const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
576
- if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
577
- Object.assign(frontmatter, checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }));
578
+ if (declineUrl && declineUrl !== forwardUrl) frontmatter.decline_url = declineUrl;
578
579
 
579
580
  return {
580
581
  target_path: targetPath,
@@ -587,24 +588,32 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir,
587
588
  };
588
589
  }
589
590
 
590
- // Multi-step checkout wiring (campaigns-os#641), for any page that leads into a
591
- // Checkout without being it: a select page, a checkout_step, or a landing page
592
- // whose forward links reach a Checkout.
593
- // success_url the Checkout's post-payment destination (its own next_url).
594
- // The page's next-success-url meta tag carries it, because the
595
- // SDK reads that tag for express orders placed on this page; an
596
- // express order on step 1 must land on the first upsell, not on
597
- // step 2. Forward navigation never uses that tag: a step form
598
- // navigates by data-next-checkout-step, a select page by a link.
591
+ // Multi-step checkout wiring (campaigns-os#641) for a select or checkout_step
592
+ // page on a path to a Checkout. The keys follow the starter-template contract
593
+ // (campaign-cart-starter-templates#223). Null for any other page, whose
594
+ // next_url stays its own forward link: a landing page's call-to-action reads
595
+ // next_url, so pointing it past the Checkout would skip payment.
596
+ // next_url where a completed order goes: the Checkout's post-payment
597
+ // destination (its own next_url), the meaning next_url has on a
598
+ // single-page checkout. The page's next-success-url meta tag
599
+ // carries it, because the SDK reads that tag for express orders
600
+ // placed on this page; an express order on step 1 must land on
601
+ // the first upsell, not on step 2.
602
+ // next_step this page's forward link: a step form's
603
+ // data-next-checkout-step, a select page's checkout link.
599
604
  // step_number on a checkout_step only: its position among the steps of its
600
605
  // path, from 1, for the form's data-next-step-number.
601
- function checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }) {
602
- if (!page || page.type === "checkout") return {};
606
+ function checkoutFlowFrontmatter(page, forwardUrl, { specPages, pageById, publicRouteSlug }) {
607
+ // A path of two or more pages means the page has a resolvable forward link,
608
+ // so forwardUrl is set; the guard keeps the pair together regardless, never
609
+ // emitting the Checkout's destination without a next_step to navigate by.
610
+ if (!isPrePaymentPage(page) || !forwardUrl) return null;
603
611
  const path = checkoutPathFrom(specPages, page);
604
- if (!path || path.length < 2) return {};
612
+ if (!path || path.length < 2) return null;
605
613
  const out = {};
606
614
  const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
607
- if (successUrl) out.success_url = successUrl;
615
+ if (successUrl) out.next_url = successUrl;
616
+ out.next_step = forwardUrl;
608
617
  if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
609
618
  return out;
610
619
  }