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[] => {
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;
};

View File

@@ -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();
});
};

View File

@@ -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;
}
/**

View File

@@ -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 (
<Group spacing={14} noWrap align="stretch" {...wrapProps}>
{subscriptionPlans.map((plan, index) => (
<SubscriptionPlan
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}
/>
<SubscriptionPlanMapped key={index} plan={plan} />
))}
</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;
}
}
&: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;

View File

@@ -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 }) {
<Stack spacing={6}>
<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}>
{mainSubscription.statusFormatted}
</Text>
<Text className={styles.periodText}>Trial ends in 10 days.</Text>
<SubscriptionStatusText subscription={mainSubscription} />
</Group>
</Stack>
@@ -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 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
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<ButtonProps>;
}
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}
/>
)}
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}>
<PricingPlan.BuyButton onClick={handleClick} {...subscribeButtonProps}>
Subscribe
</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
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 (
<Box className={Classes.DRAWER_BODY}>
<Box
@@ -45,10 +22,8 @@ function ChangeSubscriptionPlanContent({ closeDrawer }) {
</Callout>
<SubscriptionPlansPeriodSwitcher />
<SubscriptionPlans onSubscribe={handleSubscribe} />
<ChangeSubscriptionPlans />
</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 {
variantId: string;
variant_id: string;
}
interface ChangeMainSubscriptionPlanResponse {}