@osano/osano-cmp-react-native 0.18.5 → 0.18.6

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 (54) hide show
  1. package/lib/commonjs/ConsentManager.js +20 -2
  2. package/lib/commonjs/ConsentManager.js.map +1 -1
  3. package/lib/commonjs/constants.js +2 -3
  4. package/lib/commonjs/constants.js.map +1 -1
  5. package/lib/commonjs/doNotSell.js +95 -0
  6. package/lib/commonjs/doNotSell.js.map +1 -0
  7. package/lib/commonjs/index.js +9 -1
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/reducer.js +41 -41
  10. package/lib/commonjs/reducer.js.map +1 -1
  11. package/lib/commonjs/selectors.js +20 -2
  12. package/lib/commonjs/selectors.js.map +1 -1
  13. package/lib/commonjs/services/consent.js +34 -5
  14. package/lib/commonjs/services/consent.js.map +1 -1
  15. package/lib/commonjs/views/Drawer/Drawer.ConsentCategories.js +1 -1
  16. package/lib/commonjs/views/Drawer/Drawer.ConsentCategories.js.map +1 -1
  17. package/lib/module/ConsentManager.js +20 -2
  18. package/lib/module/ConsentManager.js.map +1 -1
  19. package/lib/module/constants.js +2 -3
  20. package/lib/module/constants.js.map +1 -1
  21. package/lib/module/doNotSell.js +87 -0
  22. package/lib/module/doNotSell.js.map +1 -0
  23. package/lib/module/index.js +9 -1
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/reducer.js +41 -41
  26. package/lib/module/reducer.js.map +1 -1
  27. package/lib/module/selectors.js +16 -1
  28. package/lib/module/selectors.js.map +1 -1
  29. package/lib/module/services/consent.js +34 -5
  30. package/lib/module/services/consent.js.map +1 -1
  31. package/lib/module/views/Drawer/Drawer.ConsentCategories.js +2 -2
  32. package/lib/module/views/Drawer/Drawer.ConsentCategories.js.map +1 -1
  33. package/lib/typescript/src/ConsentManager.d.ts +5 -1
  34. package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
  35. package/lib/typescript/src/constants.d.ts.map +1 -1
  36. package/lib/typescript/src/doNotSell.d.ts +48 -0
  37. package/lib/typescript/src/doNotSell.d.ts.map +1 -0
  38. package/lib/typescript/src/index.d.ts.map +1 -1
  39. package/lib/typescript/src/reducer.d.ts.map +1 -1
  40. package/lib/typescript/src/selectors.d.ts +3 -0
  41. package/lib/typescript/src/selectors.d.ts.map +1 -1
  42. package/lib/typescript/src/services/consent.d.ts +2 -2
  43. package/lib/typescript/src/services/consent.d.ts.map +1 -1
  44. package/lib/typescript/src/views/Drawer/Drawer.ConsentCategories.d.ts.map +1 -1
  45. package/package.json +4 -4
  46. package/src/ConsentManager.ts +20 -5
  47. package/src/constants.ts +2 -3
  48. package/src/doNotSell.ts +122 -0
  49. package/src/index.tsx +10 -1
  50. package/src/reducer.ts +53 -56
  51. package/src/selectors.ts +25 -2
  52. package/src/services/consent.ts +49 -9
  53. package/src/types/consent-config.d.ts +6 -0
  54. package/src/views/Drawer/Drawer.ConsentCategories.tsx +2 -1
@@ -1,5 +1,6 @@
1
1
  import { NativeModules, Platform } from 'react-native';
2
2
  import { CLEAR_CONSENT, CLOSE } from './actions';
3
+ import { isDoNotSellVisible } from './doNotSell';
3
4
  import {
4
5
  ConsentCategory,
5
6
  mappedJurisdictions,
@@ -52,6 +53,9 @@ export const DEFAULT_CONFIG: Config = {
52
53
  ],
53
54
  ccpaApplies: false,
54
55
  ccpaRelaxed: false,
56
+ enableDoNotSell: false,
57
+ enableDoNotSellDefault: false,
58
+ doNotSellCategories: [],
55
59
  configId: '',
56
60
  country: 'us',
57
61
  crossDomain: false,
@@ -273,6 +277,15 @@ export class ConsentConfig extends EventEmitter {
273
277
  get ccpaApplies(): boolean {
274
278
  return this._config.ccpaApplies;
275
279
  }
280
+ get enableDoNotSell(): boolean {
281
+ return !!this._config.enableDoNotSell;
282
+ }
283
+ get enableDoNotSellDefault(): boolean {
284
+ return !!this._config.enableDoNotSellDefault;
285
+ }
286
+ get doNotSellCategories(): string[] {
287
+ return this._config.doNotSellCategories ?? [];
288
+ }
276
289
  get forceManagePreferences(): boolean {
277
290
  return this._config.forceManagePreferences;
278
291
  }
@@ -531,11 +544,13 @@ export class ConsentConfig extends EventEmitter {
531
544
  false)
532
545
  );
533
546
  }
534
- get shouldRenderOptOut(): boolean {
535
- return (
536
- !this._config.iab?.hideOptOut &&
537
- !!(this._config.iab?.usp?.ccpaApplies ?? this.browsingFromUS)
538
- );
547
+ /** Whether the "Do Not Sell / Share" opt-out toggle should render. */
548
+ get isDoNotSellVisible(): boolean {
549
+ return isDoNotSellVisible({
550
+ enableDoNotSell: this.enableDoNotSell,
551
+ ccpaApplies: this._config.iab?.usp?.ccpaApplies,
552
+ browsingFromUS: this.browsingFromUS,
553
+ });
539
554
  }
540
555
 
541
556
  get iabVendorCount(): number {
package/src/constants.ts CHANGED
@@ -39,9 +39,8 @@ export function makeConsentFromCategories(
39
39
  boolean
40
40
  >,
41
41
  );
42
- if (consent[ConsentCategory.OPT_OUT]) {
43
- consent[ConsentCategory.MARKETING] = false;
44
- }
42
+ // Opt-out precedence is applied by normalizeDoNotSellConsent (config-aware),
43
+ // not here — this pure helper can't see the configured governed categories.
45
44
  return consent;
46
45
  }
47
46
 
@@ -0,0 +1,122 @@
1
+ import {
2
+ ConsentCategory,
3
+ type ConsentCategoryType,
4
+ OsanoCategories,
5
+ } from './constants';
6
+
7
+ // "Do Not Sell / Share" opt-out logic as pure functions, shared by the service
8
+ // layer, reducer, and selectors.
9
+
10
+ /**
11
+ * Visible only when `enableDoNotSell` is set and CCPA applies: `ccpaApplies`
12
+ * when the config provides it, else the US geo fallback (explicit `false` hides
13
+ * it).
14
+ */
15
+ export const isDoNotSellVisible = (opts: {
16
+ enableDoNotSell?: boolean;
17
+ ccpaApplies?: boolean | null;
18
+ browsingFromUS?: boolean;
19
+ }): boolean =>
20
+ !!opts.enableDoNotSell && (opts.ccpaApplies ?? !!opts.browsingFromUS);
21
+
22
+ /**
23
+ * The governed categories: config's `doNotSellCategories`, upper-cased and
24
+ * mapped, falling back to Marketing. ESSENTIAL and OPT_OUT are never governed —
25
+ * governing OPT_OUT would make it toggle itself off. Tolerant of malformed
26
+ * input (a non-array, or null/non-string elements from remote/cached config) so
27
+ * a bad field can't crash the consent UI.
28
+ */
29
+ export const resolveDoNotSellCategories = (
30
+ categories?: string[],
31
+ ): ConsentCategoryType[] => {
32
+ const list = Array.isArray(categories) ? categories : [];
33
+ const mapped = list
34
+ .filter((category): category is string => typeof category === 'string')
35
+ .map((category) => category.toUpperCase())
36
+ .filter(
37
+ (category): category is ConsentCategoryType =>
38
+ (OsanoCategories as string[]).includes(category) &&
39
+ category !== ConsentCategory.ESSENTIAL &&
40
+ category !== ConsentCategory.OPT_OUT,
41
+ );
42
+ return mapped.length ? mapped : [ConsentCategory.MARKETING];
43
+ };
44
+
45
+ /**
46
+ * Opt-out precedence: when opted out, forces the governed categories off.
47
+ * Config-aware, so custom `doNotSellCategories` are honored. No-op when
48
+ * Do-Not-Sell is off or not opted out.
49
+ */
50
+ export const normalizeDoNotSellConsent = <
51
+ T extends Record<ConsentCategoryType, boolean | undefined>,
52
+ >(
53
+ consent: T,
54
+ opts: { enableDoNotSell?: boolean; doNotSellCategories?: string[] },
55
+ ): T => {
56
+ if (!opts.enableDoNotSell || !consent[ConsentCategory.OPT_OUT]) {
57
+ return consent;
58
+ }
59
+ const governed = resolveDoNotSellCategories(opts.doNotSellCategories);
60
+ const result: T = { ...consent };
61
+ for (const category of governed) {
62
+ result[category] = false as T[ConsentCategoryType];
63
+ }
64
+ return result;
65
+ };
66
+
67
+ /**
68
+ * Seeds the Do-Not-Sell default into a starting selection (no-op unless
69
+ * enabled): default-on opts out and drops the governed categories; default-off
70
+ * consents them with no opt-out signal.
71
+ */
72
+ export const applyDoNotSellDefault = (
73
+ categories: ConsentCategoryType[],
74
+ opts: {
75
+ enableDoNotSell?: boolean;
76
+ enableDoNotSellDefault?: boolean;
77
+ doNotSellCategories?: string[];
78
+ },
79
+ ): ConsentCategoryType[] => {
80
+ if (!opts.enableDoNotSell) {
81
+ return categories;
82
+ }
83
+ const governed = resolveDoNotSellCategories(opts.doNotSellCategories);
84
+ const result: ConsentCategoryType[] = categories.filter(
85
+ (category) => category !== ConsentCategory.OPT_OUT,
86
+ );
87
+ if (opts.enableDoNotSellDefault) {
88
+ return [
89
+ ...result.filter((category) => !governed.includes(category)),
90
+ ConsentCategory.OPT_OUT,
91
+ ];
92
+ }
93
+ for (const category of governed) {
94
+ if (!result.includes(category)) {
95
+ result.push(category);
96
+ }
97
+ }
98
+ return result;
99
+ };
100
+
101
+ /**
102
+ * Categories to force off to keep the opt-out and its governed categories
103
+ * mutually exclusive: opt-out on clears the governed ones; a governed one on
104
+ * clears the opt-out. Empty when disabled or toggling off.
105
+ */
106
+ export const doNotSellSideEffects = (
107
+ category: ConsentCategoryType,
108
+ isConsented: boolean,
109
+ opts: { enableDoNotSell?: boolean; doNotSellCategories?: string[] },
110
+ ): ConsentCategoryType[] => {
111
+ if (!opts.enableDoNotSell || !isConsented) {
112
+ return [];
113
+ }
114
+ const governed = resolveDoNotSellCategories(opts.doNotSellCategories);
115
+ if (category === ConsentCategory.OPT_OUT) {
116
+ return governed;
117
+ }
118
+ if (governed.includes(category)) {
119
+ return [ConsentCategory.OPT_OUT];
120
+ }
121
+ return [];
122
+ };
package/src/index.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  makeConsentFromCategories,
20
20
  type ConsentCategoryType,
21
21
  } from './constants';
22
+ import { normalizeDoNotSellConsent } from './doNotSell';
22
23
  import { OsanoPublicContext } from './context';
23
24
  import { usePrevious } from './hooks/usePrevious';
24
25
  import useThunkReducer, { type Store } from './hooks/useThunkReducer';
@@ -97,7 +98,11 @@ export const Osano: React.FunctionComponent<
97
98
  */
98
99
  const categories = Array.isArray(category) ? category : [category];
99
100
  consentedCategories = formatConsentArray(
100
- makeConsentFromCategories(categories),
101
+ normalizeDoNotSellConsent(makeConsentFromCategories(categories), {
102
+ enableDoNotSell: consentManager.current.config.enableDoNotSell,
103
+ doNotSellCategories:
104
+ consentManager.current.config.doNotSellCategories,
105
+ }),
101
106
  );
102
107
  }
103
108
  return recordConsent(consentManager.current, consentedCategories);
@@ -205,6 +210,10 @@ export const Osano: React.FunctionComponent<
205
210
  // We need to reset the state when the configId or customerId changes
206
211
  dispatch(
207
212
  reload({
213
+ // Pass config so init-time opt-out normalization runs on the
214
+ // stored consent even in headless consumers, where SET_CONFIG's
215
+ // UI effect never fires.
216
+ config: consentManager.current.config,
208
217
  configId: reloadProps.configId,
209
218
  customerId: reloadProps.customerId,
210
219
  consent: reloadProps.consent,
package/src/reducer.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  OsanoCategories,
25
25
  type ConsentCategoryType,
26
26
  } from './constants';
27
+ import { doNotSellSideEffects, normalizeDoNotSellConsent } from './doNotSell';
27
28
  import type { Disclosure, OsanoAction, OsanoState } from './types/store';
28
29
  import {
29
30
  getFinalOptOutValue,
@@ -36,11 +37,17 @@ import { ConsentConfig } from './ConsentManager';
36
37
 
37
38
  export const init = (initialArgs: OsanoState & any): OsanoState => {
38
39
  const { consent, ...reloadProps } = initialArgs;
39
- const consentRecord = consent
40
- ? Array.isArray(consent)
41
- ? makeConsentFromCategories(consent)
42
- : { ...consent }
43
- : initialState.consent;
40
+ const consentRecord = normalizeDoNotSellConsent(
41
+ consent
42
+ ? Array.isArray(consent)
43
+ ? makeConsentFromCategories(consent)
44
+ : { ...consent }
45
+ : initialState.consent,
46
+ {
47
+ enableDoNotSell: reloadProps.config?.enableDoNotSell,
48
+ doNotSellCategories: reloadProps.config?.doNotSellCategories,
49
+ },
50
+ );
44
51
  return {
45
52
  ...initialState,
46
53
  ...reloadProps,
@@ -109,11 +116,17 @@ export function reducer(state: OsanoState, action: OsanoAction): OsanoState {
109
116
  */
110
117
  case SAVE_BEGIN: {
111
118
  const consentedCategories: ConsentCategoryType[] = action.payload;
112
- const consent = makeConsentFromCategories(consentedCategories);
119
+ const consent = normalizeDoNotSellConsent(
120
+ makeConsentFromCategories(consentedCategories),
121
+ {
122
+ enableDoNotSell: state.config?.enableDoNotSell,
123
+ doNotSellCategories: state.config?.doNotSellCategories,
124
+ },
125
+ );
113
126
  return {
114
127
  ...state,
115
128
  consent,
116
- savedConsent: { ...consent },
129
+ savedConsent: { ...consent } as Record<ConsentCategoryType, boolean>,
117
130
  };
118
131
  }
119
132
  case CLEAR_CONSENT: {
@@ -144,64 +157,48 @@ export function reducer(state: OsanoState, action: OsanoAction): OsanoState {
144
157
  }
145
158
  case SET_ALL_CONSENT: {
146
159
  const acceptOrDeny = action.payload;
147
- return setAllConsent(acceptOrDeny)(state);
160
+ const next = setAllConsent(acceptOrDeny)(state);
161
+ return {
162
+ ...next,
163
+ consent: normalizeDoNotSellConsent(next.consent, {
164
+ enableDoNotSell: state.config?.enableDoNotSell,
165
+ doNotSellCategories: state.config?.doNotSellCategories,
166
+ }),
167
+ };
148
168
  }
149
169
  case TOGGLE_CATEGORY: {
150
- const { category, value } = action.payload;
170
+ // The action creator accepts either the shorthand string form
171
+ // (toggleCategory(ConsentCategory.X)) or an explicit { category, value }.
172
+ const { category, value } =
173
+ typeof action.payload === 'string'
174
+ ? { category: action.payload, value: undefined }
175
+ : action.payload;
151
176
  if (typeof category !== 'string') {
152
177
  return state;
153
178
  }
179
+ const key = category as ConsentCategoryType;
154
180
 
155
- // don't turn MARKETING on if OPT_OUT is already on
156
- if (
157
- category === ConsentCategory.MARKETING &&
158
- state.consent[ConsentCategory.OPT_OUT] === true
159
- ) {
160
- return state;
161
- }
181
+ // Resolve the new value: an explicit boolean, otherwise flip the current
182
+ // value (an unset category defaults to on).
183
+ const current = state.consent[key];
184
+ const newValue =
185
+ typeof value === 'boolean'
186
+ ? value
187
+ : current !== undefined
188
+ ? !current
189
+ : true;
162
190
 
163
- // turn MARKETING off if turning on OPT_OUT
164
- if (
165
- category === ConsentCategory.OPT_OUT &&
166
- value === true &&
167
- state.consent[ConsentCategory.MARKETING]
168
- ) {
169
- return {
170
- ...state,
171
- consent: {
172
- ...state.consent,
173
- [ConsentCategory.MARKETING]: false,
174
- [category as ConsentCategoryType]: value,
175
- },
176
- };
177
- }
191
+ // Keep the opt-out and its governed categories mutually exclusive.
192
+ const forcedOff = doNotSellSideEffects(key, newValue, {
193
+ enableDoNotSell: state.config?.enableDoNotSell,
194
+ doNotSellCategories: state.config?.doNotSellCategories,
195
+ });
178
196
 
179
- if (typeof value === 'boolean') {
180
- return {
181
- ...state,
182
- consent: {
183
- ...state.consent,
184
- [category as ConsentCategoryType]: value,
185
- },
186
- };
197
+ const consent = { ...state.consent, [key]: newValue };
198
+ for (const forced of forcedOff) {
199
+ consent[forced] = false;
187
200
  }
188
- if (state.consent[category as ConsentCategoryType] !== undefined) {
189
- return {
190
- ...state,
191
- consent: {
192
- ...state.consent,
193
- [category as ConsentCategoryType]:
194
- !state.consent[category as ConsentCategoryType],
195
- },
196
- };
197
- }
198
- return {
199
- ...state,
200
- consent: {
201
- ...state.consent,
202
- [category as ConsentCategoryType]: true,
203
- },
204
- };
201
+ return { ...state, consent };
205
202
  }
206
203
  case SHOW_DIALOG:
207
204
  return {
package/src/selectors.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  } from './constants';
6
6
  import type { Disclosure, OsanoState } from './types/store';
7
7
 
8
+ import { resolveDoNotSellCategories } from './doNotSell';
8
9
  import { get } from './utils';
9
10
 
10
11
  export const isCategoryAccepted = (
@@ -14,14 +15,36 @@ export const isCategoryAccepted = (
14
15
 
15
16
  export const isAnalyticsAlwaysAllowed = (state: OsanoState) =>
16
17
  !!state.config?.type.analyticsAlways;
18
+
19
+ export const isBrowsingFromUS = (state: OsanoState) =>
20
+ !!state.config?.browsingFromUS;
21
+
22
+ export const isDoNotSellEnabled = (state: OsanoState) =>
23
+ !!state.config?.enableDoNotSell;
24
+
25
+ // Whether to render the "Do Not Sell / Share" opt-out toggle. Delegates to the
26
+ // config's visibility rule (tenant flag AND ccpaApplies/US).
27
+ export const shouldRenderOptOut = (state: OsanoState) =>
28
+ !!state.config?.isDoNotSellVisible;
29
+
30
+ // The categories the opt-out governs (config's doNotSellCategories, defaulting
31
+ // to Marketing). ESSENTIAL and OPT_OUT are never governed.
32
+ const isGovernedByOptOut = (state: OsanoState, category: ConsentCategoryType) =>
33
+ resolveDoNotSellCategories(state.config?.doNotSellCategories).includes(
34
+ category,
35
+ );
36
+
17
37
  export const isCategoryDisabled = (
18
38
  state: OsanoState,
19
39
  category: ConsentCategoryType,
20
40
  ) =>
21
41
  category === ConsentCategory.ESSENTIAL ||
22
42
  (category === ConsentCategory.OPT_OUT && isGPCSupportEnabled(state)) ||
23
- (category === ConsentCategory.MARKETING &&
24
- (state.config?.shouldRenderOptOut || isGPCSupportEnabled(state)) &&
43
+ // A governed category is locked off while the opt-out is active. Keyed off the
44
+ // configured governed categories, so a non-governed category (e.g. Marketing
45
+ // when the tenant governs Analytics) stays enabled.
46
+ (isGovernedByOptOut(state, category) &&
47
+ (shouldRenderOptOut(state) || isGPCSupportEnabled(state)) &&
25
48
  isCategoryAccepted(state, ConsentCategory.OPT_OUT));
26
49
 
27
50
  export const isDrawerOpen = (state: OsanoState) => state.stack.length > 0;
@@ -9,6 +9,7 @@ import {
9
9
  formatConsentArray,
10
10
  makeConsentFromCategories,
11
11
  } from '../constants';
12
+ import { applyDoNotSellDefault, normalizeDoNotSellConsent } from '../doNotSell';
12
13
  import { clearStorage, getStorage, setStorage } from '../native/OsanoCmp';
13
14
  import { getConsentExpirationSeconds } from './gdprMappings';
14
15
  import type { Config } from '../types/consent-config';
@@ -220,7 +221,13 @@ export const migrateConsentStorage = async (
220
221
  let consent = JSON.parse(oldConsent) as ConsentCategoryType[];
221
222
  if (consent.length > 0) {
222
223
  consent = formatConsentArray(
223
- makeConsentFromCategories(consent),
224
+ normalizeDoNotSellConsent(
225
+ makeConsentFromCategories(consent),
226
+ {
227
+ enableDoNotSell: cmp.config.enableDoNotSell,
228
+ doNotSellCategories: cmp.config.doNotSellCategories,
229
+ },
230
+ ),
224
231
  );
225
232
  // Store with new user-specific key
226
233
  storeConsent(cmp, consent).then(() => {
@@ -247,10 +254,23 @@ export const migrateConsentStorage = async (
247
254
 
248
255
  // The consent to fall back to when a visitor has no stored consent yet. Flavor 7
249
256
  // shows analytics enabled on first load; every other flavor stays essential-only.
250
- const firstLoadConsent = (cmp: ConsentManager): ConsentCategoryType[] =>
251
- cmp.config?.analyticsDefaultsOnAtFirstLoad
252
- ? [ConsentCategory.ESSENTIAL, ConsentCategory.ANALYTICS]
253
- : [ConsentCategory.ESSENTIAL];
257
+ // The Do-Not-Sell default is then applied, but only when the opt-out would
258
+ // actually be visible (tenant flag + CCPA/US), so we never seed an OPT_OUT the
259
+ // user cannot see or control.
260
+ const firstLoadConsent = (cmp: ConsentManager): ConsentCategoryType[] => {
261
+ let categories: ConsentCategoryType[] = [ConsentCategory.ESSENTIAL];
262
+ if (cmp.config?.analyticsDefaultsOnAtFirstLoad) {
263
+ categories.push(ConsentCategory.ANALYTICS);
264
+ }
265
+ if (cmp.config?.isDoNotSellVisible) {
266
+ categories = applyDoNotSellDefault(categories, {
267
+ enableDoNotSell: cmp.config.enableDoNotSell,
268
+ enableDoNotSellDefault: cmp.config.enableDoNotSellDefault,
269
+ doNotSellCategories: cmp.config.doNotSellCategories,
270
+ });
271
+ }
272
+ return categories;
273
+ };
254
274
 
255
275
  // Clear any stale storage, then seed the first-load default. Because this only
256
276
  // runs when there is no stored consent, a returning visitor's saved opt-out is
@@ -442,7 +462,13 @@ export const recordConsent = (
442
462
  .then(() => consent ?? cmp.consent ?? [ConsentCategory.ESSENTIAL])
443
463
  .then((consentCategories) => {
444
464
  consentCategories = formatConsentArray(
445
- makeConsentFromCategories(consentCategories),
465
+ normalizeDoNotSellConsent(
466
+ makeConsentFromCategories(consentCategories),
467
+ {
468
+ enableDoNotSell: cmp.config.enableDoNotSell,
469
+ doNotSellCategories: cmp.config.doNotSellCategories,
470
+ },
471
+ ),
446
472
  );
447
473
  storeConsent(cmp, consentCategories, true);
448
474
  return consentCategories;
@@ -526,8 +552,19 @@ export const fetchConsent = async (cmp: ConsentManager) => {
526
552
  // first-load default.
527
553
  return settleEssentialOnly(cmp);
528
554
  }
529
- const remoteConsentCategories = consented.map(
530
- (category) => ConsentCategory[category as keyof typeof ConsentCategory],
555
+ const remoteConsentCategories = formatConsentArray(
556
+ normalizeDoNotSellConsent(
557
+ makeConsentFromCategories(
558
+ consented.map(
559
+ (category) =>
560
+ ConsentCategory[category as keyof typeof ConsentCategory],
561
+ ),
562
+ ),
563
+ {
564
+ enableDoNotSell: cmp.config.enableDoNotSell,
565
+ doNotSellCategories: cmp.config.doNotSellCategories,
566
+ },
567
+ ),
531
568
  );
532
569
  // Preserve the server timestamp so restoring can't re-date the record.
533
570
  await storeConsent(
@@ -550,7 +587,10 @@ export const fetchConsent = async (cmp: ConsentManager) => {
550
587
  // Remote consent is newer, unexpired, or we haven't recorded any
551
588
  // consent yet. Store it, preserving the server timestamp.
552
589
  const remoteConsentCategories = formatConsentArray(
553
- makeConsentFromCategories(consented),
590
+ normalizeDoNotSellConsent(makeConsentFromCategories(consented), {
591
+ enableDoNotSell: cmp.config.enableDoNotSell,
592
+ doNotSellCategories: cmp.config.doNotSellCategories,
593
+ }),
554
594
  );
555
595
  await storeConsent(
556
596
  cmp,
@@ -44,6 +44,12 @@ export type Config = {
44
44
  categories: ConsentCategoryType[];
45
45
  ccpaApplies: boolean;
46
46
  ccpaRelaxed: boolean;
47
+ // "Do Not Sell / Share" opt-out controls. `enableDoNotSell` gates whether the
48
+ // opt-out toggle renders; `enableDoNotSellDefault` seeds it on at first load;
49
+ // `doNotSellCategories` lists the categories the opt-out applies to.
50
+ enableDoNotSell: boolean;
51
+ enableDoNotSellDefault: boolean;
52
+ doNotSellCategories: string[];
47
53
  configId: string;
48
54
  country: string;
49
55
  crossDomain: boolean;
@@ -50,6 +50,7 @@ import {
50
50
  getStack,
51
51
  isCategoryAccepted,
52
52
  isCategoryDisabled,
53
+ shouldRenderOptOut,
53
54
  } from '../../selectors';
54
55
  import type { Disclosure } from '../../types/store';
55
56
  import DrawerHeader from './Drawer.Header';
@@ -598,7 +599,7 @@ export const ConsentCategoriesDrawer: React.FC = () => {
598
599
  return 'none';
599
600
  }, [previousStack, stack]);
600
601
  const useTcf = config?.tcfIsApplicable;
601
- const needsOptOut = config?.shouldRenderOptOut;
602
+ const needsOptOut = shouldRenderOptOut(state);
602
603
  const categories: ConsentCategoryItem[] = useMemo(() => {
603
604
  const newCategories: ConsentCategoryItem[] = (
604
605
  useTcf ? config.iabCategories : config?.categories || []