@nextcommerce/campaigns-os 1.61.0 → 1.63.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/AGENTS.md +2 -1
- package/CHANGELOG.md +127 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +3 -0
- package/campaign-spec/dist/rules/index.js +9 -0
- package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
- package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
- package/campaign-spec/dist/types.d.ts +59 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
- package/contracts/release-ledger.json +1260 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +59 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +7 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +34 -3
- package/schemas/campaign-spec.v5.schema.json +34 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -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 +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutPathHasPackages — every path from an entry page to a Checkout passes
|
|
3
|
+
* at least one page that sells a main-cart package (campaigns-os#711, #206).
|
|
4
|
+
*
|
|
5
|
+
* A Checkout the shopper reaches with nothing in the cart cannot take an
|
|
6
|
+
* order, and until this rule nothing said so before a test order failed. The
|
|
7
|
+
* selling page may be any page on the path up to and including the Checkout:
|
|
8
|
+
* a select page's bundle picker, a landing page's package cards, a checkout
|
|
9
|
+
* step, or the Checkout's own selector. So the rule counts per path, never per
|
|
10
|
+
* page: a Checkout with no packages of its own passes when every path into it
|
|
11
|
+
* sells first.
|
|
12
|
+
*
|
|
13
|
+
* "Main-cart package" is the ratified #206 contract (checkout-flow.ts
|
|
14
|
+
* `isMainCartPackage`, docs/template-family-contracts.md "Main-cart entry"):
|
|
15
|
+
* an entry with neither `is_upsell: true` nor `is_order_bump: true`. Bump rows
|
|
16
|
+
* alone sell nothing, `default_selected` never changes the role, and entries
|
|
17
|
+
* on upsell, downsell or thank-you pages never count.
|
|
18
|
+
*
|
|
19
|
+
* Paths are walked the way OneCheckoutPerPath walks them: both edges a page
|
|
20
|
+
* can traverse (routing.ts), starting at the funnel's entry pages
|
|
21
|
+
* (checkout-flow.ts `entryPages`). No page order or name is consulted. The
|
|
22
|
+
* search runs, per entry page, over (page, sold yet) states and stops at the
|
|
23
|
+
* first Checkout, so each empty Checkout is reported once per entry however
|
|
24
|
+
* many of that entry's paths reach it. A second Checkout on one path is
|
|
25
|
+
* OneCheckoutPerPath's to report.
|
|
26
|
+
*
|
|
27
|
+
* With build_scope.mode === 'partial' the finding is a warning: a partial
|
|
28
|
+
* build may ship a Checkout that a page outside the build fills.
|
|
29
|
+
*/
|
|
30
|
+
import { outgoingEdgeIds } from "../routing.js";
|
|
31
|
+
import { entryPages, isPaymentPage, resolveRouteTargetPage, sellsMainCartPackage } from "../checkout-flow.js";
|
|
32
|
+
export const CheckoutPathHasPackages = {
|
|
33
|
+
id: 'CheckoutPathHasPackages',
|
|
34
|
+
severity: 'error',
|
|
35
|
+
tags: ['structure', 'spec-only'],
|
|
36
|
+
check(spec) {
|
|
37
|
+
const violations = [];
|
|
38
|
+
const partial = spec.build_scope?.mode === 'partial';
|
|
39
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
40
|
+
const pages = (funnel.pages ?? []).filter((page) => page && page.enabled !== false);
|
|
41
|
+
const indexOf = new Map();
|
|
42
|
+
(funnel.pages ?? []).forEach((page, idx) => indexOf.set(page, idx));
|
|
43
|
+
for (const entry of entryPages(funnel)) {
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const reported = new Set();
|
|
46
|
+
const stack = [[entry, false]];
|
|
47
|
+
while (stack.length) {
|
|
48
|
+
const [page, before] = stack.pop();
|
|
49
|
+
const sold = before || sellsMainCartPackage(page);
|
|
50
|
+
const key = `${indexOf.get(page)}:${sold}`;
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
if (isPaymentPage(page)) {
|
|
55
|
+
if (!sold && !reported.has(page)) {
|
|
56
|
+
reported.add(page);
|
|
57
|
+
violations.push({
|
|
58
|
+
ruleId: 'CheckoutPathHasPackages',
|
|
59
|
+
severity: partial ? 'warning' : 'error',
|
|
60
|
+
message: `A path from entry page "${entry.id}" reaches Checkout "${page.id}" without passing a page that sells a main-cart package, so the cart is empty and no order can be placed. ` +
|
|
61
|
+
'Add a package entry not marked is_upsell or is_order_bump to the Checkout or a page before it on this path.' +
|
|
62
|
+
(partial ? ' Valid for partial builds when a page outside this build fills the cart.' : ''),
|
|
63
|
+
path: `/funnels/${funnelIdx}/pages/${indexOf.get(page)}`,
|
|
64
|
+
data: { pageId: page.id, entryPageId: entry.id, isPartialScope: partial },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const target of outgoingEdgeIds(page)) {
|
|
70
|
+
const next = resolveRouteTargetPage(pages, target);
|
|
71
|
+
if (next)
|
|
72
|
+
stack.push([next, sold]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return violations;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -35,11 +35,14 @@ export { CheckoutForwardTarget } from './checkout-forward-target.ts';
|
|
|
35
35
|
export { PrePaymentForwardTarget } from './pre-payment-forward-target.ts';
|
|
36
36
|
export { CheckoutStepReachesCheckout } from './checkout-step-reaches-checkout.ts';
|
|
37
37
|
export { OneCheckoutPerPath } from './one-checkout-per-path.ts';
|
|
38
|
+
export { CheckoutPathHasPackages } from './checkout-path-has-packages.ts';
|
|
38
39
|
export { UpsellWithoutCheckout } from './upsell-without-checkout.ts';
|
|
39
40
|
export { DownsellWithoutUpsell } from './downsell-without-upsell.ts';
|
|
40
41
|
export { PackagePricingSanity } from './package-pricing-sanity.ts';
|
|
41
42
|
export { ShippingMethodsPresent } from './shipping-methods-present.ts';
|
|
42
43
|
export { ShippingCountriesShape } from './shipping-countries-shape.ts';
|
|
44
|
+
export { CampaignCurrencies } from './campaign-currencies.ts';
|
|
45
|
+
export { PackageCurrencyPrices } from './package-currency-prices.ts';
|
|
43
46
|
export { OfferRefIntegrity } from './offer-ref-integrity.ts';
|
|
44
47
|
export { ExitIntentValidation } from './exit-intent-validation.ts';
|
|
45
48
|
export { PromoCodeInputValidation } from './promo-code-input-validation.ts';
|
|
@@ -34,11 +34,14 @@ import { CheckoutForwardTarget } from "./checkout-forward-target.js";
|
|
|
34
34
|
import { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
|
|
35
35
|
import { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
|
|
36
36
|
import { OneCheckoutPerPath } from "./one-checkout-per-path.js";
|
|
37
|
+
import { CheckoutPathHasPackages } from "./checkout-path-has-packages.js";
|
|
37
38
|
import { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
|
|
38
39
|
import { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
|
|
39
40
|
import { CampaignMetadata } from "./campaign-metadata.js";
|
|
40
41
|
import { ShippingMethodsPresent } from "./shipping-methods-present.js";
|
|
41
42
|
import { ShippingCountriesShape } from "./shipping-countries-shape.js";
|
|
43
|
+
import { CampaignCurrencies } from "./campaign-currencies.js";
|
|
44
|
+
import { PackageCurrencyPrices } from "./package-currency-prices.js";
|
|
42
45
|
import { PackagePricingSanity } from "./package-pricing-sanity.js";
|
|
43
46
|
import { OfferRefIntegrity } from "./offer-ref-integrity.js";
|
|
44
47
|
import { ExitIntentValidation } from "./exit-intent-validation.js";
|
|
@@ -69,11 +72,14 @@ export { CheckoutForwardTarget } from "./checkout-forward-target.js";
|
|
|
69
72
|
export { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
|
|
70
73
|
export { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
|
|
71
74
|
export { OneCheckoutPerPath } from "./one-checkout-per-path.js";
|
|
75
|
+
export { CheckoutPathHasPackages } from "./checkout-path-has-packages.js";
|
|
72
76
|
export { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
|
|
73
77
|
export { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
|
|
74
78
|
export { PackagePricingSanity } from "./package-pricing-sanity.js";
|
|
75
79
|
export { ShippingMethodsPresent } from "./shipping-methods-present.js";
|
|
76
80
|
export { ShippingCountriesShape } from "./shipping-countries-shape.js";
|
|
81
|
+
export { CampaignCurrencies } from "./campaign-currencies.js";
|
|
82
|
+
export { PackageCurrencyPrices } from "./package-currency-prices.js";
|
|
77
83
|
export { OfferRefIntegrity } from "./offer-ref-integrity.js";
|
|
78
84
|
export { ExitIntentValidation } from "./exit-intent-validation.js";
|
|
79
85
|
export { PromoCodeInputValidation } from "./promo-code-input-validation.js";
|
|
@@ -114,11 +120,14 @@ export const allRules = [
|
|
|
114
120
|
PrePaymentForwardTarget,
|
|
115
121
|
CheckoutStepReachesCheckout,
|
|
116
122
|
OneCheckoutPerPath,
|
|
123
|
+
CheckoutPathHasPackages,
|
|
117
124
|
UpsellWithoutCheckout,
|
|
118
125
|
DownsellWithoutUpsell,
|
|
119
126
|
PackagePricingSanity,
|
|
120
127
|
ShippingMethodsPresent,
|
|
121
128
|
ShippingCountriesShape,
|
|
129
|
+
CampaignCurrencies,
|
|
130
|
+
PackageCurrencyPrices,
|
|
122
131
|
OfferRefIntegrity,
|
|
123
132
|
ExitIntentValidation,
|
|
124
133
|
PromoCodeInputValidation,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PackageCurrencyPrices — checks each page package's price in every currency
|
|
3
|
+
* the campaign sells in (#714).
|
|
4
|
+
*
|
|
5
|
+
* A package row may carry `prices[]`: one `{ currency, price, price_retail?,
|
|
6
|
+
* price_recurring? }` per currency the Campaigns App has a price for, read
|
|
7
|
+
* from the Campaigns API (`GET /campaigns/?currency=<code>`). That read
|
|
8
|
+
* returns only the packages priced in the requested currency, so a package
|
|
9
|
+
* with no price in a listed currency is missing from the campaign for a
|
|
10
|
+
* shopper paying in it.
|
|
11
|
+
*
|
|
12
|
+
* 1. `prices`, when present, is an array of entries, each with a
|
|
13
|
+
* three-letter upper-case ISO 4217 `currency` and a non-negative
|
|
14
|
+
* numeric `price`, and no currency twice.
|
|
15
|
+
* 2. A row with `prices` has an entry for every currency in
|
|
16
|
+
* campaign.available_currencies and for campaign.currency.
|
|
17
|
+
*
|
|
18
|
+
* A row without `prices` (absent or null) is silent: older Maps and
|
|
19
|
+
* hand-written specs carry the default currency's price only (`price`), and
|
|
20
|
+
* nothing is known about the others. Every finding is a warning, since a
|
|
21
|
+
* missing price is fixed in the Campaigns App, not in the spec.
|
|
22
|
+
*
|
|
23
|
+
* The selling currencies are the spec's own `campaign.currency` and
|
|
24
|
+
* `campaign.available_currencies`. The Campaigns App API's
|
|
25
|
+
* `additional_currencies` is not a CampaignSpec field (CampaignCurrencies), so
|
|
26
|
+
* it is not read here either.
|
|
27
|
+
*/
|
|
28
|
+
import type { Rule } from '../types.ts';
|
|
29
|
+
export declare const PackageCurrencyPrices: Rule;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PackageCurrencyPrices — checks each page package's price in every currency
|
|
3
|
+
* the campaign sells in (#714).
|
|
4
|
+
*
|
|
5
|
+
* A package row may carry `prices[]`: one `{ currency, price, price_retail?,
|
|
6
|
+
* price_recurring? }` per currency the Campaigns App has a price for, read
|
|
7
|
+
* from the Campaigns API (`GET /campaigns/?currency=<code>`). That read
|
|
8
|
+
* returns only the packages priced in the requested currency, so a package
|
|
9
|
+
* with no price in a listed currency is missing from the campaign for a
|
|
10
|
+
* shopper paying in it.
|
|
11
|
+
*
|
|
12
|
+
* 1. `prices`, when present, is an array of entries, each with a
|
|
13
|
+
* three-letter upper-case ISO 4217 `currency` and a non-negative
|
|
14
|
+
* numeric `price`, and no currency twice.
|
|
15
|
+
* 2. A row with `prices` has an entry for every currency in
|
|
16
|
+
* campaign.available_currencies and for campaign.currency.
|
|
17
|
+
*
|
|
18
|
+
* A row without `prices` (absent or null) is silent: older Maps and
|
|
19
|
+
* hand-written specs carry the default currency's price only (`price`), and
|
|
20
|
+
* nothing is known about the others. Every finding is a warning, since a
|
|
21
|
+
* missing price is fixed in the Campaigns App, not in the spec.
|
|
22
|
+
*
|
|
23
|
+
* The selling currencies are the spec's own `campaign.currency` and
|
|
24
|
+
* `campaign.available_currencies`. The Campaigns App API's
|
|
25
|
+
* `additional_currencies` is not a CampaignSpec field (CampaignCurrencies), so
|
|
26
|
+
* it is not read here either.
|
|
27
|
+
*/
|
|
28
|
+
import { isCurrencyCode } from "./campaign-currencies.js";
|
|
29
|
+
// A non-negative amount: a finite number, or a plain decimal string ("49.99").
|
|
30
|
+
// Hex, binary and exponent strings ("0x10", "1e3") are not prices.
|
|
31
|
+
function isPrice(value) {
|
|
32
|
+
if (typeof value === 'number')
|
|
33
|
+
return Number.isFinite(value) && value >= 0;
|
|
34
|
+
return typeof value === 'string' && /^\d+(\.\d+)?$/.test(value.trim());
|
|
35
|
+
}
|
|
36
|
+
// An optional amount: absent, null, or a price.
|
|
37
|
+
function isOptionalPrice(value) {
|
|
38
|
+
return value === undefined || value === null || isPrice(value);
|
|
39
|
+
}
|
|
40
|
+
// What is wrong with one prices[] entry, or null when it is well formed.
|
|
41
|
+
function entryDefect(entry) {
|
|
42
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
43
|
+
return 'is not a { currency, price } object';
|
|
44
|
+
const row = entry;
|
|
45
|
+
const defects = [];
|
|
46
|
+
if (!isCurrencyCode(row.currency))
|
|
47
|
+
defects.push('a three-letter upper-case ISO 4217 currency');
|
|
48
|
+
if (!isPrice(row.price))
|
|
49
|
+
defects.push('a non-negative decimal price');
|
|
50
|
+
for (const field of ['price_retail', 'price_recurring']) {
|
|
51
|
+
if (!isOptionalPrice(row[field]))
|
|
52
|
+
defects.push(`a non-negative decimal ${field} (or none)`);
|
|
53
|
+
}
|
|
54
|
+
return defects.length ? `needs ${defects.join(' and ')}` : null;
|
|
55
|
+
}
|
|
56
|
+
// The currencies the campaign sells in: the valid listed codes and a valid
|
|
57
|
+
// default. Malformed codes are CampaignCurrencies' to report.
|
|
58
|
+
function sellingCurrencies(spec) {
|
|
59
|
+
const campaign = spec.campaign;
|
|
60
|
+
if (!campaign || typeof campaign !== 'object')
|
|
61
|
+
return [];
|
|
62
|
+
const listed = Array.isArray(campaign.available_currencies) ? campaign.available_currencies : [];
|
|
63
|
+
return [...new Set([campaign.currency, ...listed].filter(isCurrencyCode))];
|
|
64
|
+
}
|
|
65
|
+
export const PackageCurrencyPrices = {
|
|
66
|
+
id: 'PackageCurrencyPrices',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
tags: ['fast', 'spec-only'],
|
|
69
|
+
check(spec) {
|
|
70
|
+
const violations = [];
|
|
71
|
+
const selling = sellingCurrencies(spec);
|
|
72
|
+
spec.funnels.forEach((funnel, funnelIdx) => {
|
|
73
|
+
;
|
|
74
|
+
(funnel.pages ?? []).forEach((page, pageIdx) => {
|
|
75
|
+
;
|
|
76
|
+
(page.packages ?? []).forEach((pkg, pkgIdx) => {
|
|
77
|
+
const prices = pkg.prices;
|
|
78
|
+
if (prices === undefined || prices === null)
|
|
79
|
+
return;
|
|
80
|
+
const path = `/funnels/${funnelIdx}/pages/${pageIdx}/packages/${pkgIdx}/prices`;
|
|
81
|
+
const name = pkg.name || pkg.ref_id;
|
|
82
|
+
const label = `"${page.label || page.id}" — Package "${name}"`;
|
|
83
|
+
if (!Array.isArray(prices)) {
|
|
84
|
+
violations.push({
|
|
85
|
+
ruleId: 'PackageCurrencyPrices',
|
|
86
|
+
severity: 'warning',
|
|
87
|
+
message: `${label}: prices must be an array of { currency, price } entries.`,
|
|
88
|
+
path,
|
|
89
|
+
data: { check: 'prices_shape', pageId: page.id, ref_id: pkg.ref_id, received: prices },
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
prices.forEach((entry, index) => {
|
|
95
|
+
const currency = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry.currency : undefined;
|
|
96
|
+
const defect = entryDefect(entry);
|
|
97
|
+
// A malformed entry with a valid currency still counts that
|
|
98
|
+
// currency as listed: the entry's own finding names the fix, and a
|
|
99
|
+
// second "has no EUR price" finding would report it twice.
|
|
100
|
+
if (defect) {
|
|
101
|
+
violations.push({
|
|
102
|
+
ruleId: 'PackageCurrencyPrices',
|
|
103
|
+
severity: 'warning',
|
|
104
|
+
message: `${label}: prices[${index}] ${defect}.`,
|
|
105
|
+
path: `${path}/${index}`,
|
|
106
|
+
data: { check: 'price_entry_shape', pageId: page.id, ref_id: pkg.ref_id, received: entry },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
else if (isCurrencyCode(currency) && seen.has(currency)) {
|
|
110
|
+
violations.push({
|
|
111
|
+
ruleId: 'PackageCurrencyPrices',
|
|
112
|
+
severity: 'warning',
|
|
113
|
+
message: `${label}: prices lists ${currency} more than once.`,
|
|
114
|
+
path: `${path}/${index}`,
|
|
115
|
+
data: { check: 'price_currency_repeated', pageId: page.id, ref_id: pkg.ref_id, currency },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (isCurrencyCode(currency))
|
|
119
|
+
seen.add(currency);
|
|
120
|
+
});
|
|
121
|
+
const missing = selling.filter((code) => !seen.has(code));
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
const list = missing.length === 1 ? missing[0] : `${missing.slice(0, -1).join(', ')} or ${missing[missing.length - 1]}`;
|
|
124
|
+
violations.push({
|
|
125
|
+
ruleId: 'PackageCurrencyPrices',
|
|
126
|
+
severity: 'warning',
|
|
127
|
+
message: `${label} has no ${list} price, so the campaign leaves it out in ${missing.length === 1 ? 'that currency' : 'those currencies'}. Add the price in the Campaigns App, then refresh the Map.`,
|
|
128
|
+
path,
|
|
129
|
+
data: { check: 'price_currency_missing', pageId: page.id, ref_id: pkg.ref_id, missing },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
return violations;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -108,6 +108,14 @@ export interface PageOffer {
|
|
|
108
108
|
benefit?: OfferBenefit;
|
|
109
109
|
[key: string]: unknown;
|
|
110
110
|
}
|
|
111
|
+
/** One currency's price for a page package (#714). */
|
|
112
|
+
export interface PackageCurrencyPrice {
|
|
113
|
+
/** Three-letter upper-case ISO 4217 code. */
|
|
114
|
+
currency: string;
|
|
115
|
+
price: number | string;
|
|
116
|
+
price_retail?: number | string | null;
|
|
117
|
+
price_recurring?: number | string | null;
|
|
118
|
+
}
|
|
111
119
|
export interface PagePackage {
|
|
112
120
|
ref_id?: string | number;
|
|
113
121
|
/**
|
|
@@ -118,6 +126,14 @@ export interface PagePackage {
|
|
|
118
126
|
name?: string | null;
|
|
119
127
|
price?: number | string | null;
|
|
120
128
|
price_retail?: number | string | null;
|
|
129
|
+
/**
|
|
130
|
+
* The package's price in each currency the Campaigns App prices it in,
|
|
131
|
+
* read from the Campaigns API (`GET /campaigns/?currency=<code>`). `price`
|
|
132
|
+
* and `price_retail` above stay the default currency's. Optional; a row
|
|
133
|
+
* that carries it should list every currency the campaign sells in
|
|
134
|
+
* (PackageCurrencyPrices).
|
|
135
|
+
*/
|
|
136
|
+
prices?: PackageCurrencyPrice[];
|
|
121
137
|
product_name?: string | null;
|
|
122
138
|
product_sku?: string | null;
|
|
123
139
|
product_variant_name?: string | null;
|
|
@@ -519,12 +535,17 @@ export interface Campaign {
|
|
|
519
535
|
*
|
|
520
536
|
* Modeled on real production-funnel usage, NOT the idealized "SDK fires the
|
|
521
537
|
* canonical dl_* set" view. The field reality the block must express:
|
|
522
|
-
* - Events
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
538
|
+
* - Events reach vendors four ways: SDK providers (`providers`: GTM, Meta,
|
|
539
|
+
* RudderStack, a custom endpoint, configured through config.js);
|
|
540
|
+
* SDK-blocked-then-manual (`blockedEvents` suppresses the SDK event, a
|
|
541
|
+
* side script re-fires it via raw fbq/gtag to control timing); the starter
|
|
542
|
+
* templates' vendor adapters (`vendors`: GA4, Axon, Taboola, Triple Whale,
|
|
543
|
+
* TikTok, Northbeam, Snapchat, Pinterest, switched on by a campaigns.json
|
|
544
|
+
* key, riding the SDK's dl_* stream through the template forwarder); and
|
|
545
|
+
* out-of-band pixels a merchant loads some other way, typically through
|
|
546
|
+
* their own GTM container (`out_of_band_pixels`, e.g. Everflow), which
|
|
547
|
+
* never touch the dataLayer. So outbound pixel fires, not dataLayer
|
|
548
|
+
* events, are the QA source of truth.
|
|
528
549
|
* - A custom provider may carry an endpoint + transform (cookie injection).
|
|
529
550
|
* - Querystring params split into two classes: CONTENT (param.* → visibility
|
|
530
551
|
* via data-next-hide) and TRACKING (utm params, gclid, fbclid, subN, click-ids that
|
|
@@ -549,8 +570,33 @@ export interface AnalyticsProvider {
|
|
|
549
570
|
blockedEvents?: string[];
|
|
550
571
|
[key: string]: unknown;
|
|
551
572
|
}
|
|
552
|
-
/**
|
|
553
|
-
*
|
|
573
|
+
/**
|
|
574
|
+
* One starter-template vendor adapter, keyed in `analytics.vendors` by the
|
|
575
|
+
* vendor key (ga4 | axon | taboola | triplewhale | tiktok | northbeam | snap |
|
|
576
|
+
* pinterest; registry: analytics-vendors.ts). This is the shape the Map
|
|
577
|
+
* Builder emits; its spec compiler writes the matching flat keys into the
|
|
578
|
+
* campaign's `_data/campaigns.json` entry, and `spec derive` mirrors the id
|
|
579
|
+
* back from there.
|
|
580
|
+
*/
|
|
581
|
+
export interface AnalyticsVendor {
|
|
582
|
+
/** false turns the vendor off (no campaigns.json key is compiled). */
|
|
583
|
+
enabled?: boolean;
|
|
584
|
+
/** The vendor id: the value of the vendor's campaigns.json id key (`ga4_id`, `triplewhale_name`, …). */
|
|
585
|
+
id?: string;
|
|
586
|
+
/** Triple Whale only: `triplewhale_platform` (e.g. SHOPIFY). The template default, custom-msp, is omitted. */
|
|
587
|
+
platform?: string;
|
|
588
|
+
/** Turns on the vendor's identity / advanced-matching flag (raw email and phone; the `*_enabled` key). */
|
|
589
|
+
identityEnabled?: boolean;
|
|
590
|
+
/** dl_* events the adapter forwards (`<vendor>_allowed_events`); absent means all. */
|
|
591
|
+
allowedEvents?: string[];
|
|
592
|
+
/** dl_* events the adapter drops (`<vendor>_blocked_events`), e.g. purchases on a Shop Sync store. */
|
|
593
|
+
blockedEvents?: string[];
|
|
594
|
+
[key: string]: unknown;
|
|
595
|
+
}
|
|
596
|
+
/** A pixel/tag fired OUTSIDE the SDK and the template adapters (via the
|
|
597
|
+
* merchant's GTM container or a raw snippet). QA must still expect it on a
|
|
598
|
+
* live run even though it never appears in the dataLayer. A vendor the starter
|
|
599
|
+
* templates ship an adapter for belongs in `vendors`, not here. */
|
|
554
600
|
export interface OutOfBandPixel {
|
|
555
601
|
vendor: string;
|
|
556
602
|
loaded_via?: 'gtm' | 'script' | (string & {});
|
|
@@ -595,9 +641,13 @@ export interface UtmTransfer {
|
|
|
595
641
|
excludedDomains?: string[];
|
|
596
642
|
}
|
|
597
643
|
export interface AnalyticsContract {
|
|
644
|
+
/** false turns the whole contract off, as mode "disabled" does. */
|
|
645
|
+
enabled?: boolean;
|
|
598
646
|
mode?: AnalyticsMode;
|
|
599
|
-
/**
|
|
647
|
+
/** SDK providers, keyed by provider kind: gtm | facebook | rudderstack | custom | … */
|
|
600
648
|
providers?: Record<string, AnalyticsProvider>;
|
|
649
|
+
/** Starter-template vendor adapters, keyed by vendor key (see AnalyticsVendor). */
|
|
650
|
+
vendors?: Record<string, AnalyticsVendor>;
|
|
601
651
|
out_of_band_pixels?: OutOfBandPixel[];
|
|
602
652
|
manual_events?: ManualEvent[];
|
|
603
653
|
utmTransfer?: UtmTransfer;
|