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;
|
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'}
|
||||||
|
|||||||
@@ -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,
|
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'}>
|
||||||
|
|||||||
@@ -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'} />;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user