summaryrefslogtreecommitdiff
path: root/node_modules/@jet-app/app-store/tmp/src/common/product-page/product-page-variants.js
blob: 71b493525e54bc2c583979d86d66da0f280a46a6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
/**
 * Builds objects for Product Page Variants (PPV)
 *
 * PPV has two components:
 * - Custom Product Pages:          Special versions of product pages with different metadata (e.g. artwork).
 * - Product Page Treatments:       A/B testing treatments for icon, artwork, etc.
 *
 * At a high level:
 * - Multiple versions of product pages now exist (Default and Custom Pages)
 * - Each version of product page **may** have a special A/B testing treatment determined by xp_ab testing cookie.
 *
 * Example page configurations are:
 * - Default page with no treatment.
 * - Default page with treatment B
 * - Custom page A with treatment C.
 *
 * There are two signals that determine which variant to show:
 * - `meta.cppData`:            Describes which CPP to show / link to. May be explicitly requested via `ppid` param, or automagically programmed.
 * - `meta.experimentData`:     Describes which experiments are running, so client can select the correct treatment.
 *
 * Based on contents of above, we choose which asset to use for given model. As a rule, we use assets in the following order:
 * 1. Assets for CPP (if any)
 * 2. Assets for Treatment (if any)
 * 3. Default Assets
 * i.e. CPP overrides Treatments overrides Default
 *
 * For 2021, scope is limited to:
 * - iOS only
 * - CPPs don't have treatments (though we don't care at our layer)
 */
import { asString, isDefinedNonNull, isDefinedNonNullNonEmpty, isNull, isNullOrEmpty, traverse, } from "../../foundation/json-parsing/server-data";
import { unreachable } from "../../foundation/util/errors";
import { contentAttributeAsDictionary } from "../content/attributes";
import { productVariantTreatmentId } from "../../foundation/experimentation/product-page-experiments";
import { isNothing, isSome } from "@jet/environment/types/optional";
// region API
/**
 * Whether or not custom attributes data should be fetched. This is effectively a feature gate.
 * Downstream builder code should parameterize based presence of custom attributes within response, not this flag.
 */
export function shouldFetchCustomAttributes(objectGraph) {
    // Custom attributes are used on web or for PPV (iOS Only)
    return (objectGraph.client.isWeb || objectGraph.host.isiOS) && objectGraph.bag.enableProductPageVariants;
}
/**
 * Returns the app variant data for given `data` and `treatmentGroupId`
 * @param data The data for app resource
 * @param treatmentGroupId The treatment group to retrieve variant for. (For testing)
 */
export function productVariantDataForData(objectGraph, data, treatmentGroupId) {
    if (treatmentGroupId === undefined) {
        treatmentGroupId = getClientTreatmentGroupId(objectGraph);
    }
    if (isNullOrEmpty(data.id)) {
        return undefined;
    }
    const variantData = {
        adamID: data.id,
        productPageId: defaultIdentifier,
        treatmentPageIdMap: { [defaultIdentifier]: defaultIdentifier },
        experimentIdMap: {},
        experimentLocaleMap: {},
    };
    if (isNullOrEmpty(traverse(data, "meta", null))) {
        return variantData; // skip if `meta` is empty.
    }
    /**
     * Determine productPageId (CPP or default)
     */
    copyCustomProductPageData(objectGraph, variantData, data);
    /**
     * Determine treatmentPageId or `default` pageId
     *
     * # Why default, and not `variantData.productPageId` (which can be a CPP id?)
     * - At product-level, CPPs DON'T support AB Testing Treatments today.
     */
    copyExperimentPageData(objectGraph, variantData, defaultIdentifier, treatmentGroupId, data);
    if (!preprocessor.PRODUCTION_BUILD && objectGraph.client.isiOS) {
        if (objectGraph.bag.enableAdditionalLoggingForPPV) {
            objectGraph.console.log(`[PPV] productVariantDataForData: id: ${data.id} productPageId: ${variantData.productPageId} treatmentPageId[default]: ${variantData.treatmentPageIdMap[defaultIdentifier]}`);
        }
    }
    return variantData;
}
/**
 * Copy custom product page data into `ProductVariantData` for given MAPI apps resource.
 * @param objectGraph Object graph
 * @param variantData Variant data to copy CPP data to
 * @param data Apps resource data to source CPP data from
 */
function copyCustomProductPageData(objectGraph, variantData, data) {
    const cppData = traverse(data, "meta.cppData", null);
    if (isNullOrEmpty(cppData)) {
        return;
    }
    // MAPI checks the validity of the ppid provided, so if it exists, always use it.
    const customProductPageId = asString(cppData, "ppid");
    if (isDefinedNonNullNonEmpty(customProductPageId)) {
        variantData.productPageId = customProductPageId;
    }
}
/**
 * Copy treatment page data into `ProductVariantData` for a specified `pageId`.
 * @param objectGraph Object graph
 * @param variantData Variant data to copy experiment data to
 * @param pageId The page id to determine treatment for. (Treatments are unique to each page id)
 * @param data Apps resource data to source experiment data from
 */
function copyExperimentPageData(objectGraph, variantData, pageId, treatmentGroupId, data) {
    const experimentData = traverse(data, "meta.experimentData", null);
    if (isNullOrEmpty(experimentData)) {
        return;
    }
    // Evaluate for specified `pageId`, which may **not** match `variantData.productPageId`
    const pageExperimentData = traverse(experimentData, pageId, null);
    if (isNullOrEmpty(pageExperimentData)) {
        return;
    }
    const experimentId = asString(pageExperimentData, "experimentId");
    const experimentLocale = asString(pageExperimentData, "experimentLocale");
    let treatmentPageId = null;
    const trafficAllocation = traverse(pageExperimentData, "trafficAllocation", null);
    if (isDefinedNonNullNonEmpty(trafficAllocation)) {
        // Nonpersonalized endpoint. Resolve treatment from traffic allocation
        treatmentPageId = matchingTreatmentPageIdFromTrafficAllocation(objectGraph, trafficAllocation, treatmentGroupId);
    }
    else {
        // Personalized endpoint. Infer treatment from thinned variation.
        treatmentPageId = matchingTreatmentPageIdFromThinnedCustomAttributes(objectGraph, data, pageId);
    }
    if (isDefinedNonNullNonEmpty(treatmentPageId) && isDefinedNonNullNonEmpty(experimentId)) {
        variantData.treatmentPageIdMap[pageId] = treatmentPageId;
        variantData.experimentIdMap[pageId] = experimentId;
        if (isDefinedNonNullNonEmpty(experimentLocale)) {
            variantData.experimentLocaleMap[pageId] = experimentLocale;
        }
    }
}
/**
 * Selects the variant custom attribute for given key in custom attributes json.
 *
 * For a given attribute key, e.g. `customArtwork`, and a variant data specifying which product page id and treatment id,
 * it looks in the following locations in priority order:
 * - customAttributes.{productPageId}.{treatmentPageId}
 * - customAttributes.{productPageId}.default
 * - customAttributes.default.{treatmentPageId}
 * - customAttributes.default.default.
 *
 * This effectively means:
 * - Custom Product Page w/ AB Treatment.
 * - Custom Product Page w/ default treament
 * - Default Product Page w/ AB Treatment.
 * - Default Product Page w/ default treament
 *
 * @param objectGraph Dependency cocktail
 * @param customAttributes A `customAttributes` JSON attribute dict. This is a field in a specific platform attribute, e.g. `platformAttributes.ios.customAttributes`
 * @param productVariantData Specifies product page id and treatment id to use.
 * @param attributeKey The attribute to find
 * @param allowNondefaultTreatmentInNondefaultPage Whether or not to use nondefault treatment can be fetched for nondefault page. Used to limit AB Testing effects on CPPs
 * @returns `JSONValue` containing attribute for given key, or `null`
 */
export function variantAttributeForKey(objectGraph, customAttributes, productVariantData, attributeKey, allowNondefaultTreatmentInNondefaultPage) {
    if (isNullOrEmpty(customAttributes)) {
        return null;
    }
    // Contains a set of search paths for custom attributes by priority
    let searchPaths;
    if (productVariantData.productPageId !== defaultIdentifier) {
        /**
         * `productPageId` is nondefault (CPP). Priorities are:
         * - CPP Page Data
         * - AB Treatment on Default Page (may be default treatment) IF attribute allows AB testing on CPP (allowNondefaultTreatmentInNondefaultPage).
         * - Default Treatment on Default Page
         *
         * Note that treatment for `productPageId` is skipped today.
         */
        const treatmentForDefault = productVariantData.treatmentPageIdMap[defaultIdentifier];
        if (allowNondefaultTreatmentInNondefaultPage) {
            // Allow default.treatment after cpp search path.
            searchPaths = [
                `${productVariantData.productPageId}.${defaultIdentifier}.${attributeKey}`,
                `${defaultIdentifier}.${treatmentForDefault}.${attributeKey}`,
                `${defaultIdentifier}.${defaultIdentifier}.${attributeKey}`,
            ];
        }
        else {
            // Skip default.treatment after cpp search path.
            searchPaths = [
                `${productVariantData.productPageId}.${defaultIdentifier}.${attributeKey}`,
                `${defaultIdentifier}.${defaultIdentifier}.${attributeKey}`,
            ];
        }
    }
    else {
        /**
         * `productPageId` is default. Priorities are:
         * - AB Treatment on Default Page (may be default)
         * - Default Treatment on Default Page
         */
        const treatmentForDefault = productVariantData.treatmentPageIdMap[defaultIdentifier];
        searchPaths = [
            `${defaultIdentifier}.${treatmentForDefault}.${attributeKey}`,
            `${defaultIdentifier}.${defaultIdentifier}.${attributeKey}`,
        ];
    }
    for (const path of searchPaths) {
        const variantAttribute = traverse(customAttributes, path);
        if (isDefinedNonNull(variantAttribute)) {
            // `variantAttribute` can be an "empty" override, e.g. no screenshots or no video.
            return variantAttribute;
        }
    }
    return null;
}
/**
 * Extract the product variant ID (a.k.a. `ppid`, a.k.a. Custom Product Page Identiier) from `ProductVariantData`
 *
 * # Product Variant ID v.s. `productPageId`
 * Product Variant ID is the canonical value for `ppid` query param, used to **request** custom variants for apps resource.
 * `productPageId` may be equal to product page id, except for when `productPageId` is default which indicates a lack of product variant id.
 *
 * @param productVariantData The data to get data for.
 * @returns `string` or `null` for the product variant id
 * @seealso customProductPageIdForData
 */
export function productVariantIDForVariantData(productVariantData) {
    // Default is not a valid variant ID.
    if (isNothing(productVariantData) || productVariantData.productPageId === defaultIdentifier) {
        return null;
    }
    return productVariantData.productPageId;
}
/**
 * Convenience function to retrieve the custom product page id for given `Data` directly
 * @param objectGraph Object graph
 * @param data The data to get custom product page id for.
 * @seealso productVariantIDForVariantData
 */
export function customProductPageIdForData(objectGraph, data) {
    const variantData = productVariantDataForData(objectGraph, data);
    return productVariantIDForVariantData(variantData);
}
/**
 * Extract all available product variant IDs (a.k.a. `ppId` or `cppId`) from the given app data.
 *
 * @param objectGraph Dependencies all the way down.
 * @param data The app data from which to get the available product variant IDs.
 * @returns `string[]` for the available product variant ids, or null if there are none.
 */
export function allProductVariantIdsForData(objectGraph, data) {
    const customAttributes = contentAttributeAsDictionary(objectGraph, data, "customAttributes");
    if (customAttributes === null || isNullOrEmpty(customAttributes)) {
        return null;
    }
    const keys = Object.keys(customAttributes);
    const allProductVariantIds = keys.filter((key) => key !== defaultIdentifier);
    return allProductVariantIds;
}
/**
 * Determines the treatment of product page to use for given data from a trafficAllocation json.
 * This is used on unpersonalized endpoints, where client must resolve traffic allocation manually.
 * @param trafficAllocation The traffic allocation for a specific page.
 * @param treatmentGroupId The treatment group to retrieve treatment page id for, e.g. "5". This is usually provided by a cookie.
 */
function matchingTreatmentPageIdFromTrafficAllocation(objectGraph, trafficAllocation, treatmentGroupId) {
    if (treatmentGroupId === undefined || isNullOrEmpty(treatmentGroupId)) {
        return defaultIdentifier; // Default if no AB bucket.
    }
    /**
     * Iterate over traffic allocation map that looks like:
     * "85b6a82c-43e6-11eb-b378-0242ac130002": ["1", "19", "51", ...]
     * "43de034f-43e6-11eb-b378-0242ac130002": ["2", "34", "55", ...]
     */
    for (const [treatmentPageId, includedTreatmentGroups] of Object.entries(trafficAllocation)) {
        if (isDefinedNonNullNonEmpty(includedTreatmentGroups) &&
            includedTreatmentGroups.indexOf(treatmentGroupId) !== -1) {
            return treatmentPageId;
        }
    }
    return defaultIdentifier;
}
/**
 * Determines the treatment of product page to use for given data based on thinned custom attributes.
 * This is used on personalized endpoints, where server can thin the response.
 * @param pageExperimentData The experiment data specific a page variant
 * @param treatmentGroupId The treatment group to retrieve treatment page id for, e.g. "5". This is usually provided by a cookie.
 */
function matchingTreatmentPageIdFromThinnedCustomAttributes(objectGraph, data, pageId) {
    // Traverse keys on `customAttributes` to find treatment page id.
    const customAttributesForPage = contentAttributeAsDictionary(objectGraph, data, `customAttributes.${pageId}`);
    if (isNullOrEmpty(customAttributesForPage)) {
        return defaultIdentifier;
    }
    const treatmentPageId = Object.keys(customAttributesForPage)[0];
    if (isNullOrEmpty(treatmentPageId)) {
        return defaultIdentifier;
    }
    return treatmentPageId;
}
// endregion
// region Treatment Group ID
/**
 * Retrieve the treatment group id, which is a identifier for a traffic bucket the client belongs in.
 */
function getClientTreatmentGroupId(objectGraph) {
    /**
     * Use xp_ab cookie value
     */
    const treatmentId = productVariantTreatmentId(objectGraph);
    if (!preprocessor.PRODUCTION_BUILD) {
        if (lastSeenClientTreatmentId !== treatmentId) {
            objectGraph.console.log("[PPV] Treatment Group ID", treatmentId);
            lastSeenClientTreatmentId = treatmentId;
        }
    }
    return treatmentId;
}
// endregion
// region API - Metrics
/**
 * Builds the metrics dictionary to add to page fields for a software page w/ product variant features (CPP, AB Testing)
 * @param productVariantData The variant data of **page** to build page fields for.
 */
export function pageFieldsForPageInfoProductVariantData(productVariantData) {
    const fields = {};
    /**
     * Custom Product Page Fields
     */
    if (productVariantDataHasVariant(productVariantData, "customProductPage")) {
        fields["pageCustomId"] = productVariantData.productPageId;
    }
    /**
     * AB Testing Page Fields.
     * Always from "default" (instead of productVariantData.productPageId).
     * CPPs don't support AB testing.
     */
    const treatmentPageId = productVariantData.treatmentPageIdMap[defaultIdentifier];
    const experimentId = productVariantData.experimentIdMap[defaultIdentifier];
    const experimentLocale = productVariantData.experimentLocaleMap[defaultIdentifier];
    if (productVariantDataHasVariant(productVariantData, "abExperiment")) {
        fields["pageVariantId"] = treatmentPageId;
        fields["pageExperimentId"] = experimentId;
        fields["pageExperimentLocale"] = experimentLocale;
    }
    return fields;
}
/**
 * Builds content field included in impressions and locations metrics for a specific product variant.
 * @param productVariantData The variant data of impressionable data to build impression fields for.
 */
export function contentFieldsForProductVariantData(productVariantData) {
    const fields = {};
    /**
     * Custom Product Page Content Fields.
     * Used to describe the Custom Product Page data being presented in a lockup,
     * and the Custom Product Page the lockup points to.
     */
    if (productVariantDataHasVariant(productVariantData, "customProductPage")) {
        fields["pageCustomId"] = productVariantData.productPageId;
    }
    /**
     * AB Testing Content Fields.
     * Always from "default" attributes (instead of productVariantData.productPageId).
     * CPPs don't support AB testing.
     */
    const treatmentPageId = productVariantData.treatmentPageIdMap[defaultIdentifier];
    const experimentId = productVariantData.experimentIdMap[defaultIdentifier];
    const experimentLocale = productVariantData.experimentLocaleMap[defaultIdentifier];
    if (productVariantDataHasVariant(productVariantData, "abExperiment")) {
        fields["variantId"] = treatmentPageId;
        fields["experimentId"] = experimentId;
        fields["experimentLocale"] = experimentLocale;
    }
    return fields;
}
/**
 * Update `buyParams` with PPV metrics fields from the page and content's product variant data
 *
 * # Why are there two product variant data?
 * Consider the following scenario:
 * - On Page for App A that has AB tests
 * - The page features App B, which also has AB Tests.
 * On the given page, there are two AB tests occuring in the page. In the `PurchaseConfiguration` for App A and App B, there are two product variant captured:
 * - Page Product Variant Data for App A (Since we're on App A's Product Page)
 * - Item Product Variant Data for App A or B, for App A and B respectively.
 *
 * @param buyParams The buyParam to add product page variant metrics to
 * @param adamID The adam id of item being purchased.
 * @param pageProductVariantData The product variant data for **PAGE** the purchase is occuring on. May be the same as `itemProductVariantData`. May be undefined.
 * @param itemProductVariantData The product variant data for **ITEM** being purchased. May be undefined.
 */
export function addProductPageVariantMetricsToBuyParams(buyParams, adamID, pageProductVariantData, itemProductVariantData) {
    /**
     * Only add certain page / fields of fields if data is present, adam id matches, and information isn't redundant.
     */
    const addPageFields = isDefinedNonNull(pageProductVariantData) && pageProductVariantData.adamID === adamID;
    const addContentFields = isDefinedNonNull(itemProductVariantData) && itemProductVariantData.adamID === adamID && !addPageFields; // if we're adding page fields, don't add item variant fields
    // Product variant data of **PAGE** to buy params.
    if (addPageFields && isDefinedNonNull(pageProductVariantData)) {
        const productVariantPageFields = pageFieldsForPageInfoProductVariantData(pageProductVariantData);
        for (const key of Object.keys(productVariantPageFields)) {
            const value = asString(productVariantPageFields, key);
            if (isSome(value)) {
                buyParams.set(key, value);
            }
        }
    }
    // Product variant data of **ITEM** within a page to buy params.
    if (addContentFields && isDefinedNonNull(itemProductVariantData)) {
        const productVariantFields = contentFieldsForProductVariantData(itemProductVariantData);
        for (const key of Object.keys(productVariantFields)) {
            const value = asString(productVariantFields, key);
            if (isSome(value)) {
                buyParams.set(key, value);
            }
        }
    }
}
/**
 * Whether or not product variant data has an custom product page / ab testing variants for it.
 * While we always create a `ProductVariantData` to parse the attributes, this is used to determine if the data actually has
 * an developer-supplied variant for custom product pages or ab testing experiment from a content perspective.
 *
 * @param productVariantData The product variant data to check.
 * @param variantType The type of variation to query if product variant actually exist for.
 * @returns Whether or not specified variant exists for content.
 */
export function productVariantDataHasVariant(productVariantData, variantType) {
    if (isNull(productVariantData)) {
        return false;
    }
    switch (variantType) {
        case "customProductPage":
            return (isDefinedNonNullNonEmpty(productVariantData.productPageId) &&
                productVariantData.productPageId !== defaultIdentifier);
        case "abExperiment":
            return isDefinedNonNullNonEmpty(productVariantData.experimentIdMap);
        default:
            unreachable(variantType);
    }
}
/**
 * Convenience API for `productVariantDataHasVariant`
 * @param objectGraph Dependency soup
 * @param data The apps resource to determine whether a variant is present for
 * @param variantType The type of variation to query if product variant actually exist for.
 * @returns Whether or not specified variant exists for content.

 */
export function appDataHasVariant(objectGraph, data, variantType) {
    const variantData = productVariantDataForData(objectGraph, data);
    if (isNothing(variantData)) {
        return null;
    }
    return productVariantDataHasVariant(variantData, variantType);
}
// endregion
// region Requests
/**
 * Add PPV specific query parameters necessary to fetch the correct variant in subsequent requests for unhydrated items.
 * Specifically this adds:
 *      &ppid[apps:<appid>]=<cppid>
 * for every `Data` resource that had a non-default variant identifier.
 *
 * This should be called for every `Request` initializer that passes a `Data[]`, found via "new .*Request\(.*s\)" regex.
 *
 * ## Why does this not live in `Request` initializer?
 * It would be ideal to handle this in the initializer for `Request` that accepts `Data[]` (and extracts `ids`), but
 * `Request` lives in `foundation` and (tries) to not have feature specific logic, just URL abstractions.
 * Adding CPP ID determining logic in `Request` constructor violates dependency rules.
 * This can be solved by another layer of abstraction (see `BaseRequest`), but most requests use `Requests` directly today.
 *
 * @param request The request to modify.
 * @param items Items to add PPV query params for.
 */
export function addVariantParametersToRequestForItems(objectGraph, request, items) {
    /**
     * For catalog requets for `Data[]`, we also need to specify custom variant if it was initially vended with one:
     * so the same custom variant is fetched
     */
    for (const item of items) {
        const cppId = customProductPageIdForData(objectGraph, item);
        if (isDefinedNonNull(cppId)) {
            request.addingQuery(`ppid[apps:${item.id}]`, `${cppId}`);
        }
    }
}
// endregion
// region Constants
/**
 * A constant to designate a `default` productPageId or treatmentPageId identifier.
 */
const defaultIdentifier = "default";
/**
 * Last seen client treatment group id for logging.
 */
let lastSeenClientTreatmentId;
// endregion Constants
//# sourceMappingURL=product-page-variants.js.map