/** * 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:]= * 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