Files
plunk/apps/web/src/components/BillingLimits.tsx
T

432 lines
15 KiB
TypeScript

import {memo, useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {BillingLimitSchemas} from '@plunk/shared';
import {
Alert,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Progress,
} from '@plunk/ui';
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
import {AnimatePresence, motion} from 'framer-motion';
import type {z} from 'zod';
import {type BillingLimitsData, type CategoryLimit, useBillingLimits} from '../lib/hooks/useBillingLimits';
import {network} from '../lib/network';
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
const PRICE_PER_EMAIL = 0.1; // 0.001 USD/EUR = 0.1 cents
/**
* Calculate the monetary cost for a given number of emails
* @param emailCount - Number of emails
* @param currency - Currency code (e.g., 'usd', 'eur')
* @returns Formatted currency string
*/
const formatEmailCost = (emailCount: number, currency: string | null): string => {
if (!currency) {
return '';
}
// Calculate cost in smallest currency unit (cents)
const costInCents = emailCount * PRICE_PER_EMAIL;
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(costInCents / 100);
} catch {
// Fallback if currency is invalid
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
}
};
interface BillingLimitsProps {
projectId: string;
hasSubscription: boolean;
billingEnabled: boolean;
}
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
export function BillingLimits({projectId, hasSubscription, billingEnabled}: BillingLimitsProps) {
const [isEditing, setIsEditing] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch billing limits using SWR (fetch for both free and paid tiers when billing is enabled)
const {limitsData, isLoading, mutate} = useBillingLimits(projectId, billingEnabled);
const form = useForm<LimitsFormValues>({
resolver: zodResolver(BillingLimitSchemas.update),
defaultValues: {
workflows: null,
campaigns: null,
transactional: null,
},
});
// Update form when limits data changes
useEffect(() => {
if (limitsData) {
form.reset({
workflows: limitsData.workflows.limit,
campaigns: limitsData.campaigns.limit,
transactional: limitsData.transactional.limit,
});
}
}, [limitsData, form]);
const onSubmit = async (values: LimitsFormValues) => {
try {
setErrorMessage(null);
setSuccessMessage(null);
await network.fetch<BillingLimitsData, typeof BillingLimitSchemas.update>(
'PUT',
`/users/@me/projects/${projectId}/billing-limits`,
values,
);
// Revalidate SWR cache
await mutate();
setIsEditing(false);
setSuccessMessage('Billing limits updated successfully');
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to update billing limits');
}
};
const handleCancel = () => {
if (limitsData) {
form.reset({
workflows: limitsData.workflows.limit,
campaigns: limitsData.campaigns.limit,
transactional: limitsData.transactional.limit,
});
}
setIsEditing(false);
setErrorMessage(null);
};
// Free tier projects can view their usage but can't edit limits
const canEditLimits = hasSubscription;
// If billing is not enabled, don't show the component
if (!billingEnabled) {
return null;
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Billing Limits</CardTitle>
<CardDescription>Set monthly limits for each email category</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Billing Limits</CardTitle>
<CardDescription>
{hasSubscription
? 'Set monthly limits for each email category. Limits reset on the 1st of each month.'
: 'Free tier projects have a total limit of 1,000 emails per month across all categories.'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Free tier info banner */}
{!hasSubscription && limitsData && (
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
You&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited
emails or custom limits.
</p>
</div>
</Alert>
)}
{/* Success/Error Messages */}
<AnimatePresence mode="wait">
{successMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{successMessage}
</motion.div>
)}
{errorMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
{/* Usage Display (when not editing) */}
{!isEditing && limitsData && (
<div className="space-y-4">
{/* For free tier, show total usage across all categories */}
{!hasSubscription ? (
<UsageDisplay
category="Total Emails (All Categories)"
usage={limitsData.workflows}
currency={limitsData.currency}
/>
) : (
<>
<UsageDisplay category="Workflows" usage={limitsData.workflows} currency={limitsData.currency} />
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} currency={limitsData.currency} />
<UsageDisplay
category="Transactional"
usage={limitsData.transactional}
currency={limitsData.currency}
/>
</>
)}
{canEditLimits && (
<div className="flex justify-end pt-4">
<Button onClick={() => setIsEditing(true)}>Edit Limits</Button>
</div>
)}
</div>
)}
{/* Edit Form */}
{isEditing && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="workflows"
render={({field}) => {
const estimatedCost =
limitsData?.currency && field.value
? formatEmailCost(Number(field.value), limitsData.currency)
: null;
return (
<FormItem>
<FormLabel>Workflow Emails Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Unlimited"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
/>
</FormControl>
<FormDescription>
Maximum workflow emails per month. Leave empty for unlimited.
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="campaigns"
render={({field}) => {
const estimatedCost =
limitsData?.currency && field.value
? formatEmailCost(Number(field.value), limitsData.currency)
: null;
return (
<FormItem>
<FormLabel>Campaign Emails Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Unlimited"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
/>
</FormControl>
<FormDescription>
Maximum campaign emails per month. Leave empty for unlimited.
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="transactional"
render={({field}) => {
const estimatedCost =
limitsData?.currency && field.value
? formatEmailCost(Number(field.value), limitsData.currency)
: null;
return (
<FormItem>
<FormLabel>Transactional Emails Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Unlimited"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
/>
</FormControl>
<FormDescription>
Maximum transactional emails per month. Leave empty for unlimited.
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</Form>
)}
</div>
</CardContent>
</Card>
);
}
interface UsageDisplayProps {
category: string;
usage: CategoryLimit;
currency: string | null;
}
const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: UsageDisplayProps) {
const statusColor = useMemo(() => {
if (usage.isBlocked) return 'text-red-600';
if (usage.isWarning) return 'text-orange-600';
return 'text-green-600';
}, [usage.isBlocked, usage.isWarning]);
const progressColor = useMemo(() => {
if (usage.isBlocked) return 'bg-red-600';
if (usage.isWarning) return 'bg-orange-500';
return 'bg-green-600';
}, [usage.isBlocked, usage.isWarning]);
const statusIcon = useMemo(() => {
if (usage.isBlocked) return <AlertCircle className="h-4 w-4" />;
if (usage.isWarning) return <AlertTriangle className="h-4 w-4" />;
return <Check className="h-4 w-4" />;
}, [usage.isBlocked, usage.isWarning]);
const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString();
// Calculate monetary values
const usageCost = currency ? formatEmailCost(usage.usage, currency) : null;
const limitCost = currency && usage.limit !== null ? formatEmailCost(usage.limit, currency) : null;
return (
<div className="border border-neutral-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium text-neutral-900">{category}</h3>
<div className="flex items-baseline gap-2 mt-1">
<p className="text-sm text-neutral-600">
{usage.usage.toLocaleString()} / {limitText} emails
</p>
{currency && (
<>
<span className="text-neutral-300"></span>
<p className="text-sm text-neutral-500">
{usageCost}
{limitCost && ` / ${limitCost}`}
</p>
</>
)}
</div>
</div>
<div className={`flex items-center gap-2 ${statusColor}`}>
{statusIcon}
<span className="text-sm font-medium">
{usage.limit === null ? 'Unlimited' : `${Math.round(usage.percentage)}%`}
</span>
</div>
</div>
{usage.limit !== null && (
<>
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={progressColor} />
{usage.isBlocked && (
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className={'text-sm'}>
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
</p>
</div>
</Alert>
)}
{usage.isWarning && !usage.isBlocked && (
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
<strong>Warning:</strong> You&apos;ve used {Math.round(usage.percentage)}% of your{' '}
{category.toLowerCase()} email limit.
</p>
</div>
</Alert>
)}
</>
)}
</div>
);
});