import { combineReducers } from 'redux'; import * as constants from '../core/constants'; import baseReducer, { autoshipByDefault, auth, authUrl, benefitMessages, defaultFrequencies, eligibilityGroups, environment, firstOrderPlaceDate, incentives, locale, merchantId, nextUpcomingOrder, optedin as coreOptedin, optedout, offerId, previewStandardOffer, previewUpsellOffer, productToSubscribe, sessionId, templates, prepaidShipmentsSelected } from '../core/reducer'; import { getFirstSellingPlan, hasShopifySellingPlans, isOgFrequency, mapFrequencyToSellingPlan, safeProductId, getMatchingProductIfExists } from '../core/utils'; import type { AutoshipEligibleState, OfferElement, OptedInState, OptInItem, PriceState, ReceiveOfferPayload, SetupProductPayload } from '../core/types/reducer'; import { sellingPlanAllocationsReducer, getSellingPlans } from './reducers/productPlans'; import config, { getPrepaidSellingPlans } from './reducers/config'; import { experimentsReducer } from '../core/experiments'; import { getPayAsYouGoSellingPlanGroup, getPayAsYouGoSellingPlanGroups, sellingPlansToEveryPeriod, sellingPlansToFrequencies, getPrepaidShipments, getDefaultPrepaidOption } from './utils'; import { EmptyObject } from '../core/types/utility'; import { ELIGIBILITY_GROUPS } from '../core/constants'; const overrideLineKey = (state, productId, newValue) => { const keys = Object.keys(state).filter(it => it.startsWith(productId.toString())); if (keys.length) { return { ...state, ...keys.reduce((acc, cur) => ({ ...acc, [cur]: newValue }), {}) }; } return state; }; export const getDefaultSellingPlan = ( sellingPlans: string[], frequenciesEveryPeriod: string[], defaultFrequency: string | undefined ) => { if (!defaultFrequency) { return null; } if (!isOgFrequency(defaultFrequency)) { return defaultFrequency; } if (hasShopifySellingPlans(sellingPlans, frequenciesEveryPeriod)) { const sellingPlan = mapFrequencyToSellingPlan(sellingPlans, frequenciesEveryPeriod, defaultFrequency); if (sellingPlan) { return sellingPlan; } return getFirstSellingPlan(sellingPlans); } return defaultFrequency; }; export const mapExistingOptinsFromOfferResponse = ( state: OptedInState, offerEl: OfferElement | EmptyObject, frequencyConfig: ReceiveOfferPayload['frequencyConfig'] ) => state.map(it => { if (isOgFrequency(it?.frequency)) { return { ...it, frequency: hasShopifySellingPlans(frequencyConfig?.frequencies, frequencyConfig?.frequenciesEveryPeriod) ? mapFrequencyToSellingPlan( frequencyConfig?.frequencies, frequencyConfig?.frequenciesEveryPeriod, it.frequency ) || mapFrequencyToSellingPlan( frequencyConfig?.frequencies, frequencyConfig?.frequenciesEveryPeriod, offerEl?.defaultFrequency ) || getFirstSellingPlan(frequencyConfig?.frequencies) : it.frequency }; } return it; }); export const reduceNewOptinsFromOfferResponse = ( { autoship = {}, autoship_by_default = {}, default_frequencies = {}, in_stock = {}, eligibility_groups = {} }: ReceiveOfferPayload, existingOptins: OptedInState, offerEl: OfferElement | EmptyObject, frequencyConfig: ReceiveOfferPayload['frequencyConfig'], prepaidSellingPlans: ReceiveOfferPayload['prepaidSellingPlans'] ) => Object.keys(autoship).reduce((acc, id) => { // if the user is not opted in and the product is set to default to subscription if (!existingOptins.some(it => it.id === id) && autoship_by_default[id] && in_stock[id]) { if (autoship[id]) { return acc.concat({ id, frequency: getOptInDefaultFrequency({ frequencyConfig, offerEl, default_frequencies, id }) }); } else if (eligibility_groups[id]?.includes(ELIGIBILITY_GROUPS.PREPAID)) { // if the product is prepaid eligible but not subscription eligible, opt them into a prepaid subscription const prepaidPlan = prepaidSellingPlans ? getDefaultPrepaidOption(prepaidSellingPlans) : null; return acc.concat({ id, // we might not have a prepaid plan yet if the Shopify product request hasn't completed (SETUP_PRODUCT) frequency: prepaidPlan?.sellingPlan || PREPAID_PLACEHOLDER, prepaidShipments: prepaidPlan?.numberShipments || null }); } } return acc; }, []); const getOptInDefaultFrequency = ({ frequencyConfig, offerEl, default_frequencies, id }: { frequencyConfig: ReceiveOfferPayload['frequencyConfig']; offerEl: OfferElement | EmptyObject; default_frequencies: ReceiveOfferPayload['default_frequencies']; id: string; }) => { const { frequencies: sellingPlans, frequenciesEveryPeriod } = frequencyConfig; const { defaultFrequency } = offerEl || {}; const psdf = default_frequencies[id]; let frequency; if (default_frequencies[id] && hasShopifySellingPlans(sellingPlans, frequenciesEveryPeriod)) { frequency = mapFrequencyToSellingPlan(sellingPlans, frequenciesEveryPeriod, `${psdf.every}_${psdf.every_period}`) || getDefaultSellingPlan(sellingPlans, frequenciesEveryPeriod, defaultFrequency) || getFirstSellingPlan(sellingPlans); } else if (default_frequencies[id]) { frequency = `${psdf.every}_${psdf.every_period}`; } else { frequency = getDefaultSellingPlan(sellingPlans, frequenciesEveryPeriod, defaultFrequency) || '_'; // Placeholder to be backfilled in SETUP_PRODUCT reducer } return frequency; }; const productOrVariantInStockReducer = (acc, cur) => ({ ...overrideLineKey(acc, cur.id, cur.available), [cur.id]: cur.available }); const reduceProductCartLine = (acc, cur) => { const productId = safeProductId(cur.key); return { ...acc, [cur.key]: acc[productId] || null }; }; export const autoshipEligible = (state: AutoshipEligibleState = {}, action): AutoshipEligibleState => { if (constants.SETUP_CART === action.type) { const { payload: cart } = action; return cart.items.reduce(reduceProductCartLine, state); } if (constants.SETUP_PRODUCT === action.type) { const { payload: { product } } = action as { payload: SetupProductPayload }; const applicableSellingPlanGroups = getPayAsYouGoSellingPlanGroups(product?.selling_plan_groups); const ogSellingPlanIds = new Set( applicableSellingPlanGroups.flatMap(group => group.selling_plans.map(sellingPlan => sellingPlan.id)) ?? [] ); return product.variants.reduce((acc, cur) => { const productSellingPlansFromOG = cur?.selling_plan_allocations?.filter(sellingPlan => ogSellingPlanIds.has(sellingPlan.selling_plan_id)) ?? []; // a product is autoship eligible if it has plans from the default or PSFL selling plan group // the presence of prepaid selling plans does not mean it is autoship eligible const isAutoshipEligible = productSellingPlansFromOG.length > 0; return { ...overrideLineKey(acc, cur.id, isAutoshipEligible), [cur.id]: isAutoshipEligible }; }, state); } if (constants.SET_PREVIEW_STANDARD_OFFER === action.type) { if (action.payload.isPreview !== true) return state; return { ...state, ...{ [action.payload.productId]: true } }; } return state; }; export const inStock = (state = {}, action) => { if (constants.SETUP_CART === action.type) { const cart = action.payload; return cart.items.reduce(reduceProductCartLine, state); } if (constants.SETUP_PRODUCT === action.type) { const { payload: { product } } = action as { payload: SetupProductPayload }; // it's unclear whether this needs to check the base product object or could only check the variants // leaving for now to preserve backwards compatibility return [product, ...(product?.variants ?? [])]?.reduce(productOrVariantInStockReducer, state) || state; } // force offer to refresh when requesting a new one if (constants.REQUEST_OFFER === action.type && action.payload.product === null) { return { ...state }; } if (constants.SET_PREVIEW_STANDARD_OFFER === action.type) { if (action.payload.isPreview !== true) return state; return { ...state, ...{ [action.payload.productId]: true } }; } return state; }; export const offer = (state = {}, _action) => state; function getOptedInItem(cartItem) { const prepaidShipments = getPrepaidShipments(cartItem.selling_plan_allocation.selling_plan); const item: OptInItem = { id: cartItem.key, frequency: `${cartItem.selling_plan_allocation.selling_plan.id}` }; if (prepaidShipments) { item.prepaidShipments = prepaidShipments; } return item; } const PREPAID_PLACEHOLDER = 'prepaid-replace-me'; export const optedin = (state: OptedInState = [], action): OptedInState => { if (constants.SETUP_CART === action.type) { const cart = action.payload; return state .filter(it => !it.id.includes(':')) .concat(cart.items.reduce((acc, cur) => (cur.selling_plan_allocation ? [...acc, getOptedInItem(cur)] : acc), [])); } if (constants.RECEIVE_OFFER === action.type) { const payload = action.payload as ReceiveOfferPayload; const { offer: offerEl = {}, frequencyConfig, prepaidSellingPlans } = payload; const existingOptins = mapExistingOptinsFromOfferResponse(state, offerEl, frequencyConfig); const newOptins = reduceNewOptinsFromOfferResponse( payload, existingOptins, offerEl, frequencyConfig, prepaidSellingPlans ); return [...existingOptins, ...newOptins]; } if (constants.SETUP_PRODUCT === action.type) { const { product } = action.payload; const sellingPlanGroup = getPayAsYouGoSellingPlanGroup(product?.selling_plan_groups); const prepaidSellingPlans = getPrepaidSellingPlans(product); const frequencies = sellingPlanGroup ? sellingPlansToFrequencies(sellingPlanGroup) : []; const frequenciesEveryPeriod = sellingPlanGroup ? sellingPlansToEveryPeriod(sellingPlanGroup) : []; return state .map(curr => { const prepaidSellingPlansForVariant = prepaidSellingPlans[curr.id]; if (sellingPlanGroup && isOgFrequency(curr.frequency)) { return { ...curr, frequency: mapFrequencyToSellingPlan(frequencies, frequenciesEveryPeriod, curr.frequency) || getFirstSellingPlan(frequencies) }; } else if (curr.frequency === PREPAID_PLACEHOLDER && prepaidSellingPlansForVariant?.length > 0) { // if we opted the user into a prepaid plan on RECEIVE_OFFER and now have the actual selling plan information, update it const { sellingPlan, numberShipments } = getDefaultPrepaidOption(prepaidSellingPlansForVariant); return { ...curr, frequency: sellingPlan, prepaidShipments: numberShipments }; } return curr; }) .filter(i => i.frequency !== PREPAID_PLACEHOLDER); // remove any leftover placeholders } if (constants.PRODUCT_CHANGE_PREPAID_SHIPMENTS === action.type) { const { payload } = action; // core reducer sets prepaid shipments const newState = coreOptedin(state, action); // get the new frequency (selling plan) that matches the prepaid shipments const [oldone, rest] = getMatchingProductIfExists(newState, payload.product); return rest.concat({ ...oldone, ...payload.product, frequency: payload.frequency }); } return coreOptedin(state, action); }; export const price = (state: PriceState = {}, action): PriceState => { if (constants.SETUP_PRODUCT === action.type) { const { payload: { product } } = action as { payload: SetupProductPayload }; return ( product.variants?.reduce( (acc, cur) => ({ ...acc, [cur.id]: { value: cur.price } }), state ) || state ); } return state; }; export const productOffer = (state = {}, _action) => state; export const productPlans = (state = {}, action) => { if (constants.SETUP_PRODUCT === action.type) { const { payload: { product, currency } } = action as { payload: SetupProductPayload }; const sellingPlans = getSellingPlans(product); return ( product.variants.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur.selling_plan_allocations?.reduce( (accumulator, current) => sellingPlanAllocationsReducer(accumulator, current, sellingPlans, currency), [] ) }), state ) || state ); } if (constants.SETUP_CART === action.type) { const cart = action.payload; return ( cart.items.reduce( (acc, cur) => cur.selling_plan_allocation ? { ...acc, [cur.key]: sellingPlanAllocationsReducer([], cur.selling_plan_allocation, [], cart.currency) } : acc, state ) || state ); } return state; }; const reducer = combineReducers({ auth, authUrl, autoshipByDefault, autoshipEligible, config, defaultFrequencies, eligibilityGroups, environment, firstOrderPlaceDate, incentives, inStock, locale, merchantId, nextUpcomingOrder, offer, offerId, experiments: experimentsReducer, optedin, optedout, previewStandardOffer, previewUpsellOffer, price, productOffer, productPlans, productToSubscribe, sessionId, templates, prepaidShipmentsSelected, benefitMessages }); export default function shopifyReducer(state, action) { if (window.og && window.og.previewMode) return baseReducer(state, action); return reducer(state, action); }