@mindful-web/marko-web-omeda-identity-x 1.89.2 → 1.90.1
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/README.md +75 -0
- package/components/identify.marko.js +2 -2
- package/index.js +14 -1
- package/omeda-data/get-promo-code-for.js +7 -0
- package/package.json +2 -2
- package/test/opt-in-hook-config.spec.js +256 -0
- package/utils/conversion-source-buckets.js +139 -0
- package/utils/resolve-opt-in-hook-config.js +85 -0
package/README.md
CHANGED
|
@@ -99,6 +99,81 @@ A related trap when reading site configs: some repos' `onAuthenticationSuccess.p
|
|
|
99
99
|
`product.deploymentTypeId` and emits `deploymentTypes`. Check which formatter consumes a list before
|
|
100
100
|
assuming its ids are products.
|
|
101
101
|
|
|
102
|
+
### Per-source overrides (`source`)
|
|
103
|
+
|
|
104
|
+
A hook's appends are otherwise unconditional — every login source gets the same products, opt-ins
|
|
105
|
+
and demographics. Add a `source` map to vary them by where the conversion came from:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
// sites/<site>/config/identity-x-opt-in-hooks.js
|
|
109
|
+
module.exports = {
|
|
110
|
+
onLoginLinkSent: {
|
|
111
|
+
productIds: [15375],
|
|
112
|
+
deploymentTypeIds: [332],
|
|
113
|
+
source: {
|
|
114
|
+
contentGating: { productIds: [] }, // no product for either content gate…
|
|
115
|
+
contentGate: { productIds: [42] }, // …except this one specifically
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Resolve it in a formatter with
|
|
122
|
+
[`resolveOptInHookConfig`](./utils/resolve-opt-in-hook-config.js), which returns `undefined` when
|
|
123
|
+
the hook isn't configured, so the usual early bail is unchanged:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
const resolveOptInHookConfig = require('@mindful-web/marko-web-omeda-identity-x/utils/resolve-opt-in-hook-config');
|
|
127
|
+
|
|
128
|
+
const { demographics, deploymentTypeIds, productIds } = resolveOptInHookConfig({
|
|
129
|
+
optInHooks: identityXOptInHooks,
|
|
130
|
+
hook: 'onLoginLinkSent',
|
|
131
|
+
source: loginSource,
|
|
132
|
+
}) || {};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Layering** is `base → bucket → exact source`, each a shallow **per-key replace**: only the keys
|
|
136
|
+
an override names are replaced, the rest inherit. That is what lets `productIds: []` mean "no
|
|
137
|
+
products for this source" while leaving `deploymentTypeIds` alone. An empty array is truthy, so the
|
|
138
|
+
existing `(productIds || []).reduce(...)` in every formatter produces no appends unchanged. An
|
|
139
|
+
exact-source key always beats the bucket containing it.
|
|
140
|
+
|
|
141
|
+
**Keys** may be a bucket or an exact login source, in any casing (`contentGate`,
|
|
142
|
+
`content_meter_login` and `CONTENT_GATE` are equivalent). The buckets are the four areas of the
|
|
143
|
+
Insights → Audience → Audience Conversion report:
|
|
144
|
+
|
|
145
|
+
| Bucket key | Login sources |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `contentGating` | `contentGate`, `content_meter_login` |
|
|
148
|
+
| `newsletter` (or `newsletterSignups`) | `newsletterSignup`, `recommendedSignup` |
|
|
149
|
+
| `featureGating` | `comments`, `contentAccess`, `contentDownload`, `equipmentCalculator`, `loadAnalyzer`, `top250`, `pib_login`, `contactCompany`, `ingredientIssues` |
|
|
150
|
+
| `other` | `default`, `subscribe`, `google-one-tap`, `idxApi` |
|
|
151
|
+
|
|
152
|
+
Two groupings surprise people, and both are deliberate upstream: **`comments` is feature gating**,
|
|
153
|
+
not other; and **`contentAccess` / `contentDownload` are feature gating**, not content gating (they
|
|
154
|
+
gate an asset rather than an article, and have no Views signal). Sources in no bucket —
|
|
155
|
+
`change-email`, `progressiveProfile`, `truckHistoryReport`, `poultryTrendsLiveChartPage` — are
|
|
156
|
+
reachable only by an exact-source key.
|
|
157
|
+
|
|
158
|
+
The bucket definitions are mirrored from `mindful-reporting`'s
|
|
159
|
+
`packages/audience/repo/src/repos/member-events/conversion-sources.ts`; there is no package shared
|
|
160
|
+
between the two repos, so [`conversion-source-buckets.js`](./utils/conversion-source-buckets.js)
|
|
161
|
+
duplicates them. **Edit both together** — a source missing here silently falls back to the base
|
|
162
|
+
config rather than erroring.
|
|
163
|
+
|
|
164
|
+
Two caveats:
|
|
165
|
+
|
|
166
|
+
- **Each hook names the value differently.** `onLoginLinkSent` receives `source`,
|
|
167
|
+
`onAuthenticationSuccess` receives `loginSource`, `onUserProfileUpdate` receives `actionSource`.
|
|
168
|
+
Pass whichever local holds it.
|
|
169
|
+
- **`onUserProfileUpdate` has no reliable source.** Neither `routes/profile.js` nor
|
|
170
|
+
`routes/progressive-profile.js` passes one; `actionSource` only arrives if a client put it in
|
|
171
|
+
`additionalEventData`, and is usually `undefined`. Overrides on that hook are inert whenever it
|
|
172
|
+
is — the value resolves to the base config.
|
|
173
|
+
|
|
174
|
+
An unrecognized, empty or missing source is never an error: the set of login sources is open (any
|
|
175
|
+
site template can introduce a new `source="…"`), so it resolves to the base config.
|
|
176
|
+
|
|
102
177
|
### Customer re-sync interval
|
|
103
178
|
|
|
104
179
|
`userResyncIntervalMs` is read from the **IdentityX** config (`idxConfig`), not from the properties
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.90.1/components/identify.marko",
|
|
6
6
|
marko_component = require("./identify.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_getCookieId = require("@mindful-web/marko-web-omeda-identity-x/utils/get-cookie-id"),
|
|
@@ -51,7 +51,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
51
51
|
}, marko_component);
|
|
52
52
|
|
|
53
53
|
marko_template.meta = {
|
|
54
|
-
id: "/@mindful-web/marko-web-omeda-identity-x$1.
|
|
54
|
+
id: "/@mindful-web/marko-web-omeda-identity-x$1.90.1/components/identify.marko",
|
|
55
55
|
component: "./identify.marko",
|
|
56
56
|
tags: [
|
|
57
57
|
"@mindful-web/marko-web-identity-x/components/identify.marko",
|
package/index.js
CHANGED
|
@@ -296,11 +296,24 @@ module.exports = (app, params = {}) => {
|
|
|
296
296
|
* "onUserProfileUpdate"
|
|
297
297
|
* )} IDXHookTypeEnum
|
|
298
298
|
*
|
|
299
|
+
* The login sources shipped by this monorepo's packages. **Not exhaustive** — the set is open:
|
|
300
|
+
* a site template may pass any `source="…"` string of its own (fusable alone adds `loadAnalyzer`,
|
|
301
|
+
* `top250`, `pib_login`, `equipmentCalculator`), so treat an unlisted value as valid data rather
|
|
302
|
+
* than a bug. Grouping into report buckets lives in `utils/conversion-source-buckets.js`.
|
|
303
|
+
*
|
|
299
304
|
* @typedef {(
|
|
300
305
|
* "default"|
|
|
301
306
|
* "newsletterSignup"|
|
|
307
|
+
* "recommendedSignup"|
|
|
302
308
|
* "comments"|
|
|
303
|
-
* "contentGate"
|
|
309
|
+
* "contentGate"|
|
|
310
|
+
* "content_meter_login"|
|
|
311
|
+
* "contentAccess"|
|
|
312
|
+
* "contentDownload"|
|
|
313
|
+
* "progressiveProfile"|
|
|
314
|
+
* "google-one-tap"|
|
|
315
|
+
* "change-email"|
|
|
316
|
+
* "subscribe"
|
|
304
317
|
* )} OIDXLoginSource
|
|
305
318
|
*
|
|
306
319
|
* @typedef {(
|
|
@@ -15,6 +15,7 @@ const { get } = require('@mindful-web/object-path');
|
|
|
15
15
|
* @param {string} [params.comments='Comments']
|
|
16
16
|
* @param {object} [params.contentMeterLogin]
|
|
17
17
|
* @param {string} [params.contentGate='HardGate']
|
|
18
|
+
* @param {string} [params.googleOneTap='GoogleOneTap']
|
|
18
19
|
* @param {object} [params.req]
|
|
19
20
|
* @returns {string}
|
|
20
21
|
*/
|
|
@@ -36,6 +37,10 @@ module.exports = ({
|
|
|
36
37
|
overlay: 'MeterGate',
|
|
37
38
|
},
|
|
38
39
|
contentGate = 'HardGate',
|
|
40
|
+
// Google Sign-In. `routes/google.js` uses one `loginSource` for both of its
|
|
41
|
+
// surfaces (the One-Tap prompt and the rendered button), so this single code
|
|
42
|
+
// covers both.
|
|
43
|
+
googleOneTap = 'GoogleOneTap',
|
|
39
44
|
req,
|
|
40
45
|
}) => {
|
|
41
46
|
if (get(req, 'cookies.omeda_promo_code')) return get(req, 'cookies.omeda_promo_code');
|
|
@@ -53,6 +58,8 @@ module.exports = ({
|
|
|
53
58
|
return `${promoCodePrefix}${contentMeterLogin.default}`;
|
|
54
59
|
case 'contentGate':
|
|
55
60
|
return `${promoCodePrefix}${contentGate}`;
|
|
61
|
+
case 'google-one-tap':
|
|
62
|
+
return `${promoCodePrefix}${googleOneTap}`;
|
|
56
63
|
default:
|
|
57
64
|
return `${promoCodePrefix}${defaultPromoCode}`;
|
|
58
65
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindful-web/marko-web-omeda-identity-x",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.90.1",
|
|
4
4
|
"description": "Marko Omeda+IdentityX integration tools",
|
|
5
5
|
"repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-omeda-identity-x",
|
|
6
6
|
"author": "Josh Worden <josh@parameter1.com>",
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"chai": "^4.3.7",
|
|
34
34
|
"mocha": "^6.2.3"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "e0185d370d34b4d465ceedae9e453b1925e5e6f5"
|
|
37
37
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
const { describe, it } = require('mocha');
|
|
2
|
+
const { expect } = require('chai');
|
|
3
|
+
const {
|
|
4
|
+
BUCKETS,
|
|
5
|
+
getBucketForKey,
|
|
6
|
+
getBucketForSource,
|
|
7
|
+
normalizeSource,
|
|
8
|
+
} = require('../utils/conversion-source-buckets');
|
|
9
|
+
const resolveOptInHookConfig = require('../utils/resolve-opt-in-hook-config');
|
|
10
|
+
|
|
11
|
+
const HOOK = 'onLoginLinkSent';
|
|
12
|
+
|
|
13
|
+
describe('utils/conversion-source-buckets', () => {
|
|
14
|
+
describe('normalizeSource', () => {
|
|
15
|
+
// Expected values are `constantCase(...)` from the `change-case` package, which is what
|
|
16
|
+
// mindful-reporting applies when it writes the member event. Every real login-source literal
|
|
17
|
+
// in the fleet is listed so a regression in the regexes shows up as a named failure.
|
|
18
|
+
const cases = [
|
|
19
|
+
['contentGate', 'CONTENT_GATE'],
|
|
20
|
+
['content_meter_login', 'CONTENT_METER_LOGIN'],
|
|
21
|
+
['newsletterSignup', 'NEWSLETTER_SIGNUP'],
|
|
22
|
+
['recommendedSignup', 'RECOMMENDED_SIGNUP'],
|
|
23
|
+
['comments', 'COMMENTS'],
|
|
24
|
+
['contentAccess', 'CONTENT_ACCESS'],
|
|
25
|
+
['contentDownload', 'CONTENT_DOWNLOAD'],
|
|
26
|
+
['equipmentCalculator', 'EQUIPMENT_CALCULATOR'],
|
|
27
|
+
['loadAnalyzer', 'LOAD_ANALYZER'],
|
|
28
|
+
['pib_login', 'PIB_LOGIN'],
|
|
29
|
+
['google-one-tap', 'GOOGLE_ONE_TAP'],
|
|
30
|
+
['change-email', 'CHANGE_EMAIL'],
|
|
31
|
+
['progressiveProfile', 'PROGRESSIVE_PROFILE'],
|
|
32
|
+
['truckHistoryReport', 'TRUCK_HISTORY_REPORT'],
|
|
33
|
+
['poultryTrendsLiveChartPage', 'POULTRY_TRENDS_LIVE_CHART_PAGE'],
|
|
34
|
+
['default', 'DEFAULT'],
|
|
35
|
+
['subscribe', 'SUBSCRIBE'],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
cases.forEach(([input, expected]) => {
|
|
39
|
+
it(`normalizes ${input} to ${expected}`, () => {
|
|
40
|
+
expect(normalizeSource(input)).to.equal(expected);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The one case a naive implementation gets wrong. Reporting's key is `TOP250`; splitting on a
|
|
45
|
+
// letter→digit boundary would yield `TOP_250` and silently stop matching.
|
|
46
|
+
it('does not insert a separator between a letter and a digit', () => {
|
|
47
|
+
expect(normalizeSource('top250')).to.equal('TOP250');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is a no-op for already-normalized input', () => {
|
|
51
|
+
['TOP250', 'CONTENT_GATE', 'GOOGLE_ONE_TAP'].forEach((v) => {
|
|
52
|
+
expect(normalizeSource(v)).to.equal(v);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles null and undefined without throwing', () => {
|
|
57
|
+
expect(normalizeSource(undefined)).to.equal('');
|
|
58
|
+
expect(normalizeSource(null)).to.equal('');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('getBucketForSource', () => {
|
|
63
|
+
it('buckets content-gating sources', () => {
|
|
64
|
+
expect(getBucketForSource('contentGate')).to.equal(BUCKETS.contentGating);
|
|
65
|
+
expect(getBucketForSource('content_meter_login')).to.equal(BUCKETS.contentGating);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('buckets newsletter sources', () => {
|
|
69
|
+
expect(getBucketForSource('newsletterSignup')).to.equal(BUCKETS.newsletter);
|
|
70
|
+
expect(getBucketForSource('recommendedSignup')).to.equal(BUCKETS.newsletter);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// The two counterintuitive groupings, asserted so nobody "corrects" the map.
|
|
74
|
+
it('buckets comments as feature gating, not other', () => {
|
|
75
|
+
expect(getBucketForSource('comments')).to.equal(BUCKETS.featureGating);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('buckets content access/download as feature gating, not content gating', () => {
|
|
79
|
+
expect(getBucketForSource('contentAccess')).to.equal(BUCKETS.featureGating);
|
|
80
|
+
expect(getBucketForSource('contentDownload')).to.equal(BUCKETS.featureGating);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('buckets google one tap as other', () => {
|
|
84
|
+
expect(getBucketForSource('google-one-tap')).to.equal(BUCKETS.other);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns undefined for deliberately unbucketed sources', () => {
|
|
88
|
+
['change-email', 'progressiveProfile', 'truckHistoryReport'].forEach((v) => {
|
|
89
|
+
expect(getBucketForSource(v)).to.equal(undefined);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns undefined for unknown and empty sources', () => {
|
|
94
|
+
expect(getBucketForSource('somethingBrandNew')).to.equal(undefined);
|
|
95
|
+
expect(getBucketForSource(undefined)).to.equal(undefined);
|
|
96
|
+
expect(getBucketForSource('')).to.equal(undefined);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('getBucketForKey', () => {
|
|
101
|
+
it('accepts the report area names', () => {
|
|
102
|
+
expect(getBucketForKey('contentGating')).to.equal(BUCKETS.contentGating);
|
|
103
|
+
expect(getBucketForKey('featureGating')).to.equal(BUCKETS.featureGating);
|
|
104
|
+
expect(getBucketForKey('newsletter')).to.equal(BUCKETS.newsletter);
|
|
105
|
+
expect(getBucketForKey('other')).to.equal(BUCKETS.other);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('accepts the plural newsletter heading shown in the UI', () => {
|
|
109
|
+
expect(getBucketForKey('newsletterSignups')).to.equal(BUCKETS.newsletter);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns undefined for an exact-source key', () => {
|
|
113
|
+
expect(getBucketForKey('contentGate')).to.equal(undefined);
|
|
114
|
+
expect(getBucketForKey('google-one-tap')).to.equal(undefined);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('utils/resolve-opt-in-hook-config', () => {
|
|
120
|
+
const base = { demographics: [], deploymentTypeIds: [332], productIds: [366] };
|
|
121
|
+
|
|
122
|
+
it('returns undefined when the hook is not configured', () => {
|
|
123
|
+
expect(resolveOptInHookConfig({ optInHooks: {}, hook: HOOK, source: 'contentGate' })).to.equal(undefined);
|
|
124
|
+
expect(resolveOptInHookConfig({ hook: HOOK, source: 'contentGate' })).to.equal(undefined);
|
|
125
|
+
expect(resolveOptInHookConfig()).to.equal(undefined);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns the base config when no overrides are declared', () => {
|
|
129
|
+
const resolved = resolveOptInHookConfig({
|
|
130
|
+
optInHooks: { [HOOK]: base },
|
|
131
|
+
hook: HOOK,
|
|
132
|
+
source: 'contentGate',
|
|
133
|
+
});
|
|
134
|
+
expect(resolved).to.deep.equal(base);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('never returns the source map as payload data', () => {
|
|
138
|
+
const resolved = resolveOptInHookConfig({
|
|
139
|
+
optInHooks: { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } },
|
|
140
|
+
hook: HOOK,
|
|
141
|
+
source: 'contentGate',
|
|
142
|
+
});
|
|
143
|
+
expect(resolved).to.not.have.property('source');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('applies a bucket override, per key', () => {
|
|
147
|
+
const resolved = resolveOptInHookConfig({
|
|
148
|
+
optInHooks: { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } },
|
|
149
|
+
hook: HOOK,
|
|
150
|
+
source: 'contentGate',
|
|
151
|
+
});
|
|
152
|
+
// productIds suppressed; everything else inherited.
|
|
153
|
+
expect(resolved).to.deep.equal({ demographics: [], deploymentTypeIds: [332], productIds: [] });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('applies a bucket override to every source in that bucket', () => {
|
|
157
|
+
const optInHooks = { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } };
|
|
158
|
+
['contentGate', 'content_meter_login'].forEach((source) => {
|
|
159
|
+
const resolved = resolveOptInHookConfig({ optInHooks, hook: HOOK, source });
|
|
160
|
+
expect(resolved.productIds, `source ${source}`).to.deep.equal([]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('leaves sources outside the overridden bucket on the base config', () => {
|
|
165
|
+
const resolved = resolveOptInHookConfig({
|
|
166
|
+
optInHooks: { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } },
|
|
167
|
+
hook: HOOK,
|
|
168
|
+
source: 'newsletterSignup',
|
|
169
|
+
});
|
|
170
|
+
expect(resolved).to.deep.equal(base);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('lets an exact-source override beat its own bucket', () => {
|
|
174
|
+
const optInHooks = {
|
|
175
|
+
[HOOK]: {
|
|
176
|
+
...base,
|
|
177
|
+
source: {
|
|
178
|
+
contentGating: { productIds: [] },
|
|
179
|
+
contentGate: { productIds: [42] },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
// The carve-out wins for the named source...
|
|
184
|
+
expect(resolveOptInHookConfig({ optInHooks, hook: HOOK, source: 'contentGate' }).productIds).to.deep.equal([42]);
|
|
185
|
+
// ...while its bucket sibling still gets the bucket rule.
|
|
186
|
+
expect(resolveOptInHookConfig({ optInHooks, hook: HOOK, source: 'content_meter_login' }).productIds).to.deep.equal([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('applies an exact-source override for an unbucketed source', () => {
|
|
190
|
+
const resolved = resolveOptInHookConfig({
|
|
191
|
+
optInHooks: { [HOOK]: { ...base, source: { progressiveProfile: { productIds: [7] } } } },
|
|
192
|
+
hook: HOOK,
|
|
193
|
+
source: 'progressiveProfile',
|
|
194
|
+
});
|
|
195
|
+
expect(resolved.productIds).to.deep.equal([7]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('matches override keys regardless of casing', () => {
|
|
199
|
+
['contentGate', 'CONTENT_GATE', 'content-gate'].forEach((key) => {
|
|
200
|
+
const resolved = resolveOptInHookConfig({
|
|
201
|
+
optInHooks: { [HOOK]: { ...base, source: { [key]: { productIds: [9] } } } },
|
|
202
|
+
hook: HOOK,
|
|
203
|
+
source: 'contentGate',
|
|
204
|
+
});
|
|
205
|
+
expect(resolved.productIds, `key ${key}`).to.deep.equal([9]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('can override several keys at once', () => {
|
|
210
|
+
const resolved = resolveOptInHookConfig({
|
|
211
|
+
optInHooks: {
|
|
212
|
+
[HOOK]: { ...base, source: { contentGating: { productIds: [], deploymentTypeIds: [] } } },
|
|
213
|
+
},
|
|
214
|
+
hook: HOOK,
|
|
215
|
+
source: 'contentGate',
|
|
216
|
+
});
|
|
217
|
+
expect(resolved).to.deep.equal({ demographics: [], deploymentTypeIds: [], productIds: [] });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does not mutate the supplied config', () => {
|
|
221
|
+
const optInHooks = { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } };
|
|
222
|
+
resolveOptInHookConfig({ optInHooks, hook: HOOK, source: 'contentGate' });
|
|
223
|
+
expect(optInHooks[HOOK].productIds).to.deep.equal([366]);
|
|
224
|
+
expect(optInHooks[HOOK].source.contentGating.productIds).to.deep.equal([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('falls back to base for an unknown, empty or missing source', () => {
|
|
228
|
+
const optInHooks = { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } };
|
|
229
|
+
[undefined, '', 'somethingBrandNew'].forEach((source) => {
|
|
230
|
+
expect(resolveOptInHookConfig({ optInHooks, hook: HOOK, source }), `source ${source}`).to.deep.equal(base);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('ignores a non-object source map or override value', () => {
|
|
235
|
+
expect(resolveOptInHookConfig({
|
|
236
|
+
optInHooks: { [HOOK]: { ...base, source: 'nope' } },
|
|
237
|
+
hook: HOOK,
|
|
238
|
+
source: 'contentGate',
|
|
239
|
+
})).to.deep.equal(base);
|
|
240
|
+
|
|
241
|
+
expect(resolveOptInHookConfig({
|
|
242
|
+
optInHooks: { [HOOK]: { ...base, source: { contentGating: 'nope' } } },
|
|
243
|
+
hook: HOOK,
|
|
244
|
+
source: 'contentGate',
|
|
245
|
+
})).to.deep.equal(base);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('resolves each hook independently', () => {
|
|
249
|
+
const optInHooks = {
|
|
250
|
+
onLoginLinkSent: { productIds: [1], source: { contentGating: { productIds: [] } } },
|
|
251
|
+
onAuthenticationSuccess: { productIds: [2] },
|
|
252
|
+
};
|
|
253
|
+
expect(resolveOptInHookConfig({ optInHooks, hook: 'onLoginLinkSent', source: 'contentGate' }).productIds).to.deep.equal([]);
|
|
254
|
+
expect(resolveOptInHookConfig({ optInHooks, hook: 'onAuthenticationSuccess', source: 'contentGate' }).productIds).to.deep.equal([2]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groups IdentityX login sources into the same four "source buckets" the Insights → Audience →
|
|
3
|
+
* Audience Conversion report uses, so a site's `identityXOptInHooks` can override its Omeda
|
|
4
|
+
* appends per bucket rather than per individual source.
|
|
5
|
+
*
|
|
6
|
+
* ## Where these names come from
|
|
7
|
+
*
|
|
8
|
+
* The buckets are defined in the **reporting** repo, not here:
|
|
9
|
+
* `mindful-reporting/packages/audience/repo/src/repos/member-events/conversion-sources.ts`
|
|
10
|
+
* (consumed by `classifyConversionRow` in
|
|
11
|
+
* `packages/repo/src/insights/audience-captures/conversion-query.ts`). `SOURCE_TO_BUCKET` below is
|
|
12
|
+
* a deliberate mirror of that file's four maps — there is no package shared between mindful-web
|
|
13
|
+
* and mindful-reporting, so the duplication cannot be avoided.
|
|
14
|
+
*
|
|
15
|
+
* **Keep the two in sync.** If reporting adds a source to a bucket and this map isn't updated,
|
|
16
|
+
* that source silently falls back to a site's base hook config rather than erroring — a quiet
|
|
17
|
+
* wrong answer. When editing either file, edit both.
|
|
18
|
+
*
|
|
19
|
+
* ## The two vocabularies
|
|
20
|
+
*
|
|
21
|
+
* The website emits mixed casing (`contentGate`, `content_meter_login`, `google-one-tap`), while
|
|
22
|
+
* reporting keys on UPPER_SNAKE (`CONTENT_GATE`, `CONTENT_METER_LOGIN`, `GOOGLE_ONE_TAP`). The
|
|
23
|
+
* bridge is `constantCase(source)`, applied when the member event is written
|
|
24
|
+
* (`mindful-reporting/graphql/idx-compat/src/resolvers/app-user.ts`). `normalizeSource` reproduces
|
|
25
|
+
* that transform so this map can use the exact strings reporting uses.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes a login source to the UPPER_SNAKE form reporting stores, matching `constantCase`
|
|
30
|
+
* from the `change-case` package (which is not a dependency here — this is three replacements,
|
|
31
|
+
* not worth one).
|
|
32
|
+
*
|
|
33
|
+
* Only a lower→upper boundary introduces a separator. A letter→digit boundary must NOT:
|
|
34
|
+
* `constantCase('top250')` is `TOP250`, and reporting's key is `TOP250`, so a `([a-z])(\d)` rule
|
|
35
|
+
* would produce `TOP_250` and silently stop matching. Already-normalized input is a no-op, so
|
|
36
|
+
* config may be written in either vocabulary.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} value
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
const normalizeSource = (value) => String(value == null ? '' : value)
|
|
42
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
43
|
+
.replace(/[-\s]+/g, '_')
|
|
44
|
+
.toUpperCase();
|
|
45
|
+
|
|
46
|
+
/** The bucket a source belongs to, mirroring reporting's `ConversionRowArea`. */
|
|
47
|
+
const BUCKETS = {
|
|
48
|
+
contentGating: 'contentGating',
|
|
49
|
+
featureGating: 'featureGating',
|
|
50
|
+
newsletter: 'newsletter',
|
|
51
|
+
other: 'other',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalized login source → bucket. Mirrors, in order, `CONTENT_GATING_SOURCE_TO_NAME`,
|
|
56
|
+
* `NEWSLETTER_SOURCES`, `FEATURE_GATING_SOURCE_TO_NAME` and `OTHER_SOURCE_TO_NAME` from
|
|
57
|
+
* reporting's `conversion-sources.ts`.
|
|
58
|
+
*
|
|
59
|
+
* Two groupings routinely surprise people, and both are deliberate upstream:
|
|
60
|
+
* - `COMMENTS` is **feature** gating, not `other`.
|
|
61
|
+
* - `CONTENT_ACCESS` / `CONTENT_DOWNLOAD` are **feature** gating, not `contentGating` — they gate
|
|
62
|
+
* an asset rather than an article, and carry no p1-events Views signal.
|
|
63
|
+
*
|
|
64
|
+
* Sources deliberately absent (no bucket in the report, so reachable only by an exact-source
|
|
65
|
+
* override key): `CHANGE_EMAIL`, `PROGRESSIVE_PROFILE`, `TRUCK_HISTORY_REPORT`,
|
|
66
|
+
* `POULTRY_TRENDS_LIVE_CHART_PAGE`.
|
|
67
|
+
*/
|
|
68
|
+
const SOURCE_TO_BUCKET = {
|
|
69
|
+
// Content gating — the only areas with a Views signal, hence a real conversion rate.
|
|
70
|
+
CONTENT_GATE: BUCKETS.contentGating,
|
|
71
|
+
CONTENT_METER_LOGIN: BUCKETS.contentGating,
|
|
72
|
+
|
|
73
|
+
// Newsletter widgets. "Recommended" uses its own source rather than NEWSLETTER_SIGNUP.
|
|
74
|
+
NEWSLETTER_SIGNUP: BUCKETS.newsletter,
|
|
75
|
+
RECOMMENDED_SIGNUP: BUCKETS.newsletter,
|
|
76
|
+
|
|
77
|
+
// Feature gating — page/tool gates.
|
|
78
|
+
COMMENTS: BUCKETS.featureGating,
|
|
79
|
+
CONTACT_COMPANY: BUCKETS.featureGating,
|
|
80
|
+
CONTENT_ACCESS: BUCKETS.featureGating,
|
|
81
|
+
CONTENT_DOWNLOAD: BUCKETS.featureGating,
|
|
82
|
+
EQUIPMENT_CALCULATOR: BUCKETS.featureGating,
|
|
83
|
+
INGREDIENT_ISSUES: BUCKETS.featureGating,
|
|
84
|
+
LOAD_ANALYZER: BUCKETS.featureGating,
|
|
85
|
+
PIB_LOGIN: BUCKETS.featureGating,
|
|
86
|
+
TOP250: BUCKETS.featureGating,
|
|
87
|
+
|
|
88
|
+
// Other / catch-all.
|
|
89
|
+
DEFAULT: BUCKETS.other,
|
|
90
|
+
GOOGLE_ONE_TAP: BUCKETS.other,
|
|
91
|
+
IDX_API: BUCKETS.other,
|
|
92
|
+
SUBSCRIBE: BUCKETS.other,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Normalized override key → bucket. Accepts the report's internal area name and, for the
|
|
97
|
+
* newsletter bucket, the plural heading the UI actually displays ("Newsletter Signups"), since
|
|
98
|
+
* that is what a config author reading the report is most likely to type.
|
|
99
|
+
*/
|
|
100
|
+
const BUCKET_KEY_TO_BUCKET = {
|
|
101
|
+
CONTENT_GATING: BUCKETS.contentGating,
|
|
102
|
+
FEATURE_GATING: BUCKETS.featureGating,
|
|
103
|
+
NEWSLETTER: BUCKETS.newsletter,
|
|
104
|
+
NEWSLETTER_SIGNUPS: BUCKETS.newsletter,
|
|
105
|
+
OTHER: BUCKETS.other,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns the bucket for a login source, or `undefined` when the source has no bucket (unknown,
|
|
110
|
+
* empty, or one of the deliberately unbucketed values above).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} [source]
|
|
113
|
+
* @returns {string|undefined}
|
|
114
|
+
*/
|
|
115
|
+
const getBucketForSource = (source) => {
|
|
116
|
+
if (!source) return undefined;
|
|
117
|
+
return SOURCE_TO_BUCKET[normalizeSource(source)];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns the bucket an override key refers to, or `undefined` when the key is not a bucket name
|
|
122
|
+
* (i.e. it should be treated as an exact source key).
|
|
123
|
+
*
|
|
124
|
+
* @param {string} [key]
|
|
125
|
+
* @returns {string|undefined}
|
|
126
|
+
*/
|
|
127
|
+
const getBucketForKey = (key) => {
|
|
128
|
+
if (!key) return undefined;
|
|
129
|
+
return BUCKET_KEY_TO_BUCKET[normalizeSource(key)];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
BUCKETS,
|
|
134
|
+
BUCKET_KEY_TO_BUCKET,
|
|
135
|
+
SOURCE_TO_BUCKET,
|
|
136
|
+
getBucketForKey,
|
|
137
|
+
getBucketForSource,
|
|
138
|
+
normalizeSource,
|
|
139
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { getBucketForKey, getBucketForSource, normalizeSource } = require('./conversion-source-buckets');
|
|
2
|
+
|
|
3
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a site's `identityXOptInHooks[hook]` config for one login source, applying any
|
|
7
|
+
* per-source overrides declared under that hook's `source` key.
|
|
8
|
+
*
|
|
9
|
+
* ## Config shape
|
|
10
|
+
*
|
|
11
|
+
* ```js
|
|
12
|
+
* // sites/<site>/config/identity-x-opt-in-hooks.js
|
|
13
|
+
* onAuthenticationSuccess: {
|
|
14
|
+
* productIds: [83, 88, 84],
|
|
15
|
+
* deploymentTypeIds: [332],
|
|
16
|
+
* source: {
|
|
17
|
+
* contentGating: { productIds: [] }, // both content gates: no products, keep deployments
|
|
18
|
+
* contentGate: { productIds: [42] }, // ...except this one specifically
|
|
19
|
+
* },
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Override keys may be either a **bucket** (`contentGating`, `featureGating`, `newsletter` /
|
|
24
|
+
* `newsletterSignups`, `other`) or an **exact login source** (`contentGate`,
|
|
25
|
+
* `content_meter_login`, `google-one-tap`, …), in any casing — keys are normalized before
|
|
26
|
+
* comparison, so `contentGate`, `content_meter_login` and `CONTENT_GATE` all work. See
|
|
27
|
+
* `./conversion-source-buckets` for which sources belong to which bucket (`COMMENTS` is feature
|
|
28
|
+
* gating, not other; `CONTENT_ACCESS`/`CONTENT_DOWNLOAD` are feature gating, not content gating).
|
|
29
|
+
*
|
|
30
|
+
* ## Layering
|
|
31
|
+
*
|
|
32
|
+
* `base → bucket override → exact-source override`, each a shallow **per-key replace**: only the
|
|
33
|
+
* keys present in an override replace the base, and unspecified keys inherit. That is what makes
|
|
34
|
+
* `productIds: []` express "no products for this source" while leaving `deploymentTypeIds` and
|
|
35
|
+
* `demographics` alone. An empty array is truthy, so the consuming formatters' existing
|
|
36
|
+
* `(productIds || []).reduce(...)` produces no appends without any change on their side.
|
|
37
|
+
*
|
|
38
|
+
* An exact-source key always wins over the bucket that contains it, so a bucket-wide rule can be
|
|
39
|
+
* carved out for one source. Bucket names and source names do not currently collide.
|
|
40
|
+
*
|
|
41
|
+
* ## Degradation
|
|
42
|
+
*
|
|
43
|
+
* The set of login sources is **open** — any site template can introduce a new `source="..."` —
|
|
44
|
+
* so an unrecognized, empty or `undefined` source is not an error: it resolves to the base config.
|
|
45
|
+
* A non-object `source` value is ignored the same way.
|
|
46
|
+
*
|
|
47
|
+
* Note the three hooks name this value differently in their formatter args: `onLoginLinkSent`
|
|
48
|
+
* receives `source`, `onAuthenticationSuccess` receives `loginSource`, and `onUserProfileUpdate`
|
|
49
|
+
* receives `actionSource` (which is client-supplied and frequently `undefined` — overrides on that
|
|
50
|
+
* hook are inert whenever it is). Pass whichever local holds the value.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} params
|
|
53
|
+
* @param {object} [params.optInHooks] The site's `identityXOptInHooks` config.
|
|
54
|
+
* @param {string} params.hook One of `onLoginLinkSent`, `onAuthenticationSuccess`,
|
|
55
|
+
* `onUserProfileUpdate`.
|
|
56
|
+
* @param {string} [params.source] The login source for this request.
|
|
57
|
+
* @returns {object|undefined} The resolved config (without its `source` map), or `undefined` when
|
|
58
|
+
* the hook is not configured — so callers can keep their existing early bail.
|
|
59
|
+
*/
|
|
60
|
+
module.exports = ({ optInHooks, hook, source } = {}) => {
|
|
61
|
+
const hookConfig = isPlainObject(optInHooks) ? optInHooks[hook] : undefined;
|
|
62
|
+
if (!isPlainObject(hookConfig)) return undefined;
|
|
63
|
+
|
|
64
|
+
const { source: overrides, ...base } = hookConfig;
|
|
65
|
+
if (!isPlainObject(overrides) || !source) return base;
|
|
66
|
+
|
|
67
|
+
const bucket = getBucketForSource(source);
|
|
68
|
+
const normalizedSource = normalizeSource(source);
|
|
69
|
+
|
|
70
|
+
// A single pass over the override keys, so one key cannot be applied twice and the two layers
|
|
71
|
+
// stay independent of the order the config happens to declare them in.
|
|
72
|
+
const layers = Object.keys(overrides).reduce((acc, key) => {
|
|
73
|
+
const value = overrides[key];
|
|
74
|
+
if (!isPlainObject(value)) return acc;
|
|
75
|
+
const keyBucket = getBucketForKey(key);
|
|
76
|
+
if (keyBucket) {
|
|
77
|
+
if (keyBucket === bucket) acc.bucket.push(value);
|
|
78
|
+
return acc;
|
|
79
|
+
}
|
|
80
|
+
if (normalizeSource(key) === normalizedSource) acc.exact.push(value);
|
|
81
|
+
return acc;
|
|
82
|
+
}, { bucket: [], exact: [] });
|
|
83
|
+
|
|
84
|
+
return [...layers.bucket, ...layers.exact].reduce((acc, layer) => ({ ...acc, ...layer }), base);
|
|
85
|
+
};
|