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 {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits'; import {network} from '../lib/network'; interface BillingLimitsProps { projectId: string; hasSubscription: boolean; billingEnabled: boolean; } type LimitsFormValues = z.infer; export function BillingLimits({projectId, hasSubscription, 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, }, }); // 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( '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 ( Billing Limits Set monthly limits for each email category

Loading...

); } return ( Billing Limits {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.'}
{/* Free tier info banner */} {!hasSubscription && 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 */} {!hasSubscription ? ( ) : ( <> )} {canEditLimits && (
)}
)} {/* Edit Form */} {isEditing && (
( Workflow Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum workflow emails per month. Leave empty for unlimited. )} /> ( Campaign Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum campaign emails per month. Leave empty for unlimited. )} /> ( Transactional Emails Limit field.onChange(e.target.value === '' ? null : e.target.value)} /> Maximum transactional emails per month. Leave empty for unlimited. )} />
)}
); } interface UsageDisplayProps { category: string; usage: CategoryLimit; } const UsageDisplay = memo(function UsageDisplay({category, usage}: 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 ; if (usage.isWarning) return ; return ; }, [usage.isBlocked, usage.isWarning]); const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString(); return (

{category}

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

{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.

)} )}
); });