mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
Merge pull request #11619 from overleaf/jel-react-personal-subscription-dash-change-plan
[web] Query display price data for change plan UI in React subscription dash GitOrigin-RevId: e05779d1f289ee55a1b8be17b182905fca66e44f
This commit is contained in:
+23
-9
@@ -1,20 +1,34 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import LoadingSpinner from '../../../../../../../shared/components/loading-spinner'
|
||||||
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
|
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
|
||||||
import { ChangeToGroupPlan } from './change-to-group-plan'
|
import { ChangeToGroupPlan } from './change-to-group-plan'
|
||||||
import { IndividualPlansTable } from './individual-plans-table'
|
import { IndividualPlansTable } from './individual-plans-table'
|
||||||
|
|
||||||
export function ChangePlan() {
|
export function ChangePlan() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { plans, recurlyLoadError, showChangePersonalPlan } =
|
const {
|
||||||
useSubscriptionDashboardContext()
|
plans,
|
||||||
|
queryingIndividualPlansData,
|
||||||
|
recurlyLoadError,
|
||||||
|
showChangePersonalPlan,
|
||||||
|
} = useSubscriptionDashboardContext()
|
||||||
|
|
||||||
if (!showChangePersonalPlan || !plans || recurlyLoadError) return null
|
if (!showChangePersonalPlan || !plans || recurlyLoadError) return null
|
||||||
|
|
||||||
return (
|
if (queryingIndividualPlansData) {
|
||||||
<>
|
return (
|
||||||
<h2>{t('change_plan')}</h2>
|
<>
|
||||||
<IndividualPlansTable plans={plans} />
|
<h2>{t('change_plan')}</h2>
|
||||||
<ChangeToGroupPlan />
|
<LoadingSpinner />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>{t('change_plan')}</h2>
|
||||||
|
<IndividualPlansTable plans={plans} />
|
||||||
|
<ChangeToGroupPlan />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -74,7 +74,7 @@ function PlansRow({ plan }: { plan: Plan }) {
|
|||||||
<strong>{plan.name}</strong>
|
<strong>{plan.name}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{/* todo: {{ displayPrice }} */}/ {plan.annual ? t('year') : t('month')}
|
{plan.displayPrice} / {plan.annual ? t('year') : t('month')}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ChangePlanButton plan={plan} />
|
<ChangePlanButton plan={plan} />
|
||||||
@@ -96,9 +96,13 @@ function PlansRows({ plans }: { plans: Array<Plan> }) {
|
|||||||
|
|
||||||
export function IndividualPlansTable({ plans }: { plans: Array<Plan> }) {
|
export function IndividualPlansTable({ plans }: { plans: Array<Plan> }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { recurlyLoadError, showChangePersonalPlan } =
|
||||||
|
useSubscriptionDashboardContext()
|
||||||
|
|
||||||
|
if (!showChangePersonalPlan || !plans || recurlyLoadError) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="table">
|
<table className="table table-vertically-centered-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('name')}</th>
|
<th>{t('name')}</th>
|
||||||
|
|||||||
+39
-3
@@ -13,6 +13,8 @@ import {
|
|||||||
import { Plan } from '../../../../../types/subscription/plan'
|
import { Plan } from '../../../../../types/subscription/plan'
|
||||||
import { Institution } from '../../../../../types/institution'
|
import { Institution } from '../../../../../types/institution'
|
||||||
import getMeta from '../../../utils/meta'
|
import getMeta from '../../../utils/meta'
|
||||||
|
import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
|
||||||
|
import { isRecurlyLoaded } from '../util/is-recurly-loaded'
|
||||||
|
|
||||||
type SubscriptionDashboardContextValue = {
|
type SubscriptionDashboardContextValue = {
|
||||||
hasDisplayedSubscription: boolean
|
hasDisplayedSubscription: boolean
|
||||||
@@ -20,6 +22,7 @@ type SubscriptionDashboardContextValue = {
|
|||||||
managedGroupSubscriptions: Array<ManagedGroupSubscription>
|
managedGroupSubscriptions: Array<ManagedGroupSubscription>
|
||||||
personalSubscription?: Subscription
|
personalSubscription?: Subscription
|
||||||
plans: Array<Plan>
|
plans: Array<Plan>
|
||||||
|
queryingIndividualPlansData: boolean
|
||||||
recurlyLoadError: boolean
|
recurlyLoadError: boolean
|
||||||
setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
|
setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
showCancellation: boolean
|
showCancellation: boolean
|
||||||
@@ -40,11 +43,15 @@ export function SubscriptionDashboardProvider({
|
|||||||
const [recurlyLoadError, setRecurlyLoadError] = useState(false)
|
const [recurlyLoadError, setRecurlyLoadError] = useState(false)
|
||||||
const [showCancellation, setShowCancellation] = useState(false)
|
const [showCancellation, setShowCancellation] = useState(false)
|
||||||
const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
|
const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
|
||||||
|
const [plans, setPlans] = useState([])
|
||||||
|
const [queryingIndividualPlansData, setQueryingIndividualPlansData] =
|
||||||
|
useState(true)
|
||||||
|
|
||||||
const plans = getMeta('ol-plans')
|
const plansWithoutDisplayPrice = getMeta('ol-plans')
|
||||||
const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
|
const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
|
||||||
const personalSubscription = getMeta('ol-subscription')
|
const personalSubscription = getMeta('ol-subscription')
|
||||||
const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
|
const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
|
||||||
|
const recurlyApiKey = getMeta('ol-recurlyApiKey')
|
||||||
|
|
||||||
const hasDisplayedSubscription =
|
const hasDisplayedSubscription =
|
||||||
institutionMemberships?.length > 0 ||
|
institutionMemberships?.length > 0 ||
|
||||||
@@ -52,10 +59,37 @@ export function SubscriptionDashboardProvider({
|
|||||||
managedGroupSubscriptions
|
managedGroupSubscriptions
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window.recurly === 'undefined' || !window.recurly) {
|
if (!isRecurlyLoaded()) {
|
||||||
setRecurlyLoadError(true)
|
setRecurlyLoadError(true)
|
||||||
|
} else {
|
||||||
|
recurly.configure(recurlyApiKey)
|
||||||
}
|
}
|
||||||
}, [setRecurlyLoadError])
|
}, [recurlyApiKey, setRecurlyLoadError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRecurlyLoaded() && plansWithoutDisplayPrice && personalSubscription) {
|
||||||
|
const { currency, taxRate } = personalSubscription.recurly
|
||||||
|
const fetchPlansDisplayPrices = async () => {
|
||||||
|
for (const plan of plansWithoutDisplayPrice) {
|
||||||
|
try {
|
||||||
|
const priceData = await loadDisplayPriceWithTaxPromise(
|
||||||
|
plan.planCode,
|
||||||
|
currency,
|
||||||
|
taxRate
|
||||||
|
)
|
||||||
|
if (priceData?.totalForDisplay) {
|
||||||
|
plan.displayPrice = priceData.totalForDisplay
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPlans(plansWithoutDisplayPrice)
|
||||||
|
setQueryingIndividualPlansData(false)
|
||||||
|
}
|
||||||
|
fetchPlansDisplayPrices().catch(console.error)
|
||||||
|
}
|
||||||
|
}, [personalSubscription, plansWithoutDisplayPrice])
|
||||||
|
|
||||||
const value = useMemo<SubscriptionDashboardContextValue>(
|
const value = useMemo<SubscriptionDashboardContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -64,6 +98,7 @@ export function SubscriptionDashboardProvider({
|
|||||||
managedGroupSubscriptions,
|
managedGroupSubscriptions,
|
||||||
personalSubscription,
|
personalSubscription,
|
||||||
plans,
|
plans,
|
||||||
|
queryingIndividualPlansData,
|
||||||
recurlyLoadError,
|
recurlyLoadError,
|
||||||
setRecurlyLoadError,
|
setRecurlyLoadError,
|
||||||
showCancellation,
|
showCancellation,
|
||||||
@@ -77,6 +112,7 @@ export function SubscriptionDashboardProvider({
|
|||||||
managedGroupSubscriptions,
|
managedGroupSubscriptions,
|
||||||
personalSubscription,
|
personalSubscription,
|
||||||
plans,
|
plans,
|
||||||
|
queryingIndividualPlansData,
|
||||||
recurlyLoadError,
|
recurlyLoadError,
|
||||||
setRecurlyLoadError,
|
setRecurlyLoadError,
|
||||||
showCancellation,
|
showCancellation,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function isRecurlyLoaded() {
|
||||||
|
return typeof recurly !== 'undefined'
|
||||||
|
}
|
||||||
@@ -62,6 +62,12 @@ th {
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-vertically-centered-cells {
|
||||||
|
> tbody > tr > td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Condensed table w/ half padding
|
// Condensed table w/ half padding
|
||||||
|
|
||||||
.table-condensed {
|
.table-condensed {
|
||||||
|
|||||||
+2
-2
@@ -79,7 +79,7 @@ describe('<ActiveSubscription />', function () {
|
|||||||
expectedInActiveSubscription(annualActiveSubscription)
|
expectedInActiveSubscription(annualActiveSubscription)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows change plan UI when button clicked', function () {
|
it('shows change plan UI when button clicked', async function () {
|
||||||
renderActiveSubscription(annualActiveSubscription)
|
renderActiveSubscription(annualActiveSubscription)
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||||
@@ -88,7 +88,7 @@ describe('<ActiveSubscription />', function () {
|
|||||||
// confirm main dash UI still shown
|
// confirm main dash UI still shown
|
||||||
screen.getByText('You are currently subscribed to the', { exact: false })
|
screen.getByText('You are currently subscribed to the', { exact: false })
|
||||||
|
|
||||||
screen.getByRole('heading', { name: 'Change plan' })
|
await screen.findByRole('heading', { name: 'Change plan' })
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole('button', { name: 'Change to this plan' }).length > 0
|
screen.getAllByRole('button', { name: 'Change to this plan' }).length > 0
|
||||||
).to.be.true
|
).to.be.true
|
||||||
|
|||||||
+32
-22
@@ -30,13 +30,17 @@ describe('<ChangePlan />', function () {
|
|||||||
expect(container.firstChild).to.be.null
|
expect(container.firstChild).to.be.null
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the individual plans table', function () {
|
it('renders the individual plans table and group plans UI', async function () {
|
||||||
renderWithSubscriptionDashContext(
|
renderWithSubscriptionDashContext(
|
||||||
<ActiveSubscription subscription={annualActiveSubscription} />,
|
<ActiveSubscription subscription={annualActiveSubscription} />,
|
||||||
{
|
{
|
||||||
metaTags: [
|
metaTags: [
|
||||||
{ name: 'ol-subscription', value: annualActiveSubscription },
|
{ name: 'ol-subscription', value: annualActiveSubscription },
|
||||||
plansMetaTag,
|
plansMetaTag,
|
||||||
|
{
|
||||||
|
name: 'ol-recommendedCurrency',
|
||||||
|
value: 'USD',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -44,6 +48,8 @@ describe('<ChangePlan />', function () {
|
|||||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||||
fireEvent.click(button)
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
await screen.findByText('Looking for multiple licenses?')
|
||||||
|
|
||||||
const changeToPlanButtons = screen.queryAllByRole('button', {
|
const changeToPlanButtons = screen.queryAllByRole('button', {
|
||||||
name: 'Change to this plan',
|
name: 'Change to this plan',
|
||||||
})
|
})
|
||||||
@@ -51,30 +57,17 @@ describe('<ChangePlan />', function () {
|
|||||||
screen.getByText('Your plan')
|
screen.getByText('Your plan')
|
||||||
|
|
||||||
const annualPlans = plans.filter(plan => plan.annual)
|
const annualPlans = plans.filter(plan => plan.annual)
|
||||||
expect(screen.getAllByText('/ year').length).to.equal(annualPlans.length)
|
expect(screen.getAllByText('/ year', { exact: false }).length).to.equal(
|
||||||
expect(screen.getAllByText('/ month').length).to.equal(
|
annualPlans.length
|
||||||
|
)
|
||||||
|
expect(screen.getAllByText('/ month', { exact: false }).length).to.equal(
|
||||||
plans.length - annualPlans.length
|
plans.length - annualPlans.length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText('loading', { exact: false })).to.be.null
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the change to group plan UI', function () {
|
it('renders "Your new plan" and "Keep current plan" when there is a pending plan change', async function () {
|
||||||
renderWithSubscriptionDashContext(
|
|
||||||
<ActiveSubscription subscription={annualActiveSubscription} />,
|
|
||||||
{
|
|
||||||
metaTags: [
|
|
||||||
{ name: 'ol-subscription', value: annualActiveSubscription },
|
|
||||||
plansMetaTag,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
|
||||||
fireEvent.click(button)
|
|
||||||
|
|
||||||
screen.getByText('Looking for multiple licenses?')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders "Your new plan" and "Keep current plan" when there is a pending plan change', function () {
|
|
||||||
renderWithSubscriptionDashContext(
|
renderWithSubscriptionDashContext(
|
||||||
<ActiveSubscription subscription={pendingSubscriptionChange} />,
|
<ActiveSubscription subscription={pendingSubscriptionChange} />,
|
||||||
{
|
{
|
||||||
@@ -88,7 +81,7 @@ describe('<ChangePlan />', function () {
|
|||||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||||
fireEvent.click(button)
|
fireEvent.click(button)
|
||||||
|
|
||||||
screen.getByText('Your new plan')
|
await screen.findByText('Your new plan')
|
||||||
screen.getByRole('button', { name: 'Keep my current plan' })
|
screen.getByRole('button', { name: 'Keep my current plan' })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,4 +97,21 @@ describe('<ChangePlan />', function () {
|
|||||||
)
|
)
|
||||||
expect(container).not.to.be.null
|
expect(container).not.to.be.null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows a loading message while still querying Recurly for prices', function () {
|
||||||
|
renderWithSubscriptionDashContext(
|
||||||
|
<ActiveSubscription subscription={pendingSubscriptionChange} />,
|
||||||
|
{
|
||||||
|
metaTags: [
|
||||||
|
{ name: 'ol-subscription', value: pendingSubscriptionChange },
|
||||||
|
plansMetaTag,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
screen.findByText('Loading', { exact: false })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+38
-2
@@ -1,11 +1,13 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import { SubscriptionDashboardProvider } from '../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
|
import { SubscriptionDashboardProvider } from '../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
|
||||||
|
import { plans } from '../fixtures/plans'
|
||||||
|
|
||||||
export function renderWithSubscriptionDashContext(
|
export function renderWithSubscriptionDashContext(
|
||||||
component: React.ReactElement,
|
component: React.ReactElement,
|
||||||
options?: {
|
options?: {
|
||||||
metaTags?: { name: string; value: string | object | Array<object> }[]
|
metaTags?: { name: string; value: string | object | Array<object> }[]
|
||||||
recurlyNotLoaded?: boolean
|
recurlyNotLoaded?: boolean
|
||||||
|
queryingRecurly?: boolean
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const SubscriptionDashboardProviderWrapper = ({
|
const SubscriptionDashboardProviderWrapper = ({
|
||||||
@@ -23,7 +25,41 @@ export function renderWithSubscriptionDashContext(
|
|||||||
|
|
||||||
if (!options?.recurlyNotLoaded) {
|
if (!options?.recurlyNotLoaded) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.recurly = {}
|
global.recurly = {
|
||||||
|
configure: () => {},
|
||||||
|
Pricing: {
|
||||||
|
Subscription: () => {
|
||||||
|
return {
|
||||||
|
plan: (planCode: string) => {
|
||||||
|
const plan = plans.find(p => p.planCode === planCode)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
next: {
|
||||||
|
total: plan?.price_in_cents
|
||||||
|
? plan.price_in_cents / 100
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currency: () => {
|
||||||
|
return {
|
||||||
|
catch: () => {
|
||||||
|
return {
|
||||||
|
done: (callback: (response: object) => void) => {
|
||||||
|
if (!options?.queryingRecurly) {
|
||||||
|
return callback(response)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(component, {
|
return render(component, {
|
||||||
@@ -33,6 +69,6 @@ export function renderWithSubscriptionDashContext(
|
|||||||
|
|
||||||
export function cleanUpContext() {
|
export function cleanUpContext() {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete window.recurly
|
delete global.recurly
|
||||||
window.metaAttributesCache = new Map()
|
window.metaAttributesCache = new Map()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Features = {
|
|||||||
|
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
annual?: boolean
|
annual?: boolean
|
||||||
|
displayPrice?: string
|
||||||
featureDescription?: Record<string, unknown>[]
|
featureDescription?: Record<string, unknown>[]
|
||||||
features?: Features
|
features?: Features
|
||||||
groupPlan?: boolean
|
groupPlan?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user