@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.
Files changed (129) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +3 -0
  22. package/campaign-spec/dist/rules/index.js +9 -0
  23. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  24. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  25. package/campaign-spec/dist/types.d.ts +59 -9
  26. package/compatibility.json +1 -1
  27. package/contracts/commerce-surface-catalog.json +59 -53
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
  44. package/contracts/release-ledger.json +1260 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
  46. package/contracts/supported-surface.json +8 -8
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/build-packet.md +430 -37
  49. package/docs/campaign-standardization-report.md +4 -4
  50. package/docs/campaigns-os-build-flow.md +54 -3
  51. package/docs/campaignspec-authoring-examples.md +59 -4
  52. package/docs/design-source-package.md +18 -6
  53. package/docs/effects.md +17 -9
  54. package/docs/local-setup.md +1 -1
  55. package/docs/orientation-contract-reference.md +1 -1
  56. package/docs/polish-evidence.md +2 -0
  57. package/docs/progress-snapshots.md +7 -4
  58. package/docs/qa-and-test-orders.md +147 -1
  59. package/docs/readback.md +40 -5
  60. package/docs/runtime-readiness.md +7 -3
  61. package/docs/sdk-storage-compatibility.md +1 -1
  62. package/docs/skills-revision.md +10 -10
  63. package/package.json +1 -1
  64. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  65. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  66. package/schemas/campaign-spec.v4.schema.json +34 -3
  67. package/schemas/campaign-spec.v5.schema.json +34 -3
  68. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  69. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  70. package/skills/campaign-readback-classification/SKILL.md +3 -3
  71. package/skills/campaign-run-evidence/SKILL.md +3 -3
  72. package/skills/contribution-intake/SKILL.md +3 -3
  73. package/skills/next-campaigns-build/SKILL.md +4 -3
  74. package/skills/next-campaigns-os/SKILL.md +6 -4
  75. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  76. package/skills/next-campaigns-polish/SKILL.md +5 -4
  77. package/skills/next-campaigns-qa/SKILL.md +7 -4
  78. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  79. package/skills.json +11 -11
  80. package/src/brand-theme.mjs +2 -1
  81. package/src/build-brief.mjs +4 -1
  82. package/src/campaign-workspace.mjs +288 -15
  83. package/src/change-scope.mjs +284 -0
  84. package/src/cli.mjs +308 -86
  85. package/src/commercial-journey.mjs +16 -3
  86. package/src/commercial-parity.mjs +15 -7
  87. package/src/content-residue.mjs +4 -2
  88. package/src/design-source-package.mjs +36 -0
  89. package/src/design-source-publication.mjs +9 -6
  90. package/src/directory-lock.mjs +73 -19
  91. package/src/doctor/checks.mjs +79 -7
  92. package/src/doctor/ci.mjs +75 -10
  93. package/src/doctor/inspect.mjs +23 -6
  94. package/src/doctor/next-step.mjs +7 -0
  95. package/src/doctor-sidecar.mjs +3 -2
  96. package/src/evidence-debt.mjs +47 -7
  97. package/src/finding-cause.mjs +12 -11
  98. package/src/input-refresh.mjs +339 -32
  99. package/src/invocation.mjs +7 -1
  100. package/src/live-campaign-refs.mjs +7 -2
  101. package/src/local-preview-policy.mjs +35 -2
  102. package/src/page-kit-analytics-vendors.mjs +132 -0
  103. package/src/polish-browser.mjs +3 -1
  104. package/src/polish-readability.mjs +221 -9
  105. package/src/polish-visual.mjs +9 -2
  106. package/src/progress-node.mjs +3 -2
  107. package/src/qa-analytics-parity.mjs +17 -1
  108. package/src/qa-browser.mjs +40 -4
  109. package/src/qa-commercial-parity.mjs +50 -3
  110. package/src/qa-node.mjs +196 -16
  111. package/src/qa-publish.mjs +9 -8
  112. package/src/qa-sidecar.mjs +16 -4
  113. package/src/qa-vendor-beacons.mjs +232 -0
  114. package/src/qa-vendor-endpoints.mjs +306 -0
  115. package/src/qc-accept.mjs +29 -1
  116. package/src/qc-check-registry.mjs +5 -1
  117. package/src/qc-results.mjs +73 -26
  118. package/src/readback.mjs +58 -10
  119. package/src/route-identity.mjs +10 -0
  120. package/src/run-record.mjs +18 -10
  121. package/src/runtime-state-ignore.mjs +6 -2
  122. package/src/sdk-storage-compatibility.mjs +21 -2
  123. package/src/source-html-intake.mjs +15 -1
  124. package/src/spec-derive-campaign.mjs +216 -0
  125. package/src/spec-derive.mjs +473 -53
  126. package/src/spec-identity.mjs +14 -0
  127. package/src/stage-ledger.mjs +40 -1
  128. package/src/stage-record.mjs +58 -13
  129. 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 fire from three sources: SDK auto, SDK-blocked-then-manual
523
- * (`blockedEvents` suppresses the SDK event, a side script re-fires it via
524
- * raw fbq/gtag to control timing), and fully out-of-band pixels loaded via
525
- * GTM (Everflow / TriplePixel / Northbeam / RudderStack) that never touch
526
- * the dataLayer. So outbound pixel fires — not dataLayer events — are the
527
- * QA source of truth.
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
- /** A pixel/tag fired OUTSIDE the SDK (via GTM or a raw snippet) — QA must still
553
- * expect it on a live run even though it never appears in the dataLayer. */
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
- /** Keyed by provider kind: gtm | facebook | rudderstack | custom | … */
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@nextcommerce/campaigns-os",
3
- "version": "1.61.0",
3
+ "version": "1.63.0",
4
4
  "status": "developer-preview",
5
5
  "contracts": {
6
6
  "campaign_spec": "4.2-5.0",