Skip to content

Commit

Permalink
feat(sdk): add plan checkout flow (#461)
Browse files Browse the repository at this point in the history
* chore: add billing callback urls to sdk billing config

* feat: call checkout api on plan action click

* chore: disable plan action on current plan

* chore: show plan name in billing screen

* refactor: remove plan suffix

* chore: show loader and error with plan checkout
  • Loading branch information
rsbh authored Jan 19, 2024
1 parent 5571823 commit 80ae337
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 22 deletions.
1 change: 1 addition & 0 deletions sdks/js/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"class-variance-authority": "^0.7.0",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"query-string": "^8.1.0",
"react-hook-form": "^7.46.2",
"react-image-crop": "^10.1.8",
"react-loading-skeleton": "^3.3.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import billingStyles from './billing.module.css';
import {
V1Beta1BillingAccount,
V1Beta1PaymentMethod,
V1Beta1Plan,
V1Beta1Subscription
} from '~/src';
import { InfoCircledIcon } from '@radix-ui/react-icons';
Expand Down Expand Up @@ -128,19 +129,44 @@ const PaymentMethod = ({

interface CurrentPlanInfoProps {
subscription?: V1Beta1Subscription;
isLoading?: boolean;
}

const CurrentPlanInfo = ({ subscription }: CurrentPlanInfoProps) => {
const CurrentPlanInfo = ({ subscription, isLoading }: CurrentPlanInfoProps) => {
const navigate = useNavigate({ from: '/billing' });
const { client } = useFrontier();
const [isPlansLoading, setIsPlansLoading] = useState(false);
const [plan, setPlan] = useState<V1Beta1Plan>();

// TODO: get planName from list plan api
const planName = subscription?.plan_id;
useEffect(() => {
async function getPlan(planId: string) {
setIsPlansLoading(true);
try {
const resp = await client?.frontierServiceGetPlan(planId);
if (resp?.data?.plan) {
setPlan(resp?.data?.plan);
}
} catch (err: any) {
toast.error('Something went wrong', {
description: err.message
});
console.error(err);
} finally {
setIsPlansLoading(false);
}
}
if (subscription?.plan_id) {
getPlan(subscription?.plan_id);
}
}, [client, subscription?.plan_id]);

const planName = plan?.title;

const planInfo = subscription
? {
message: `You are subscribed to ${planName} plan`,
message: `You are subscribed to ${planName}.`,
action: {
label: 'Upgrare',
label: 'Upgrade',
link: '/plans'
}
}
Expand All @@ -157,7 +183,11 @@ const CurrentPlanInfo = ({ subscription }: CurrentPlanInfoProps) => {
navigate({ to: planInfo.action.link });
};

return (
const showLoader = isLoading || isPlansLoading;

return showLoader ? (
<Skeleton />
) : (
<Flex
className={billingStyles.currentPlanInfoBox}
align={'center'}
Expand Down Expand Up @@ -247,9 +277,10 @@ export default function Billing() {
isLoading={isBillingAccountLoading}
/>
</Flex>
{isActiveSubscriptionLoading ? null : (
<CurrentPlanInfo subscription={activeSubscription} />
)}
<CurrentPlanInfo
subscription={activeSubscription}
isLoading={isActiveSubscriptionLoading}
/>
</Flex>
</Flex>
<Outlet />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,16 @@ describe('Plans:helpers:groupPlansPricingByInterval', () => {
planName: 'starter_plan_plan-1',
amount: 0,
behavior: '',
currency: 'INR'
currency: 'INR',
interval: 'year'
},
month: {
planId: 'plan-2',
planName: 'starter_plan_plan-2',
amount: 0,
behavior: '',
currency: 'INR'
currency: 'INR',
interval: 'month'
}
},
features: {}
Expand All @@ -161,7 +163,8 @@ describe('Plans:helpers:groupPlansPricingByInterval', () => {
behavior: '',
currency: 'INR',
planId: 'plan-3',
planName: 'starter_plan_plan-3'
planName: 'starter_plan_plan-3',
interval: 'month'
}
},
features: {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function groupPlansPricingByInterval(plans: V1Beta1Plan[]) {
plansMap[slug].intervals[planInterval] = {
planId: plan?.id || '',
planName: plan?.name || '',
interval: planInterval,
...productPrices
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@raystack/apsara';
import { styles } from '../styles';
import { useFrontier } from '~/react/contexts/FrontierContext';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { V1Beta1Feature, V1Beta1Plan } from '~/src';
import { toast } from 'sonner';
import Skeleton from 'react-loading-skeleton';
Expand All @@ -20,6 +20,7 @@ import {
PlanIntervalPricing
} from '~/src/types';
import checkCircle from '~/react/assets/check-circle.svg';
import qs from 'query-string';

const PlansLoader = () => {
return (
Expand Down Expand Up @@ -81,11 +82,24 @@ const PlanPricingColumn = ({
plan: PlanIntervalPricing;
featureMap: Record<string, V1Beta1Feature>;
}) => {
const {
client,
activeOrganization,
billingAccount,
config,
activeSubscription
} = useFrontier();

const [isLoading, setIsLoading] = useState(false);

const planIntervals = (Object.keys(plan.intervals).sort() ||
[]) as IntervalKeys[];
const [selectedInterval, setSelectedInterval] = useState<IntervalKeys>(
planIntervals[0]
);
const [selectedInterval, setSelectedInterval] = useState<IntervalKeys>(() => {
const activePlan = Object.values(plan?.intervals).find(
p => p.planId === activeSubscription?.plan_id
);
return activePlan?.interval || planIntervals[0];
});

const onIntervalChange = (value: IntervalKeys) => {
if (value) {
Expand All @@ -95,6 +109,71 @@ const PlanPricingColumn = ({

const selectedIntervalPricing = plan.intervals[selectedInterval];

const action = useMemo(() => {
if (selectedIntervalPricing.planId === activeSubscription?.plan_id) {
return {
disabled: true,
text: 'Current Plan'
};
}
return {
disabled: false,
text: 'Upgrade'
};
}, [activeSubscription?.plan_id, selectedIntervalPricing.planId]);

const onPlanActionClick = useCallback(async () => {
setIsLoading(true);
try {
if (activeOrganization?.id && billingAccount?.id) {
const query = qs.stringify(
{
details: btoa(
qs.stringify({
billing_id: billingAccount?.id,
organization_id: activeOrganization?.id,
type: 'plans'
})
),
checkout_id: '{{.CheckoutID}}'
},
{ encode: false }
);
const cancel_url = `${config?.billing?.cancelUrl}?${query}`;
const success_url = `${config?.billing?.successUrl}?${query}`;

const resp = await client?.frontierServiceCreateCheckout(
activeOrganization?.id,
billingAccount?.id,
{
cancel_url: cancel_url,
success_url: success_url,
subscription_body: {
plan: selectedIntervalPricing?.planId
}
}
);
if (resp?.data?.checkout_session?.checkout_url) {
window.location.href = resp?.data?.checkout_session?.checkout_url;
}
}
} catch (err: any) {
console.error(err);
toast.error('Something went wrong', {
description: err?.message
});
} finally {
setIsLoading(false);
}
}, [
activeOrganization?.id,
billingAccount?.id,
config?.billing?.cancelUrl,
config?.billing?.successUrl,
client,
selectedIntervalPricing?.planId
]);

return (
<Flex direction={'column'} style={{ flex: 1 }}>
<Flex className={plansStyles.planInfoColumn} direction="column">
Expand All @@ -116,8 +195,13 @@ const PlanPricingColumn = ({
</Text>
</Flex>
<Flex direction="column" gap="medium">
<Button variant={'secondary'} className={plansStyles.planActionBtn}>
Current Plan
<Button
variant={'secondary'}
className={plansStyles.planActionBtn}
onClick={onPlanActionClick}
disabled={action?.disabled || isLoading}
>
{isLoading ? 'Upgrading...' : action.text}
</Button>
{planIntervals.length > 1 ? (
<ToggleGroup
Expand Down Expand Up @@ -147,7 +231,7 @@ const PlanPricingColumn = ({
className={plansStyles.featureCell}
>
<Text size={2} className={plansStyles.featureTableHeading}>
Features
{plan.title}
</Text>
</Flex>
</Flex>
Expand Down
14 changes: 11 additions & 3 deletions sdks/js/packages/core/react/contexts/FrontierContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,16 @@ interface FrontierContextProviderProps {
setIsActiveSubscriptionLoading: Dispatch<SetStateAction<boolean>>;
}

const defaultConfig = {
const defaultConfig: FrontierClientOptions = {
endpoint: 'http://localhost:8080',
redirectLogin: 'http://localhost:3000',
redirectSignup: 'http://localhost:3000/signup',
redirectMagicLinkVerify: 'http://localhost:3000/magiclink-verify',
callbackUrl: 'http://localhost:3000/callback'
callbackUrl: 'http://localhost:3000/callback',
billing: {
successUrl: 'http://localhost:3000/success',
cancelUrl: 'http://localhost:3000/cancel'
}
};

const initialValues: FrontierContextProviderProps = {
Expand Down Expand Up @@ -268,7 +272,11 @@ export const FrontierContextProvider = ({
return (
<FrontierContext.Provider
value={{
config: { ...defaultConfig, ...config },
config: {
...defaultConfig,
...config,
billing: { ...defaultConfig.billing, ...config.billing }
},
client: frontierClient,
organizations,
setOrganizations,
Expand Down
2 changes: 2 additions & 0 deletions sdks/js/packages/core/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from 'react';

export interface FrontierClientBillingOptions {
supportEmail?: string;
successUrl?: string;
cancelUrl?: string;
}

export interface FrontierClientOptions {
Expand Down
1 change: 1 addition & 0 deletions sdks/js/packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface IntervalPricing {
export interface IntervalPricingWithPlan extends IntervalPricing {
planId: string;
planName: string;
interval: IntervalKeys;
}

export interface PlanIntervalPricing {
Expand Down
27 changes: 27 additions & 0 deletions sdks/js/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 80ae337

Please sign in to comment.