@nextcommerce/campaigns-os 1.57.0 → 1.60.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.
- package/CHANGELOG.md +176 -0
- package/README.md +6 -5
- package/campaign-spec/dist/checkout-flow.d.ts +7 -0
- package/campaign-spec/dist/checkout-flow.js +17 -1
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -28
- package/contracts/effects.v1.json +5 -5
- package/contracts/release-ledger.json +975 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +35 -19
- package/docs/campaign-build-brief.md +1 -1
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +1 -1
- package/docs/qa-and-test-orders.md +54 -12
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -7
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +9 -10
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +193 -0
- package/skills.json +18 -10
- package/src/built-site-scope.mjs +96 -12
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +50 -81
- package/src/doctor/checks.mjs +79 -6
- package/src/doctor/inspect.mjs +2 -8
- package/src/doctor/next-step.mjs +9 -17
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -0
- package/src/polish-gate.mjs +1 -5
- package/src/polish-node.mjs +5 -4
- package/src/progress-node.mjs +2 -1
- package/src/qa-browser.mjs +137 -2
- package/src/qa-cart-entry.mjs +105 -0
- package/src/qa-node.mjs +3 -1
- package/src/qa-verdict.mjs +24 -1
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +27 -18
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +218 -35
|
@@ -165,9 +165,29 @@ export function evaluatePageKitBuildSummary({
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// page-kit >= 0.3.0 neither builds nor copies .html under assets/ and lists
|
|
169
|
+
// each such file in a top-level assetWarnings array (ASSET_IGNORED), never
|
|
170
|
+
// in pages[].warnings. A link or iframe to one 404s on the built site, and
|
|
171
|
+
// this is the only signal, so it is surfaced, but never escalated: removing
|
|
172
|
+
// or moving the file is the author's call. Entries carry no campaignSlug;
|
|
173
|
+
// the file path (src/<slug>/assets/...) names the campaign.
|
|
174
|
+
const assetWarnings = Array.isArray(summary.assetWarnings)
|
|
175
|
+
? summary.assetWarnings.filter((warning) => typeof warning?.file === "string" && `/${warning.file.replace(/\\/g, "/")}`.includes(`/${publicRouteSlug}/assets/`))
|
|
176
|
+
: [];
|
|
177
|
+
for (const warning of assetWarnings) {
|
|
178
|
+
flagged += 1;
|
|
179
|
+
warnings.push({
|
|
180
|
+
code: "built_output.build_asset_warning",
|
|
181
|
+
message:
|
|
182
|
+
`Page Kit did not build or copy "${warning.file}": [${warning.code}] ${warning.message}. ` +
|
|
183
|
+
`A link or iframe to it 404s on the built site; move it out of assets/ if it is a page, or remove it.`,
|
|
184
|
+
detail: { file: warning.file, warning_code: warning.code },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
if (failed === 0 && flagged === 0) {
|
|
169
189
|
ready.push(
|
|
170
|
-
`Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors or
|
|
190
|
+
`Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors, shape warnings or asset warnings`
|
|
171
191
|
);
|
|
172
192
|
}
|
|
173
193
|
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { compareVersions, extractVersion } from "./repo-scan.mjs";
|
|
3
4
|
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
4
5
|
|
|
5
6
|
export const PAGE_KIT_CAMPAIGNS_REL_PATH = "_data/campaigns.json";
|
|
6
7
|
|
|
8
|
+
// next-campaign-page-kit 0.3.0 stopped building or copying .html under a
|
|
9
|
+
// campaign's top-level assets/. Source-page discovery follows the target's
|
|
10
|
+
// installed page-kit, read from node_modules the way tooling setup reads it;
|
|
11
|
+
// a missing, unreadable or malformed install keeps the 0.2.x rule. That is
|
|
12
|
+
// deliberate: doctor's built-page lookup calls this from about ten checks, so
|
|
13
|
+
// throwing would take doctor down over a broken install that the build itself
|
|
14
|
+
// fails on loudly, and tooling setup already refuses a declared page-kit that
|
|
15
|
+
// is not installed.
|
|
16
|
+
export const PAGE_KIT_ASSETS_HTML_IGNORED_FROM = "0.3.0";
|
|
17
|
+
export function pageKitIgnoresAssetsHtml(targetRepo) {
|
|
18
|
+
try {
|
|
19
|
+
const { version } = JSON.parse(readFileSync(join(targetRepo, "node_modules", "next-campaign-page-kit", "package.json"), "utf8"));
|
|
20
|
+
return extractVersion(version) !== null && compareVersions(version, PAGE_KIT_ASSETS_HTML_IGNORED_FROM) >= 0;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
// Public doctor/next/verdict evidence must never inherit arbitrary merchant
|
|
8
27
|
// configuration from campaigns.json. The raw entry is validation-local; this
|
|
9
28
|
// fixed projection is the only loader metadata safe to retain in artifacts.
|
package/src/polish-gate.mjs
CHANGED
|
@@ -45,11 +45,7 @@ function terminalAssembly(report) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function currentBuildFingerprint(report) {
|
|
48
|
-
return normalizeString(report?.stages?.assembly?.build_fingerprint)
|
|
49
|
-
|| normalizeString(report?.stages?.assembly?.artifact_fingerprint)
|
|
50
|
-
|| normalizeString(report?.build_fingerprint)
|
|
51
|
-
|| normalizeString(report?.artifact_fingerprint)
|
|
52
|
-
|| null;
|
|
48
|
+
return normalizeString(report?.stages?.assembly?.build_fingerprint) || null;
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
export function currentSourcePackageMaterialFingerprint(report) {
|
package/src/polish-node.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
import { computeBuildFingerprint } from "./built-site-scope.mjs";
|
|
6
|
+
import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
|
|
7
7
|
import {
|
|
8
8
|
buildPageLoadCapture,
|
|
9
9
|
MAX_POLISH_CAPTURE_URL_LENGTH,
|
|
@@ -402,7 +402,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
402
402
|
// an output that has drifted from what build recorded would be evidence
|
|
403
403
|
// about a build that no longer exists, and an output that changes during the
|
|
404
404
|
// browser pass fails the unchanged-binding assertion after it.
|
|
405
|
-
const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint);
|
|
405
|
+
const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint, report.stages.assembly);
|
|
406
406
|
const runId = nonemptyString(report.run_id);
|
|
407
407
|
const reportPacketPath = nonemptyString(report?.inputs?.packet_path);
|
|
408
408
|
if (!runId || !reportPacketPath) {
|
|
@@ -585,7 +585,7 @@ function uncapturedPages(packet, plan) {
|
|
|
585
585
|
// (permissions, a file vanishing mid-walk), or an output that no longer
|
|
586
586
|
// matches what build recorded. None of them may surface as an uncaught
|
|
587
587
|
// filesystem error, and none may bind as a null.
|
|
588
|
-
function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
|
|
588
|
+
function boundOutputFingerprint(outputRoot, slug, buildFingerprint, assembly = null) {
|
|
589
589
|
let current;
|
|
590
590
|
try {
|
|
591
591
|
current = computeBuildFingerprint(outputRoot);
|
|
@@ -604,7 +604,8 @@ function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
|
|
|
604
604
|
if (current.fingerprint !== buildFingerprint) {
|
|
605
605
|
throw new Error(
|
|
606
606
|
`polish capture refuses: built output under _site/${slug}/ no longer matches stages.assembly.build_fingerprint `
|
|
607
|
-
+ `(recorded ${buildFingerprint}, current ${current.fingerprint}).
|
|
607
|
+
+ `(recorded ${buildFingerprint}, current ${current.fingerprint}). ${describeBuildOutputDrift(assembly, current).summary} `
|
|
608
|
+
+ "Re-run build and record the current fingerprint first.",
|
|
608
609
|
);
|
|
609
610
|
}
|
|
610
611
|
return current.fingerprint;
|
package/src/progress-node.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {join,resolve,dirname} from 'node:path';
|
|
|
6
6
|
import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
|
|
7
7
|
import {specMaterialHash} from './spec-identity.mjs';
|
|
8
8
|
import {effectiveStageStatus} from './input-currency.mjs';
|
|
9
|
+
import {qaRecordedBuildFingerprint} from './stage-ledger.mjs';
|
|
9
10
|
import {sameFile} from './fs-identity.mjs';
|
|
10
11
|
import {withDirectoryLock} from './directory-lock.mjs';
|
|
11
12
|
import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
|
|
@@ -64,7 +65,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
64
65
|
// The report is also bound to the spec material on disk now, so output and
|
|
65
66
|
// verdict evidence recorded against it can match.
|
|
66
67
|
const reportBound=stagesBound&&localHash===hash(report?.identity?.spec_material_hash);
|
|
67
|
-
const qaSource=hash(report
|
|
68
|
+
const qaSource=hash(qaRecordedBuildFingerprint(report));
|
|
68
69
|
const verdict=qaResult?.verdict;
|
|
69
70
|
const qa=verdict?{
|
|
70
71
|
verdict_id:id(verdict.run_id),disposition:accepted(verdict.disposition,['ready','ready_with_exceptions','blocked']),
|
package/src/qa-browser.mjs
CHANGED
|
@@ -52,6 +52,13 @@ import {
|
|
|
52
52
|
isCartEntryCode,
|
|
53
53
|
resolveCartEntryPage,
|
|
54
54
|
sdkCartSnapshotScript,
|
|
55
|
+
SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
|
|
56
|
+
SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
|
|
57
|
+
SLOT_VARIANT_FIELD_SELECTOR,
|
|
58
|
+
SLOT_VARIANT_SELECT_SELECTOR,
|
|
59
|
+
slotVariantKey,
|
|
60
|
+
slotVariantLabel,
|
|
61
|
+
slotVariantStepScript,
|
|
55
62
|
summarizeSelectionSurface,
|
|
56
63
|
UNDECLARED_ROUTE_ATTRIBUTES,
|
|
57
64
|
} from "./qa-cart-entry.mjs";
|
|
@@ -304,7 +311,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
304
311
|
const identifier = planId(plan);
|
|
305
312
|
const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
|
|
306
313
|
const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
307
|
-
orders.push(firstAttempt.order);
|
|
314
|
+
orders.push(testOrderEntry(firstAttempt.order, identifier, 1));
|
|
308
315
|
orderPlans.push(plan);
|
|
309
316
|
// Every attempt this path actually SUBMITTED, in order. The confirmed
|
|
310
317
|
// creation count is read from these and never from the deciding result
|
|
@@ -364,7 +371,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
364
371
|
const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
365
372
|
attemptsForPlan.push(rerunAttempt);
|
|
366
373
|
if (rerunAttempt.order) {
|
|
367
|
-
orders.push(rerunAttempt.order);
|
|
374
|
+
orders.push(testOrderEntry(rerunAttempt.order, identifier, 2));
|
|
368
375
|
orderPlans.push(plan);
|
|
369
376
|
}
|
|
370
377
|
if (rerunAttempt.budget_exhausted) {
|
|
@@ -500,6 +507,17 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
500
507
|
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
|
|
501
508
|
}
|
|
502
509
|
|
|
510
|
+
// One test_orders[] entry per attempt, never one per planned path: a re-run
|
|
511
|
+
// places its own real order and the operator cleaning up test orders needs both.
|
|
512
|
+
// So each entry names the planned path it belongs to (`plan_id`, the same id the
|
|
513
|
+
// creation budget charges) and which attempt it was, 1-based, so a reader can
|
|
514
|
+
// tell 5 paths with one retry each from 10 paths (#661). A copy, so the attempt
|
|
515
|
+
// result the assertions read is left as the runner returned it.
|
|
516
|
+
function testOrderEntry(order, identifier, attempt) {
|
|
517
|
+
if (!order || typeof order !== "object") return order;
|
|
518
|
+
return { ...order, plan_id: order.plan_id ?? identifier, attempt };
|
|
519
|
+
}
|
|
520
|
+
|
|
503
521
|
// Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
|
|
504
522
|
// tag-fires on a baseline (legacy) URL and a candidate (migrated) URL, then
|
|
505
523
|
// diff them into parity assertions. Highest-value target is the thank-you /
|
|
@@ -4378,6 +4396,11 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4378
4396
|
cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
|
|
4379
4397
|
control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
|
|
4380
4398
|
}
|
|
4399
|
+
// Each bundle slot may need a variant (size, colour) before the page lets
|
|
4400
|
+
// Next through (campaigns-os#667). Fill every visible empty slot variant
|
|
4401
|
+
// select with its first in-stock option now, after the card that decides
|
|
4402
|
+
// the slots, and refuse by name when one has nothing to choose.
|
|
4403
|
+
const variantSelections = control.kind === "checkout_button" ? await fillSlotVariants(page) : [];
|
|
4381
4404
|
// The index is the control's position among what its own locator matches,
|
|
4382
4405
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
4383
4406
|
const controlSelector = control.kind === "add_to_cart"
|
|
@@ -4414,6 +4437,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4414
4437
|
control_kind: control.kind,
|
|
4415
4438
|
package_id: control.package_id || null,
|
|
4416
4439
|
...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
|
|
4440
|
+
...(variantSelections.length ? { variant_selections: variantSelections } : {}),
|
|
4417
4441
|
sdk_ready: sdkReady,
|
|
4418
4442
|
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4419
4443
|
checkout_selection_surface: surface,
|
|
@@ -4422,6 +4446,117 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4422
4446
|
};
|
|
4423
4447
|
}
|
|
4424
4448
|
|
|
4449
|
+
// Chooses a select page's slot variants one at a time (a choice can
|
|
4450
|
+
// re-render its slot) and returns the choices. A field showing a dropdown UI
|
|
4451
|
+
// is picked like a shopper, toggle then row, even when its select is
|
|
4452
|
+
// pre-filled, because a page may count a field as chosen only when a row was
|
|
4453
|
+
// clicked (campaigns-os#667). A select with no dropdown UI is filled only when
|
|
4454
|
+
// empty; a pre-filled native-only page returns [] untouched. Throws
|
|
4455
|
+
// ENTRY_VARIANT_UNFILLED, before any click on the checkout control, when a
|
|
4456
|
+
// field has no in-stock row or option, a pick cannot be clicked, or the page
|
|
4457
|
+
// keeps asking for more choices.
|
|
4458
|
+
const SLOT_VARIANT_FILL_LIMIT = 40;
|
|
4459
|
+
const SLOT_VARIANT_ROW_WAIT_MS = 2000;
|
|
4460
|
+
|
|
4461
|
+
// The slot variant field a pick names, found again by its slot identity
|
|
4462
|
+
// (data-next-bundle-id, data-next-slot-index, variant code) so a re-render
|
|
4463
|
+
// since the pick was planned does not leave a stale index; the planned index
|
|
4464
|
+
// is the fallback for a field that carries no slot index.
|
|
4465
|
+
async function slotVariantField(page, pick) {
|
|
4466
|
+
const fields = page.locator(SLOT_VARIANT_FIELD_SELECTOR);
|
|
4467
|
+
const index = await fields.evaluateAll((nodes, want) => {
|
|
4468
|
+
const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
4469
|
+
const found = nodes.findIndex((node) => {
|
|
4470
|
+
const slotIndex = Number.parseInt(node.getAttribute("data-next-slot-index") ?? "", 10);
|
|
4471
|
+
if (!Number.isFinite(slotIndex) || slotIndex + 1 !== want.slot) return false;
|
|
4472
|
+
if ((clean(node.getAttribute("data-next-bundle-id")) || null) !== want.bundle_id) return false;
|
|
4473
|
+
const select = node.querySelector("select");
|
|
4474
|
+
const code = clean(select?.getAttribute("data-next-variant-code") || select?.getAttribute("data-variant-code") || node.getAttribute("data-next-variant-code") || select?.name) || null;
|
|
4475
|
+
return code === want.variant_code;
|
|
4476
|
+
});
|
|
4477
|
+
return found >= 0 ? found : want.field_index;
|
|
4478
|
+
}, pick).catch(() => pick.field_index);
|
|
4479
|
+
return fields.nth(index);
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
// The pick's row, matched by its value among the field's rows (no selector is
|
|
4483
|
+
// built from the value), once it is visible; null if it does not show in time.
|
|
4484
|
+
async function visibleSlotVariantRow(page, pick, timeoutMs) {
|
|
4485
|
+
const deadline = Date.now() + timeoutMs;
|
|
4486
|
+
for (;;) {
|
|
4487
|
+
const field = await slotVariantField(page, pick);
|
|
4488
|
+
const rows = field.locator(SLOT_VARIANT_DROPDOWN_ROW_SELECTOR);
|
|
4489
|
+
const index = await rows.evaluateAll((nodes, value) => nodes.findIndex((node) => {
|
|
4490
|
+
if (String(node.getAttribute("value") || "").replace(/\s+/g, " ").trim() !== value) return false;
|
|
4491
|
+
const rect = node.getBoundingClientRect();
|
|
4492
|
+
const style = getComputedStyle(node);
|
|
4493
|
+
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
4494
|
+
}), pick.value).catch(() => -1);
|
|
4495
|
+
if (index >= 0) return rows.nth(index);
|
|
4496
|
+
if (Date.now() >= deadline) return null;
|
|
4497
|
+
await page.waitForTimeout(100);
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4500
|
+
async function fillSlotVariants(page) {
|
|
4501
|
+
const selections = [];
|
|
4502
|
+
const picked = [];
|
|
4503
|
+
const step = () => page.evaluate(slotVariantStepScript(), {
|
|
4504
|
+
selector: SLOT_VARIANT_SELECT_SELECTOR,
|
|
4505
|
+
fieldSelector: SLOT_VARIANT_FIELD_SELECTOR,
|
|
4506
|
+
toggleSelector: SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
|
|
4507
|
+
rowSelector: SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
|
|
4508
|
+
picked,
|
|
4509
|
+
}).catch(() => ({ done: true }));
|
|
4510
|
+
for (let pass = 0; pass < SLOT_VARIANT_FILL_LIMIT; pass += 1) {
|
|
4511
|
+
const result = await step();
|
|
4512
|
+
if (result?.done) {
|
|
4513
|
+
if (selections.length) await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
|
|
4514
|
+
return selections;
|
|
4515
|
+
}
|
|
4516
|
+
if (result?.unfillable?.detail) {
|
|
4517
|
+
throw codedError(CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED, `${slotVariantLabel(result.unfillable)}: ${result.unfillable.detail}; the page would refuse Next`);
|
|
4518
|
+
}
|
|
4519
|
+
if (result?.unfillable) {
|
|
4520
|
+
throw codedError(
|
|
4521
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4522
|
+
`${slotVariantLabel(result.unfillable)} has no in-stock ${result.unfillable.via === "dropdown" ? "dropdown row" : "option"} to choose (${result.unfillable.options} ${result.unfillable.via === "dropdown" ? "row" : "option"}(s), all disabled or placeholder); the page would refuse Next`,
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4525
|
+
const choice = result.pick || result.filled;
|
|
4526
|
+
if (result.pick) {
|
|
4527
|
+
try {
|
|
4528
|
+
const field = await slotVariantField(page, result.pick);
|
|
4529
|
+
await clickControl(field.locator(SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR).filter({ visible: true }).first(), { timeout: 5000 });
|
|
4530
|
+
// The menu may open late or re-render on toggle: re-resolve the field
|
|
4531
|
+
// and the row by its value, and wait for that row to show.
|
|
4532
|
+
const row = await visibleSlotVariantRow(page, result.pick, SLOT_VARIANT_ROW_WAIT_MS);
|
|
4533
|
+
if (!row) {
|
|
4534
|
+
throw codedError(
|
|
4535
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4536
|
+
`${slotVariantLabel(choice)} dropdown row "${choice.value}" did not show within ${SLOT_VARIANT_ROW_WAIT_MS}ms of opening the dropdown; the page would refuse Next`,
|
|
4537
|
+
);
|
|
4538
|
+
}
|
|
4539
|
+
await clickControl(row, { timeout: 5000 });
|
|
4540
|
+
} catch (error) {
|
|
4541
|
+
if (error?.code === CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED) throw error;
|
|
4542
|
+
throw codedError(
|
|
4543
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4544
|
+
`${slotVariantLabel(choice)} dropdown row "${choice.value}" could not be clicked (${String(error?.message || error).split("\n")[0]}); the page would refuse Next`,
|
|
4545
|
+
);
|
|
4546
|
+
}
|
|
4547
|
+
picked.push(slotVariantKey(choice));
|
|
4548
|
+
}
|
|
4549
|
+
selections.push({ bundle_id: choice.bundle_id, slot: choice.slot, variant_code: choice.variant_code, value: choice.value, via: result.pick ? "dropdown" : "select" });
|
|
4550
|
+
await page.waitForTimeout(250);
|
|
4551
|
+
}
|
|
4552
|
+
const pending = await step();
|
|
4553
|
+
if (pending?.done) return selections;
|
|
4554
|
+
throw codedError(
|
|
4555
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4556
|
+
`${slotVariantLabel(pending.pick || pending.filled || pending.unfillable)} still needed a choice after ${SLOT_VARIANT_FILL_LIMIT} variant choices; the page keeps clearing them`,
|
|
4557
|
+
);
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4425
4560
|
// The package of the bundle card a select page shows as selected, or null.
|
|
4426
4561
|
async function selectedBundleCardPackage(page) {
|
|
4427
4562
|
return page.evaluate((cardSelector) => {
|
package/src/qa-cart-entry.mjs
CHANGED
|
@@ -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) {
|
package/src/qa-verdict.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
19
|
-
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.
|
|
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
|
+
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.
|
|
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
|
|
574
|
-
|
|
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 !==
|
|
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)
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
}
|