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:
committed by
GitHub
parent
001903ddac
commit
f46286b503
@@ -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'}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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'} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user