import { createSelector } from 'reselect'; import memoize from 'lodash.memoize'; import { stringifyFrequency } from './api'; import platform from '../platform'; import { mapFrequencyToSellingPlan, safeProductId } from './utils'; import { Incentive, OfferElement, ProductFrequencyConfig, State } from './types/reducer'; import { money, percentage } from '../shopify/utils'; import { INCENTIVE_STANDARD_TYPES } from './constants'; memoize.Cache = Map; type BaseProduct = { id: string; components?: string[]; }; function arraysEqual(a: T[], b: T[]) { if (a === b) return true; if (a === null || b === null) return false; if (a.length !== b.length) return false; // If you don't care about the order of the elements inside // the array, you should sort both arrays here. // Please note that calling sort on an array will modify that array. // you might want to clone your array first. for (let i = 0; i < a.length; ++i) { if (a[i] !== b[i]) return false; } return true; } function resolveFrequency(sellingPlans: string[], frequenciesEveryPeriod: string[], frequency) { const ogFrequency = stringifyFrequency(frequency); if (!platform.shopify_selling_plans) return ogFrequency; return mapFrequencyToSellingPlan(sellingPlans, frequenciesEveryPeriod, ogFrequency); } export const isSameProduct = (a: T, b: S) => { if ((a as BaseProduct) === b) return true; if (typeof a === 'object' && typeof b === 'object' && a && b) { if (a.id === b.id) { if (!(Array.isArray(a.components) && Array.isArray(b.components))) { return true; } if (arraysEqual((a.components || []).sort(), (b.components || []).sort())) { return true; } } } return false; }; /** * Returns a list of opted in products id from the state * @param {object} state */ export const optedinSelector = (state: State) => state.optedin || []; const optedoutSelector = (state: State) => state.optedout || []; export const autoshipSelector = (state: State) => state.autoshipByDefault || {}; const defaultFrequenciesSelector = (state: State) => state.defaultFrequencies || {}; const prepaidSellingPlansSelector = (state: State) => state?.config?.prepaidSellingPlans || []; const prepaidShipmentsSelectedSelector = (state: State) => state?.prepaidShipmentsSelected || {}; /** * Creates a function with state arguments that return the true when * productId is in the optedin array or not in optedout or autoship by default */ export const makeOptedinSelector = memoize( (product: BaseProduct) => createSelector(optedinSelector, optedoutSelector, autoshipSelector, (optedin, optedout, autoshipByDefault) => { const entry = optedin.find(b => isSameProduct(product, b)); if (entry) { return entry; } if (optedout.find(b => isSameProduct(product, b))) { return false; } if (product && autoshipByDefault[product.id]) { return { id: product.id }; } return false; }), product => JSON.stringify(product) ); /** * Creates a function with state arguments that return the true when * productId is in the optedin array */ export const makeSubscribedSelector = memoize( (product: BaseProduct) => createSelector(optedinSelector, optedin => { const entry = optedin.find(b => isSameProduct(product, b)); if (entry) { return entry; } return false; }), product => JSON.stringify(product) ); export const makePrepaidSubscribedSelector = memoize( (product: BaseProduct) => createSelector(optedinSelector, optedin => optedin.some(b => isSameProduct(product, b) && b.prepaidShipments)), product => JSON.stringify(product) ); export const makePrepaidShipmentsSelectedSelector = memoize( (product: BaseProduct) => createSelector( prepaidShipmentsSelectedSelector, prepaidShipmentsSelected => prepaidShipmentsSelected[product.id] || null ), product => JSON.stringify(product) ); /** * Creates a function with state arguments that return the true when * productId is in the optedout array */ export const makeOptedoutSelector = memoize((product: BaseProduct) => createSelector(optedoutSelector, optedout => optedout.find(b => isSameProduct(product, b))) ); export const makeProductFrequencyOptedInSelector = memoize( (product: BaseProduct) => createSelector( makeOptedinSelector(product), productOptin => (productOptin && 'frequency' in productOptin && productOptin.frequency) || null ), product => JSON.stringify(product) ); export const makeProductPrepaidShipmentsOptedInSelector = memoize( (product: BaseProduct) => createSelector( makeOptedinSelector(product), productOptin => (productOptin && 'prepaidShipments' in productOptin && productOptin.prepaidShipments) || null ), product => JSON.stringify(product) ); export const makeProductPrepaidShipmentOptionsSelector = memoize((productId: string) => createSelector(prepaidSellingPlansSelector, prepaidSellingPlans => { const shipmentsList = prepaidSellingPlans[safeProductId(productId)]?.map(({ numberShipments }) => numberShipments) || []; return shipmentsList.sort((a, b) => a - b); }) ); /** * If the product has a product-specific default frequency configured in OG, return that frequency */ export const makeProductSpecificDefaultFrequencySelector = memoize((productId: string) => createSelector( defaultFrequenciesSelector, makeProductFrequenciesSelector(productId), (defaultFrequencies, { frequencies: sellingPlans = [], frequenciesEveryPeriod = [] }) => (defaultFrequencies[safeProductId(productId)] && resolveFrequency(sellingPlans, frequenciesEveryPeriod, defaultFrequencies[safeProductId(productId)])) || null ) ); export const makeProductFrequencyOptionsSelector = memoize((productId: string) => createSelector(makeProductFrequenciesSelector(productId), productFrequencies => productFrequencies.frequencies) ); /** * returns the default frequency for the product from the config state * all products have a defaultFrequency stored in state, even if a specific frequency is not configured in OG's database * this takes more into account, e.g. whether the customer had opted into a specific frequency previously - see the config reducer for how this is calculated */ export const makeProductDefaultFrequencySelector = memoize((productId: string) => createSelector(makeProductFrequenciesSelector(productId), productFrequencies => productFrequencies.defaultFrequency) ); /** * Get the configured frequencies for the given product IDs * Using this selector should be preferred over accessing config values directly */ export const makeProductFrequenciesSelector = memoize((productId: string) => createSelector( (state: State) => state?.config?.productFrequencies, (state: State) => state?.config?.frequencies, (state: State) => state?.config?.frequenciesEveryPeriod, (state: State) => state?.config?.frequenciesText, (state: State) => state?.config?.defaultFrequency, ( productFrequencies, oldFrequencies, oldFrequenciesEveryPeriod, oldFrequenciesText, oldDefaultFrequency ): ProductFrequencyConfig => { if (productFrequencies) { // for Shopify, always use productFrequencies // this is necessary to handle cases where different product variants have different selling plans associated with them return productFrequencies[safeProductId(productId)] || {}; } else { // productFrequencies are only populated for Shopify // fall back to the old "global" frequency values if it is not set // these would only be present if the merchant explicitly called `offers.config({ frequencies: [...] })`, so they generally won't be defined return { frequencies: oldFrequencies, frequenciesEveryPeriod: oldFrequenciesEveryPeriod, frequenciesText: oldFrequenciesText, defaultFrequency: oldDefaultFrequency }; } } ) ); // this selector is only called when an action is dispatched, so we don't need to memoize // other selectors are called whenever the Redux state is updated export const makeFrequencyForPrepaidShipmentsSelector = (product: BaseProduct, prepaidShipments: number) => createSelector( prepaidSellingPlansSelector, makeProductFrequenciesSelector(product.id), (prepaidSellingPlans, { frequencies }) => { if (prepaidShipments) { const productId = safeProductId(product.id); const plan = prepaidSellingPlans[productId]?.find(p => p.numberShipments === prepaidShipments); return plan ? plan.sellingPlan : null; } return frequencies[0]; } ); export const makePrepaidSellingPlansSelector = (product: string) => createSelector(prepaidSellingPlansSelector, prepaidSellingPlans => { const productId = safeProductId(product); return prepaidSellingPlans[productId] || []; }); /** Determine the discounted price of the product, based on the incentives returned from the Offers endpoint. This assumes a pay-as-you-go subscription. */ export const makeDiscountedProductPriceSelector = memoize((productId: string) => createSelector( (state: State) => state.price || {}, (state: State) => state.incentives || {}, (state: State) => state.config.storeCurrency, (prices, incentives, currency) => { const productPriceObj = prices[safeProductId(productId)]; if (productPriceObj === undefined || productPriceObj === null || !currency) return {}; const productPrice = productPriceObj.value; let regularPrice = productPrice; let subscriptionPrice = productPrice; const productIncentives = incentives[safeProductId(productId)]; const incentive = productIncentives?.initial.find(findRelevantIncentive); let formatted_discount = ''; if (incentive) { if (incentive.type === 'Discount Percent') { // note: productPrice is in cents ($10 => 1000), so we round to the nearest whole number after applying the discount subscriptionPrice = Math.round((productPrice * (100 - incentive.value)) / 100); formatted_discount = percentage(incentive.value); } else if (incentive.type === 'Discount Amount' && currency === 'USD') { // for now, we only support USD for "dollar-off" discounts // productPrice is in cents, while the incentive value is in dollars, so we multiply by 100 subscriptionPrice = Math.max(0, productPrice - Math.round(incentive.value * 100)); } } return { regularPrice: money(regularPrice, currency), subscriptionPrice: money(subscriptionPrice, currency), discountRate: formatted_discount || money(regularPrice - subscriptionPrice, currency) }; } ) ); const validIncentiveStandards = [INCENTIVE_STANDARD_TYPES.PROGRAM_WIDE, INCENTIVE_STANDARD_TYPES.PSI]; function findRelevantIncentive(incentive: Incentive) { return ( incentive.object === 'item' && (incentive.type === 'Discount Percent' || incentive.type === 'Discount Amount') && // only attempt to determine a discount if the incentive is standardized, i.e. we have a criteria object incentive.criteria && // note: the API should return either a PSI or a program-wide, not both incentive.criteria.node_type === 'PREMISE' && validIncentiveStandards.includes(incentive.criteria.standard) ); } /** * Convert a string from camel case to kebab case. */ export const kebabCase = (string: string) => { return string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); }; export const getFallbackValue = (element: HTMLElement & { offer: OfferElement }, key: string, defaultValue?) => (element && element.hasAttribute && element.hasAttribute(kebabCase(key)) && element[key]) || (element.offer && typeof (element.offer[key] !== 'undefined') && element.offer[key]) || defaultValue; /** * Returns a list of opted in products id from the state */ export const templatesSelector = (state: State) => ({ templates: state.templates || [] }); /** * Returns true if no selling plan has price adjustments (except for prepaid, which still use price adjustments). This means that we are calculating the subscription discount using a Shopify Discount Function instead of that information being stored in the selling plan. * Generally, the Shopify Discount Function is used when the merchant is using standard flex incentives, i.e. the offer profile is standardized */ export const isShopifyDiscountFunctionInUseSelector = (state: State) => { const plans = Object.values(state.productPlans).flat(); return plans.length > 0 && plans.every(plan => plan.hasPriceAdjustments === false || plan.prepaidShipments); }; /** * Pick benefit message to be rendered in PDP. Preferences are: * 1. Message matching the browser's locale exactly (eg "es-MX") * 2. Message matching any locale with the same language prefix (eg "es-ES" for "es-MX") * 3. US English message ("en-US") * Returns null when none are present — the incentive is then skipped. */ const resolveLocaleMessage = (localeMap: Record): string | null => { if (!localeMap || typeof localeMap !== 'object') return null; const enUS = 'en-US'; const browserLocale = navigator?.language || enUS; const langPrefix = browserLocale.split('-')[0]; const partialMatch = Object.keys(localeMap).find(key => key !== browserLocale && key.split('-')[0] === langPrefix); const msg = localeMap[browserLocale] || localeMap[partialMatch] || localeMap[enUS]; return typeof msg === 'string' && msg.length > 0 ? msg : null; }; /** * Walks the product's applicable incentives (initial first, then ongoing) and * returns the deduped list of messages — one per unique incentive id that * (a) was present in the offer response's `incentives_display_enhanced` and * (b) has a configured benefit message in the active locale. Returns { messages: [] } * when no qualifying incentive has a matching message. */ export const makeBenefitMessagesSelector = memoize( (product: BaseProduct) => createSelector( (state: State) => (state.incentives || {})[safeProductId(product?.id)], (state: State) => state.benefitMessages?.channels?.offers || {}, (productIncentives, benefitMap) => { if (!productIncentives) return { messages: [] as string[] }; const isPreview = window?.og?.previewMode; const seenIds = new Set(); const seenMessages = new Set(); [productIncentives.initial, productIncentives.ongoing].forEach(list => { (list || []).forEach(incentive => { if (!isPreview && !incentive?.enhanced) return; const id = incentive.id; if (!id || seenIds.has(id)) return; seenIds.add(id); const msg = resolveLocaleMessage(benefitMap[id]); if (!msg) return; seenMessages.add(msg); }); }); return { messages: [...seenMessages] }; } ), product => JSON.stringify(product) );