Skip to content

Commit 6ff1ad5

Browse files
feat: plans page revamp (#1534)
1 parent 13f2269 commit 6ff1ad5

16 files changed

Lines changed: 1391 additions & 46 deletions

File tree

web/apps/client-demo/src/Router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Teams from './pages/settings/Teams';
2222
import TeamDetails from './pages/settings/TeamDetails';
2323
import ServiceAccounts from './pages/settings/ServiceAccounts';
2424
import ServiceAccountDetails from './pages/settings/ServiceAccountDetails';
25+
import Plans from './pages/settings/Plans';
2526

2627
function Router() {
2728
return (
@@ -50,6 +51,7 @@ function Router() {
5051
<Route path="teams/:teamId" element={<TeamDetails />} />
5152
<Route path="service-accounts" element={<ServiceAccounts />} />
5253
<Route path="service-accounts/:serviceAccountId" element={<ServiceAccountDetails />} />
54+
<Route path="plans" element={<Plans />} />
5355
</Route >
5456
<Route path="*" element={<Navigate to="/" replace />} />
5557
</Routes >

web/apps/client-demo/src/pages/Settings.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const NAV_ITEMS = [
1414
{ label: 'Billing', path: 'billing' },
1515
{ label: 'Tokens', path: 'tokens' },
1616
{ label: 'Teams', path: 'teams' },
17-
{ label: 'Service Accounts', path: 'service-accounts' }
17+
{ label: 'Service Accounts', path: 'service-accounts' },
18+
{ label: 'Plans', path: 'plans' }
1819
];
1920

2021
export default function Settings() {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { PlansView } from '@raystack/frontier/react';
2+
3+
export default function Plans() {
4+
return <PlansView />;
5+
}

web/sdk/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export {
4444
ServiceAccountsView,
4545
ServiceAccountDetailsView
4646
} from './views-new/service-accounts';
47+
export { PlansView } from './views-new/plans';
4748

4849
export type {
4950
FrontierClientOptions,

web/sdk/react/views-new/billing/components/payment-method-card.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -90,30 +90,36 @@ export function PaymentMethodCard({
9090

9191
const isBtnDisabled = isLoading || isActionLoading;
9292

93+
const actionBtn = (
94+
<Button
95+
variant="outline"
96+
color="neutral"
97+
size="small"
98+
onClick={updatePaymentMethod}
99+
disabled={isBtnDisabled}
100+
data-test-id="frontier-sdk-update-payment-method-btn"
101+
>
102+
{isPaymentMethodAvailable ? 'Update' : 'Add method'}
103+
</Button>
104+
);
105+
93106
return (
94107
<div className={styles.detailsBox}>
95108
<Flex align="center" justify="between">
96109
<Text size="regular" weight="medium">
97110
Payment method
98111
</Text>
99112
{isAllowed ? (
100-
<Tooltip>
101-
<Tooltip.Trigger render={<span />}>
102-
<Button
103-
variant="outline"
104-
color="neutral"
105-
size="small"
106-
onClick={updatePaymentMethod}
107-
disabled={isBtnDisabled}
108-
data-test-id="frontier-sdk-update-payment-method-btn"
109-
>
110-
{isPaymentMethodAvailable ? 'Update' : 'Add method'}
111-
</Button>
112-
</Tooltip.Trigger>
113-
<Tooltip.Content>
114-
{AuthTooltipMessage}
115-
</Tooltip.Content>
116-
</Tooltip>
113+
isBtnDisabled ? (
114+
<Tooltip>
115+
<Tooltip.Trigger render={<span />}>
116+
{actionBtn}
117+
</Tooltip.Trigger>
118+
<Tooltip.Content>
119+
{AuthTooltipMessage}
120+
</Tooltip.Content>
121+
</Tooltip>
122+
) : actionBtn
117123
) : null}
118124
</Flex>
119125
<Flex direction="column" gap={2}>
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useMemo, useState } from 'react';
4+
import {
5+
Amount,
6+
Button,
7+
Dialog,
8+
Flex,
9+
Skeleton,
10+
Text
11+
} from '@raystack/apsara-v1';
12+
import { toastManager } from '@raystack/apsara-v1';
13+
import { useFrontier } from '~/react/contexts/FrontierContext';
14+
import { usePlans } from '../hooks/use-plans';
15+
import { useMessages } from '~/react/hooks/useMessages';
16+
import { getPlanChangeAction } from '~/react/utils';
17+
import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants';
18+
import { timestampToDayjs } from '~/utils/timestamp';
19+
import { Plan } from '@raystack/proton/frontier';
20+
21+
export interface ConfirmPlanChangePayload {
22+
planId: string;
23+
amount?: number;
24+
currency?: string;
25+
}
26+
27+
export interface ConfirmPlanChangeDialogProps {
28+
handle: ReturnType<typeof Dialog.createHandle<ConfirmPlanChangePayload>>;
29+
}
30+
31+
export function ConfirmPlanChangeDialog({
32+
handle
33+
}: ConfirmPlanChangeDialogProps) {
34+
return (
35+
<Dialog handle={handle}>
36+
{({ payload }) => (
37+
<ConfirmPlanChangeContent
38+
planId={payload?.planId ?? ''}
39+
amount={payload?.amount}
40+
currency={payload?.currency}
41+
handle={handle}
42+
/>
43+
)}
44+
</Dialog>
45+
);
46+
}
47+
48+
interface ConfirmPlanChangeContentProps {
49+
planId: string;
50+
amount?: number;
51+
currency?: string;
52+
handle: ConfirmPlanChangeDialogProps['handle'];
53+
}
54+
55+
function ConfirmPlanChangeContent({
56+
planId,
57+
amount,
58+
currency,
59+
handle
60+
}: ConfirmPlanChangeContentProps) {
61+
const {
62+
activePlan,
63+
isAllPlansLoading,
64+
config,
65+
activeSubscription,
66+
paymentMethod,
67+
basePlan,
68+
allPlans
69+
} = useFrontier();
70+
71+
const message = useMessages();
72+
73+
const {
74+
changePlan,
75+
checkoutPlan,
76+
isLoading: isChangePlanLoading,
77+
verifyPlanChange,
78+
verifySubscriptionCancel,
79+
cancelSubscription,
80+
checkBasePlan
81+
} = usePlans();
82+
83+
const [newPlan, setNewPlan] = useState<Plan>();
84+
const [isNewPlanLoading, setIsNewPlanLoading] = useState(false);
85+
86+
const currentPlan = useMemo(
87+
() => allPlans.find(plan => plan.id === planId),
88+
[allPlans, planId]
89+
);
90+
91+
const isNewPlanBasePlan = checkBasePlan(planId);
92+
const newPlanMetadata = newPlan?.metadata as Record<string, number>;
93+
const activePlanMetadata = activePlan?.metadata as Record<string, number>;
94+
95+
const planAction = getPlanChangeAction(
96+
Number(newPlanMetadata?.weightage),
97+
Number(activePlanMetadata?.weightage)
98+
);
99+
100+
const handleClose = useCallback(() => handle.close(), [handle]);
101+
102+
const isUpgrade = planAction.btnLabel === 'Upgrade';
103+
104+
const isAlreadySubscribed = activeSubscription?.planId !== undefined;
105+
const isCheckoutRequired =
106+
!paymentMethod ||
107+
(Object.keys(paymentMethod).length === 0 && (amount || 0) > 0);
108+
109+
const newPlanSlug = isNewPlanBasePlan ? 'base' : newPlan?.name;
110+
const planChangeSlug = activePlan?.name
111+
? `${activePlan?.name}:${newPlanSlug}`
112+
: '';
113+
const planChangeMessage = planChangeSlug
114+
? message(`billing.plan_change.${planChangeSlug}`)
115+
: '';
116+
117+
const defaultDowngradeMessage = newPlan?.title
118+
? `Downgrading your plan to ${newPlan.title} will limit access.`
119+
: 'Downgrading your plan will limit access.';
120+
121+
const verifyChange = useCallback(async () => {
122+
const planPhase = isNewPlanBasePlan
123+
? await verifySubscriptionCancel({})
124+
: await verifyPlanChange({ planId });
125+
const actionName = planAction?.btnLabel.toLowerCase();
126+
if (planPhase) {
127+
const changeDate = timestampToDayjs(planPhase?.effectiveAt)?.format(
128+
config?.dateFormat || DEFAULT_DATE_FORMAT
129+
);
130+
toastManager.add({
131+
title: `Plan ${actionName} successful`,
132+
description: `Your plan will ${actionName} on ${changeDate}`,
133+
type: 'success'
134+
});
135+
handleClose();
136+
}
137+
}, [
138+
handleClose,
139+
config?.dateFormat,
140+
planAction?.btnLabel,
141+
planId,
142+
verifyPlanChange,
143+
verifySubscriptionCancel,
144+
isNewPlanBasePlan
145+
]);
146+
147+
const onConfirm = useCallback(async () => {
148+
if (isNewPlanBasePlan) {
149+
cancelSubscription({ onSuccess: verifyChange });
150+
} else if (isAlreadySubscribed && !isCheckoutRequired) {
151+
changePlan({
152+
planId,
153+
onSuccess: verifyChange,
154+
immediate: planAction.immediate
155+
});
156+
} else {
157+
checkoutPlan({
158+
planId,
159+
isTrial: false,
160+
onSuccess: data => {
161+
window.location.href = data?.checkoutUrl as string;
162+
}
163+
});
164+
}
165+
}, [
166+
isNewPlanBasePlan,
167+
isAlreadySubscribed,
168+
isCheckoutRequired,
169+
cancelSubscription,
170+
verifyChange,
171+
changePlan,
172+
checkoutPlan,
173+
planId,
174+
planAction.immediate
175+
]);
176+
177+
useEffect(() => {
178+
if (planId) {
179+
setIsNewPlanLoading(true);
180+
try {
181+
const plan = isNewPlanBasePlan ? basePlan : currentPlan;
182+
if (plan) setNewPlan(plan);
183+
} finally {
184+
setIsNewPlanLoading(false);
185+
}
186+
}
187+
}, [planId, isNewPlanBasePlan, basePlan, currentPlan]);
188+
189+
const isLoading = isAllPlansLoading || isNewPlanLoading;
190+
191+
const cycleSwitchDate = activeSubscription?.currentPeriodEndAt
192+
? timestampToDayjs(activeSubscription?.currentPeriodEndAt)?.format(
193+
config?.dateFormat || DEFAULT_DATE_FORMAT
194+
)
195+
: 'the next billing cycle';
196+
197+
const effectiveDateLabel = planAction?.immediate
198+
? 'effective immediately'
199+
: `effective from ${cycleSwitchDate}`;
200+
201+
return (
202+
<Dialog.Content width={400}>
203+
<Dialog.Header>
204+
<Dialog.Title>
205+
{isLoading ? (
206+
<Skeleton height="20px" width="150px" />
207+
) : (
208+
`Verify ${planAction?.btnLabel}`
209+
)}
210+
</Dialog.Title>
211+
</Dialog.Header>
212+
213+
<Dialog.Body>
214+
<Flex direction="column" gap={7}>
215+
{isLoading ? (
216+
<Skeleton height="16px" />
217+
) : (
218+
<Text size="small">
219+
<Text size="small" weight="medium" as="span">
220+
Current plan:
221+
</Text>{' '}
222+
<Text size="small" variant="secondary" as="span">
223+
{activePlan?.title}
224+
</Text>
225+
</Text>
226+
)}
227+
228+
{isLoading ? (
229+
<Skeleton height="16px" />
230+
) : (
231+
<Text size="small">
232+
<Text size="small" weight="medium" as="span">
233+
New plan:
234+
</Text>{' '}
235+
<Text size="small" variant="secondary" as="span">
236+
{newPlan?.title} ({effectiveDateLabel})
237+
</Text>
238+
</Text>
239+
)}
240+
241+
{isLoading ? (
242+
<Skeleton height="16px" />
243+
) : isUpgrade ? (
244+
<Text size="small">
245+
<Text size="small" weight="medium" as="span">
246+
Price:
247+
</Text>{' '}
248+
<Text size="small" variant="secondary" as="span">
249+
<Amount
250+
value={amount || 0}
251+
currency={currency}
252+
hideDecimals={config?.billing?.hideDecimals}
253+
valueInMinorUnits={false}
254+
/>
255+
</Text>
256+
</Text>
257+
) : (
258+
<Text size="small" variant="secondary">
259+
{planChangeMessage || defaultDowngradeMessage}
260+
</Text>
261+
)}
262+
</Flex>
263+
</Dialog.Body>
264+
265+
<Dialog.Footer>
266+
<Flex justify="end" gap={5}>
267+
<Button
268+
variant="outline"
269+
color="neutral"
270+
onClick={handleClose}
271+
data-test-id="frontier-sdk-confirm-plan-change-cancel-button"
272+
>
273+
Cancel
274+
</Button>
275+
<Button
276+
variant="solid"
277+
color="accent"
278+
onClick={onConfirm}
279+
disabled={isLoading || isChangePlanLoading}
280+
loading={isChangePlanLoading}
281+
loaderText={`${planAction?.btnLoadingLabel}...`}
282+
data-test-id="frontier-sdk-confirm-plan-change-submit-button"
283+
>
284+
{planAction?.btnLabel}
285+
</Button>
286+
</Flex>
287+
</Dialog.Footer>
288+
</Dialog.Content>
289+
);
290+
}

0 commit comments

Comments
 (0)