Merge pull request #20968 from overleaf/jdt-subscription-page-addons

feat: add all add-ons to price calculation in subs view model
GitOrigin-RevId: d03374192d735278c6459fc6341a72d0b0c7c3aa
This commit is contained in:
Jimmy Domagala-Tang 2024-11-04 08:44:46 -08:00 committed by Copybot
parent 811e935ced
commit 9818912cb7
19 changed files with 493 additions and 88 deletions

View file

@ -68,15 +68,23 @@ function formatPriceLocalized(priceInCents, currency = 'USD', locale) {
return formatCurrencyLocalized(priceInCurrencyUnit, currency, locale)
}
function formatDate(date) {
function formatDateTime(date) {
if (!date) {
return null
}
return dateformat(date, 'mmmm dS, yyyy h:MM TT Z', true)
}
function formatDate(date) {
if (!date) {
return null
}
return dateformat(date, 'mmmm dS, yyyy', true)
}
module.exports = {
formatPriceDefault,
formatPriceLocalized,
formatDateTime,
formatDate,
}

View file

@ -250,17 +250,13 @@ async function buildUsersSubscriptionViewModel(
// Note: tax_in_cents already includes the tax for any addon.
let addOnPrice = 0
let additionalLicenses = 0
if (
plan.membersLimitAddOn &&
Array.isArray(recurlySubscription.subscription_add_ons)
) {
recurlySubscription.subscription_add_ons.forEach(addOn => {
if (addOn.add_on_code === plan.membersLimitAddOn) {
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
additionalLicenses += addOn.quantity
}
})
}
const addOns = recurlySubscription.subscription_add_ons || []
addOns.forEach(addOn => {
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
if (addOn.add_on_code === plan.membersLimitAddOn) {
additionalLicenses += addOn.quantity
}
})
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
personalSubscription.recurly = {
tax,
@ -270,14 +266,17 @@ async function buildUsersSubscriptionViewModel(
billingDetailsLink: buildHostedLink('billing-details'),
accountManagementLink: buildHostedLink('account-management'),
additionalLicenses,
addOns: recurlySubscription.subscription_add_ons || [],
addOns,
totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDate(
nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
recurlySubscription.current_period_ends_at
),
nextPaymentDueDate: SubscriptionFormatters.formatDate(
recurlySubscription.current_period_ends_at
),
currency: recurlySubscription.currency,
state: recurlySubscription.state,
trialEndsAtFormatted: SubscriptionFormatters.formatDate(
trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
recurlySubscription.trial_ends_at
),
trial_ends_at: recurlySubscription.trial_ends_at,
@ -297,24 +296,18 @@ async function buildUsersSubscriptionViewModel(
let pendingAddOnTax = 0
let pendingAddOnPrice = 0
if (recurlySubscription.pending_subscription.subscription_add_ons) {
if (
pendingPlan.membersLimitAddOn &&
Array.isArray(
recurlySubscription.pending_subscription.subscription_add_ons
)
) {
recurlySubscription.pending_subscription.subscription_add_ons.forEach(
addOn => {
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
pendingAdditionalLicenses += addOn.quantity
}
}
)
}
const pendingRecurlyAddons =
recurlySubscription.pending_subscription.subscription_add_ons
pendingRecurlyAddons.forEach(addOn => {
pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
pendingAdditionalLicenses += addOn.quantity
}
})
// Need to calculate tax ourselves as we don't get tax amounts for pending subs
pendingAddOnTax =
personalSubscription.recurly.taxRate * pendingAddOnPrice
pendingPlan.addOns = pendingRecurlyAddons
}
const pendingSubscriptionTax =
personalSubscription.recurly.taxRate *

View file

@ -66,6 +66,7 @@
"add_more_managers": "",
"add_more_members": "",
"add_new_email": "",
"add_ons_are": "",
"add_or_remove_project_from_tag": "",
"add_people": "",
"add_role_and_department": "",
@ -120,6 +121,7 @@
"are_you_getting_an_undefined_control_sequence_error": "",
"are_you_still_at": "",
"are_you_sure": "",
"are_you_sure_you_want_to_cancel_add_on": "",
"as_email": "",
"ask_proj_owner_to_unlink_from_current_github": "",
"ask_proj_owner_to_upgrade_for_full_history": "",
@ -162,6 +164,7 @@
"can_now_relink_dropbox": "",
"can_view": "",
"cancel": "",
"cancel_add_on": "",
"cancel_anytime": "",
"cancel_my_account": "",
"cancel_my_subscription": "",
@ -1037,6 +1040,7 @@
"pdf_viewer": "",
"pdf_viewer_error": "",
"pending_additional_licenses": "",
"pending_addon_cancellation": "",
"pending_invite": "",
"percent_discount_for_groups": "",
"percent_is_the_percentage_of_the_line_width": "",
@ -1187,6 +1191,7 @@
"remote_service_error": "",
"remove": "",
"remove_access": "",
"remove_add_on": "",
"remove_collaborator": "",
"remove_from_group": "",
"remove_link": "",
@ -1470,6 +1475,7 @@
"sure_you_want_to_change_plan": "",
"sure_you_want_to_delete": "",
"sure_you_want_to_leave_group": "",
"switch_plan": "",
"switch_to_editor": "",
"switch_to_pdf": "",
"symbol_palette": "",
@ -1511,6 +1517,7 @@
"thanks_for_subscribing": "",
"thanks_for_subscribing_you_help_sl": "",
"thanks_settings_updated": "",
"the_add_on_will_remain_active_until": "",
"the_following_files_already_exist_in_this_project": "",
"the_following_files_and_folders_already_exist_in_this_project": "",
"the_following_folder_already_exists_in_this_project": "",
@ -1694,6 +1701,7 @@
"untrash": "",
"update": "",
"update_account_info": "",
"update_billing_details": "",
"update_dropbox_settings": "",
"update_your_billing_details": "",
"updates_to_project_sharing": "",
@ -1743,6 +1751,7 @@
"view_hub_subtext": "",
"view_in_template_gallery": "",
"view_invitation": "",
"view_invoices": "",
"view_labs_experiments": "",
"view_less": "",
"view_logs": "",
@ -1856,6 +1865,7 @@
"your_new_plan": "",
"your_password_was_detected": "",
"your_plan": "",
"your_plan_is": "",
"your_plan_is_changing_at_term_end": "",
"your_plan_is_limited_to_n_editors": "",
"your_plan_is_limited_to_n_editors_plural": "",

View file

@ -1,5 +1,6 @@
import { useTranslation, Trans } from 'react-i18next'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { AI_STANDALONE_PLAN_CODE } from '../../data/add-on-codes'
function FreePlan() {
const { t } = useTranslation()
@ -30,7 +31,7 @@ function FreePlan() {
{isSplitTestEnabled('ai-add-on') && (
<a
className="btn btn-secondary"
href="/user/subscription/new?planCode=assistant"
href={`/user/subscription/new?planCode=${AI_STANDALONE_PLAN_CODE}`}
>
{t('buy_overleaf_assist')}
</a>

View file

@ -1,11 +1,17 @@
import { Trans, useTranslation } from 'react-i18next'
import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { ActiveSubscription } from './states/active/active'
import { ActiveAiAddonSubscription } from './states/active/active-ai-addon'
import { CanceledSubscription } from './states/canceled'
import { ExpiredSubscription } from './states/expired'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import {
AI_STANDALONE_PLAN_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
AI_ADD_ON_CODE,
} from '../../data/add-on-codes'
function PastDueSubscriptionAlert({
subscription,
@ -40,7 +46,20 @@ function PersonalSubscriptionStates({
const { t } = useTranslation()
const state = subscription?.recurly.state
if (state === 'active') {
const hasAiAddon = subscription?.addOns?.some(
addOn => addOn.addOnCode === AI_ADD_ON_CODE
)
const onAiStandalonePlan = [
AI_STANDALONE_PLAN_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
].includes(subscription.planCode)
const planHasAi = onAiStandalonePlan || hasAiAddon
if (state === 'active' && planHasAi) {
return <ActiveAiAddonSubscription subscription={subscription} />
} else if (state === 'active') {
return <ActiveSubscription subscription={subscription} />
} else if (state === 'canceled') {
return <CanceledSubscription subscription={subscription} />

View file

@ -0,0 +1,188 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../../../shared/price-exceptions'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { PendingPlanChange } from './pending-plan-change'
import SubscriptionRemainder from './subscription-remainder'
import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import { CancelAiAddOnModal } from './change-plan/modals/cancel-ai-add-on-modal'
import {
AI_STANDALONE_PLAN_CODE,
ADD_ON_NAME,
AI_STANDALONE_PLAN_NAME,
AI_STANDALONE_ANNUAL_PLAN_CODE,
} from '../../../../data/add-on-codes'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import OLButton from '@/features/ui/components/ol/ol-button'
export function ActiveAiAddonSubscription({
subscription,
}: {
subscription: RecurlySubscription
}) {
const { t } = useTranslation()
const { recurlyLoadError, showCancellation, setModalIdShown } =
useSubscriptionDashboardContext()
if (showCancellation) return <CancelSubscription />
const onStandalonePlan = [
AI_STANDALONE_PLAN_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
].includes(subscription.planCode)
const handlePlanChange = () => setModalIdShown('change-plan')
const handleCancelClick = () => setModalIdShown('cancel-ai-add-on')
return (
<>
<p className="mb-0">
<Trans
i18nKey="your_plan_is"
values={{
planName: onStandalonePlan
? AI_STANDALONE_PLAN_NAME
: subscription.plan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={{ strong: <strong /> }}
/>
</p>
<p>
<Trans
i18nKey="add_ons_are"
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
values={{
addOnName: ADD_ON_NAME,
}}
components={{ strong: <strong /> }}
/>
</p>
<p>
{subscription.pendingPlan && (
<PendingPlanChange subscription={subscription} />
)}
</p>
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
<p>{t('want_change_to_apply_before_plan_end')}</p>
)}
<p>
<Trans
i18nKey="next_payment_of_x_collectected_on_y"
values={{
paymentAmmount: subscription.recurly.displayPrice,
collectionDate: subscription.recurly.nextPaymentDueDate,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<PriceExceptions subscription={subscription} />
{!recurlyLoadError && (
<p>
<i>
<SubscriptionRemainder subscription={subscription} hideTime />
</i>
</p>
)}
{!recurlyLoadError && (
<p className="d-inline-flex flex-wrap gap-1">
{onStandalonePlan ? (
<StandaloneAiPlanActions
handlePlanChange={handlePlanChange}
handleCancelClick={handleCancelClick}
/>
) : (
<PlanWithAddonsActions
handlePlanChange={handlePlanChange}
handleCancelClick={handleCancelClick}
/>
)}
</p>
)}
<p>
<a
href={subscription.recurly.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
>
{t('view_invoices')}
</a>
</p>
<p>
<a
href={subscription.recurly.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
>
{t('update_billing_details')}
</a>
</p>
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<CancelAiAddOnModal />
</>
)
}
function StandaloneAiPlanActions({
handlePlanChange,
handleCancelClick,
}: {
handlePlanChange(): void
handleCancelClick(): void
}) {
const { t } = useTranslation()
return (
<>
<OLButton variant="secondary" onClick={handlePlanChange}>
{t('upgrade')}
</OLButton>
<OLButton variant="danger-ghost" onClick={handleCancelClick}>
{t('cancel_add_on')}
</OLButton>
</>
)
}
function PlanWithAddonsActions({
handlePlanChange,
handleCancelClick,
}: {
handlePlanChange(): void
handleCancelClick(): void
}) {
const { t } = useTranslation()
return (
<>
<OLButton variant="secondary" onClick={handlePlanChange}>
{t('switch_plan')}
</OLButton>
<>
<OLButton variant="danger-ghost" onClick={handleCancelClick}>
{t('remove_add_on')}
</OLButton>{' '}
<CancelSubscriptionButton />
</>
</>
)
}

View file

@ -15,7 +15,7 @@ import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { BuyAiAddOnButton } from './change-plan/modals/buy-ai-add-on-modal'
import { BuyAiAddOnButton } from './change-plan/buy-ai-add-on-button'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export function ActiveSubscription({
@ -30,6 +30,7 @@ export function ActiveSubscription({
if (showCancellation) return <CancelSubscription />
const aiAddOnAvailable = isSplitTestEnabled('ai-add-on')
return (
<>
<p>
@ -148,6 +149,7 @@ export function ActiveSubscription({
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
{/* to be removed once we have purchasing options ready */}
{aiAddOnAvailable && <BuyAiAddOnButton />}
</>
)

View file

@ -0,0 +1,12 @@
import { Button } from 'react-bootstrap'
import { postJSON } from '@/infrastructure/fetch-json'
import { AI_ADD_ON_CODE } from '../../../../../data/add-on-codes'
export function BuyAiAddOnButton() {
const addAiAddon = async () => {
await postJSON(`/user/subscription/addon/${AI_ADD_ON_CODE}/add`)
location.reload()
}
return <Button onClick={addAiAddon}>Purchase AI subscription </Button>
}

View file

@ -1,37 +0,0 @@
import { Button } from 'react-bootstrap'
import { postJSON } from '@/infrastructure/fetch-json'
import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
import { RecurlySubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
const AI_ADDON_CODE = 'assistant'
export function BuyAiAddOnButton() {
const { personalSubscription } = useSubscriptionDashboardContext()
const recurlySub = personalSubscription as RecurlySubscription
const hasSub = recurlySub?.recurly?.addOns?.some(
addOn => addOn.add_on_code === AI_ADDON_CODE
)
const paths = {
purchaseAddon: `/user/subscription/addon/${AI_ADDON_CODE}/add`,
removeAddon: `/user/subscription/addon/${AI_ADDON_CODE}/remove`,
}
const addAiAddon = async () => {
await postJSON(paths.purchaseAddon)
location.reload()
}
// gotta change here to change the time to next billing cycle
const removeAiAddon = async () => {
await postJSON(paths.removeAddon)
location.reload()
}
return hasSub ? (
<Button onClick={removeAiAddon}>Cancel AI subscription</Button>
) : (
<Button onClick={addAiAddon}>Purchase AI subscription </Button>
)
}

View file

@ -0,0 +1,120 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import { useLocation } from '../../../../../../../../shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import {
AI_ADD_ON_CODE,
AI_STANDALONE_PLAN_CODE,
ADD_ON_NAME,
} from '../../../../../../data/add-on-codes'
import {
cancelSubscriptionUrl,
redirectAfterCancelSubscriptionUrl,
} from '../../../../../../data/subscription-url'
export function CancelAiAddOnModal() {
const modalId: SubscriptionDashModalIds = 'cancel-ai-add-on'
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const { t } = useTranslation()
const { handleCloseModal, modalIdShown, personalSubscription } =
useSubscriptionDashboardContext()
const location = useLocation()
if (!personalSubscription) return null
const onStandalone = personalSubscription.planCode === AI_STANDALONE_PLAN_CODE
const cancellationEndpoint = onStandalone
? cancelSubscriptionUrl
: `/user/subscription/addon/${AI_ADD_ON_CODE}/remove`
async function handleConfirmChange() {
setError(false)
setInflight(true)
try {
await postJSON(cancellationEndpoint)
location.assign(redirectAfterCancelSubscriptionUrl)
} catch (e) {
setError(true)
setInflight(false)
}
}
if (modalIdShown !== modalId) return null
return (
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('cancel_add_on')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<p>
<Trans
i18nKey="are_you_sure_you_want_to_cancel_add_on"
values={{
addOnName: ADD_ON_NAME,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={{ strong: <strong /> }}
/>
</p>
<p>{t('the_add_on_will_remain_active_until')}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={inflight}
onClick={handleCloseModal}
>
{t('back')}
</OLButton>
<OLButton
variant="danger"
disabled={inflight}
isLoading={inflight}
onClick={handleConfirmChange}
bs3Props={{
loading: inflight
? t('processing_uppercase') + '…'
: t('cancel_add_on'),
}}
>
{t('cancel_add_on')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View file

@ -1,5 +1,7 @@
import { Trans } from 'react-i18next'
import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { PendingRecurlyPlan } from '../../../../../../../../types/subscription/plan'
import { AI_ADD_ON_CODE, ADD_ON_NAME } from '../../../../data/add-on-codes'
export function PendingPlanChange({
subscription,
@ -8,6 +10,21 @@ export function PendingPlanChange({
}) {
if (!subscription.pendingPlan) return null
const pendingPlan = subscription.pendingPlan as PendingRecurlyPlan
const hasAiAddon = subscription.addOns?.some(
addOn => addOn.addOnCode === AI_ADD_ON_CODE
)
const pendingAiAddonCancellation =
hasAiAddon &&
!pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE)
const pendingAdditionalLicenses =
(subscription.recurly.pendingAdditionalLicenses &&
subscription.recurly.pendingAdditionalLicenses > 0) ||
subscription.recurly.additionalLicenses > 0
return (
<>
{subscription.pendingPlan.name !== subscription.plan.name && (
@ -25,9 +42,7 @@ export function PendingPlanChange({
/>
)}
{((subscription.recurly.pendingAdditionalLicenses &&
subscription.recurly.pendingAdditionalLicenses > 0) ||
subscription.recurly.additionalLicenses > 0) && (
{pendingAdditionalLicenses && (
<>
{' '}
<Trans
@ -48,6 +63,21 @@ export function PendingPlanChange({
/>
</>
)}
{pendingAiAddonCancellation && (
<>
{' '}
<Trans
i18nKey="pending_addon_cancellation"
values={{
addOnName: ADD_ON_NAME,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={{ strong: <strong /> }}
/>
</>
)}
</>
)
}

View file

@ -3,19 +3,26 @@ import { RecurlySubscription } from '../../../../../../../../types/subscription/
type SubscriptionRemainderProps = {
subscription: RecurlySubscription
hideTime?: boolean
}
function SubscriptionRemainder({ subscription }: SubscriptionRemainderProps) {
function SubscriptionRemainder({
subscription,
hideTime,
}: SubscriptionRemainderProps) {
const stillInATrial =
subscription.recurly.trialEndsAtFormatted &&
subscription.recurly.trial_ends_at &&
new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
const terminationDate = hideTime
? subscription.recurly.nextPaymentDueDate
: subscription.recurly.nextPaymentDueAt
return stillInATrial ? (
<Trans
i18nKey="subscription_will_remain_active_until_end_of_trial_period_x"
values={{
terminationDate: subscription.recurly.nextPaymentDueAt,
terminationDate,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
@ -28,7 +35,7 @@ function SubscriptionRemainder({ subscription }: SubscriptionRemainderProps) {
<Trans
i18nKey="subscription_will_remain_active_until_end_of_billing_period_x"
values={{
terminationDate: subscription.recurly.nextPaymentDueAt,
terminationDate,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}

View file

@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { AI_STANDALONE_PLAN_CODE } from '../../../data/add-on-codes'
export function ExpiredSubscription({
subscription,
@ -24,7 +25,7 @@ export function ExpiredSubscription({
{isSplitTestEnabled('ai-add-on') && (
<a
className="btn btn-secondary me-1"
href="/user/subscription/new?planCode=assistant"
href={`/user/subscription/new?planCode=${AI_STANDALONE_PLAN_CODE}`}
>
{t('buy_overleaf_assist')}
</a>

View file

@ -0,0 +1,6 @@
export const AI_STANDALONE_PLAN_CODE = 'assistant'
export const AI_ADD_ON_CODE = 'assistant'
// we dont want translations on plan or add-on names
export const ADD_ON_NAME = "Error Assist"
export const AI_STANDALONE_PLAN_NAME = "Overleaf Free"
export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual'

View file

@ -78,6 +78,7 @@
"add_more_managers": "Add more managers",
"add_more_members": "Add more members",
"add_new_email": "Add new email",
"add_ons_are": "<strong>Add-ons:</strong> __addOnName__",
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
"add_people": "Add people",
"add_role_and_department": "Add role and department",
@ -154,6 +155,7 @@
"are_you_getting_an_undefined_control_sequence_error": "Are you getting an Undefined Control Sequence error? If you are, make sure youve loaded the graphicx package—<0>\\usepackage{graphicx}</0>—in the preamble (first section of code) in your document. <1>Learn more</1>",
"are_you_still_at": "Are you still at <0>__institutionName__</0>?",
"are_you_sure": "Are you sure?",
"are_you_sure_you_want_to_cancel_add_on": "Are you sure you want to cancel the <strong>__addOnName__</strong> add-on?",
"article": "Article",
"articles": "Articles",
"as_a_member_of_sso_required": "As a member of <b>__institutionName__</b>, you must log in to <b>__appName__</b> through your institution.",
@ -223,6 +225,7 @@
"can_now_relink_dropbox": "You can now <0>relink your Dropbox account</0>.",
"can_view": "Can view",
"cancel": "Cancel",
"cancel_add_on": "Cancel add-on",
"cancel_anytime": "Were confident that youll love __appName__, but if not you can cancel anytime. Well give you your money back, no questions asked, if you let us know within 30 days.",
"cancel_my_account": "Cancel my subscription",
"cancel_my_subscription": "Cancel my subscription",
@ -1495,6 +1498,7 @@
"pdf_viewer_error": "There was a problem displaying the PDF for this project.",
"pending": "Pending",
"pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
"pending_addon_cancellation": "Your subscription will change to remove the <strong>__addOnName__</strong> add-on at the end of the current billing period.",
"pending_invite": "Pending invite",
"per_month": "per month",
"per_user": "per user",
@ -1698,6 +1702,7 @@
"remote_service_error": "The remote service produced an error",
"remove": "Remove",
"remove_access": "Remove access",
"remove_add_on": "Remove add-on",
"remove_collaborator": "Remove collaborator",
"remove_from_group": "Remove from group",
"remove_link": "Remove link",
@ -2053,6 +2058,7 @@
"sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
"sure_you_want_to_leave_group": "Are you sure you want to leave this group?",
"sv": "Swedish",
"switch_plan": "Switch plan",
"switch_to_editor": "Switch to editor",
"switch_to_pdf": "Switch to PDF",
"symbol_palette": "Symbol palette",
@ -2112,6 +2118,7 @@
"thanks_for_subscribing": "Thanks for subscribing!",
"thanks_for_subscribing_you_help_sl": "Thank you for subscribing to the __planName__ plan. Its support from people like yourself that allows __appName__ to continue to grow and improve.",
"thanks_settings_updated": "Thanks, your settings have been updated.",
"the_add_on_will_remain_active_until": "The add-on will remain active until the end of the current billing period.",
"the_file_supplied_is_of_an_unsupported_type ": "The link to open this content on Overleaf pointed to the wrong kind of file. Valid file types are .tex documents and .zip files. If this keeps happening for links on a particular site, please report this to them.",
"the_following_files_already_exist_in_this_project": "The following files already exist in this project:",
"the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:",
@ -2326,6 +2333,7 @@
"up_to": "Up to",
"update": "Update",
"update_account_info": "Update Account Info",
"update_billing_details": "Update billing details",
"update_dropbox_settings": "Update Dropbox Settings",
"update_your_billing_details": "Update Your Billing Details",
"updates_to_project_sharing": "Updates to project sharing",
@ -2387,6 +2395,7 @@
"view_hub_subtext": "Access and download subscription statistics and a list of users",
"view_in_template_gallery": "View it in the template gallery",
"view_invitation": "View Invitation",
"view_invoices": "View Invoices",
"view_labs_experiments": "View Labs Experiments",
"view_less": "View less",
"view_logs": "View logs",
@ -2527,6 +2536,7 @@
"your_password_has_been_successfully_changed": "Your password has been successfully changed",
"your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
"your_plan": "Your plan",
"your_plan_is": "<strong>Your plan:</strong> __planName__",
"your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
"your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
"your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",

View file

@ -8,6 +8,7 @@ import dateformat from 'dateformat'
const today = new Date()
const oneYearFromToday = new Date().setFullYear(today.getFullYear() + 1)
const nextPaymentDueAt = dateformat(oneYearFromToday, 'dS mmmm yyyy')
const nextPaymentDueDate = dateformat(oneYearFromToday, 'dS mmmm yyyy')
const sevenDaysFromToday = new Date().setDate(today.getDate() + 7)
const sevenDaysFromTodayFormatted = dateformat(
sevenDaysFromToday,
@ -40,6 +41,7 @@ export const annualActiveSubscription: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
@ -80,6 +82,7 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'EUR',
state: 'active',
trialEndsAtFormatted: null,
@ -119,6 +122,7 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
@ -159,6 +163,7 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'expired',
trialEndsAtFormatted: null,
@ -199,6 +204,7 @@ export const canceledSubscription: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'canceled',
trialEndsAtFormatted: null,
@ -239,6 +245,7 @@ export const pendingSubscriptionChange: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
@ -290,6 +297,7 @@ export const groupActiveSubscription: GroupSubscription = {
additionalLicenses: 0,
totalLicenses: 10,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
@ -335,6 +343,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
additionalLicenses: 11,
totalLicenses: 21,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
@ -398,6 +407,7 @@ export const trialSubscription: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt: sevenDaysFromTodayFormatted,
nextPaymentDueDate: sevenDaysFromTodayFormatted,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: sevenDaysFromTodayFormatted,
@ -469,6 +479,7 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt: sevenDaysFromTodayFormatted,
nextPaymentDueDate: sevenDaysFromTodayFormatted,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: sevenDaysFromTodayFormatted,
@ -518,6 +529,7 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,

View file

@ -4,3 +4,4 @@ export type SubscriptionDashModalIds =
| 'keep-current-plan'
| 'leave-group'
| 'change-plan'
| 'cancel-ai-add-on'

View file

@ -1,26 +1,16 @@
import { CurrencyCode } from '../currency'
import { Nullable } from '../../utils'
import { Plan } from '../plan'
import { Plan, AddOn } from '../plan'
import { User } from '../../user'
type SubscriptionState = 'active' | 'canceled' | 'expired'
// the add-ons attached to a recurly subsription
export type AddOn = {
add_on_code: string
add_on_type: string
quantity: number
revenue_schedule_type: string
unit_amount_in_cents: number
}
// when puchasing a new add-on in recurly, we only need to provide the code
export type PurchasingAddOnCode = {
code: string
}
type Recurly = {
addOns?: AddOn[]
tax: number
taxRate: number
billingDetailsLink: string
@ -28,6 +18,7 @@ type Recurly = {
additionalLicenses: number
totalLicenses: number
nextPaymentDueAt: string
nextPaymentDueDate: string
currency: CurrencyCode
state?: SubscriptionState
trialEndsAtFormatted: Nullable<string>
@ -73,6 +64,7 @@ export type Subscription = {
recurlySubscription_id: string
plan: Plan
pendingPlan?: Plan
addOns?: AddOn[]
}
export type RecurlySubscription = Subscription & {

View file

@ -15,10 +15,40 @@ type Features = {
zotero: boolean
}
// add-ons stored on the subscription
export type AddOn = {
addOnCode: string
quantity: number
unitAmountInCents: number
}
// add-ons directly accessed through recurly
export type RecurlyAddOn = {
add_on_code: string
quantity: number
unit_amount_in_cents: number
}
export type PendingRecurlyPlan = {
annual?: boolean
displayPrice?: string
featureDescription?: Record<string, unknown>[]
addOns?: RecurlyAddOn[]
features?: Features
groupPlan?: boolean
hideFromUsers?: boolean
membersLimit?: number
membersLimitAddOn?: string
name: string
planCode: string
price_in_cents: number
}
export type Plan = {
annual?: boolean
displayPrice?: string
featureDescription?: Record<string, unknown>[]
addOns?: AddOn[]
features?: Features
groupPlan?: boolean
hideFromUsers?: boolean