Refactor billing components to improve price display and modularity (#132)

* Refactor billing components to improve price display and modularity
- Created new `PlanCostDisplay` component to centralize price formatting logic
- Simplified price rendering in plan picker and pricing table
- Removed redundant price calculation code
- Improved handling of metered and tiered pricing display
This commit is contained in:
Giancarlo Buomprisco
2025-02-03 12:06:40 +07:00
committed by GitHub
parent 001903ddac
commit f46286b503
4 changed files with 117 additions and 119 deletions

View File

@@ -242,12 +242,8 @@ function Tiers({
const isIncluded = tier.cost === 0; const isIncluded = tier.cost === 0;
return ( return (
<span <span className={'text-secondary-foreground text-xs'} key={index}>
className={'text-secondary-foreground flex gap-x-2 text-xs'} <span>-</span>{' '}
key={index}
>
<span>-</span>
<If condition={isLastTier}> <If condition={isLastTier}>
<span className={'font-bold'}> <span className={'font-bold'}>
{formatCurrency({ {formatCurrency({
@@ -255,8 +251,7 @@ function Tiers({
value: tier.cost, value: tier.cost,
locale, locale,
})} })}
</span> </span>{' '}
<If condition={tiersLength > 1}> <If condition={tiersLength > 1}>
<span> <span>
<Trans <Trans
@@ -268,7 +263,6 @@ function Tiers({
/> />
</span> </span>
</If> </If>
<If condition={tiersLength === 1}> <If condition={tiersLength === 1}>
<span> <span>
<Trans <Trans
@@ -279,15 +273,13 @@ function Tiers({
/> />
</span> </span>
</If> </If>
</If> </If>{' '}
<If condition={!isLastTier}> <If condition={!isLastTier}>
<If condition={isIncluded}> <If condition={isIncluded}>
<span> <span>
<Trans i18nKey={'billing:includedUpTo'} values={{ unit, upTo }} /> <Trans i18nKey={'billing:includedUpTo'} values={{ unit, upTo }} />
</span> </span>
</If> </If>{' '}
<If condition={!isIncluded}> <If condition={!isIncluded}>
<span className={'font-bold'}> <span className={'font-bold'}>
{formatCurrency({ {formatCurrency({
@@ -295,8 +287,7 @@ function Tiers({
value: tier.cost, value: tier.cost,
locale, locale,
})} })}
</span> </span>{' '}
<span> <span>
<Trans <Trans
i18nKey={'billing:fromPreviousTierUpTo'} i18nKey={'billing:fromPreviousTierUpTo'}

View File

@@ -0,0 +1,98 @@
'use client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import type { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Trans } from '@kit/ui/trans';
type PlanCostDisplayProps = {
primaryLineItem: z.infer<typeof LineItemSchema>;
currencyCode: string;
interval?: string;
alwaysDisplayMonthlyPrice?: boolean;
className?: string;
};
/**
* @name PlanCostDisplay
* @description
* This component is used to display the cost of a plan. It will handle
* the display of the cost for metered plans by using the lowest tier using the format "Starting at {price} {unit}"
*/
export function PlanCostDisplay({
primaryLineItem,
currencyCode,
interval,
alwaysDisplayMonthlyPrice = true,
className,
}: PlanCostDisplayProps) {
const { i18n } = useTranslation();
const { shouldDisplayTier, lowestTier, tierTranslationKey, displayCost } =
useMemo(() => {
const shouldDisplayTier =
primaryLineItem.type === 'metered' &&
Array.isArray(primaryLineItem.tiers) &&
primaryLineItem.tiers.length > 0;
const isMultiTier =
Array.isArray(primaryLineItem.tiers) &&
primaryLineItem.tiers.length > 1;
const lowestTier = primaryLineItem.tiers?.reduce((acc, curr) => {
if (acc && acc.cost < curr.cost) {
return acc;
}
return curr;
}, primaryLineItem.tiers?.[0]);
const isYearlyPricing = interval === 'year';
const cost =
isYearlyPricing && alwaysDisplayMonthlyPrice
? Number(primaryLineItem.cost / 12)
: primaryLineItem.cost;
return {
shouldDisplayTier,
isMultiTier,
lowestTier,
tierTranslationKey: isMultiTier
? 'billing:startingAtPriceUnit'
: 'billing:priceUnit',
displayCost: cost,
};
}, [primaryLineItem, interval, alwaysDisplayMonthlyPrice]);
if (shouldDisplayTier) {
const formattedCost = formatCurrency({
currencyCode: currencyCode.toLowerCase(),
value: lowestTier?.cost ?? 0,
locale: i18n.language,
});
return (
<span className={'text-lg'}>
<Trans
i18nKey={tierTranslationKey}
values={{
price: formattedCost,
unit: primaryLineItem.unit,
}}
/>
</span>
);
}
const formattedCost = formatCurrency({
currencyCode: currencyCode.toLowerCase(),
value: displayCost,
locale: i18n.language,
});
return <span className={className}>{formattedCost}</span>;
}

View File

@@ -15,7 +15,6 @@ import {
getPrimaryLineItem, getPrimaryLineItem,
getProductPlanPair, getProductPlanPair,
} from '@kit/billing'; } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -38,6 +37,7 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details'; import { LineItemDetails } from './line-item-details';
import { PlanCostDisplay } from './plan-cost-display';
export function PlanPicker( export function PlanPicker(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
@@ -108,8 +108,6 @@ export function PlanPicker(
const isRecurringPlan = const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan; selectedPlan?.paymentType === 'recurring' || !selectedPlan;
const locale = useTranslation().i18n.language;
return ( return (
<Form {...form}> <Form {...form}>
<div <div
@@ -238,30 +236,6 @@ export function PlanPicker(
throw new Error(`Base line item was not found`); throw new Error(`Base line item was not found`);
} }
const shouldDisplayTier =
primaryLineItem.type === 'metered' &&
Array.isArray(primaryLineItem.tiers) &&
primaryLineItem.tiers.length > 0;
const isMultiTier =
Array.isArray(primaryLineItem.tiers) &&
primaryLineItem.tiers.length > 1;
const lowestTier = primaryLineItem.tiers?.reduce(
(acc, curr) => {
if (acc && acc.cost < curr.cost) {
return acc;
}
return curr;
},
primaryLineItem.tiers[0],
);
const tierTranslationKey = isMultiTier
? 'billing:startingAtPriceUnit'
: 'billing:priceUnit';
return ( return (
<RadioGroupItemLabel <RadioGroupItemLabel
selected={selected} selected={selected}
@@ -341,34 +315,14 @@ export function PlanPicker(
} }
> >
<div> <div>
<If <Price key={plan.id}>
condition={shouldDisplayTier} <PlanCostDisplay
fallback={ primaryLineItem={primaryLineItem}
<Price key={plan.id}> currencyCode={product.currency}
<span> interval={selectedInterval}
{formatCurrency({ alwaysDisplayMonthlyPrice={true}
currencyCode:
product.currency.toLowerCase(),
value: primaryLineItem.cost,
locale,
})}
</span>
</Price>
}
>
<Trans
i18nKey={tierTranslationKey}
values={{
price: formatCurrency({
currencyCode:
product.currency.toLowerCase(),
value: lowestTier?.cost ?? 0,
locale,
}),
unit: primaryLineItem.unit,
}}
/> />
</If> </Price>
<div> <div>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>

View File

@@ -14,7 +14,6 @@ import {
getPlanIntervals, getPlanIntervals,
getPrimaryLineItem, getPrimaryLineItem,
} from '@kit/billing'; } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
@@ -23,6 +22,7 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details'; import { LineItemDetails } from './line-item-details';
import { PlanCostDisplay } from './plan-cost-display';
interface Paths { interface Paths {
signUp: string; signUp: string;
@@ -152,8 +152,7 @@ function PricingItem(
}>, }>,
) { ) {
const highlighted = props.product.highlighted ?? false; const highlighted = props.product.highlighted ?? false;
const lineItem = props.primaryLineItem!;
const lineItem = props.primaryLineItem;
// we exclude flat line items from the details since // we exclude flat line items from the details since
// it doesn't need further explanation // it doesn't need further explanation
@@ -218,11 +217,10 @@ function PricingItem(
<div className={'flex flex-col space-y-2'}> <div className={'flex flex-col space-y-2'}>
<Price isMonthlyPrice={props.alwaysDisplayMonthlyPrice}> <Price isMonthlyPrice={props.alwaysDisplayMonthlyPrice}>
<LineItemPrice <PlanCostDisplay
plan={props.plan} primaryLineItem={lineItem}
product={props.product} currencyCode={props.product.currency}
interval={interval} interval={interval}
lineItem={lineItem}
alwaysDisplayMonthlyPrice={props.alwaysDisplayMonthlyPrice} alwaysDisplayMonthlyPrice={props.alwaysDisplayMonthlyPrice}
/> />
</Price> </Price>
@@ -492,46 +490,3 @@ function DefaultCheckoutButton(
</Link> </Link>
); );
} }
function LineItemPrice({
lineItem,
plan,
interval,
product,
alwaysDisplayMonthlyPrice = true,
}: {
lineItem: z.infer<typeof LineItemSchema> | undefined;
plan: {
label?: string;
};
interval: Interval | undefined;
product: {
currency: string;
};
alwaysDisplayMonthlyPrice?: boolean;
}) {
const { i18n } = useTranslation();
const isYearlyPricing = interval === 'year';
const cost = lineItem
? isYearlyPricing
? alwaysDisplayMonthlyPrice
? Number(lineItem.cost / 12).toFixed(2)
: lineItem.cost
: lineItem?.cost
: 0;
const costString =
lineItem &&
formatCurrency({
currencyCode: product.currency,
locale: i18n.language,
value: cost,
});
const labelString = plan.label && (
<Trans i18nKey={plan.label} defaults={plan.label} />
);
return costString ?? labelString ?? <Trans i18nKey={'billing:custom'} />;
}