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, IconSpinner, 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; tier: 'free' | 'paid'; billingEnabled: boolean; } type LimitsFormValues = z.infer; export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsProps) { const [isEditing, setIsEditing] = useState(false); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(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({ resolver: zodResolver(BillingLimitSchemas.update), defaultValues: { workflows: null, campaigns: null, transactional: null, inbound: null, }, }); // Update form when limits data changes useEffect(() => { if (limitsData) { form.reset({ workflows: limitsData.workflows.limit, campaigns: limitsData.campaigns.limit, transactional: limitsData.transactional.limit, inbound: limitsData.inbound.limit, }); } }, [limitsData, form]); const onSubmit = async (values: LimitsFormValues) => { try { setErrorMessage(null); setSuccessMessage(null); await network.fetch( '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, inbound: limitsData.inbound.limit, }); } setIsEditing(false); setErrorMessage(null); }; // Free tier projects can view their usage but can't edit limits const canEditLimits = tier === 'paid'; // If billing is not enabled, don't show the component if (!billingEnabled) { return null; } if (isLoading) { return ( Billing Limits Set monthly limits for each email category
); } return ( Billing Limits {tier === 'paid' ? '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.'}
{/* Free tier info banner */} {tier !== 'paid' && limitsData && (

You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited emails or custom limits.

)} {/* Success/Error Messages */} {successMessage && ( {successMessage} )} {errorMessage && ( {errorMessage} )} {/* Usage Display (when not editing) */} {!isEditing && limitsData && (
{/* For free tier, show total usage across all categories */} {tier !== 'paid' ? ( ) : ( <> )} {canEditLimits && (
)}
)} {/* Edit Form */} {isEditing && (
{ const estimatedCost = limitsData?.currency && field.value ? formatEmailCost(Number(field.value), limitsData.currency) : null; return ( Workflow Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum workflow emails per month. Leave empty for unlimited. {estimatedCost && ≈ {estimatedCost}/month} ); }} /> { const estimatedCost = limitsData?.currency && field.value ? formatEmailCost(Number(field.value), limitsData.currency) : null; return ( Campaign Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum campaign emails per month. Leave empty for unlimited. {estimatedCost && ≈ {estimatedCost}/month} ); }} /> { const estimatedCost = limitsData?.currency && field.value ? formatEmailCost(Number(field.value), limitsData.currency) : null; return ( Transactional Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum transactional emails per month. Leave empty for unlimited. {estimatedCost && ≈ {estimatedCost}/month} ); }} /> { const estimatedCost = limitsData?.currency && field.value ? formatEmailCost(Number(field.value), limitsData.currency) : null; return ( Inbound Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum inbound emails per month. Leave empty for unlimited. {estimatedCost && ≈ {estimatedCost}/month} ); }} />
)}
); } 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-700'; if (usage.isWarning) return 'text-amber-700'; return 'text-neutral-600'; }, [usage.isBlocked, usage.isWarning]); const progressColor = useMemo(() => { if (usage.isBlocked) return 'bg-red-600'; if (usage.isWarning) return 'bg-amber-500'; return 'bg-neutral-900'; }, [usage.isBlocked, usage.isWarning]); const statusIcon = useMemo(() => { if (usage.isBlocked) return ; if (usage.isWarning) return ; return ; }, [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 (

{category}

{usage.usage.toLocaleString()} / {limitText} emails

{currency && ( <>

{usageCost} {limitCost && ` / ${limitCost}`}

)}
{statusIcon} {usage.limit === null ? 'Unlimited' : `${Math.round(usage.percentage)}%`}
{usage.limit !== null && ( <> {usage.isBlocked && (

Limit reached: No more {category.toLowerCase()} emails can be sent this month.

)} {usage.isWarning && !usage.isBlocked && (

Warning: You've used {Math.round(usage.percentage)}% of your{' '} {category.toLowerCase()} email limit.

)} )}
); });