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;
return (
<span
className={'text-secondary-foreground flex gap-x-2 text-xs'}
key={index}
>
<span>-</span>
<span className={'text-secondary-foreground text-xs'} key={index}>
<span>-</span>{' '}
<If condition={isLastTier}>
<span className={'font-bold'}>
{formatCurrency({
@@ -255,8 +251,7 @@ function Tiers({
value: tier.cost,
locale,
})}
</span>
</span>{' '}
<If condition={tiersLength > 1}>
<span>
<Trans
@@ -268,7 +263,6 @@ function Tiers({
/>
</span>
</If>
<If condition={tiersLength === 1}>
<span>
<Trans
@@ -279,15 +273,13 @@ function Tiers({
/>
</span>
</If>
</If>
</If>{' '}
<If condition={!isLastTier}>
<If condition={isIncluded}>
<span>
<Trans i18nKey={'billing:includedUpTo'} values={{ unit, upTo }} />
</span>
</If>
</If>{' '}
<If condition={!isIncluded}>
<span className={'font-bold'}>
{formatCurrency({
@@ -295,8 +287,7 @@ function Tiers({
value: tier.cost,
locale,
})}
</span>
</span>{' '}
<span>
<Trans
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,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
@@ -38,6 +37,7 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
import { PlanCostDisplay } from './plan-cost-display';
export function PlanPicker(
props: React.PropsWithChildren<{
@@ -108,8 +108,6 @@ export function PlanPicker(
const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
const locale = useTranslation().i18n.language;
return (
<Form {...form}>
<div
@@ -238,30 +236,6 @@ export function PlanPicker(
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 (
<RadioGroupItemLabel
selected={selected}
@@ -341,34 +315,14 @@ export function PlanPicker(
}
>
<div>
<If
condition={shouldDisplayTier}
fallback={
<Price key={plan.id}>
<span>
{formatCurrency({
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,
}}
<Price key={plan.id}>
<PlanCostDisplay
primaryLineItem={primaryLineItem}
currencyCode={product.currency}
interval={selectedInterval}
alwaysDisplayMonthlyPrice={true}
/>
</If>
</Price>
<div>
<span className={'text-muted-foreground'}>

View File

@@ -14,7 +14,6 @@ import {
getPlanIntervals,
getPrimaryLineItem,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
@@ -23,6 +22,7 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
import { PlanCostDisplay } from './plan-cost-display';
interface Paths {
signUp: string;
@@ -152,8 +152,7 @@ function PricingItem(
}>,
) {
const highlighted = props.product.highlighted ?? false;
const lineItem = props.primaryLineItem;
const lineItem = props.primaryLineItem!;
// we exclude flat line items from the details since
// it doesn't need further explanation
@@ -218,11 +217,10 @@ function PricingItem(
<div className={'flex flex-col space-y-2'}>
<Price isMonthlyPrice={props.alwaysDisplayMonthlyPrice}>
<LineItemPrice
plan={props.plan}
product={props.product}
<PlanCostDisplay
primaryLineItem={lineItem}
currencyCode={props.product.currency}
interval={interval}
lineItem={lineItem}
alwaysDisplayMonthlyPrice={props.alwaysDisplayMonthlyPrice}
/>
</Price>
@@ -492,46 +490,3 @@ function DefaultCheckoutButton(
</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'} />;
}