Skip to content

Commit 96bf6c6

Browse files
committed
Refactor ChangePlansPage for improved readability and add downgrade plan validation
1 parent 9351e44 commit 96bf6c6

1 file changed

Lines changed: 137 additions & 47 deletions

File tree

  • src/frontend/src/pages/ChangePlansPage

src/frontend/src/pages/ChangePlansPage/index.tsx

Lines changed: 137 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ const STATIC_PLANS: PlanConfig[] = (planConfigData.plans ?? []).map((plan) => ({
5252

5353
function CheckIcon() {
5454
return (
55-
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4 text-sky-600">
55+
<svg
56+
viewBox="0 0 24 24"
57+
aria-hidden="true"
58+
className="h-4 w-4 text-sky-600"
59+
>
5660
<path
5761
d="M20.285 6.709a1 1 0 0 1 0 1.414l-9.192 9.192a1 1 0 0 1-1.414 0L3.715 11.55a1 1 0 0 1 1.414-1.415l5.136 5.136 8.485-8.485a1 1 0 0 1 1.535-.077z"
5862
fill="currentColor"
@@ -111,6 +115,23 @@ export default function ChangePlansPage() {
111115
const [errorMessage, setErrorMessage] = useState<string | null>(null);
112116
const [confirmOpen, setConfirmOpen] = useState(false);
113117

118+
const getPlanByKey = (planKey: string) =>
119+
STATIC_PLANS.find((plan) => plan.key === planKey) ?? null;
120+
121+
const isDowngradePlanSelection = (nextPlanKey: string) => {
122+
const currentPlan = getPlanByKey(currentPlanKey);
123+
const nextPlan = getPlanByKey(nextPlanKey);
124+
125+
if (!currentPlan || !nextPlan) {
126+
return false;
127+
}
128+
129+
return nextPlan.monthlyPriceUsdCents < currentPlan.monthlyPriceUsdCents;
130+
};
131+
132+
const downgradePlanMessage =
133+
"You are currently on a higher plan. Please cancel your current subscription to switch plans at the end of the billing cycle.";
134+
114135
useEffect(() => {
115136
const params = new URLSearchParams(location.search);
116137
const nextMode = params.get("mode") === "seats" ? "seats" : "plan";
@@ -159,7 +180,8 @@ export default function ChangePlansPage() {
159180
return;
160181
}
161182

162-
const billingData = (billingRes.data ?? null) as BillingAccessResponse | null;
183+
const billingData = (billingRes.data ??
184+
null) as BillingAccessResponse | null;
163185
setBilling(billingData);
164186

165187
const subscribedPaddlePlanKey = String(
@@ -174,7 +196,10 @@ export default function ChangePlansPage() {
174196
const normalizedPlanKey = matchedPlan?.key ?? subscribedPaddlePlanKey;
175197
const seats =
176198
Number(
177-
billingData?.subscription_seats ?? billingData?.seats ?? billingData?.quantity ?? MIN_SEATS,
199+
billingData?.subscription_seats ??
200+
billingData?.seats ??
201+
billingData?.quantity ??
202+
MIN_SEATS,
178203
) || MIN_SEATS;
179204

180205
setCurrentPlanKey(normalizedPlanKey);
@@ -185,7 +210,11 @@ export default function ChangePlansPage() {
185210
} catch (error: any) {
186211
if (mounted) {
187212
setErrorMessage(
188-
String(error?.response?.data?.detail ?? error?.message ?? "Failed to load billing data."),
213+
String(
214+
error?.response?.data?.detail ??
215+
error?.message ??
216+
"Failed to load billing data.",
217+
),
189218
);
190219
}
191220
} finally {
@@ -209,7 +238,10 @@ export default function ChangePlansPage() {
209238
setPendingSeats((prev) => prev + 1);
210239
};
211240

212-
function buildPreviewMessage(previewData: any, nextBillingDate?: string): string | null {
241+
function buildPreviewMessage(
242+
previewData: any,
243+
nextBillingDate?: string,
244+
): string | null {
213245
if (!previewData) return null;
214246

215247
const hasImmediateCharge = Boolean(previewData?.immediate_transaction);
@@ -221,7 +253,9 @@ export default function ChangePlansPage() {
221253

222254
if (nextBillingDate) {
223255
const date = new Date(nextBillingDate);
224-
const formatted = date.toLocaleDateString(undefined, { dateStyle: "medium" });
256+
const formatted = date.toLocaleDateString(undefined, {
257+
dateStyle: "medium",
258+
});
225259
return `Your plan will change on ${formatted}. No charge today.`;
226260
}
227261

@@ -244,9 +278,16 @@ export default function ChangePlansPage() {
244278
return;
245279
}
246280

281+
if (isDowngradePlanSelection(planKey)) {
282+
setSummaryMessage(downgradePlanMessage);
283+
return;
284+
}
285+
247286
try {
248287
const selected = STATIC_PLANS.find((plan) => plan.key === planKey);
249-
const selectedPriceId = selected ? priceMap[selected.paddlePlanKey] : undefined;
288+
const selectedPriceId = selected
289+
? priceMap[selected.paddlePlanKey]
290+
: undefined;
250291

251292
if (!selectedPriceId) {
252293
throw new Error("Missing price id for selected plan.");
@@ -257,17 +298,18 @@ export default function ChangePlansPage() {
257298
seats: currentSeats,
258299
});
259300

260-
const amount = extractPreviewAmount(preview);
261301
setPreviewData(preview);
262-
setPreviewMessage(
263-
buildPreviewMessage(preview, billing?.next_billed_at)
264-
);
302+
setPreviewMessage(buildPreviewMessage(preview, billing?.next_billed_at));
265303
setSummaryMessage(
266304
`Plan change: ${currentPlanKey || "current"}${planKey} (${currentSeats} seats).`,
267305
);
268306
} catch (error: any) {
269307
setErrorMessage(
270-
String(error?.response?.data?.detail ?? error?.message ?? "Failed to preview plan change."),
308+
String(
309+
error?.response?.data?.detail ??
310+
error?.message ??
311+
"Failed to preview plan change.",
312+
),
271313
);
272314
}
273315
};
@@ -282,19 +324,29 @@ export default function ChangePlansPage() {
282324
try {
283325
if (mode === "seats") {
284326
const preview = await previewChange({ seats: pendingSeats });
285-
const amount = extractPreviewAmount(preview);
286327
setPreviewData(preview);
287328
setPreviewMessage(
288-
buildPreviewMessage(preview, billing?.next_billed_at)
329+
buildPreviewMessage(preview, billing?.next_billed_at),
289330
);
290331
} else {
291332
if (selectedPlanKey === ENTERPRISE_PLAN_KEY) {
292-
setSummaryMessage("Please contact us to switch to the Enterprise plan.");
333+
setSummaryMessage(
334+
"Please contact us to switch to the Enterprise plan.",
335+
);
293336
return;
294337
}
295338

296-
const selected = STATIC_PLANS.find((plan) => plan.key === selectedPlanKey);
297-
const selectedPriceId = selected ? priceMap[selected.paddlePlanKey] : undefined;
339+
if (isDowngradePlanSelection(selectedPlanKey)) {
340+
setSummaryMessage(downgradePlanMessage);
341+
return;
342+
}
343+
344+
const selected = STATIC_PLANS.find(
345+
(plan) => plan.key === selectedPlanKey,
346+
);
347+
const selectedPriceId = selected
348+
? priceMap[selected.paddlePlanKey]
349+
: undefined;
298350

299351
if (!selectedPriceId) {
300352
throw new Error("Missing price id for selected plan.");
@@ -305,10 +357,9 @@ export default function ChangePlansPage() {
305357
seats: currentSeats,
306358
});
307359

308-
const amount = extractPreviewAmount(preview);
309360
setPreviewData(preview);
310361
setPreviewMessage(
311-
buildPreviewMessage(preview, billing?.next_billed_at)
362+
buildPreviewMessage(preview, billing?.next_billed_at),
312363
);
313364
setSummaryMessage(
314365
`Plan change: ${currentPlanKey || "current"}${selectedPlanKey} (${currentSeats} seats).`,
@@ -341,13 +392,19 @@ export default function ChangePlansPage() {
341392
setCurrentSeats(pendingSeats);
342393
} else {
343394
if (selectedPlanKey === ENTERPRISE_PLAN_KEY) {
344-
setSummaryMessage("Please contact us to switch to the Enterprise plan.");
395+
setSummaryMessage(
396+
"Please contact us to switch to the Enterprise plan.",
397+
);
345398
setConfirmOpen(false);
346399
return;
347400
}
348401

349-
const selected = STATIC_PLANS.find((plan) => plan.key === selectedPlanKey);
350-
const selectedPriceId = selected ? priceMap[selected.paddlePlanKey] : undefined;
402+
const selected = STATIC_PLANS.find(
403+
(plan) => plan.key === selectedPlanKey,
404+
);
405+
const selectedPriceId = selected
406+
? priceMap[selected.paddlePlanKey]
407+
: undefined;
351408

352409
if (!selectedPriceId) {
353410
throw new Error("Missing price id for selected plan.");
@@ -364,20 +421,28 @@ export default function ChangePlansPage() {
364421
navigate("/settings/pricing-plans", { replace: true });
365422
} catch (error: any) {
366423
setErrorMessage(
367-
String(error?.response?.data?.detail ?? error?.message ?? "Failed to apply billing change."),
424+
String(
425+
error?.response?.data?.detail ??
426+
error?.message ??
427+
"Failed to apply billing change.",
428+
),
368429
);
369430
} finally {
370431
setSubmitting(false);
371432
}
372433
};
373434

374435
const canSubmitSeatMode =
375-
mode === "seats" && !actionsBlocked && pendingSeats >= MIN_SEATS && pendingSeats !== currentSeats;
436+
mode === "seats" &&
437+
!actionsBlocked &&
438+
pendingSeats >= MIN_SEATS &&
439+
pendingSeats !== currentSeats;
376440
const canSubmitPlanMode =
377441
mode === "plan" &&
378442
!actionsBlocked &&
379443
Boolean(selectedPlanKey) &&
380444
selectedPlanKey !== currentPlanKey &&
445+
!isDowngradePlanSelection(selectedPlanKey) &&
381446
selectedPlanKey !== ENTERPRISE_PLAN_KEY;
382447

383448
return (
@@ -404,7 +469,8 @@ export default function ChangePlansPage() {
404469
<>
405470
{actionsBlocked && (
406471
<div className="mt-6 rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
407-
Actions are disabled because your subscription is cancelled or you are not an admin.
472+
Actions are disabled because your subscription is cancelled
473+
or you are not an admin.
408474
</div>
409475
)}
410476

@@ -430,24 +496,31 @@ export default function ChangePlansPage() {
430496
{STATIC_PLANS.map((plan) => {
431497
const isCurrentPlan = plan.key === currentPlanKey;
432498
const isSelectedPlan = plan.key === selectedPlanKey;
433-
const planDisabledInSeatMode = mode === "seats" && !isCurrentPlan;
434-
const isDisabledPlan = actionsBlocked || planDisabledInSeatMode;
499+
const planDisabledInSeatMode =
500+
mode === "seats" && !isCurrentPlan;
501+
const isDisabledPlan =
502+
actionsBlocked || planDisabledInSeatMode;
435503

436504
const borderClass = isCurrentPlan
437505
? "border-emerald-500"
438506
: isSelectedPlan
439507
? "border-sky-400"
440508
: "border-slate-200";
441509

442-
const cardOpacity = mode === "seats" && !isCurrentPlan ? "opacity-50" : "opacity-100";
443-
444-
const seatsForCard = isCurrentPlan ? pendingSeats : currentSeats;
445-
const total = (seatsForCard * plan.monthlyPriceUsdCents) / 100;
446-
const canEditSeats = mode === "seats" && isCurrentPlan && !actionsBlocked;
510+
const cardOpacity =
511+
mode === "seats" && !isCurrentPlan
512+
? "opacity-50"
513+
: "opacity-100";
514+
515+
const seatsForCard = isCurrentPlan
516+
? pendingSeats
517+
: currentSeats;
518+
const total =
519+
(seatsForCard * plan.monthlyPriceUsdCents) / 100;
520+
const canEditSeats =
521+
mode === "seats" && isCurrentPlan && !actionsBlocked;
447522
const buttonDisabled =
448-
isDisabledPlan ||
449-
mode === "seats" ||
450-
isCurrentPlan;
523+
isDisabledPlan || mode === "seats" || isCurrentPlan;
451524

452525
return (
453526
<div
@@ -458,11 +531,14 @@ export default function ChangePlansPage() {
458531
{plan.name}
459532
</div>
460533
<div className="mt-2 text-3xl font-semibold text-slate-900">
461-
{(plan.monthlyPriceUsdCents / 100).toLocaleString("en-US", {
462-
style: "currency",
463-
currency: "USD",
464-
maximumFractionDigits: 0,
465-
})}
534+
{(plan.monthlyPriceUsdCents / 100).toLocaleString(
535+
"en-US",
536+
{
537+
style: "currency",
538+
currency: "USD",
539+
maximumFractionDigits: 0,
540+
},
541+
)}
466542
/seat/month
467543
</div>
468544

@@ -506,15 +582,20 @@ export default function ChangePlansPage() {
506582
</button>
507583
</div>
508584

509-
<div className="mt-3 text-sm text-slate-500">Estimated monthly total</div>
585+
<div className="mt-3 text-sm text-slate-500">
586+
Estimated monthly total
587+
</div>
510588
<div className="text-2xl font-semibold text-slate-900">
511589
{toCurrency(total)} USD
512590
</div>
513591
</div>
514592

515593
<ul className="mt-5 space-y-3 text-sm text-slate-600">
516594
{plan.features.map((feature) => (
517-
<li key={feature} className="flex items-center gap-2">
595+
<li
596+
key={feature}
597+
className="flex items-center gap-2"
598+
>
518599
<CheckIcon />
519600
{feature}
520601
</li>
@@ -527,7 +608,9 @@ export default function ChangePlansPage() {
527608
disabled={buttonDisabled}
528609
className="mt-6 rounded-xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition enabled:hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-300"
529610
>
530-
{isCurrentPlan ? "Current Plan" : `Select ${plan.name}`}
611+
{isCurrentPlan
612+
? "Current Plan"
613+
: `Select ${plan.name}`}
531614
</button>
532615
</div>
533616
);
@@ -537,18 +620,24 @@ export default function ChangePlansPage() {
537620
<div className="mt-8 flex flex-wrap justify-center gap-3">
538621
<Button
539622
onClick={() => void handlePreviewBeforeConfirm()}
540-
disabled={mode === "seats" ? !canSubmitSeatMode : !canSubmitPlanMode}
623+
disabled={
624+
mode === "seats" ? !canSubmitSeatMode : !canSubmitPlanMode
625+
}
541626
>
542627
{mode === "seats" ? "Update Seats" : "Confirm Plan Change"}
543628
</Button>
544-
<Button variant="outline" onClick={() => navigate("/settings/pricing-plans")}>
629+
<Button
630+
variant="outline"
631+
onClick={() => navigate("/settings/pricing-plans")}
632+
>
545633
Back
546634
</Button>
547635
</div>
548636

549637
{previewData && (
550638
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
551-
Preview received. Charges shown above will be applied only after confirmation.
639+
Preview received. Charges shown above will be applied only
640+
after confirmation.
552641
</div>
553642
)}
554643
</>
@@ -564,7 +653,8 @@ export default function ChangePlansPage() {
564653
{mode === "seats" ? "Confirm Seat Update" : "Confirm Plan Change"}
565654
</DialogTitle>
566655
<DialogDescription>
567-
{previewMessage ?? "A billing preview was generated for this change."}
656+
{previewMessage ??
657+
"A billing preview was generated for this change."}
568658
</DialogDescription>
569659
</DialogHeader>
570660
<DialogFooter>

0 commit comments

Comments
 (0)