import * as models from "../../api/models"; import * as serverData from "../../foundation/json-parsing/server-data"; import { Request, defaultAdditionalPlatformsForClient } from "../../foundation/media/data-fetching"; import * as mediaDataStructure from "../../foundation/media/data-structure"; import { Host, Parameters, Path, Protocol, ShelfRefreshType } from "../../foundation/network/url-constants"; import * as urls from "../../foundation/network/urls"; import * as routingComponents from "../../foundation/routing/routing-components"; import * as color from "../../foundation/util/color-util"; import * as validation from "@jet/environment/json/validation"; import * as todayHorizontalCardUtil from "../../common/today/today-horizontal-card-util"; import * as client from "../../foundation/wrappers/client"; import * as content from "../content/content"; import * as lockups from "../lockups/lockups"; import { addClickEventToSeeAllAction } from "../metrics/helpers/clicks"; import * as metricsHelpersModels from "../metrics/helpers/models"; import { addMetricsEventsToPageWithInformation, addPageChangeFieldsToShelfWithInformation, } from "../metrics/helpers/page"; import { ProductPageShelfMetrics } from "./product-page-shelf-metrics"; import * as moreByDeveloperShelf from "./shelves/more-by-developer-shelf"; import * as similarItemsShelf from "./shelves/similar-items-shelf"; import * as smallStoryCardShelf from "./shelves/small-story-card-shelf"; // MARK: - Constants /// Write review action query parameter value export const writeReviewAction = "write-review"; /// Reviews action query parameter value export const reviewsAction = "reviews"; /// Key for request / response for App Promotion (App Event or Winback offer) data. export const appPromotionRequirementKey = "appPromotionRequirementKey"; /// The color to use for shelves that have gray backgrounds. export const grayShelfBackgroundColor = color.dynamicWith(color.fromHex("F0F0F8"), color.fromHex("303031")); /** * The `ProductPageOnDemandShelfType` enum is used to specify the type of shelf that is being rendered via * secondary lookup */ export var ProductPageOnDemandShelfType; (function (ProductPageOnDemandShelfType) { ProductPageOnDemandShelfType["MoreByDeveloper"] = "moreByDeveloper"; ProductPageOnDemandShelfType["SimilarItems"] = "similarItems"; ProductPageOnDemandShelfType["SmallStory"] = "smallStory"; })(ProductPageOnDemandShelfType || (ProductPageOnDemandShelfType = {})); /** * The `ProductPageShelfToken` type is responsible for plumbing through all * the data needed to render an incomplete shelf (a shelf that requires a * lookup) on the product page. */ export class ProductPageShelfToken { // endregion constructor(productId, remainingItems, title, shouldInferSeeAllFromFetchedItems, capabilities, contentType, offerStyle, spotlightInAppProductIdentifier, refreshUrl, recoMetricsData, supportsArcade, onDemandShelfType) { //* ************************* //* Secondary Fetch Properties //* ************************* // Whether this is the first render of the shelf. this.isFirstRender = true; //* ************************* //* Bundle Properties //* ************************* this.isBundleShelf = false; //* ************************* //* Placeholder Properties //* ************************* // Whether this shelf is currently showing placeholder cells // if this is the case we generally use this flag to tell the shelf not to // merge after the additional fetch this.showingPlaceholders = false; this.productId = productId; this.onDemandShelfType = onDemandShelfType; this.remainingItems = remainingItems; this.title = title; this.shouldInferSeeAllFromFetchesItems = shouldInferSeeAllFromFetchedItems; this.capabilities = capabilities; this.contentType = contentType; this.offerStyle = offerStyle; this.spotlightInAppProductIdentifier = spotlightInAppProductIdentifier; this.refreshUrl = refreshUrl; this.recoMetricsData = recoMetricsData; this.supportsArcade = supportsArcade; } } /** * Returns the shelf content URL for a given array of adamIds. * @param token * @param sourcePageInformation * @returns A string containing a URL. */ export function shelfContentUrl(objectGraph, token, shelfMetrics, destinationPageInformation) { if (serverData.isNullOrEmpty(token.remainingItems)) { return null; } return (`${Protocol.internal}:/${Path.product}/${Path.shelf}/` + encodedShelfToken(token, shelfMetrics, destinationPageInformation)); } export function encodedShelfToken(token, shelfMetrics, destinationPageInformation) { token.sourceSequenceId = shelfMetrics.getSequenceId(); token.sourcePageInformation = shelfMetrics.metricsPageInformation; token.sourceLocationTracker = shelfMetrics.locationTracker; token.destinationPageInformation = destinationPageInformation; return encodeURIComponent(JSON.stringify(token)); } export function lockupsFromDataContainer(objectGraph, dataContainer, shelfMetrics, artworkUseCase, shelfContentType, offerStyle, filter, recoMetricsData, shouldShowOnUnsupportedPlatform) { return lockupsFromData(objectGraph, dataContainer.data, shelfMetrics, artworkUseCase, shelfContentType, offerStyle, filter, recoMetricsData, shouldShowOnUnsupportedPlatform); } export function lockupsFromData(objectGraph, data, shelfMetrics, artworkUseCase, shelfContentType, offerStyle, filter, recoMetricsData, shouldShowOnUnsupportedPlatform) { if (serverData.isNullOrEmpty(data)) { return null; } const remainingItems = []; const baseLockupOptions = { metricsOptions: { pageInformation: shelfMetrics.metricsPageInformation, locationTracker: shelfMetrics.locationTracker, recoMetricsData: recoMetricsData, }, artworkUseCase: artworkUseCase, canDisplayArcadeOfferButton: content.shelfContentTypeCanDisplayArcadeOfferButtons(objectGraph, shelfContentType), }; const bundleLockupOptions = { shouldShowSupportedPlatformLabel: shouldShowOnUnsupportedPlatform !== null && shouldShowOnUnsupportedPlatform !== void 0 ? shouldShowOnUnsupportedPlatform : false }; const styleLockupOptions = serverData.isNull(offerStyle) ? {} : { offerStyle: offerStyle }; const lockupListOptions = { shouldShowOnUnsupportedPlatform: shouldShowOnUnsupportedPlatform !== null && shouldShowOnUnsupportedPlatform !== void 0 ? shouldShowOnUnsupportedPlatform : false, lockupOptions: { ...baseLockupOptions, ...styleLockupOptions, ...bundleLockupOptions }, shouldConsiderDataPastLastAvailable: true, contentUnavailable: (_index, dataItem) => { remainingItems.push(dataItem); return false; }, filter: filter, }; /// Maximum number of items allowed in shelves on watchOS. const watchItemLimit = 3; const items = lockups.lockupsFromData(objectGraph, data, lockupListOptions); if (objectGraph.client.isWatch) { const trimmedItems = items.slice(0, watchItemLimit); const trimmedRemainingItems = trimmedItems.length < watchItemLimit ? remainingItems.slice(0, watchItemLimit - trimmedItems.length) : []; return { items: trimmedItems, remainingItems: trimmedRemainingItems, }; } else { return { items, remainingItems, }; } } /** * Moves the item with the specified id to the front of the lockup items. * @param id The id (adamId or iAP product identifier) for the lockup to move to the front. * @param items The list of items in which this lockup may appear. */ export function moveLockupToFront(objectGraph, id, items) { if (!items) { return; } let oldIndex = -1; let spotlightLockup = null; items.forEach((item, index) => { const lockup = item; const iapLockup = lockup; const lockupMatchesSpotlightAdamId = lockup && lockup.adamId === id; const inAppPurchaseMatchesSpotlightIdentifier = iapLockup && iapLockup.productIdentifier === id; if (lockupMatchesSpotlightAdamId || inAppPurchaseMatchesSpotlightIdentifier) { oldIndex = index; spotlightLockup = lockup; // Set the theme if an iAP if (iapLockup) { iapLockup.theme = "spotlight"; iapLockup.offerDisplayProperties = iapLockup.offerDisplayProperties.newOfferDisplayPropertiesChangingAppearance(false, "colored", "ad", { type: "blue" }); } } }); if (oldIndex !== -1) { items.splice(oldIndex, 1); items.splice(0, 0, spotlightLockup); } } const supportedCardMediaKinds = ["brandedSingleApp", "grid", "artwork", "video"]; /** * Determines whether or not to include the today card on a shelf on the product page. * * @param card The today card in question. * @return `true` if-and-only-if the card should be included on the product page's shelf. */ export function includeTodayCardOnProductPage(card) { if (supportedCardMediaKinds.indexOf(card.media.kind) === -1) { return false; } const flowAction = card.clickAction; if (!flowAction) { return true; } const urlString = flowAction.pageUrl; if (!urlString) { return true; } // We drop cards that link back to the same product page. const url = new urls.URL(urlString); const productPageRoute = allPageRoutes(); for (const ruleDefinition of productPageRoute) { const productPageRule = new routingComponents.UrlRule(ruleDefinition); if (productPageRule.matches(url)) { return false; } } return true; } export function allPageRoutes() { return [ { protocol: Protocol.https, path: `/{countryCode}/${Path.product}/{appName}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.isPPT}?`, `${Parameters.appEventId}?-caseInsensitive`, `${Parameters.offerItemId}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, { protocol: Protocol.https, path: `/{countryCode}/${Path.productBundle}/{appName}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, ], }, { protocol: Protocol.https, path: `/{countryCode}/${Path.product}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.appEventId}?-caseInsensitive`, `${Parameters.offerItemId}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, { protocol: Protocol.https, path: `/{countryCode}/${Path.productBundle}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, ], }, { protocol: Protocol.https, path: `/${Path.product}/{appName}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.appEventId}?-caseInsensitive`, `${Parameters.offerItemId}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, { protocol: Protocol.https, path: `/${Path.productBundle}/{appName}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, ], }, { protocol: Protocol.https, path: `/${Path.product}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.appEventId}?-caseInsensitive`, `${Parameters.offerItemId}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, { protocol: Protocol.https, path: `/${Path.product}/{id}`, query: [ `${Parameters.v0}?`, `${Parameters.metrics}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, { protocol: Protocol.https, path: `/${Path.productBundle}/{id}`, query: [ `${Parameters.action}?`, `${Parameters.offerName}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, ], }, { protocol: Protocol.https, query: [ Parameters.bundleIdentifier, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, { protocol: Protocol.https, query: [ Parameters.action, Parameters.ids, `${Parameters.isPurchasesApp}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, ], }, { protocol: Protocol.https, path: `WebObjects/MZStorePlatform.woa/ra/{apiVersion}/{realm}/catalog/{countryCode}/apps/{id}`, query: [`${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`], }, { protocol: Protocol.https, path: `WebObjects/MZStorePlatform.woa/ra/{apiVersion}/{realm}/catalog/{countryCode}/app-bundles/{id}`, query: [], }, { protocol: Protocol.https, path: `{apiVersion}/catalog/{countryCode}/apps/{id}`, query: [ `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.offerItemId}?`, ], }, { protocol: Protocol.https, path: `{apiVersion}/catalog/{countryCode}/app-bundles/{id}`, query: [`${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`], }, { protocol: Protocol.https, hostName: `${Host.product}`, path: `/${Path.siri}/{id}`, query: [`${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`], }, { protocol: Protocol.https, path: `/${Path.store}/${Path.viewSoftware}`, query: [ Parameters.id, `${Parameters.v0}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, ], }, { protocol: Protocol.internal, path: `/${Path.product}/{id}`, query: [ Parameters.invalidateWidgetsOnFailure, `${Parameters.metrics}?`, `${Parameters.offerItemId}?`, `${Parameters.appEventId}?`, `${Parameters.isPreloading}?`, `${Parameters.isViewOnly}?`, `${Parameters.includeUnlistedApps}?`, `${Parameters.webBrowser}?`, ], }, ]; } export function generateShelfRequest(objectGraph, url, parameters) { const tokenJson = parameters["token"]; const token = JSON.parse(tokenJson); // Determine the resource type appropriate for our shelf. let resourceType; if (token.isBundleShelf) { resourceType = "app-bundles"; } else { switch (token.contentType) { case "smallStoryCard": case "todayBrick": case "miniTodayCard": resourceType = "editorial-items"; break; case "inAppPurchaseLockup": resourceType = "in-apps"; break; default: resourceType = "apps"; } } const attributes = ["editorialArtwork", "editorialVideo", "minimumOSVersion"]; if (objectGraph.appleSilicon.isSupportEnabled) { attributes.push("macRequiredCapabilities"); } if (objectGraph.client.isMac) { attributes.push("hasMacIPAPackage"); } if (objectGraph.client.isVision) { attributes.push("compatibilityControllerRequirement"); } if (objectGraph.bag.enableUpdatedAgeRatings) { attributes.push("ageRating"); } if (content.shouldUsePrerenderedIconArtwork(objectGraph)) { attributes.push("iconArtwork"); } return new Request(objectGraph) .withIdsOfType(token.remainingItems.map((item) => item.id), resourceType) .includingAdditionalPlatforms(defaultAdditionalPlatformsForClient(objectGraph)) .includingAttributes(resourceType === "in-apps" ? [] : attributes); } /** * Render a shelf for the product page. * @param objectGraph The object graph. * @param data The Data received for the shelf. * @param parameters The Parameters for the shelf. * @param adResponse A response for the provided ad fetch, if any. * @param addPageChangeMetrics Whether to attach pageChange metrics to the created shelf. * @returns */ export async function renderShelf(objectGraph, data, parameters, adResponse, addPageChangeMetrics = false) { const tokenJson = parameters["token"]; // Parse the shelf token. // The `iAdInfo` object is constructed manually so that it's a proper `IAdSearchInformation` with callable functions. const token = JSON.parse(tokenJson, (key, value) => { if (typeof value === "object" && key === "iAdInfo" && serverData.isDefinedNonNullNonEmpty(value)) { const iAdInfo = metricsHelpersModels.IAdSearchInformation.from(objectGraph, value); return iAdInfo; } return value; }); token.isFirstRender = false; // Check if the data has reco metrics data. Prefer this over what's in the token, as it's more // relevant to what we're building. const recoMetricsData = mediaDataStructure.metricsFromMediaApiObject(data); token.recoMetricsData = recoMetricsData !== null && recoMetricsData !== void 0 ? recoMetricsData : token.recoMetricsData; if (parameters[Parameters.shelfRefreshType] === ShelfRefreshType.productPageSimilarItems) { ProductPageShelfMetrics.resetLocationTrackerForSimilarItemsDuringDownloadShelf(objectGraph, token); } const shelf = shelfFromDataContainer(objectGraph, data, token, adResponse); // TODO: Media API: Product Page Incomplete Shelf Metrics // shelf.networkTimingMetrics = response[metricsHelpers.timingValues]; if (addPageChangeMetrics) { addPageChangeFieldsToShelfWithInformation(objectGraph, shelf, token.sourcePageInformation); } // Ensure the refreshUrl is maintained on the completed shelf. shelf.refreshUrl = token.refreshUrl; if (parameters[Parameters.shelfRefreshType] === ShelfRefreshType.productPageSimilarItems) { ProductPageShelfMetrics.addImpressionsFieldsToSimilarItemsDuringDownloadShelf(objectGraph, shelf, token); } // Configure 'See All' using fetched items if (token.shouldInferSeeAllFromFetchesItems) { const seeAllAction = new models.FlowAction("page"); seeAllAction.title = objectGraph.loc.string("ACTION_SEE_ALL"); const seeAllShelf = new models.Shelf(shelf.contentType); seeAllShelf.items = lockups.shallowCopyLockupsOverridingProperties(objectGraph, shelf.items, "infer", true); const seeAllPage = new models.GenericPage([seeAllShelf]); addMetricsEventsToPageWithInformation(objectGraph, seeAllPage, token.destinationPageInformation); seeAllPage.title = token.title; seeAllAction.pageData = seeAllPage; addClickEventToSeeAllAction(objectGraph, seeAllAction, null, { pageInformation: token.sourcePageInformation, locationTracker: token.sourceLocationTracker, }); shelf.seeAllAction = seeAllAction; } if (serverData.isNullOrEmpty(shelf.items) && !token.hasExistingContent) { shelf.isHidden = true; } return shelf; } /** * Configures a shelf from a lookup response. * * This function is to be used by 'incomplete' shelves, i.e. shelves that have no * items but which require a fetch using their URL. * * @param dataContainer Data container array with app data. * @param token Token containing page context. * @param adResponse A response for the provided ad fetch, if any. * @return The fully-configured shelf. */ function shelfFromDataContainer(objectGraph, dataContainer, token, adResponse) { return validation.context("shelfFromLookupResponse", () => { switch (token.onDemandShelfType) { case ProductPageOnDemandShelfType.SimilarItems: return similarItemsShelf.createSecondaryShelf(objectGraph, dataContainer.data, token, adResponse); case ProductPageOnDemandShelfType.MoreByDeveloper: return moreByDeveloperShelf.createSecondaryShelf(objectGraph, dataContainer.data, token); case ProductPageOnDemandShelfType.SmallStory: return smallStoryCardShelf.createSecondaryShelf(objectGraph, dataContainer.data, token); default: break; } const shelf = new models.Shelf(token.contentType); // Check if the dataContainer has reco metrics data. Prefer this over what's in the token, as it's more // relevant to what we're building. const recoMetricsData = mediaDataStructure.metricsFromMediaApiObject(dataContainer); const metricsOptions = { pageInformation: token.sourcePageInformation, locationTracker: token.sourceLocationTracker, excludeAttribution: true, recoMetricsData: recoMetricsData !== null && recoMetricsData !== void 0 ? recoMetricsData : token.recoMetricsData, }; let shelfModels; switch (token.contentType) { case "todayBrick": // TODO: Media Api: TODO: Product page inline editorial item shelf const inlineCards = todayHorizontalCardUtil.featuredInTodayCardsFromData(objectGraph, dataContainer.data, metricsOptions.pageInformation, metricsOptions.locationTracker, includeTodayCardOnProductPage); if (serverData.isDefinedNonNull(inlineCards)) { shelfModels = [inlineCards]; } break; default: const lockupOptions = { metricsOptions: metricsOptions, offerStyle: token.offerStyle, artworkUseCase: content.artworkUseCaseFromShelfStyle(objectGraph, token.contentType), canDisplayArcadeOfferButton: content.shelfContentTypeCanDisplayArcadeOfferButtons(objectGraph, token.contentType), }; if (token.contentType === "inAppPurchaseLockup") { lockupOptions.skipDefaultClickAction = true; } const options = { lockupOptions: lockupOptions }; if (token.isBundleShelf) { options.filter = 0 /* filtering.Filter.None */; } const lockupModels = lockups.lockupsFromDataContainer(objectGraph, dataContainer, options); const sortableClients = { [client.watchIdentifier]: "round", [client.messagesIdentifier]: "pill" }; const sortableStyle = sortableClients[objectGraph.host.clientIdentifier]; if (sortableStyle) { lockupModels.sort((a, b) => { const aIsSortableStyle = a.icon.style === sortableStyle; const bIsSortableStyle = b.icon.style === sortableStyle; if (aIsSortableStyle && bIsSortableStyle) { return 0; } else if (aIsSortableStyle && !bIsSortableStyle) { return -1; } else { return 1; } }); } const spotlightId = token.spotlightInAppProductIdentifier; if (spotlightId) { moveLockupToFront(objectGraph, spotlightId, lockupModels); } shelfModels = lockupModels; break; } shelf.items = shelfModels; shelf.mergeWhenFetched = true; return shelf; }); } /** * Returns the *unsupported* set of media kinds for small story card shelf on given platform. * @param platform Platform to find unsupported media kinds for. */ export function filteredMediaCardKindsForSmallStoryCardOnPlatform(platform) { const filteredMediaKinds = new Set(["appIcon"]); // Policy: No app icon. if (platform === "macOS") { filteredMediaKinds.add("brandedSingleApp"); filteredMediaKinds.add("list"); filteredMediaKinds.add("inAppPurchase"); filteredMediaKinds.add("river"); } return filteredMediaKinds; } //# sourceMappingURL=product-page-common.js.map