feat: abstract the pricing plans for setup and billing page

This commit is contained in:
Ahmed Bouhuolia
2024-07-30 17:47:03 +02:00
parent 07c57ed539
commit ba7f32c1bf
13 changed files with 253 additions and 120 deletions

View File

@@ -8,7 +8,7 @@ export class GetSubscriptionsTransformer extends Transformer {
public includeAttributes = (): string[] => { public includeAttributes = (): string[] => {
return [ return [
'canceledAtFormatted', 'canceledAtFormatted',
'cancelsAtFormatted', 'endsAtFormatted',
'trialStartsAtFormatted', 'trialStartsAtFormatted',
'trialEndsAtFormatted', 'trialEndsAtFormatted',
'statusFormatted', 'statusFormatted',
@@ -42,13 +42,13 @@ export class GetSubscriptionsTransformer extends Transformer {
}; };
/** /**
* Retrieves the cancels at formatted. * Retrieves the ends at date formatted.
* @param subscription * @param subscription
* @returns {string} * @returns {string}
*/ */
public cancelsAtFormatted = (subscription) => { public endsAtFormatted = (subscription) => {
return subscription.cancelsAt return subscription.cancelsAt
? this.formatDate(subscription.cancelsAt) ? this.formatDate(subscription.endsAt)
: null; : null;
}; };

View File

@@ -1,13 +1,13 @@
exports.up = function (knex) { exports.up = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => { return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dateTime('trial_starts_at').nullable();
table.dateTime('trial_ends_at').nullable(); table.dateTime('trial_ends_at').nullable();
table.dropColumn('cancels_at');
}); });
}; };
exports.down = function (knex) { exports.down = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => { return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dropColumn('trial_starts_at').nullable();
table.dropColumn('trial_ends_at').nullable(); table.dropColumn('trial_ends_at').nullable();
table.dateTime('cancels_at').nullable();
}); });
}; };

View File

@@ -4,16 +4,14 @@ import moment from 'moment';
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
export default class PlanSubscription extends mixin(SystemModel) { export default class PlanSubscription extends mixin(SystemModel) {
lemonSubscriptionId: number; public lemonSubscriptionId: number;
canceledAt: Date; public endsAt: Date;
cancelsAt: Date; public startsAt: Date;
trialStartsAt: Date; public canceledAt: Date;
trialEndsAt: Date;
endsAt: Date; public trialEndsAt: Date;
startsAt: Date;
/** /**
* Table name. * Table name.
@@ -109,26 +107,15 @@ export default class PlanSubscription extends mixin(SystemModel) {
} }
/** /**
* Check if the subscription is expired. * Check if the subscription is active.
* 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.
* @return {Boolean} * @return {Boolean}
*/ */
public active() { public active() {
return ( return this.onTrial() || !this.ended();
!this.canceled() && !this.onTrial() && !this.ended() && this.started()
);
} }
/** /**
* Check if subscription is inactive. * Check if the subscription is inactive.
* @return {Boolean} * @return {Boolean}
*/ */
public inactive() { public inactive() {
@@ -164,11 +151,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
* @returns {boolean} * @returns {boolean}
*/ */
public canceled() { public canceled() {
return ( return !!this.canceledAt;
this.canceledAt ||
(this.cancelsAt && moment().isAfter(this.cancelsAt)) ||
false
);
} }
/** /**

View File

@@ -1,6 +1,13 @@
import { Group, GroupProps } from '@/components'; // @ts-nocheck
import { SubscriptionPlan } from './SubscriptionPlan'; 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 { useSubscriptionPlans } from './hooks';
import { withPlans } from '@/containers/Subscriptions/withPlans';
import { withSubscriptionPlanMapper } from '@/containers/Subscriptions/component/withSubscriptionPlanMapper';
interface SubscriptionPlansProps { interface SubscriptionPlansProps {
wrapProps?: GroupProps; wrapProps?: GroupProps;
@@ -9,29 +16,51 @@ interface SubscriptionPlansProps {
export function SubscriptionPlans({ export function SubscriptionPlans({
wrapProps, wrapProps,
onSubscribe onSubscribe,
}: SubscriptionPlansProps) { }: SubscriptionPlansProps) {
const subscriptionPlans = useSubscriptionPlans(); const subscriptionPlans = useSubscriptionPlans();
return ( return (
<Group spacing={14} noWrap align="stretch" {...wrapProps}> <Group spacing={14} noWrap align="stretch" {...wrapProps}>
{subscriptionPlans.map((plan, index) => ( {subscriptionPlans.map((plan, index) => (
<SubscriptionPlan <SubscriptionPlanMapped key={index} plan={plan} />
key={index}
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={onSubscribe}
/>
))} ))}
</Group> </Group>
); );
} }
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 (
<SubscriptionPlan
{...props}
onSubscribe={handleSubscribeBtnClick}
subscribeButtonProps={{
loading: isLoading,
}}
/>
);
});

View File

@@ -33,22 +33,22 @@
margin-left: 6px; margin-left: 6px;
} }
} }
&:global(.bp4-intent-success){
color: #3e703e;
}
&:global(.bp4-intent-danger){
color: #A82A2A;
}
} }
.periodStatus{ .periodStatus{
text-transform: uppercase; text-transform: uppercase;
color: #A82A2A;
font-weight: 500; font-weight: 500;
}
.periodText{
color: #AF6161;
} }
.priceAmount { .priceAmount {
font-size: 24px; font-size: 24px;
font-weight: 500; font-weight: 500;
} }
.pricePeriod {
color: #8F99A8;
}
.subscribeButton{ .subscribeButton{
border-radius: 32px; border-radius: 32px;
padding-left: 16px; padding-left: 16px;

View File

@@ -1,12 +1,15 @@
// @ts-nocheck // @ts-nocheck
import * as R from 'ramda'; import * as R from 'ramda';
import clsx from 'classnames';
import { includes } from 'lodash';
import { Box, Group, Stack } from '@/components'; 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 withAlertActions from '../Alert/withAlertActions';
import styles from './BillingSubscription.module.scss'; import styles from './BillingSubscription.module.scss';
import withDrawerActions from '../Drawer/withDrawerActions'; import withDrawerActions from '../Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers'; import { DRAWERS } from '@/constants/drawers';
import { useBillingPageBoot } from './BillingPageBoot'; import { useBillingPageBoot } from './BillingPageBoot';
import { getSubscriptionStatusText } from './_utils';
function SubscriptionRoot({ openAlert, openDrawer }) { function SubscriptionRoot({ openAlert, openDrawer }) {
const { mainSubscription } = useBillingPageBoot(); const { mainSubscription } = useBillingPageBoot();
@@ -36,11 +39,24 @@ function SubscriptionRoot({ openAlert, openDrawer }) {
<Stack spacing={6}> <Stack spacing={6}>
<h1 className={styles.title}>{mainSubscription.planName}</h1> <h1 className={styles.title}>{mainSubscription.planName}</h1>
<Group spacing={0} className={styles.period}> <Group
spacing={0}
className={clsx(styles.period, {
[Classes.INTENT_DANGER]: includes(
['on_trial', 'inactive'],
mainSubscription.status,
),
[Classes.INTENT_SUCCESS]: includes(
['active', 'canceled'],
mainSubscription.status,
),
})}
>
<Text className={styles.periodStatus}> <Text className={styles.periodStatus}>
{mainSubscription.statusFormatted} {mainSubscription.statusFormatted}
</Text> </Text>
<Text className={styles.periodText}>Trial ends in 10 days.</Text>
<SubscriptionStatusText subscription={mainSubscription} />
</Group> </Group>
</Stack> </Stack>
@@ -131,3 +147,11 @@ export const Subscription = R.compose(
withAlertActions, withAlertActions,
withDrawerActions, withDrawerActions,
)(SubscriptionRoot); )(SubscriptionRoot);
function SubscriptionStatusText({ subscription }) {
const text = getSubscriptionStatusText(subscription);
if (!text) return null;
return <Text className={styles.periodText}>{text}</Text>;
}

View File

@@ -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 '';
};

View File

@@ -1,14 +1,12 @@
// @ts-nocheck // @ts-nocheck
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda'; import * as R from 'ramda';
import { AppToaster } from '@/components';
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { PricingPlan } from '@/components/PricingPlan/PricingPlan'; import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer'; import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import { import {
WithPlansProps, WithPlansProps,
withPlans, withPlans,
} from '@/containers/Subscriptions/withPlans'; } from '@/containers/Subscriptions/withPlans';
import { ButtonProps } from '@blueprintjs/core';
interface SubscriptionPricingFeature { interface SubscriptionPricingFeature {
text: string; text: string;
@@ -27,9 +25,8 @@ interface SubscriptionPricingProps {
monthlyPriceLabel: string; monthlyPriceLabel: string;
annuallyPrice: string; annuallyPrice: string;
annuallyPriceLabel: string; annuallyPriceLabel: string;
monthlyVariantId?: string;
annuallyVariantId?: string;
onSubscribe?: (variantId: number) => void; onSubscribe?: (variantId: number) => void;
subscribeButtonProps?: Optional<ButtonProps>;
} }
interface SubscriptionPricingCombinedProps interface SubscriptionPricingCombinedProps
@@ -45,35 +42,14 @@ function SubscriptionPlanRoot({
monthlyPriceLabel, monthlyPriceLabel,
annuallyPrice, annuallyPrice,
annuallyPriceLabel, annuallyPriceLabel,
monthlyVariantId,
annuallyVariantId,
onSubscribe, onSubscribe,
subscribeButtonProps,
// #withPlans // #withPlans
plansPeriod, plansPeriod,
}: SubscriptionPricingCombinedProps) { }: SubscriptionPricingCombinedProps) {
const { mutateAsync: getLemonCheckout, isLoading } =
useGetLemonSqueezyCheckout();
const handleClick = () => { const handleClick = () => {
const variantId = onSubscribe && onSubscribe();
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,
// });
// });
}; };
return ( return (
@@ -89,7 +65,7 @@ function SubscriptionPlanRoot({
subPrice={annuallyPriceLabel} subPrice={annuallyPriceLabel}
/> />
)} )}
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}> <PricingPlan.BuyButton onClick={handleClick} {...subscribeButtonProps}>
Subscribe Subscribe
</PricingPlan.BuyButton> </PricingPlan.BuyButton>

View File

@@ -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<P>,
) => {
return function WithSubscriptionPlanMapper(
props: WithSubscriptionPlanProps &
Omit<P, keyof MappedSubscriptionPlanProps>,
) {
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 <WrappedComponent {...mappedProps} {...(restProps as P)} />;
};
};

View File

@@ -1,34 +1,11 @@
// @ts-nocheck // @ts-nocheck
import * as R from 'ramda'; import * as R from 'ramda';
import { Callout, Classes, Intent } from '@blueprintjs/core'; import { Callout, Classes } from '@blueprintjs/core';
import { AppToaster, Box } from '@/components'; import { Box } from '@/components';
import { SubscriptionPlans } from '@/containers/Setup/SetupSubscription/SubscriptionPlans';
import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher'; import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher';
import { useChangeSubscriptionPlan } from '@/hooks/query/subscription'; import { ChangeSubscriptionPlans } from './ChangeSubscriptionPlans';
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.',
});
});
};
export default function ChangeSubscriptionPlanContent() {
return ( return (
<Box className={Classes.DRAWER_BODY}> <Box className={Classes.DRAWER_BODY}>
<Box <Box
@@ -45,10 +22,8 @@ function ChangeSubscriptionPlanContent({ closeDrawer }) {
</Callout> </Callout>
<SubscriptionPlansPeriodSwitcher /> <SubscriptionPlansPeriodSwitcher />
<SubscriptionPlans onSubscribe={handleSubscribe} /> <ChangeSubscriptionPlans />
</Box> </Box>
</Box> </Box>
); );
} }
export default R.compose(withDrawerActions)(ChangeSubscriptionPlanContent);

View File

@@ -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 (
<Group spacing={14} noWrap align="stretch">
{subscriptionPlans.map((plan, index) => (
<SubscriptionPlanMapped plan={plan} />
))}
</Group>
);
}
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 (
<SubscriptionPlan
{...props}
onSubscribe={handleSubscribe}
subscribeButtonProps={{ loading: isLoading }}
/>
);
},
);

View File

@@ -0,0 +1,5 @@
import { SubscriptionPlans } from '@/constants/subscriptionModels';
export const useSubscriptionPlans = () => {
return SubscriptionPlans;
};

View File

@@ -92,7 +92,7 @@ export function useResumeMainSubscription(
} }
interface ChangeMainSubscriptionPlanValues { interface ChangeMainSubscriptionPlanValues {
variantId: string; variant_id: string;
} }
interface ChangeMainSubscriptionPlanResponse {} interface ChangeMainSubscriptionPlanResponse {}