From ba7f32c1bfc53de4e59f89cda1f039d513e27a7a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 30 Jul 2024 17:47:03 +0200 Subject: [PATCH] feat: abstract the pricing plans for setup and billing page --- .../GetSubscriptionsTransformer.ts | 8 +-- ...add_trial_columns_to_subscription_table.js | 4 +- .../models/Subscriptions/PlanSubscription.ts | 35 +++------ .../SetupSubscription/SubscriptionPlans.tsx | 65 ++++++++++++----- .../BillingSubscription.module.scss | 14 ++-- .../Subscriptions/BillingSubscription.tsx | 30 +++++++- .../src/containers/Subscriptions/_utils.ts | 17 +++++ .../component}/SubscriptionPlan.tsx | 34 ++------- .../component/withSubscriptionPlanMapper.tsx | 52 ++++++++++++++ .../ChangeSubscriptionPlanContent.tsx | 35 ++------- .../ChangeSubscriptionPlans.tsx | 72 +++++++++++++++++++ .../hooks/constants/useSubscriptionPlans.tsx | 5 ++ .../webapp/src/hooks/query/subscription.tsx | 2 +- 13 files changed, 253 insertions(+), 120 deletions(-) create mode 100644 packages/webapp/src/containers/Subscriptions/_utils.ts rename packages/webapp/src/containers/{Setup/SetupSubscription => Subscriptions/component}/SubscriptionPlan.tsx (68%) create mode 100644 packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx create mode 100644 packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx diff --git a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts index 194b41f78..3257c0034 100644 --- a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts +++ b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts @@ -8,7 +8,7 @@ export class GetSubscriptionsTransformer extends Transformer { public includeAttributes = (): string[] => { return [ 'canceledAtFormatted', - 'cancelsAtFormatted', + 'endsAtFormatted', 'trialStartsAtFormatted', 'trialEndsAtFormatted', 'statusFormatted', @@ -42,13 +42,13 @@ export class GetSubscriptionsTransformer extends Transformer { }; /** - * Retrieves the cancels at formatted. + * Retrieves the ends at date formatted. * @param subscription * @returns {string} */ - public cancelsAtFormatted = (subscription) => { + public endsAtFormatted = (subscription) => { return subscription.cancelsAt - ? this.formatDate(subscription.cancelsAt) + ? this.formatDate(subscription.endsAt) : null; }; diff --git a/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js b/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js index 1843b120a..b8addd516 100644 --- a/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js +++ b/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js @@ -1,13 +1,13 @@ exports.up = function (knex) { return knex.schema.table('subscription_plan_subscriptions', (table) => { - table.dateTime('trial_starts_at').nullable(); table.dateTime('trial_ends_at').nullable(); + table.dropColumn('cancels_at'); }); }; exports.down = function (knex) { return knex.schema.table('subscription_plan_subscriptions', (table) => { - table.dropColumn('trial_starts_at').nullable(); table.dropColumn('trial_ends_at').nullable(); + table.dateTime('cancels_at').nullable(); }); }; diff --git a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts index 3ae1c1fac..d7c988d8f 100644 --- a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts +++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts @@ -4,16 +4,14 @@ import moment from 'moment'; import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; export default class PlanSubscription extends mixin(SystemModel) { - lemonSubscriptionId: number; + public lemonSubscriptionId: number; - canceledAt: Date; - cancelsAt: Date; + public endsAt: Date; + public startsAt: Date; - trialStartsAt: Date; - trialEndsAt: Date; + public canceledAt: Date; - endsAt: Date; - startsAt: Date; + public trialEndsAt: Date; /** * Table name. @@ -109,26 +107,15 @@ export default class PlanSubscription extends mixin(SystemModel) { } /** - * Check if the subscription is expired. - * Expired mens the user his lost the right to use the product. - * @returns {Boolean} - */ - public expired() { - return this.ended() && !this.onTrial(); - } - - /** - * Check if paid subscription is active. + * Check if the subscription is active. * @return {Boolean} */ public active() { - return ( - !this.canceled() && !this.onTrial() && !this.ended() && this.started() - ); + return this.onTrial() || !this.ended(); } /** - * Check if subscription is inactive. + * Check if the subscription is inactive. * @return {Boolean} */ public inactive() { @@ -164,11 +151,7 @@ export default class PlanSubscription extends mixin(SystemModel) { * @returns {boolean} */ public canceled() { - return ( - this.canceledAt || - (this.cancelsAt && moment().isAfter(this.cancelsAt)) || - false - ); + return !!this.canceledAt; } /** diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx b/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx index 9b71ab9ce..4c0d58e58 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx @@ -1,6 +1,13 @@ -import { Group, GroupProps } from '@/components'; -import { SubscriptionPlan } from './SubscriptionPlan'; +// @ts-nocheck +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { AppToaster, Group, GroupProps } from '@/components'; +import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer'; +import { SubscriptionPlan } from '@/containers/Subscriptions/component/SubscriptionPlan'; +import { useGetLemonSqueezyCheckout } from '@/hooks/query'; import { useSubscriptionPlans } from './hooks'; +import { withPlans } from '@/containers/Subscriptions/withPlans'; +import { withSubscriptionPlanMapper } from '@/containers/Subscriptions/component/withSubscriptionPlanMapper'; interface SubscriptionPlansProps { wrapProps?: GroupProps; @@ -9,29 +16,51 @@ interface SubscriptionPlansProps { export function SubscriptionPlans({ wrapProps, - onSubscribe + onSubscribe, }: SubscriptionPlansProps) { const subscriptionPlans = useSubscriptionPlans(); return ( {subscriptionPlans.map((plan, index) => ( - + ))} ); } + +const SubscriptionPlanMapped = R.compose( + withSubscriptionPlanMapper, + withPlans(({ plansPeriod }) => ({ plansPeriod })), +)(({ plansPeriod, monthlyVariantId, annuallyVariantId, ...props }) => { + const { mutateAsync: getLemonCheckout, isLoading } = + useGetLemonSqueezyCheckout(); + + const handleSubscribeBtnClick = () => { + const variantId = + SubscriptionPlansPeriod.Monthly === plansPeriod + ? monthlyVariantId + : annuallyVariantId; + + getLemonCheckout({ variantId }) + .then((res) => { + const checkoutUrl = res.data.data.attributes.url; + window.LemonSqueezy.Url.Open(checkoutUrl); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong!', + intent: Intent.DANGER, + }); + }); + }; + return ( + + ); +}); diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss index f99f30d8e..a9f57555c 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss @@ -33,22 +33,22 @@ margin-left: 6px; } } + &:global(.bp4-intent-success){ + color: #3e703e; + } + &:global(.bp4-intent-danger){ + color: #A82A2A; + } } .periodStatus{ text-transform: uppercase; - color: #A82A2A; font-weight: 500; -} -.periodText{ - color: #AF6161; + } .priceAmount { font-size: 24px; font-weight: 500; } -.pricePeriod { - color: #8F99A8; -} .subscribeButton{ border-radius: 32px; padding-left: 16px; diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx b/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx index aff31eecb..ff63c6ca4 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx @@ -1,12 +1,15 @@ // @ts-nocheck import * as R from 'ramda'; +import clsx from 'classnames'; +import { includes } from 'lodash'; import { Box, Group, Stack } from '@/components'; -import { Button, Card, Intent, Text } from '@blueprintjs/core'; +import { Button, Card, Classes, Intent, Text } from '@blueprintjs/core'; import withAlertActions from '../Alert/withAlertActions'; import styles from './BillingSubscription.module.scss'; import withDrawerActions from '../Drawer/withDrawerActions'; import { DRAWERS } from '@/constants/drawers'; import { useBillingPageBoot } from './BillingPageBoot'; +import { getSubscriptionStatusText } from './_utils'; function SubscriptionRoot({ openAlert, openDrawer }) { const { mainSubscription } = useBillingPageBoot(); @@ -36,11 +39,24 @@ function SubscriptionRoot({ openAlert, openDrawer }) {

{mainSubscription.planName}

- + {mainSubscription.statusFormatted} - Trial ends in 10 days. + +
@@ -131,3 +147,11 @@ export const Subscription = R.compose( withAlertActions, withDrawerActions, )(SubscriptionRoot); + +function SubscriptionStatusText({ subscription }) { + const text = getSubscriptionStatusText(subscription); + + if (!text) return null; + + return {text}; +} diff --git a/packages/webapp/src/containers/Subscriptions/_utils.ts b/packages/webapp/src/containers/Subscriptions/_utils.ts new file mode 100644 index 000000000..fba7a568e --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/_utils.ts @@ -0,0 +1,17 @@ +// @ts-nocheck +export const getSubscriptionStatusText = (subscription) => { + if (subscription.status === 'on_trial') { + return subscription.onTrial + ? `Trials ends in ${subscription.trialEndsAtFormatted}` + : `Trial ended ${subscription.trialEndsAtFormatted}`; + } else if (subscription.status === 'active') { + return subscription.endsAtFormatted + ? `Renews in ${subscription.endsAtFormatted}` + : 'Lifetime subscription'; + } else if (subscription.status === 'canceled') { + return subscription.ended + ? `Expires ${subscription.endsAtFormatted}` + : `Expired ${subscription.endsAtFormatted}`; + } + return ''; +}; diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx b/packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx similarity index 68% rename from packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx rename to packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx index e4463ad70..e23780e14 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx +++ b/packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx @@ -1,14 +1,12 @@ // @ts-nocheck -import { Intent } from '@blueprintjs/core'; import * as R from 'ramda'; -import { AppToaster } from '@/components'; -import { useGetLemonSqueezyCheckout } from '@/hooks/query'; import { PricingPlan } from '@/components/PricingPlan/PricingPlan'; import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer'; import { WithPlansProps, withPlans, } from '@/containers/Subscriptions/withPlans'; +import { ButtonProps } from '@blueprintjs/core'; interface SubscriptionPricingFeature { text: string; @@ -27,9 +25,8 @@ interface SubscriptionPricingProps { monthlyPriceLabel: string; annuallyPrice: string; annuallyPriceLabel: string; - monthlyVariantId?: string; - annuallyVariantId?: string; onSubscribe?: (variantId: number) => void; + subscribeButtonProps?: Optional; } interface SubscriptionPricingCombinedProps @@ -45,35 +42,14 @@ function SubscriptionPlanRoot({ monthlyPriceLabel, annuallyPrice, annuallyPriceLabel, - monthlyVariantId, - annuallyVariantId, onSubscribe, + subscribeButtonProps, // #withPlans plansPeriod, }: SubscriptionPricingCombinedProps) { - const { mutateAsync: getLemonCheckout, isLoading } = - useGetLemonSqueezyCheckout(); - const handleClick = () => { - const variantId = - SubscriptionPlansPeriod.Monthly === plansPeriod - ? monthlyVariantId - : annuallyVariantId; - - onSubscribe && onSubscribe(variantId); - - // getLemonCheckout({ variantId }) - // .then((res) => { - // const checkoutUrl = res.data.data.attributes.url; - // window.LemonSqueezy.Url.Open(checkoutUrl); - // }) - // .catch(() => { - // AppToaster.show({ - // message: 'Something went wrong!', - // intent: Intent.DANGER, - // }); - // }); + onSubscribe && onSubscribe(); }; return ( @@ -89,7 +65,7 @@ function SubscriptionPlanRoot({ subPrice={annuallyPriceLabel} /> )} - + Subscribe diff --git a/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx b/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx new file mode 100644 index 000000000..b0cd58938 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx @@ -0,0 +1,52 @@ +// @ts-nocheck +import React from 'react'; + + +interface WithSubscriptionPlanProps { + plan: any; + onSubscribe?: (variantId: number) => void; +} + +interface MappedSubscriptionPlanProps { + slug: string; + label: string; + description: string; + features: any[]; + featured: boolean; + monthlyPrice: string; + monthlyPriceLabel: string; + annuallyPrice: string; + annuallyPriceLabel: string; + monthlyVariantId: number; + annuallyVariantId: number; + onSubscribe?: (variantId: number) => void; +} + +export const withSubscriptionPlanMapper = < + P extends MappedSubscriptionPlanProps, +>( + WrappedComponent: React.ComponentType

, +) => { + return function WithSubscriptionPlanMapper( + props: WithSubscriptionPlanProps & + Omit, + ) { + const { plan, onSubscribe, ...restProps } = props; + + const mappedProps: MappedSubscriptionPlanProps = { + slug: plan.slug, + label: plan.name, + description: plan.description, + features: plan.features, + featured: plan.featured, + monthlyPrice: plan.monthlyPrice, + monthlyPriceLabel: plan.monthlyPriceLabel, + annuallyPrice: plan.annuallyPrice, + annuallyPriceLabel: plan.annuallyPriceLabel, + monthlyVariantId: plan.monthlyVariantId, + annuallyVariantId: plan.annuallyVariantId, + onSubscribe, + }; + return ; + }; +}; diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx index 7c99e17e8..85d0361e8 100644 --- a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx @@ -1,34 +1,11 @@ // @ts-nocheck import * as R from 'ramda'; -import { Callout, Classes, Intent } from '@blueprintjs/core'; -import { AppToaster, Box } from '@/components'; -import { SubscriptionPlans } from '@/containers/Setup/SetupSubscription/SubscriptionPlans'; +import { Callout, Classes } from '@blueprintjs/core'; +import { Box } from '@/components'; import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher'; -import { useChangeSubscriptionPlan } from '@/hooks/query/subscription'; -import withDrawerActions from '@/containers/Drawer/withDrawerActions'; -import { DRAWERS } from '@/constants/drawers'; - -function ChangeSubscriptionPlanContent({ closeDrawer }) { - const { mutateAsync: changeSubscriptionPlan } = useChangeSubscriptionPlan(); - - // Handle the subscribe button click. - const handleSubscribe = (variantId: number) => { - changeSubscriptionPlan({ variant_id: variantId }) - .then(() => { - closeDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN); - AppToaster.show({ - intent: Intent.SUCCESS, - message: 'The subscription plan has been changed successfully.', - }); - }) - .catch(() => { - AppToaster.show({ - intent: Intent.DANGER, - message: 'Something went wrong.', - }); - }); - }; +import { ChangeSubscriptionPlans } from './ChangeSubscriptionPlans'; +export default function ChangeSubscriptionPlanContent() { return ( - + ); } - -export default R.compose(withDrawerActions)(ChangeSubscriptionPlanContent); diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx new file mode 100644 index 000000000..1edc6d7cf --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx @@ -0,0 +1,72 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { AppToaster, Group } from '@/components'; +import { SubscriptionPlan } from '../../component/SubscriptionPlan'; +import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer'; +import { useSubscriptionPlans } from '@/hooks/constants/useSubscriptionPlans'; +import { useChangeSubscriptionPlan } from '@/hooks/query/subscription'; +import { withSubscriptionPlanMapper } from '../../component/withSubscriptionPlanMapper'; +import { withPlans } from '../../withPlans'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { DRAWERS } from '@/constants/drawers'; + +export function ChangeSubscriptionPlans() { + const subscriptionPlans = useSubscriptionPlans(); + + return ( + + {subscriptionPlans.map((plan, index) => ( + + ))} + + ); +} + +export const SubscriptionPlanMapped = R.compose( + withSubscriptionPlanMapper, + withDrawerActions, + withPlans(({ plansPeriod }) => ({ plansPeriod })), +)( + ({ + openDrawer, + closeDrawer, + monthlyVariantId, + annuallyVariantId, + plansPeriod, + ...props + }) => { + const { mutateAsync: changeSubscriptionPlan, isLoading } = + useChangeSubscriptionPlan(); + + // Handles the subscribe button click. + const handleSubscribe = () => { + const variantId = + plansPeriod === SubscriptionPlansPeriod.Monthly + ? monthlyVariantId + : annuallyVariantId; + + changeSubscriptionPlan({ variant_id: variantId }) + .then(() => { + closeDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN); + AppToaster.show({ + message: 'The subscription plan has been changed.', + intent: Intent.SUCCESS, + }); + }) + .catch((error) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + return ( + + ); + }, +); diff --git a/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx b/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx new file mode 100644 index 000000000..3beb2a34b --- /dev/null +++ b/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx @@ -0,0 +1,5 @@ +import { SubscriptionPlans } from '@/constants/subscriptionModels'; + +export const useSubscriptionPlans = () => { + return SubscriptionPlans; +}; diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx index 9050aca1d..d3d0ebc4e 100644 --- a/packages/webapp/src/hooks/query/subscription.tsx +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -92,7 +92,7 @@ export function useResumeMainSubscription( } interface ChangeMainSubscriptionPlanValues { - variantId: string; + variant_id: string; } interface ChangeMainSubscriptionPlanResponse {}