Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { PlusSquare } from 'lucide-react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import * as z from 'zod';
|
|
|
|
import type { LineItemSchema } from '@kit/billing';
|
|
import { formatCurrency } from '@kit/shared/utils';
|
|
import { If } from '@kit/ui/if';
|
|
import { Trans } from '@kit/ui/trans';
|
|
import { cn } from '@kit/ui/utils';
|
|
|
|
const className = 'flex text-secondary-foreground items-center text-sm';
|
|
|
|
export function LineItemDetails(
|
|
props: React.PropsWithChildren<{
|
|
lineItems: z.output<typeof LineItemSchema>[];
|
|
currency: string;
|
|
selectedInterval?: string | undefined;
|
|
alwaysDisplayMonthlyPrice?: boolean;
|
|
}>,
|
|
) {
|
|
const t = useTranslations('billing');
|
|
const locale = useLocale();
|
|
const currencyCode = props?.currency.toLowerCase();
|
|
|
|
const shouldDisplayMonthlyPrice =
|
|
props.alwaysDisplayMonthlyPrice && props.selectedInterval === 'year';
|
|
|
|
const getUnitLabel = (unit: string | undefined, count: number) => {
|
|
if (!unit) {
|
|
return '';
|
|
}
|
|
|
|
const i18nKey = `units.${unit}` as never;
|
|
|
|
if (!t.has(i18nKey)) {
|
|
return unit;
|
|
}
|
|
|
|
return t(i18nKey, {
|
|
count,
|
|
defaultValue: unit,
|
|
} as never);
|
|
};
|
|
|
|
const getDisplayCost = (cost: number, hasTiers: boolean) => {
|
|
if (shouldDisplayMonthlyPrice && !hasTiers) {
|
|
return cost / 12;
|
|
}
|
|
|
|
return cost;
|
|
};
|
|
|
|
return (
|
|
<div className={'flex flex-col space-y-1'}>
|
|
{props.lineItems.map((item, index) => {
|
|
// If the item has a description, we render it as a simple text
|
|
// and pass the item as values to the translation so we can use
|
|
// the item properties in the translation.
|
|
if (item.description) {
|
|
return (
|
|
<div key={index} className={className}>
|
|
<span className={'flex items-center space-x-1.5'}>
|
|
<PlusSquare className={'w-4'} />
|
|
|
|
<Trans
|
|
i18nKey={item.description}
|
|
values={item}
|
|
defaults={item.description}
|
|
/>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SetupFee = () => (
|
|
<If condition={item.setupFee}>
|
|
<div className={className}>
|
|
<span className={'flex items-center space-x-1'}>
|
|
<PlusSquare className={'w-3'} />
|
|
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.setupFee'}
|
|
values={{
|
|
setupFee: formatCurrency({
|
|
currencyCode,
|
|
value: item.setupFee as number,
|
|
locale,
|
|
}),
|
|
}}
|
|
/>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</If>
|
|
);
|
|
|
|
const unit =
|
|
item.unit ?? (item.type === 'per_seat' ? 'member' : undefined);
|
|
|
|
const hasTiers = Boolean(item.tiers?.length);
|
|
const isDefaultSeatUnit = unit === 'member';
|
|
|
|
const FlatFee = () => (
|
|
<div className={'flex flex-col'}>
|
|
<div className={cn(className, 'space-x-1')}>
|
|
<span className={'flex items-center space-x-1'}>
|
|
<span className={'flex items-center space-x-1.5'}>
|
|
<PlusSquare className={'w-3'} />
|
|
|
|
<span>
|
|
<Trans i18nKey={'billing.basePlan'} />
|
|
</span>
|
|
</span>
|
|
|
|
<span>
|
|
<If
|
|
condition={props.selectedInterval}
|
|
fallback={<Trans i18nKey={'billing.lifetime'} />}
|
|
>
|
|
(
|
|
<Trans
|
|
i18nKey={`billing.billingInterval.${props.selectedInterval}`}
|
|
/>
|
|
)
|
|
</If>
|
|
</span>
|
|
</span>
|
|
|
|
<span>-</span>
|
|
|
|
<span className={'text-xs font-semibold'}>
|
|
{formatCurrency({
|
|
currencyCode,
|
|
value: getDisplayCost(item.cost, hasTiers),
|
|
locale,
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
<SetupFee />
|
|
|
|
<If condition={item.tiers?.length}>
|
|
<span className={'flex items-center space-x-1.5'}>
|
|
<PlusSquare className={'w-3'} />
|
|
|
|
<span className={'flex gap-x-2 text-sm'}>
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.perUnit'}
|
|
values={{
|
|
unit: getUnitLabel(unit, 1),
|
|
}}
|
|
/>
|
|
</span>
|
|
</span>
|
|
</span>
|
|
|
|
<Tiers item={item} currency={props.currency} unit={unit} />
|
|
</If>
|
|
</div>
|
|
);
|
|
|
|
const PerSeat = () => (
|
|
<div key={index} className={'flex flex-col'}>
|
|
<div className={className}>
|
|
<span className={'flex items-center space-x-1.5'}>
|
|
<PlusSquare className={'w-3'} />
|
|
|
|
<span>
|
|
<If
|
|
condition={Boolean(unit) && !isDefaultSeatUnit}
|
|
fallback={<Trans i18nKey={'billing.perTeamMember'} />}
|
|
>
|
|
<Trans
|
|
i18nKey={'billing.perUnitShort'}
|
|
values={{
|
|
unit: getUnitLabel(unit, 1),
|
|
}}
|
|
/>
|
|
</If>
|
|
</span>
|
|
|
|
<If condition={!item.tiers?.length}>
|
|
<span>-</span>
|
|
|
|
<span className={'font-semibold'}>
|
|
{formatCurrency({
|
|
currencyCode,
|
|
value: getDisplayCost(item.cost, hasTiers),
|
|
locale,
|
|
})}
|
|
</span>
|
|
</If>
|
|
</span>
|
|
</div>
|
|
|
|
<SetupFee />
|
|
|
|
<If condition={item.tiers?.length}>
|
|
<Tiers item={item} currency={props.currency} unit={unit} />
|
|
</If>
|
|
</div>
|
|
);
|
|
|
|
const Metered = () => (
|
|
<div key={index} className={'flex flex-col'}>
|
|
<div className={className}>
|
|
<span className={'flex items-center space-x-1'}>
|
|
<span className={'flex items-center space-x-1.5'}>
|
|
<PlusSquare className={'w-3'} />
|
|
|
|
<span className={'flex space-x-1'}>
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.perUnit'}
|
|
values={{
|
|
unit: getUnitLabel(unit, 1),
|
|
}}
|
|
/>
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</span>
|
|
|
|
{/* If there are no tiers, there is a flat cost for usage */}
|
|
<If condition={!item.tiers?.length}>
|
|
<span className={'font-semibold'}>
|
|
{formatCurrency({
|
|
currencyCode,
|
|
value: getDisplayCost(item.cost, hasTiers),
|
|
locale,
|
|
})}
|
|
</span>
|
|
</If>
|
|
</div>
|
|
|
|
<SetupFee />
|
|
|
|
{/* If there are tiers, we render them as a list */}
|
|
<If condition={item.tiers?.length}>
|
|
<Tiers item={item} currency={props.currency} unit={unit} />
|
|
</If>
|
|
</div>
|
|
);
|
|
|
|
switch (item.type) {
|
|
case 'flat':
|
|
return <FlatFee key={item.id} />;
|
|
|
|
case 'per_seat':
|
|
return <PerSeat key={item.id} />;
|
|
|
|
case 'metered': {
|
|
return <Metered key={item.id} />;
|
|
}
|
|
}
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Tiers({
|
|
currency,
|
|
item,
|
|
unit,
|
|
}: {
|
|
currency: string;
|
|
unit?: string;
|
|
item: z.output<typeof LineItemSchema>;
|
|
}) {
|
|
const t = useTranslations('billing');
|
|
const locale = useLocale();
|
|
|
|
// Helper to safely convert tier values to numbers for pluralization
|
|
// Falls back to plural form (2) for 'unlimited' values
|
|
const getSafeCount = (value: number | 'unlimited' | string): number => {
|
|
if (value === 'unlimited') return 2;
|
|
const num = typeof value === 'number' ? value : Number(value);
|
|
return Number.isNaN(num) ? 2 : num;
|
|
};
|
|
|
|
const getUnitLabel = (count: number) => {
|
|
if (!unit) return '';
|
|
|
|
return t(
|
|
`units.${unit}` as never,
|
|
{
|
|
count,
|
|
defaultValue: unit,
|
|
} as never,
|
|
);
|
|
};
|
|
|
|
const tiers = item.tiers?.map((tier, index) => {
|
|
const tiersLength = item.tiers?.length ?? 0;
|
|
const previousTier = item.tiers?.[index - 1];
|
|
const isLastTier = tier.upTo === 'unlimited';
|
|
|
|
const previousTierFrom =
|
|
previousTier?.upTo === 'unlimited'
|
|
? 'unlimited'
|
|
: previousTier === undefined
|
|
? 0
|
|
: previousTier.upTo + 1 || 0;
|
|
|
|
const upTo = tier.upTo;
|
|
const previousTierUpTo =
|
|
typeof previousTier?.upTo === 'number' ? previousTier.upTo : undefined;
|
|
const currentTierUpTo = typeof upTo === 'number' ? upTo : undefined;
|
|
const rangeCount =
|
|
previousTierUpTo !== undefined && currentTierUpTo !== undefined
|
|
? currentTierUpTo - previousTierUpTo
|
|
: undefined;
|
|
const isIncluded = tier.cost === 0;
|
|
|
|
return (
|
|
<span className={'text-secondary-foreground text-xs'} key={index}>
|
|
<span>-</span>{' '}
|
|
<If condition={isLastTier}>
|
|
<span className={'font-bold'}>
|
|
{formatCurrency({
|
|
currencyCode: currency.toLowerCase(),
|
|
value: tier.cost,
|
|
locale,
|
|
})}
|
|
</span>{' '}
|
|
<If condition={tiersLength > 1}>
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.andAbove'}
|
|
values={{
|
|
unit: getUnitLabel(getSafeCount(previousTierFrom) - 1),
|
|
previousTier: getSafeCount(previousTierFrom) - 1,
|
|
}}
|
|
/>
|
|
</span>
|
|
</If>
|
|
<If condition={tiersLength === 1}>
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.forEveryUnit'}
|
|
values={{
|
|
unit: getUnitLabel(1),
|
|
}}
|
|
/>
|
|
</span>
|
|
</If>
|
|
</If>{' '}
|
|
<If condition={!isLastTier}>
|
|
<If condition={isIncluded}>
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.includedUpTo'}
|
|
values={{
|
|
unit: getUnitLabel(getSafeCount(upTo)),
|
|
upTo,
|
|
}}
|
|
/>
|
|
</span>
|
|
</If>{' '}
|
|
<If condition={!isIncluded}>
|
|
<span className={'font-bold'}>
|
|
{formatCurrency({
|
|
currencyCode: currency.toLowerCase(),
|
|
value: tier.cost,
|
|
locale,
|
|
})}
|
|
</span>{' '}
|
|
<span>
|
|
<Trans
|
|
i18nKey={'billing.fromPreviousTierUpTo'}
|
|
values={{
|
|
previousTierFrom,
|
|
unit: getUnitLabel(1),
|
|
unitPlural: getUnitLabel(getSafeCount(rangeCount ?? upTo)),
|
|
upTo: rangeCount ?? upTo,
|
|
}}
|
|
/>
|
|
</span>
|
|
</If>
|
|
</If>
|
|
</span>
|
|
);
|
|
});
|
|
|
|
return <div className={'my-1 flex flex-col space-y-1.5'}>{tiers}</div>;
|
|
}
|