mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #11819 from overleaf/jel-subscription-dash-change-to-group-prices
[web] Show price in change to group plan modal on React subscription dash GitOrigin-RevId: 6a1a4be3a7d008cd9e26186c2d97bc0bdc2f82ed
This commit is contained in:
parent
cad3660f0b
commit
124306d7ac
15 changed files with 685 additions and 49 deletions
38
package-lock.json
generated
38
package-lock.json
generated
|
@ -7441,6 +7441,16 @@
|
|||
"url-parse": "^1.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
|
||||
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
|
@ -8108,6 +8118,15 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bootstrap": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.6.tgz",
|
||||
"integrity": "sha512-BlAc3YATdasbHoxMoBWODrSF6qwQO/E9X8wVxCCSa6rWjnaZfpkr2N6pUMCY6jj2+wf0muUtLySbvU9etX6YqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bson": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz",
|
||||
|
@ -34569,6 +34588,7 @@
|
|||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/bootstrap": "^5.2.6",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
|
@ -43579,6 +43599,7 @@
|
|||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/bootstrap": "*",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
|
@ -43732,7 +43753,7 @@
|
|||
"passport-local": "^1.0.0",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"passport-orcid": "0.0.4",
|
||||
"passport-saml": "3.2.3",
|
||||
"passport-saml": "^3.2.3",
|
||||
"passport-twitter": "^1.0.4",
|
||||
"pdfjs-dist213": "npm:pdfjs-dist@2.13.216",
|
||||
"pdfjs-dist31": "npm:pdfjs-dist@3.1.81",
|
||||
|
@ -45576,6 +45597,12 @@
|
|||
"url-parse": "^1.4.7"
|
||||
}
|
||||
},
|
||||
"@popperjs/core": {
|
||||
"version": "2.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
|
||||
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
|
||||
"dev": true
|
||||
},
|
||||
"@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
|
@ -46120,6 +46147,15 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/bootstrap": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.6.tgz",
|
||||
"integrity": "sha512-BlAc3YATdasbHoxMoBWODrSF6qwQO/E9X8wVxCCSa6rWjnaZfpkr2N6pUMCY6jj2+wf0muUtLySbvU9etX6YqA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@popperjs/core": "^2.9.2"
|
||||
}
|
||||
},
|
||||
"@types/bson": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz",
|
||||
|
|
|
@ -432,6 +432,7 @@
|
|||
"linked_file": "",
|
||||
"loading": "",
|
||||
"loading_github_repositories": "",
|
||||
"loading_prices": "",
|
||||
"loading_recent_github_commits": "",
|
||||
"log_entry_description": "",
|
||||
"log_entry_maximum_entries": "",
|
||||
|
@ -820,6 +821,7 @@
|
|||
"toolbar_undo": "",
|
||||
"total_per_month": "",
|
||||
"total_per_year": "",
|
||||
"total_with_subtotal_and_tax": "",
|
||||
"total_words": "",
|
||||
"track_changes": "",
|
||||
"trash": "",
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function GenericErrorAlert({
|
||||
className,
|
||||
}: {
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const alertClassName = classNames('alert', 'alert-danger', className)
|
||||
|
||||
return (
|
||||
<div className={alertClassName} aria-live="polite">
|
||||
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
|
||||
{t('generic_if_problem_continues_contact_us')}.
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -3,9 +3,11 @@ import { Modal } from 'react-bootstrap'
|
|||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { GroupPlans } from '../../../../../../../../../../types/subscription/dashboard/group-plans'
|
||||
import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
|
||||
import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
|
||||
import getMeta from '../../../../../../../../utils/meta'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import GenericErrorAlert from '../../../../generic-error-alert'
|
||||
|
||||
const educationalPercentDiscount = 40
|
||||
const groupSizeForEducationalDiscount = 10
|
||||
|
@ -53,21 +55,66 @@ function EducationDiscountAppliedOrNot({ groupSize }: { groupSize: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function GroupPrice() {
|
||||
function GroupPrice({
|
||||
groupPlanToChangeToPrice,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
}: {
|
||||
groupPlanToChangeToPrice?: PriceForDisplayData
|
||||
queryingGroupPlanToChangeToPrice: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const totalPrice =
|
||||
!queryingGroupPlanToChangeToPrice &&
|
||||
groupPlanToChangeToPrice?.totalForDisplay
|
||||
? groupPlanToChangeToPrice.totalForDisplay
|
||||
: '…'
|
||||
|
||||
const perUserPrice =
|
||||
!queryingGroupPlanToChangeToPrice &&
|
||||
groupPlanToChangeToPrice?.perUserDisplayPrice
|
||||
? groupPlanToChangeToPrice.perUserDisplayPrice
|
||||
: '…'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden>
|
||||
X <span className="small">/ {t('year')}</span>
|
||||
{totalPrice} <span className="small">/ {t('year')}</span>
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{/* TODO: price */}
|
||||
<Trans i18nKey="x_price_per_year" values={{ price: '$X' }} />
|
||||
{queryingGroupPlanToChangeToPrice ? (
|
||||
t('loading_prices')
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="x_price_per_year"
|
||||
values={{ price: groupPlanToChangeToPrice?.totalForDisplay }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<br />
|
||||
|
||||
<span className="circle-subtext">
|
||||
{/* TODO: price */}
|
||||
<Trans i18nKey="x_price_per_user" values={{ price: '$X' }} />
|
||||
<span aria-hidden>
|
||||
<Trans
|
||||
i18nKey="x_price_per_user"
|
||||
values={{
|
||||
price: perUserPrice,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{queryingGroupPlanToChangeToPrice ? (
|
||||
t('loading_prices')
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="x_price_per_user"
|
||||
values={{
|
||||
price: perUserPrice,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
@ -78,10 +125,13 @@ export function ChangeToGroupModal() {
|
|||
const { t } = useTranslation()
|
||||
const {
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToPrice,
|
||||
groupPlanToChangeToPriceError,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
handleCloseModal,
|
||||
modalIdShown,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
setGroupPlanToChangeToCode,
|
||||
setGroupPlanToChangeToSize,
|
||||
setGroupPlanToChangeToUsage,
|
||||
|
@ -100,8 +150,6 @@ export function ChangeToGroupModal() {
|
|||
|
||||
function handleGetInTouchButton() {
|
||||
handleCloseModal()
|
||||
|
||||
// @ts-ignore
|
||||
$('[data-ol-contact-form-modal="contact-us"]').modal()
|
||||
}
|
||||
|
||||
|
@ -142,10 +190,16 @@ export function ChangeToGroupModal() {
|
|||
|
||||
<Modal.Body>
|
||||
<div className="container-fluid plans group-subscription-modal">
|
||||
{groupPlanToChangeToPriceError && <GenericErrorAlert />}
|
||||
<div className="row">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="circle circle-lg">
|
||||
<GroupPrice />
|
||||
<GroupPrice
|
||||
groupPlanToChangeToPrice={groupPlanToChangeToPrice}
|
||||
queryingGroupPlanToChangeToPrice={
|
||||
queryingGroupPlanToChangeToPrice
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p>{t('each_user_will_have_access_to')}:</p>
|
||||
<ul className="list-unstyled">
|
||||
|
@ -257,11 +311,34 @@ export function ChangeToGroupModal() {
|
|||
|
||||
<Modal.Footer>
|
||||
<div className="text-center">
|
||||
{groupPlanToChangeToPrice?.includesTax && (
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="total_with_subtotal_and_tax"
|
||||
values={{
|
||||
total: groupPlanToChangeToPrice.totalForDisplay,
|
||||
subtotal: groupPlanToChangeToPrice.subtotal,
|
||||
tax: groupPlanToChangeToPrice.tax,
|
||||
}}
|
||||
components={[
|
||||
/* eslint-disable-next-line react/jsx-key */
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>{t('new_subscription_will_be_billed_immediately')}</strong>
|
||||
</p>
|
||||
<hr className="thin" />
|
||||
<button className="btn btn-primary btn-lg">{t('upgrade_now')}</button>
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
disabled={
|
||||
queryingGroupPlanToChangeToPrice || !groupPlanToChangeToPrice
|
||||
}
|
||||
>
|
||||
{t('upgrade_now')}
|
||||
</button>
|
||||
<hr className="thin" />
|
||||
<button className="btn-inline-link" onClick={handleGetInTouchButton}>
|
||||
<Trans i18nKey="need_more_than_x_licenses" values={{ x: 50 }} />{' '}
|
||||
|
|
|
@ -11,11 +11,17 @@ import {
|
|||
ManagedGroupSubscription,
|
||||
Subscription,
|
||||
} from '../../../../../types/subscription/dashboard/subscription'
|
||||
import { Plan } from '../../../../../types/subscription/plan'
|
||||
import {
|
||||
Plan,
|
||||
PriceForDisplayData,
|
||||
} from '../../../../../types/subscription/plan'
|
||||
import { Institution as ManagedInstitution } from '../components/dashboard/managed-institutions'
|
||||
import { Institution } from '../../../../../types/institution'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
|
||||
import {
|
||||
loadDisplayPriceWithTaxPromise,
|
||||
loadGroupDisplayPriceWithTaxPromise,
|
||||
} from '../util/recurly-pricing'
|
||||
import { isRecurlyLoaded } from '../util/is-recurly-loaded'
|
||||
import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
|
||||
|
||||
|
@ -23,6 +29,8 @@ type SubscriptionDashboardContextValue = {
|
|||
groupPlanToChangeToCode?: string
|
||||
groupPlanToChangeToSize: string
|
||||
groupPlanToChangeToUsage?: string
|
||||
groupPlanToChangeToPrice?: PriceForDisplayData
|
||||
groupPlanToChangeToPriceError?: boolean
|
||||
handleCloseModal: () => void
|
||||
handleOpenModal: (
|
||||
modalIdToOpen: SubscriptionDashModalIds,
|
||||
|
@ -37,6 +45,7 @@ type SubscriptionDashboardContextValue = {
|
|||
personalSubscription?: Subscription
|
||||
plans: Plan[]
|
||||
planCodeToChangeTo?: string
|
||||
queryingGroupPlanToChangeToPrice: boolean
|
||||
queryingIndividualPlansData: boolean
|
||||
recurlyLoadError: boolean
|
||||
setGroupPlanToChangeToCode: React.Dispatch<
|
||||
|
@ -84,6 +93,14 @@ export function SubscriptionDashboardProvider({
|
|||
>()
|
||||
const [groupPlanToChangeToUsage, setGroupPlanToChangeToUsage] =
|
||||
useState('enterprise')
|
||||
const [
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
setQueryingGroupPlanToChangeToPrice,
|
||||
] = useState(false)
|
||||
const [groupPlanToChangeToPrice, setGroupPlanToChangeToPrice] =
|
||||
useState<PriceForDisplayData>()
|
||||
const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] =
|
||||
useState(false)
|
||||
|
||||
const plansWithoutDisplayPrice = getMeta('ol-plans')
|
||||
const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
|
||||
|
@ -133,6 +150,44 @@ export function SubscriptionDashboardProvider({
|
|||
}
|
||||
}, [personalSubscription, plansWithoutDisplayPrice])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isRecurlyLoaded() &&
|
||||
groupPlanToChangeToCode &&
|
||||
groupPlanToChangeToSize &&
|
||||
groupPlanToChangeToUsage &&
|
||||
personalSubscription
|
||||
) {
|
||||
setQueryingGroupPlanToChangeToPrice(true)
|
||||
|
||||
const { currency, taxRate } = personalSubscription.recurly
|
||||
const fetchGroupDisplayPrice = async () => {
|
||||
setGroupPlanToChangeToPriceError(false)
|
||||
let priceData
|
||||
try {
|
||||
priceData = await loadGroupDisplayPriceWithTaxPromise(
|
||||
groupPlanToChangeToCode,
|
||||
currency,
|
||||
taxRate,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setGroupPlanToChangeToPriceError(true)
|
||||
}
|
||||
setQueryingGroupPlanToChangeToPrice(false)
|
||||
setGroupPlanToChangeToPrice(priceData)
|
||||
}
|
||||
fetchGroupDisplayPrice()
|
||||
}
|
||||
}, [
|
||||
groupPlanToChangeToUsage,
|
||||
groupPlanToChangeToSize,
|
||||
personalSubscription,
|
||||
groupPlanToChangeToCode,
|
||||
])
|
||||
|
||||
const updateManagedInstitution = useCallback(
|
||||
(institution: ManagedInstitution) => {
|
||||
setManagedInstitutions(institutions => {
|
||||
|
@ -145,6 +200,7 @@ export function SubscriptionDashboardProvider({
|
|||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setModalIdShown(undefined)
|
||||
setPlanCodeToChangeTo(undefined)
|
||||
|
@ -161,6 +217,8 @@ export function SubscriptionDashboardProvider({
|
|||
const value = useMemo<SubscriptionDashboardContextValue>(
|
||||
() => ({
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToPrice,
|
||||
groupPlanToChangeToPriceError,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
handleCloseModal,
|
||||
|
@ -174,6 +232,7 @@ export function SubscriptionDashboardProvider({
|
|||
personalSubscription,
|
||||
plans,
|
||||
planCodeToChangeTo,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
queryingIndividualPlansData,
|
||||
recurlyLoadError,
|
||||
setGroupPlanToChangeToCode,
|
||||
|
@ -189,6 +248,8 @@ export function SubscriptionDashboardProvider({
|
|||
}),
|
||||
[
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToPrice,
|
||||
groupPlanToChangeToPriceError,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
handleCloseModal,
|
||||
|
@ -202,6 +263,7 @@ export function SubscriptionDashboardProvider({
|
|||
personalSubscription,
|
||||
plans,
|
||||
planCodeToChangeTo,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
queryingIndividualPlansData,
|
||||
recurlyLoadError,
|
||||
setGroupPlanToChangeToCode,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { SubscriptionPricingState } from '@recurly/recurly-js'
|
||||
import { PriceForDisplayData } from '../../../../../types/subscription/plan'
|
||||
import { currencies, CurrencyCode } from '../data/currency'
|
||||
|
||||
function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
|
||||
|
@ -19,7 +20,7 @@ function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
|
|||
})
|
||||
}
|
||||
|
||||
function priceWithCents(price: number): string | number {
|
||||
function priceToWithCents(price: number) {
|
||||
return price % 1 !== 0 ? price.toFixed(2) : price
|
||||
}
|
||||
|
||||
|
@ -27,7 +28,7 @@ export function formatPriceForDisplayData(
|
|||
price: string,
|
||||
taxRate: number,
|
||||
currencyCode: CurrencyCode
|
||||
) {
|
||||
): PriceForDisplayData {
|
||||
const currencySymbol = currencies[currencyCode]
|
||||
|
||||
const totalPriceExTax = parseFloat(price)
|
||||
|
@ -38,7 +39,7 @@ export function formatPriceForDisplayData(
|
|||
const totalWithTax = totalPriceExTax + taxAmount
|
||||
|
||||
return {
|
||||
totalForDisplay: `${currencySymbol}${priceWithCents(totalWithTax)}`,
|
||||
totalForDisplay: `${currencySymbol}${priceToWithCents(totalWithTax)}`,
|
||||
totalAsNumber: totalWithTax,
|
||||
subtotal: `${currencySymbol}${totalPriceExTax.toFixed(2)}`,
|
||||
tax: `${currencySymbol}${taxAmount.toFixed(2)}`,
|
||||
|
@ -46,6 +47,14 @@ export function formatPriceForDisplayData(
|
|||
}
|
||||
}
|
||||
|
||||
function getPerUserDisplayPrice(
|
||||
totalPrice: number,
|
||||
currencySymbol: string,
|
||||
size: string
|
||||
): string {
|
||||
return `${currencySymbol}${priceToWithCents(totalPrice / parseInt(size))}`
|
||||
}
|
||||
|
||||
export async function loadDisplayPriceWithTaxPromise(
|
||||
planCode: string,
|
||||
currencyCode: CurrencyCode,
|
||||
|
@ -60,3 +69,31 @@ export async function loadDisplayPriceWithTaxPromise(
|
|||
if (price)
|
||||
return formatPriceForDisplayData(price.next.total, taxRate, currencyCode)
|
||||
}
|
||||
|
||||
export async function loadGroupDisplayPriceWithTaxPromise(
|
||||
groupPlanCode: string,
|
||||
currencyCode: CurrencyCode,
|
||||
taxRate: number,
|
||||
size: string,
|
||||
usage: string
|
||||
) {
|
||||
if (!recurly) return
|
||||
|
||||
const planCode = `group_${groupPlanCode}_${size}_${usage}`
|
||||
const price = await loadDisplayPriceWithTaxPromise(
|
||||
planCode,
|
||||
currencyCode,
|
||||
taxRate
|
||||
)
|
||||
|
||||
if (price) {
|
||||
const currencySymbol = currencies[currencyCode]
|
||||
price.perUserDisplayPrice = getPerUserDisplayPrice(
|
||||
price.totalAsNumber,
|
||||
currencySymbol,
|
||||
size
|
||||
)
|
||||
}
|
||||
|
||||
return price
|
||||
}
|
|
@ -839,6 +839,7 @@
|
|||
"loading": "Loading",
|
||||
"loading_content": "Creating Project",
|
||||
"loading_github_repositories": "Loading your GitHub repositories",
|
||||
"loading_prices": "loading prices",
|
||||
"loading_recent_github_commits": "Loading recent commits",
|
||||
"log_entry_description": "Log entry with level: __level__",
|
||||
"log_entry_maximum_entries": "Maximum log entries limit hit",
|
||||
|
@ -1540,6 +1541,7 @@
|
|||
"total_per_month": "Total per month",
|
||||
"total_per_year": "Total per year",
|
||||
"total_per_year_for_x_users": "total per year for __licenseSize__ users",
|
||||
"total_with_subtotal_and_tax": "Total: <0>__total__</0> (__subtotal__ + __tax__ tax) per year",
|
||||
"total_words": "Total Words",
|
||||
"tr": "Turkish",
|
||||
"track_any_change_in_real_time": "Track any change, in real-time",
|
||||
|
|
|
@ -258,6 +258,7 @@
|
|||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/bootstrap": "^5.2.6",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { expect } from 'chai'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ChangePlan } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-plan'
|
||||
import { groupPlans, plans } from '../../../../../fixtures/plans'
|
||||
import {
|
||||
annualActiveSubscription,
|
||||
annualActiveSubscriptionEuro,
|
||||
annualActiveSubscriptionPro,
|
||||
pendingSubscriptionChange,
|
||||
} from '../../../../../fixtures/subscriptions'
|
||||
import { ActiveSubscription } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
|
||||
|
@ -326,9 +329,11 @@ describe('<ChangePlan />', function () {
|
|||
describe('Change to group plan modal', function () {
|
||||
const standardPlanCollaboratorText = '10 collaborators per project'
|
||||
const professionalPlanCollaboratorText = 'Unlimited collaborators'
|
||||
it('open group plan modal "Change to a group plan" clicked', async function () {
|
||||
renderActiveSubscription(annualActiveSubscription)
|
||||
const educationInputLabel =
|
||||
'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
|
||||
|
||||
let modal: HTMLElement
|
||||
async function openModal() {
|
||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||
fireEvent.click(button)
|
||||
|
||||
|
@ -337,10 +342,19 @@ describe('<ChangePlan />', function () {
|
|||
})
|
||||
fireEvent.click(buttonGroupModal)
|
||||
|
||||
const modal = await screen.findByRole('dialog')
|
||||
modal = await screen.findByRole('dialog')
|
||||
}
|
||||
|
||||
it('open group plan modal "Change to a group plan" clicked', async function () {
|
||||
renderActiveSubscription(annualActiveSubscription)
|
||||
await openModal()
|
||||
|
||||
within(modal).getByText('Customize your group subscription')
|
||||
within(modal).getByText('Save 30% or more')
|
||||
|
||||
within(modal).getByText('$1290 per year')
|
||||
expect(within(modal).getAllByText('$129 per user').length).to.equal(2)
|
||||
|
||||
within(modal).getByText('Each user will have access to:')
|
||||
within(modal).getByText('All premium features')
|
||||
within(modal).getByText('Sync with Dropbox and GitHub')
|
||||
|
@ -354,15 +368,23 @@ describe('<ChangePlan />', function () {
|
|||
const plans = within(modal).getByRole('group')
|
||||
const planOptions = within(plans).getAllByRole('radio')
|
||||
expect(planOptions.length).to.equal(groupPlans.plans.length)
|
||||
const standardPlanRadioInput = within(modal).getByLabelText(
|
||||
'Standard'
|
||||
) as HTMLInputElement
|
||||
expect(standardPlanRadioInput.checked).to.be.true
|
||||
|
||||
const sizeSelect = within(modal).getByRole('combobox')
|
||||
const sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
|
||||
expect(sizeSelect.value).to.equal('10')
|
||||
const sizeOption = within(sizeSelect).getAllByRole('option')
|
||||
expect(sizeOption.length).to.equal(groupPlans.sizes.length)
|
||||
within(modal).getByText(
|
||||
'Overleaf offers a 40% educational discount for groups of 10 or more.'
|
||||
)
|
||||
|
||||
within(modal).getByRole('checkbox')
|
||||
const educationalCheckbox = within(modal).getByRole(
|
||||
'checkbox'
|
||||
) as HTMLInputElement
|
||||
expect(educationalCheckbox.checked).to.be.false
|
||||
within(modal).getByText(
|
||||
'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
|
||||
)
|
||||
|
@ -371,6 +393,8 @@ describe('<ChangePlan />', function () {
|
|||
'Your new subscription will be billed immediately to your current payment method.'
|
||||
)
|
||||
|
||||
expect(within(modal).queryByText('tax', { exact: false })).to.be.null
|
||||
|
||||
within(modal).getByRole('button', { name: 'Upgrade Now' })
|
||||
|
||||
within(modal).getByRole('button', {
|
||||
|
@ -380,46 +404,121 @@ describe('<ChangePlan />', function () {
|
|||
|
||||
it('changes the collaborator count when the plan changes', async function () {
|
||||
renderActiveSubscription(annualActiveSubscription)
|
||||
await openModal()
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||
fireEvent.click(button)
|
||||
|
||||
const buttonGroupModal = await screen.findByRole('button', {
|
||||
name: 'Change to a group plan',
|
||||
})
|
||||
fireEvent.click(buttonGroupModal)
|
||||
|
||||
const modal = await screen.findByRole('dialog')
|
||||
const professionalPlanOption =
|
||||
within(modal).getByLabelText('Professional')
|
||||
fireEvent.click(professionalPlanOption)
|
||||
|
||||
within(modal).getByText(professionalPlanCollaboratorText)
|
||||
await within(modal).findByText(professionalPlanCollaboratorText)
|
||||
expect(within(modal).queryByText(standardPlanCollaboratorText)).to.be.null
|
||||
})
|
||||
|
||||
it('shows educational discount applied when input checked', async function () {
|
||||
it('shows educational discount applied when input checked and also notes if not enough users to get discount', async function () {
|
||||
const discountAppliedText = '40% educational discount applied!'
|
||||
const discountNotAppliedText =
|
||||
'The educational discount is available for groups of 10 or more'
|
||||
renderActiveSubscription(annualActiveSubscription)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Change plan' })
|
||||
fireEvent.click(button)
|
||||
await openModal()
|
||||
|
||||
const buttonGroupModal = await screen.findByRole('button', {
|
||||
name: 'Change to a group plan',
|
||||
})
|
||||
fireEvent.click(buttonGroupModal)
|
||||
|
||||
const modal = await screen.findByRole('dialog')
|
||||
|
||||
const educationInput = within(modal).getByLabelText(
|
||||
'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
|
||||
)
|
||||
const educationInput = within(modal).getByLabelText(educationInputLabel)
|
||||
fireEvent.click(educationInput)
|
||||
within(modal).getByText(discountAppliedText)
|
||||
await within(modal).findByText(discountAppliedText)
|
||||
expect(within(modal).queryByText(discountNotAppliedText)).to.be.null
|
||||
|
||||
const sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
|
||||
await userEvent.selectOptions(sizeSelect, [screen.getByText('5')])
|
||||
await within(modal).findByText(discountNotAppliedText)
|
||||
expect(within(modal).queryByText(discountAppliedText)).to.be.null
|
||||
})
|
||||
|
||||
it('shows total with tax when tax applied', async function () {
|
||||
renderActiveSubscription(annualActiveSubscriptionEuro, undefined, 'EUR')
|
||||
|
||||
await openModal()
|
||||
|
||||
within(modal).getByText('Total:', { exact: false })
|
||||
expect(
|
||||
within(modal).getAllByText('€1438.40', { exact: false }).length
|
||||
).to.equal(3)
|
||||
within(modal).getByText('(€1160.00 + €278.40 tax) per year', {
|
||||
exact: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('changes the price when options change', async function () {
|
||||
renderActiveSubscription(annualActiveSubscription)
|
||||
|
||||
await openModal()
|
||||
|
||||
within(modal).getByText('$1290 per year')
|
||||
within(modal).getAllByText('$129 per user')
|
||||
|
||||
// plan type (pro collab)
|
||||
let standardPlanRadioInput = within(modal).getByLabelText(
|
||||
'Standard'
|
||||
) as HTMLInputElement
|
||||
expect(standardPlanRadioInput.checked).to.be.true
|
||||
let professionalPlanRadioInput = within(modal).getByLabelText(
|
||||
'Professional'
|
||||
) as HTMLInputElement
|
||||
expect(professionalPlanRadioInput.checked).to.be.false
|
||||
|
||||
fireEvent.click(professionalPlanRadioInput)
|
||||
|
||||
standardPlanRadioInput = within(modal).getByLabelText(
|
||||
'Standard'
|
||||
) as HTMLInputElement
|
||||
expect(standardPlanRadioInput.checked).to.be.false
|
||||
professionalPlanRadioInput = within(modal).getByLabelText(
|
||||
'Professional'
|
||||
) as HTMLInputElement
|
||||
expect(professionalPlanRadioInput.checked).to.be.true
|
||||
|
||||
await within(modal).findByText('$2590 per year')
|
||||
await within(modal).findAllByText('$259 per user')
|
||||
|
||||
// user count
|
||||
let sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
|
||||
expect(sizeSelect.value).to.equal('10')
|
||||
await userEvent.selectOptions(sizeSelect, [screen.getByText('5')])
|
||||
sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
|
||||
expect(sizeSelect.value).to.equal('5')
|
||||
|
||||
await within(modal).findByText('$1395 per year')
|
||||
await within(modal).findAllByText('$279 per user')
|
||||
|
||||
// usage (enterprise or educational)
|
||||
let educationInput = within(modal).getByLabelText(
|
||||
educationInputLabel
|
||||
) as HTMLInputElement
|
||||
expect(educationInput.checked).to.be.false
|
||||
fireEvent.click(educationInput)
|
||||
educationInput = within(modal).getByLabelText(
|
||||
educationInputLabel
|
||||
) as HTMLInputElement
|
||||
expect(educationInput.checked).to.be.true
|
||||
|
||||
// make sure doesn't change price until back at min user to qualify
|
||||
await within(modal).findByText('$1395 per year')
|
||||
await within(modal).findAllByText('$279 per user')
|
||||
|
||||
await userEvent.selectOptions(sizeSelect, [screen.getByText('10')])
|
||||
|
||||
await within(modal).findByText('$1550 per year')
|
||||
await within(modal).findAllByText('$155 per user')
|
||||
})
|
||||
|
||||
it('has pro as the default group plan type if user is on a pro plan', async function () {
|
||||
renderActiveSubscription(annualActiveSubscriptionPro)
|
||||
|
||||
await openModal()
|
||||
|
||||
const standardPlanRadioInput = within(modal).getByLabelText(
|
||||
'Professional'
|
||||
) as HTMLInputElement
|
||||
expect(standardPlanRadioInput.checked).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -228,3 +228,202 @@ export const groupPlans: GroupPlans = {
|
|||
],
|
||||
sizes: ['2', '3', '4', '5', '10', '20', '50'],
|
||||
}
|
||||
|
||||
export const groupPriceByUsageTypeAndSize = {
|
||||
educational: {
|
||||
professional: {
|
||||
EUR: {
|
||||
'2': {
|
||||
price_in_cents: 51600,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 77400,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 103200,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 129000,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 143000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 264000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 605000,
|
||||
},
|
||||
},
|
||||
USD: {
|
||||
'2': {
|
||||
price_in_cents: 55800,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 83700,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 111600,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 139500,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 155000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 286000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 655000,
|
||||
},
|
||||
},
|
||||
},
|
||||
collaborator: {
|
||||
EUR: {
|
||||
'2': {
|
||||
price_in_cents: 25000,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 37500,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 50000,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 62500,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 69000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 128000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 295000,
|
||||
},
|
||||
},
|
||||
USD: {
|
||||
'2': {
|
||||
price_in_cents: 27800,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 41700,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 55600,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 69500,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 77000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 142000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 325000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
professional: {
|
||||
EUR: {
|
||||
'2': {
|
||||
price_in_cents: 51600,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 77400,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 103200,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 129000,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 239000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 442000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 1010000,
|
||||
},
|
||||
},
|
||||
USD: {
|
||||
'2': {
|
||||
price_in_cents: 55800,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 83700,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 111600,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 139500,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 259000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 478000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 1095000,
|
||||
},
|
||||
},
|
||||
},
|
||||
collaborator: {
|
||||
EUR: {
|
||||
'2': {
|
||||
price_in_cents: 25000,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 37500,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 50000,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 62500,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 116000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 214000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 490000,
|
||||
},
|
||||
},
|
||||
USD: {
|
||||
'2': {
|
||||
price_in_cents: 27800,
|
||||
},
|
||||
'3': {
|
||||
price_in_cents: 41700,
|
||||
},
|
||||
'4': {
|
||||
price_in_cents: 55600,
|
||||
},
|
||||
'5': {
|
||||
price_in_cents: 69500,
|
||||
},
|
||||
'10': {
|
||||
price_in_cents: 129000,
|
||||
},
|
||||
'20': {
|
||||
price_in_cents: 238000,
|
||||
},
|
||||
'50': {
|
||||
price_in_cents: 545000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -51,6 +51,83 @@ export const annualActiveSubscription: Subscription = {
|
|||
},
|
||||
}
|
||||
|
||||
export const annualActiveSubscriptionEuro: Subscription = {
|
||||
manager_ids: ['abc123'],
|
||||
member_ids: [],
|
||||
invited_emails: [],
|
||||
groupPlan: false,
|
||||
membersLimit: 0,
|
||||
_id: 'def456',
|
||||
admin_id: 'abc123',
|
||||
teamInvites: [],
|
||||
planCode: 'collaborator-annual',
|
||||
recurlySubscription_id: 'ghi789',
|
||||
plan: {
|
||||
planCode: 'collaborator-annual',
|
||||
name: 'Standard (Collaborator) Annual',
|
||||
price_in_cents: 21900,
|
||||
annual: true,
|
||||
featureDescription: [],
|
||||
},
|
||||
recurly: {
|
||||
tax: 4296,
|
||||
taxRate: 0.24,
|
||||
billingDetailsLink: '/user/subscription/recurly/billing-details',
|
||||
accountManagementLink: '/user/subscription/recurly/account-management',
|
||||
additionalLicenses: 0,
|
||||
totalLicenses: 0,
|
||||
nextPaymentDueAt,
|
||||
currency: 'EUR',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
displayPrice: '€221.96',
|
||||
},
|
||||
}
|
||||
|
||||
export const annualActiveSubscriptionPro: Subscription = {
|
||||
manager_ids: ['abc123'],
|
||||
member_ids: [],
|
||||
invited_emails: [],
|
||||
groupPlan: false,
|
||||
membersLimit: 0,
|
||||
_id: 'def456',
|
||||
admin_id: 'abc123',
|
||||
teamInvites: [],
|
||||
planCode: 'professional',
|
||||
recurlySubscription_id: 'ghi789',
|
||||
plan: {
|
||||
planCode: 'professional',
|
||||
name: 'Professional',
|
||||
price_in_cents: 4500,
|
||||
featureDescription: [],
|
||||
},
|
||||
recurly: {
|
||||
tax: 0,
|
||||
taxRate: 0,
|
||||
billingDetailsLink: '/user/subscription/recurly/billing-details',
|
||||
accountManagementLink: '/user/subscription/recurly/account-management',
|
||||
additionalLicenses: 0,
|
||||
totalLicenses: 0,
|
||||
nextPaymentDueAt,
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
displayPrice: '$42.00',
|
||||
},
|
||||
}
|
||||
|
||||
export const pastDueExpiredSubscription: Subscription = {
|
||||
manager_ids: ['abc123'],
|
||||
member_ids: [],
|
||||
|
|
|
@ -5,9 +5,11 @@ import { renderWithSubscriptionDashContext } from './render-with-subscription-da
|
|||
|
||||
export function renderActiveSubscription(
|
||||
subscription: Subscription,
|
||||
tags: { name: string; value: string | object | Array<object> }[] = []
|
||||
tags: { name: string; value: string | object | Array<object> }[] = [],
|
||||
currencyCode?: string
|
||||
) {
|
||||
const renderOptions = {
|
||||
currencyCode,
|
||||
metaTags: [
|
||||
...tags,
|
||||
{ name: 'ol-plans', value: plans },
|
||||
|
@ -18,7 +20,7 @@ export function renderActiveSubscription(
|
|||
{ name: 'ol-subscription', value: subscription },
|
||||
{
|
||||
name: 'ol-recommendedCurrency',
|
||||
value: 'USD',
|
||||
value: currencyCode || 'USD',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import _ from 'lodash'
|
||||
import { SubscriptionDashboardProvider } from '../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
|
||||
import { plans } from '../fixtures/plans'
|
||||
import { groupPriceByUsageTypeAndSize, plans } from '../fixtures/plans'
|
||||
|
||||
export function renderWithSubscriptionDashContext(
|
||||
component: React.ReactElement,
|
||||
|
@ -8,6 +9,7 @@ export function renderWithSubscriptionDashContext(
|
|||
metaTags?: { name: string; value: string | object | Array<object> }[]
|
||||
recurlyNotLoaded?: boolean
|
||||
queryingRecurly?: boolean
|
||||
currencyCode?: string
|
||||
}
|
||||
) {
|
||||
const SubscriptionDashboardProviderWrapper = ({
|
||||
|
@ -31,7 +33,20 @@ export function renderWithSubscriptionDashContext(
|
|||
Subscription: () => {
|
||||
return {
|
||||
plan: (planCode: string) => {
|
||||
const plan = plans.find(p => p.planCode === planCode)
|
||||
let plan
|
||||
const isGroupPlan = planCode.includes('group')
|
||||
if (isGroupPlan) {
|
||||
const [, planType, size, usage] = planCode.split('_')
|
||||
const currencyCode = options?.currencyCode || 'USD'
|
||||
plan = _.get(groupPriceByUsageTypeAndSize, [
|
||||
usage,
|
||||
planType,
|
||||
currencyCode,
|
||||
size,
|
||||
])
|
||||
} else {
|
||||
plan = plans.find(p => p.planCode === planCode)
|
||||
}
|
||||
|
||||
const response = {
|
||||
next: {
|
||||
|
|
|
@ -28,3 +28,12 @@ export type Plan = {
|
|||
planCode: string
|
||||
price_in_cents: number
|
||||
}
|
||||
|
||||
export type PriceForDisplayData = {
|
||||
totalForDisplay: string
|
||||
totalAsNumber: number
|
||||
subtotal: string
|
||||
tax: string
|
||||
includesTax: boolean
|
||||
perUserDisplayPrice?: string
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue