diff --git a/ghost/portal/src/actions.js b/ghost/portal/src/actions.js index 10b8807d7b..ad2bb36c84 100644 --- a/ghost/portal/src/actions.js +++ b/ghost/portal/src/actions.js @@ -1,4 +1,4 @@ -import {createPopupNotification, getMemberEmail, getMemberName, removePortalLinkFromUrl} from './utils/helpers'; +import {createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl} from './utils/helpers'; function switchPage({data, state}) { return { @@ -99,7 +99,8 @@ async function signup({data, state, api}) { if (plan.toLowerCase() === 'free') { await api.member.sendMagicLink(data); } else { - await api.member.checkoutPlan({plan, email, name, newsletters, offerId}); + const {tierId, cadence} = getProductCadenceFromPrice({site: state?.site, priceId: plan}); + await api.member.checkoutPlan({plan, tierId, cadence, email, name, newsletters, offerId}); } return { page: 'magiclink', @@ -119,8 +120,11 @@ async function signup({data, state, api}) { async function checkoutPlan({data, state, api}) { try { const {plan, offerId} = data; + const {tierId, cadence} = getProductCadenceFromPrice({site: state?.site, priceId: plan}); await api.member.checkoutPlan({ plan, + tierId, + cadence, offerId, metadata: { checkoutType: 'upgrade' @@ -140,8 +144,15 @@ async function checkoutPlan({data, state, api}) { async function updateSubscription({data, state, api}) { try { const {plan, planId, subscriptionId, cancelAtPeriodEnd} = data; + const {tierId, cadence} = getProductCadenceFromPrice({site: state?.site, priceId: planId}); + await api.member.updateSubscription({ - planName: plan, subscriptionId, cancelAtPeriodEnd, planId: planId + planName: plan, + tierId, + cadence, + subscriptionId, + cancelAtPeriodEnd, + planId: planId }); const member = await api.member.sessionData(); const action = 'updateSubscription:success'; diff --git a/ghost/portal/src/components/common/ProductsSection.js b/ghost/portal/src/components/common/ProductsSection.js index 7bff1e6599..75f57d7120 100644 --- a/ghost/portal/src/components/common/ProductsSection.js +++ b/ghost/portal/src/components/common/ProductsSection.js @@ -1,7 +1,7 @@ import React, {useContext, useEffect, useState} from 'react'; import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; -import {getCurrencySymbol, getPriceString, getStripeAmount, getMemberActivePrice, getProductFromPrice, getFreeTierTitle, getFreeTierDescription, getFreeProduct, getFreeProductBenefits, formatNumber, isCookiesDisabled, hasOnlyFreeProduct} from '../../utils/helpers'; +import {getCurrencySymbol, getPriceString, getStripeAmount, getMemberActivePrice, getProductFromPrice, getFreeTierTitle, getFreeTierDescription, getFreeProduct, getFreeProductBenefits, formatNumber, isCookiesDisabled, hasOnlyFreeProduct, isMemberActivePrice} from '../../utils/helpers'; import AppContext from '../../AppContext'; import calculateDiscount from '../../utils/discount'; @@ -882,7 +882,7 @@ function ProductDescription({product, selectedPrice, activePrice}) { } function ChangeProductCard({product, onPlanSelect}) { - const {member} = useContext(AppContext); + const {member, site} = useContext(AppContext); const {selectedProduct, setSelectedProduct, selectedInterval} = useContext(ProductsContext); const cardClass = selectedProduct === product.id ? 'gh-portal-product-card checked' : 'gh-portal-product-card'; const monthlyPrice = product.monthlyPrice; @@ -891,7 +891,7 @@ function ChangeProductCard({product, onPlanSelect}) { const selectedPrice = selectedInterval === 'month' ? monthlyPrice : yearlyPrice; - const currentPlan = (selectedPrice.id === memberActivePrice.id); + const currentPlan = isMemberActivePrice({member, site, priceId: selectedPrice.id}); return (
{ diff --git a/ghost/portal/src/tests/SignupFlow.test.js b/ghost/portal/src/tests/SignupFlow.test.js index 8317b2378c..3a3a81f83a 100644 --- a/ghost/portal/src/tests/SignupFlow.test.js +++ b/ghost/portal/src/tests/SignupFlow.test.js @@ -327,7 +327,9 @@ describe('Signup', () => { email: 'jamie@example.com', name: 'Jamie Larsen', offerId: undefined, - plan: singleTierProduct.yearlyPrice.id + plan: singleTierProduct.yearlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'year' }); }); @@ -367,7 +369,9 @@ describe('Signup', () => { email: 'jamie@example.com', name: 'Jamie Larsen', offerId: undefined, - plan: singleTierProduct.yearlyPrice.id + plan: singleTierProduct.yearlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'year' }); const magicLink = await within(popupIframeDocument).findByText(/now check your email/i); expect(magicLink).toBeInTheDocument(); @@ -408,7 +412,9 @@ describe('Signup', () => { email: 'jamie@example.com', name: '', offerId: undefined, - plan: singleTierProduct.monthlyPrice.id + plan: singleTierProduct.monthlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'month' }); }); @@ -444,7 +450,9 @@ describe('Signup', () => { email: 'jamie@example.com', name: 'Jamie Larsen', offerId: undefined, - plan: singleTierProduct.yearlyPrice.id + plan: singleTierProduct.yearlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'year' }); }); @@ -459,6 +467,7 @@ describe('Signup', () => { offer: FixtureOffer }); let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id; + let tier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid'); let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); expect(triggerButtonFrame).toBeInTheDocument(); @@ -480,7 +489,9 @@ describe('Signup', () => { email: 'jamie@example.com', name: 'Jamie Larsen', offerId, - plan: planId + plan: planId, + tierId: tier.id, + cadence: 'month' }); window.location.hash = ''; @@ -501,6 +512,7 @@ describe('Signup', () => { offer: FixtureOffer }); let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id; + let tier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid'); let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); expect(triggerButtonFrame).not.toBeInTheDocument(); @@ -516,7 +528,9 @@ describe('Signup', () => { email: undefined, name: undefined, offerId: offerId, - plan: planId + plan: planId, + tierId: tier.id, + cadence: 'month' }); window.location.hash = ''; @@ -679,6 +693,7 @@ describe('Signup', () => { offer: FixtureOffer }); let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id; + let tier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid'); let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); expect(triggerButtonFrame).toBeInTheDocument(); @@ -700,7 +715,9 @@ describe('Signup', () => { email: 'jamie@example.com', name: 'Jamie Larsen', offerId, - plan: planId + plan: planId, + tierId: tier.id, + cadence: 'month' }); window.location.hash = ''; @@ -720,6 +737,7 @@ describe('Signup', () => { site, offer: FixtureOffer }); + const singleTier = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid'); let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id; let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); @@ -736,7 +754,9 @@ describe('Signup', () => { email: undefined, name: undefined, offerId: offerId, - plan: planId + plan: planId, + tierId: singleTier.id, + cadence: 'month' }); window.location.hash = ''; diff --git a/ghost/portal/src/tests/UpgradeFlow.test.js b/ghost/portal/src/tests/UpgradeFlow.test.js index a8b10f2f33..c9842cbb6b 100644 --- a/ghost/portal/src/tests/UpgradeFlow.test.js +++ b/ghost/portal/src/tests/UpgradeFlow.test.js @@ -215,7 +215,9 @@ describe('Logged-in free member', () => { checkoutType: 'upgrade' }, offerId: undefined, - plan: singleTierProduct.monthlyPrice.id + plan: singleTierProduct.monthlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'month' }); }); @@ -250,7 +252,9 @@ describe('Logged-in free member', () => { checkoutType: 'upgrade' }, offerId: undefined, - plan: singleTierProduct.yearlyPrice.id + plan: singleTierProduct.yearlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'year' }); }); @@ -266,6 +270,7 @@ describe('Logged-in free member', () => { offer: FixtureOffer }); let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id; + let singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid'); let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); expect(triggerButtonFrame).toBeInTheDocument(); @@ -285,7 +290,9 @@ describe('Logged-in free member', () => { email: 'jimmie@example.com', name: 'Jimmie Larson', offerId, - plan: planId + plan: planId, + tierId: singleTierProduct.id, + cadence: 'month' }); window.location.hash = ''; @@ -308,6 +315,7 @@ describe('Logged-in free member', () => { offer: FixtureOffer }); let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id; + let singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid'); let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); expect(triggerButtonFrame).not.toBeInTheDocument(); @@ -324,7 +332,9 @@ describe('Logged-in free member', () => { checkoutType: 'upgrade' }, offerId: offerId, - plan: planId + plan: planId, + tierId: singleTierProduct.id, + cadence: 'month' }); window.location.hash = ''; @@ -364,7 +374,9 @@ describe('Logged-in free member', () => { checkoutType: 'upgrade' }, offerId: undefined, - plan: singleTierProduct.yearlyPrice.id + plan: singleTierProduct.yearlyPrice.id, + tierId: singleTierProduct.id, + cadence: 'year' }); }); @@ -380,6 +392,7 @@ describe('Logged-in free member', () => { offer: FixtureOffer }); let planId = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid').monthlyPrice.id; + let singleTierProduct = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid'); let offerId = FixtureOffer.id; expect(popupFrame).toBeInTheDocument(); expect(triggerButtonFrame).toBeInTheDocument(); @@ -399,7 +412,9 @@ describe('Logged-in free member', () => { email: 'jimmie@example.com', name: 'Jimmie Larson', offerId, - plan: planId + plan: planId, + tierId: singleTierProduct.id, + cadence: 'month' }); window.location.hash = ''; diff --git a/ghost/portal/src/utils/api.js b/ghost/portal/src/utils/api.js index 14c11afed7..006905717f 100644 --- a/ghost/portal/src/utils/api.js +++ b/ghost/portal/src/utils/api.js @@ -1,4 +1,4 @@ -import {transformApiSiteData} from './helpers'; +import {transformApiSiteData, transformApiTiersData} from './helpers'; function getAnalyticsMetadata() { const analyticsTag = document.querySelector('meta[name=ghost-analytics-id]'); @@ -311,7 +311,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { }); }, - async checkoutPlan({plan, cancelUrl, successUrl, email: customerEmail, name, offerId, newsletters, metadata = {}} = {}) { + async checkoutPlan({plan, tierId, cadence, cancelUrl, successUrl, email: customerEmail, name, offerId, newsletters, metadata = {}} = {}) { const siteUrlObj = new URL(siteUrl); const identity = await api.member.identity(); const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'}); @@ -328,10 +328,20 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { fp_tid: (window.FPROM || window.$FPROM)?.data?.tid, ...metadata }; - const analyticsData = getAnalyticsMetadata(); - if (analyticsData) { - metadataObj.ghost_analytics_entry_id = analyticsData.entry_id; - metadataObj.ghost_analytics_source_url = analyticsData.source_url; + + const body = { + priceId: offerId ? null : plan, + offerId, + identity: identity, + metadata: metadataObj, + successUrl, + cancelUrl, + customerEmail: customerEmail + }; + if (tierId && cadence) { + delete body.priceId; + body.tierId = offerId ? null : tierId; + body.cadence = offerId ? null : cadence; } return makeRequest({ url, @@ -339,15 +349,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - priceId: offerId ? null : plan, - offerId, - identity: identity, - metadata: metadataObj, - successUrl, - cancelUrl, - customerEmail: customerEmail - }) + body: JSON.stringify(body) }).then(function (res) { if (!res.ok) { throw new Error('Could not create stripe checkout session'); @@ -413,7 +415,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { }); }, - async updateSubscription({subscriptionId, planName, planId, smartCancel, cancelAtPeriodEnd, cancellationReason}) { + async updateSubscription({subscriptionId, tierId, cadence, planId, smartCancel, cancelAtPeriodEnd, cancellationReason}) { const identity = await api.member.identity(); const url = endpointFor({type: 'members', resource: 'subscriptions'}) + subscriptionId + '/'; const body = { @@ -427,6 +429,13 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { if (body) { body.metadata = analyticsData; } + + if (tierId && cadence) { + delete body.priceId; + body.tierId = tierId; + body.cadence = cadence; + } + return makeRequest({ url, method: 'PUT', @@ -456,7 +465,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { site = { ...settings, newsletters, - tiers + tiers: transformApiTiersData({tiers}) }; } catch (e) { // Ignore diff --git a/ghost/portal/src/utils/helpers.js b/ghost/portal/src/utils/helpers.js index f70268ce50..c4ee664f44 100644 --- a/ghost/portal/src/utils/helpers.js +++ b/ghost/portal/src/utils/helpers.js @@ -110,6 +110,8 @@ export function getPriceFromSubscription({subscription}) { id: subscription.price.price_id, price: subscription.price.amount / 100, name: subscription.price.nickname, + tierId: subscription.tier?.id, + cadence: subscription.price?.interval === 'month' ? 'month' : 'year', currency: subscription.price.currency.toLowerCase(), currency_symbol: getCurrencySymbol(subscription.price.currency) }; @@ -122,6 +124,15 @@ export function getMemberActivePrice({member}) { return getPriceFromSubscription({subscription}); } +export function isMemberActivePrice({priceId, site, member}) { + const activePrice = getMemberActivePrice({member}); + const {tierId, cadence} = getProductCadenceFromPrice({site, priceId}); + if (activePrice?.tierId === tierId && activePrice?.cadence === cadence) { + return true; + } + return false; +} + export function getSubscriptionFromId({member, subscriptionId}) { if (isPaidMember({member})) { const subscriptions = member.subscriptions || []; @@ -452,6 +463,24 @@ export function getProductFromPrice({site, priceId}) { }); } +export function getProductCadenceFromPrice({site, priceId}) { + if (priceId === 'free') { + return getFreeProduct({site}); + } + const products = getAllProductsForSite({site}); + const tier = products.find((product) => { + return (product?.monthlyPrice?.id === priceId) || (product?.yearlyPrice?.id === priceId); + }); + let cadence = 'month'; + if (tier?.yearlyPrice?.id === priceId) { + cadence = 'year'; + } + return { + tierId: tier?.id, + cadence + }; +} + export function getAvailablePrices({site, products = null}) { const { portal_plans: portalPlans = [], @@ -659,3 +688,61 @@ export const getUpdatedOfferPrice = ({offer, price, useFormatted = false}) => { export const isActiveOffer = ({offer}) => { return offer?.status === 'active'; }; + +function createMonthlyPrice({tier, priceId}) { + if (tier?.monthly_price) { + return { + id: `price-${priceId}`, + active: true, + type: 'recurring', + nickname: 'Monthly', + currency: tier.currency, + amount: tier.monthly_price, + interval: 'month' + }; + } + return null; +} + +function createYearlyPrice({tier, priceId}) { + if (tier?.yearly_price) { + return { + id: `price-${priceId}`, + active: true, + type: 'recurring', + nickname: 'Yearly', + currency: tier.currency, + amount: tier.yearly_price, + interval: 'year' + }; + } + return null; +} + +function createBenefits({tier}) { + tier?.benefits?.map((benefit) => { + return { + name: benefit + }; + }); +} + +export const transformApiTiersData = ({tiers}) => { + let priceId = 0; + + return tiers.map((tier) => { + let monthlyPrice = createMonthlyPrice({tier, priceId}); + priceId += 1; + + let yearlyPrice = createYearlyPrice({tier, priceId}); + priceId += 1; + + let benefits = createBenefits({tier}); + return { + ...tier, + benefits: benefits, + monthly_price: monthlyPrice, + yearly_price: yearlyPrice + }; + }); +}; diff --git a/ghost/portal/src/utils/helpers.test.js b/ghost/portal/src/utils/helpers.test.js index c57840d549..40356a5406 100644 --- a/ghost/portal/src/utils/helpers.test.js +++ b/ghost/portal/src/utils/helpers.test.js @@ -169,6 +169,8 @@ describe('Helpers - ', () => { const value = getPriceFromSubscription({subscription}); expect(value).toStrictEqual({ ...subscription.price, + tierId: undefined, + cadence: 'year', stripe_price_id: subscription.price.id, id: subscription.price.price_id, price: subscription.price.amount / 100,