353 lines
12 KiB
TypeScript
353 lines
12 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 {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<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'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} />
|
|
) : (
|
|
<>
|
|
<UsageDisplay category="Workflows" usage={limitsData.workflows} />
|
|
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} />
|
|
<UsageDisplay category="Transactional" usage={limitsData.transactional} />
|
|
</>
|
|
)}
|
|
|
|
{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}) => (
|
|
<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.</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="campaigns"
|
|
render={({field}) => (
|
|
<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.</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="transactional"
|
|
render={({field}) => (
|
|
<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.
|
|
</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;
|
|
}
|
|
|
|
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 <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();
|
|
|
|
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>
|
|
<p className="text-sm text-neutral-500">
|
|
{usage.usage.toLocaleString()} / {limitText} emails this month
|
|
</p>
|
|
</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-xs">
|
|
<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-xs">
|
|
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
|
{category.toLowerCase()} email limit.
|
|
</p>
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|