mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-14 03:40:31 +00:00
feat: abstract the pricing plans for setup and billing page
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
17
packages/webapp/src/containers/Subscriptions/_utils.ts
Normal file
17
packages/webapp/src/containers/Subscriptions/_utils.ts
Normal 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 '';
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />;
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SubscriptionPlans } from '@/constants/subscriptionModels';
|
||||
|
||||
export const useSubscriptionPlans = () => {
|
||||
return SubscriptionPlans;
|
||||
};
|
||||
@@ -92,7 +92,7 @@ export function useResumeMainSubscription(
|
||||
}
|
||||
|
||||
interface ChangeMainSubscriptionPlanValues {
|
||||
variantId: string;
|
||||
variant_id: string;
|
||||
}
|
||||
interface ChangeMainSubscriptionPlanResponse {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user