feat(billing): surface custom limit overrides and usage on account card

The dashboard account card showed base plan limits and ignored the
per-user custom limits admins can set, so a user whose limits were
raised saw stale numbers. Surface the effective values and make the
overrides legible.

- api: include the effective bulkSendLimit in the current-subscription
  usage payload (the only effective limit it was missing)
- subscription card: render the effective limit with the standard plan
  value struck through, an amber "Custom" chip and tooltip, a "Custom
  limits" header badge, a new Devices tile, and daily/monthly usage
  meters (green under 80%, amber approaching, red at the limit; "0 left"
  when over the limit in grace)
- add a rolling-window info note: daily resets at 00:00 UTC, monthly is
  a rolling 30 days, separate from the subscription start/renewal dates
- compact the card and lay the four limits out as a 2x2 grid
- bulk send: cap CSV rows by the effective bulk limit so a raised
  override is honored instead of the base plan limit
- remove the unused account-settings.tsx duplicate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-10 16:40:42 +03:00
parent 92e52679fb
commit 95caebfdb9
4 changed files with 278 additions and 885 deletions

View File

@@ -135,6 +135,7 @@ export class BillingService {
processedSmsLastMonth,
dailyLimit: effectiveLimits.dailyLimit,
monthlyLimit: effectiveLimits.monthlyLimit,
bulkSendLimit: effectiveLimits.bulkSendLimit,
deviceLimit: effectiveLimits.deviceLimit,
dailyRemaining:
effectiveLimits.dailyLimit === -1
@@ -214,6 +215,7 @@ export class BillingService {
processedSmsLastMonth,
dailyLimit: effectiveLimits.dailyLimit,
monthlyLimit: effectiveLimits.monthlyLimit,
bulkSendLimit: effectiveLimits.bulkSendLimit,
deviceLimit: effectiveLimits.deviceLimit,
dailyRemaining:
effectiveLimits.dailyLimit === -1

View File

@@ -1,792 +0,0 @@
'use client'
import { useState } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import {
AlertTriangle,
Mail,
Shield,
UserCircle,
Loader2,
Check,
Calendar,
Info,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Spinner } from '@/components/ui/spinner'
import Link from 'next/link'
import { Textarea } from '@/components/ui/textarea'
import axios from 'axios'
import { useSession } from 'next-auth/react'
import { Routes } from '@/config/routes'
import { polarCustomerPortalRequestUrl } from '@/config/external-links'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
const updateProfileSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email().optional(),
phone: z
.string()
.regex(/^\+?\d{0,14}$/, 'Invalid phone number')
.optional(),
})
type UpdateProfileFormData = z.infer<typeof updateProfileSchema>
const changePasswordSchema = z
.object({
oldPassword: z.string().min(1, 'Old password is required'),
newPassword: z
.string()
.min(8, { message: 'Password must be at least 8 characters long' }),
confirmPassword: z
.string()
.min(4, { message: 'Please confirm your password' }),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords must match',
path: ['confirmPassword'],
})
}
})
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>
export default function AccountSettings() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('')
const { update: updateSession } = useSession()
const { toast } = useToast()
const {
data: currentUser,
isLoading: isLoadingUser,
refetch: refetchCurrentUser,
} = useQuery({
queryKey: ['currentUser'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
.then((res) => res.data?.data),
})
const updateProfileForm = useForm<UpdateProfileFormData>({
resolver: zodResolver(updateProfileSchema),
defaultValues: {
name: currentUser?.name,
email: currentUser?.email,
phone: currentUser?.phone,
},
})
const changePasswordForm = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
})
const handleDeleteAccount = () => {
if (deleteConfirmEmail !== currentUser?.email) {
toast({
title: 'Please enter your correct email address',
})
return
}
requestAccountDeletion()
}
const handleVerifyEmail = () => {
// TODO: Implement email verification
}
const {
mutate: updateProfile,
isPending: isUpdatingProfile,
error: updateProfileError,
isSuccess: isUpdateProfileSuccess,
} = useMutation({
mutationFn: (data: UpdateProfileFormData) =>
httpBrowserClient.patch(ApiEndpoints.auth.updateProfile(), data),
onSuccess: () => {
refetchCurrentUser()
toast({
title: 'Profile updated successfully!',
})
updateSession({
name: updateProfileForm.getValues().name,
phone: updateProfileForm.getValues().phone,
})
},
onError: () => {
toast({
title: 'Failed to update profile',
})
},
})
const {
mutate: changePassword,
isPending: isChangingPassword,
error: changePasswordError,
isSuccess: isChangePasswordSuccess,
} = useMutation({
mutationFn: (data: ChangePasswordFormData) =>
httpBrowserClient.post(ApiEndpoints.auth.changePassword(), data),
onSuccess: () => {
toast({
title: 'Password changed successfully!',
})
changePasswordForm.reset()
},
onError: (error) => {
const errorMessage = (error as any).response?.data?.error
changePasswordForm.setError('root.serverError', {
message: errorMessage || 'Failed to change password',
})
toast({
title: 'Failed to change password',
})
},
})
const [deleteReason, setDeleteReason] = useState('')
const {
mutate: requestAccountDeletion,
isPending: isRequestingAccountDeletion,
error: requestAccountDeletionError,
isSuccess: isRequestAccountDeletionSuccess,
} = useMutation({
mutationFn: () =>
httpBrowserClient.post(ApiEndpoints.support.requestAccountDeletion(), {
message: deleteReason,
}),
onSuccess: () => {
toast({
title: 'Account deletion request submitted',
})
},
onError: () => {
toast({
title: 'Failed to submit account deletion request',
})
},
})
const CurrentSubscription = () => {
const {
data: currentSubscription,
isLoading: isLoadingSubscription,
error: subscriptionError,
} = useQuery({
queryKey: ['currentSubscription'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
.then((res) => res.data),
})
if (isLoadingSubscription)
return (
<div className='flex justify-center items-center h-full min-h-[200px] mt-10'>
<Spinner size='sm' />
</div>
)
if (subscriptionError)
return (
<p className='text-sm text-destructive'>
Failed to load subscription information
</p>
)
// Format price with currency symbol
const formatPrice = (
amount: number | null | undefined,
currency: string | null | undefined
) => {
if (amount == null || currency == null) return 'Free'
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase() || 'USD',
minimumFractionDigits: 2,
})
return formatter.format(amount / 100)
}
const getBillingInterval = (interval: string | null | undefined) => {
if (!interval) return ''
return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly'
}
return (
<div className='bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border rounded-lg shadow p-4'>
<div className='flex items-center justify-between mb-4'>
<div>
<h3 className='text-lg font-bold text-gray-900 dark:text-white'>
{currentSubscription?.plan?.name || 'Free Plan'}
</h3>
<div className='flex items-center gap-2'>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Current subscription
</p>
{currentSubscription?.amount > 0 && (
<Badge variant='outline' className='text-xs font-medium'>
{formatPrice(
currentSubscription?.amount,
currentSubscription?.currency
)}
{currentSubscription?.recurringInterval && (
<span className='ml-1'>
/{' '}
{getBillingInterval(
currentSubscription?.recurringInterval
)}
</span>
)}
</Badge>
)}
</div>
</div>
<div
className={`flex items-center px-2 py-0.5 rounded-full ${
currentSubscription?.status === 'active'
? 'bg-green-50 dark:bg-green-900/30'
: currentSubscription?.status === 'past_due'
? 'bg-amber-50 dark:bg-amber-900/30'
: 'bg-gray-50 dark:bg-gray-800/50'
}`}
>
<Check
className={`h-3 w-3 mr-1 ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}
/>
<span
className={`text-xs font-medium ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{currentSubscription?.status
? currentSubscription.status
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
: 'Active'}
</span>
</div>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-2 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Start Date
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.subscriptionStartDate
? new Date(
currentSubscription?.subscriptionStartDate
).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: 'N/A'}
</p>
</div>
</div>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-2 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Next Payment
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.currentPeriodEnd
? new Date(
currentSubscription?.currentPeriodEnd
).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: 'N/A'}
</p>
</div>
</div>
<div className='col-span-2 bg-white dark:bg-gray-800 p-3 rounded-md shadow-sm'>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2 font-medium'>
Usage Limits
</p>
<div className='grid grid-cols-3 gap-3'>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Daily
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.dailyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.dailyLimit || '0'}
{currentSubscription?.plan?.dailyLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within monthly limit)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Monthly
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.monthlyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.monthlyLimit?.toLocaleString() ||
'0'}
{currentSubscription?.plan?.monthlyLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within fair usage)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Bulk</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.bulkSendLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.bulkSendLimit || '0'}
{currentSubscription?.plan?.bulkSendLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within monthly limit)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
</div>
</div>
</div>
<div className='mt-4 flex justify-end gap-2 flex-wrap'>
{(!currentSubscription?.plan?.name ||
currentSubscription?.plan?.name?.toLowerCase() === 'free') ? (
<Link
href='/checkout/pro'
className='text-xs font-medium text-white bg-brand-600 hover:bg-brand-700 px-3 py-1.5 rounded-md transition-colors'
>
Upgrade to Pro
</Link>
) : currentSubscription?.plan?.name?.toLowerCase() === 'pro' ? (
<>
<Link
href='/checkout/scale'
className='text-xs font-medium text-white bg-teal-600 hover:bg-teal-700 px-3 py-1.5 rounded-md transition-colors'
>
Upgrade to Scale
</Link>
<Link
href={polarCustomerPortalRequestUrl(currentUser?.email)}
className='text-xs font-medium bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 px-3 py-1.5 rounded-md transition-colors'
>
Manage Subscription
</Link>
</>
) : (
<Link
href={polarCustomerPortalRequestUrl(currentUser?.email)}
className='text-xs font-medium bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 px-3 py-1.5 rounded-md transition-colors'
>
Manage Subscription
</Link>
)}
</div>
</div>
)
}
if (isLoadingUser)
return (
<div className='flex justify-center items-center h-full min-h-[200px] mt-10'>
<Spinner size='sm' />
</div>
)
return (
<div className='grid gap-6 max-w-2xl mt-10'>
<CurrentSubscription />
<Card>
<CardHeader>
<div className='flex items-center gap-2'>
<UserCircle className='h-5 w-5' />
<CardTitle>Profile Information</CardTitle>
</div>
<CardDescription>Update your profile information</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={updateProfileForm.handleSubmit((data) =>
updateProfile(data)
)}
className='space-y-4'
>
<div className='space-y-2'>
<Label htmlFor='name'>Full Name</Label>
<Input
id='name'
{...updateProfileForm.register('name')}
placeholder='Enter your full name'
defaultValue={currentUser?.name}
/>
{updateProfileForm.formState.errors.name && (
<p className='text-sm text-destructive'>
{updateProfileForm.formState.errors.name.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email' className='flex items-center gap-2'>
Email Address
{currentUser?.emailVerifiedAt && (
<Badge variant='secondary' className='ml-2'>
<Shield className='h-3 w-3 mr-1' />
Verified
</Badge>
)}
</Label>
<div className='flex gap-2'>
<Input
id='email'
type='email'
{...updateProfileForm.register('email')}
placeholder='Enter your email'
defaultValue={currentUser?.email}
disabled
/>
{!currentUser?.emailVerifiedAt ? (
<Button
type='button'
variant='outline'
onClick={handleVerifyEmail}
disabled={true}
>
{isUpdatingProfile ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<Mail className='h-4 w-4 mr-2' />
)}
Verify
</Button>
) : (
<Button variant='outline' disabled>
<Check className='h-4 w-4 mr-2' />
Verified
</Button>
)}
</div>
{updateProfileForm.formState.errors.email && (
<p className='text-sm text-destructive'>
{updateProfileForm.formState.errors.email.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='phone'>Phone Number</Label>
<Input
id='phone'
type='tel'
{...updateProfileForm.register('phone')}
placeholder='Enter your phone number'
defaultValue={currentUser?.phone}
/>
{updateProfileForm.formState.errors.phone && (
<p className='text-sm text-destructive'>
{updateProfileForm.formState.errors.phone.message}
</p>
)}
</div>
{isUpdateProfileSuccess && (
<p className='text-sm text-green-500'>
Profile updated successfully!
</p>
)}
<Button
type='submit'
className='w-full mt-6'
disabled={isUpdatingProfile}
>
{isUpdatingProfile ? (
<Loader2 className='h-4 w-4 animate-spin mr-2' />
) : null}
Save Changes
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className='flex items-center gap-2'>
<CardTitle>Change Password</CardTitle>
</div>
<CardDescription>
If you signed in with google, your can reset your password{' '}
<Link href={Routes.resetPassword} className='underline'>
here
</Link>
.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={changePasswordForm.handleSubmit((data) =>
changePassword(data)
)}
className='space-y-4'
>
<div className='space-y-2'>
<Label htmlFor='oldPassword'>Old Password</Label>
<Input
id='oldPassword'
type='password'
{...changePasswordForm.register('oldPassword')}
placeholder='Enter your old password'
/>
{changePasswordForm.formState.errors.oldPassword && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.oldPassword.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='newPassword'>New Password</Label>
<Input
id='newPassword'
type='password'
{...changePasswordForm.register('newPassword')}
placeholder='Enter your new password'
/>
{changePasswordForm.formState.errors.newPassword && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.newPassword.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
<Input
id='confirmPassword'
type='password'
{...changePasswordForm.register('confirmPassword')}
placeholder='Enter your confirm password'
/>
{changePasswordForm.formState.errors.confirmPassword && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.confirmPassword.message}
</p>
)}
</div>
{changePasswordForm.formState.errors.root?.serverError && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.root.serverError.message}
</p>
)}
{isChangePasswordSuccess && (
<p className='text-sm text-green-500'>
Password changed successfully!
</p>
)}
<Button
type='submit'
className='w-full mt-6'
disabled={isChangingPassword}
>
{isChangingPassword ? (
<Loader2 className='h-4 w-4 animate-spin mr-2' />
) : null}
Change Password
</Button>
</form>
</CardContent>
</Card>
<Card className='border-destructive/50'>
<CardHeader>
<div className='flex items-center gap-2 text-destructive'>
<AlertTriangle className='h-5 w-5' />
<CardTitle>Danger Zone</CardTitle>
</div>
<CardDescription>
Permanently delete your account and all associated data
</CardDescription>
</CardHeader>
<CardContent>
<Dialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<Button
variant='destructive'
className='w-full'
onClick={() => setIsDeleteDialogOpen(true)}
>
<AlertTriangle className='mr-2 h-4 w-4' />
Delete Account
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<AlertTriangle className='h-5 w-5 text-destructive' />
Delete Account
</DialogTitle>
<DialogDescription className='pt-4'>
<p className='mb-4'>
Are you sure you want to delete your account? This action:
</p>
<ul className='list-disc list-inside space-y-2 mb-4'>
<li>Cannot be undone</li>
<li>Will permanently delete all your data</li>
<li>Will cancel all active subscriptions</li>
<li>Will remove access to all services</li>
</ul>
{/* enter reason for deletion text area */}
<Label htmlFor='deleteReason'>Reason for deletion</Label>
<Textarea
className='my-2'
placeholder='Enter your reason for deletion'
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
/>
<p>Please type your email address to confirm:</p>
<Input
className='mt-2'
placeholder='Enter your email address'
value={deleteConfirmEmail}
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
/>
{requestAccountDeletionError && (
<p className='text-sm text-destructive'>
{(requestAccountDeletionError as any).response?.data
?.message ||
requestAccountDeletionError.message ||
'Failed to submit account deletion request'}
</p>
)}
{isRequestAccountDeletionSuccess && (
<p className='text-sm text-green-500'>
Account deletion request submitted
</p>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:gap-0'>
<Button
variant='outline'
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleDeleteAccount}
disabled={isRequestingAccountDeletion || !deleteConfirmEmail}
>
{isRequestingAccountDeletion ? (
<Loader2 className='h-4 w-4 animate-spin mr-2' />
) : (
<AlertTriangle className='h-4 w-4 mr-2' />
)}
Delete Account
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</div>
)
}

View File

@@ -55,11 +55,15 @@ export default function BulkSMSSend() {
})
const maxRows = useMemo(() => {
if (currentSubscription?.plan?.bulkSendLimit == -1) {
const effectiveBulkSendLimit =
currentSubscription?.usage?.bulkSendLimit ??
currentSubscription?.plan?.bulkSendLimit
if (effectiveBulkSendLimit == -1) {
return 9999
}
return currentSubscription?.plan?.bulkSendLimit || DEFAULT_MAX_ROWS
return effectiveBulkSendLimit || DEFAULT_MAX_ROWS
}, [currentSubscription])
const onDrop = useCallback((acceptedFiles: File[]) => {

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect } from 'react'
import { Calendar, Check, Info } from 'lucide-react'
import { Calendar, Check, Info, Sparkles } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useQuery } from '@tanstack/react-query'
@@ -16,6 +16,135 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const formatLimit = (value: number | null | undefined) => {
if (value === -1) return 'Unlimited'
if (value == null) return '0'
return value.toLocaleString()
}
type Meter = {
used: number
remaining: number
percentage: number
usedLabel: string
unlimited: boolean
}
type LimitTileProps = {
label: string
effectiveValue: number | null | undefined
planValue: number | null | undefined
isOverridden: boolean
planName?: string
tooltipUnit: string
unlimitedNote?: string
meter?: Meter
}
function LimitTile({
label,
effectiveValue,
planValue,
isOverridden,
planName,
tooltipUnit,
unlimitedNote,
meter,
}: LimitTileProps) {
const isUnlimited = effectiveValue === -1
const meterColor =
meter && meter.percentage >= 100
? 'bg-red-500'
: meter && meter.percentage >= 80
? 'bg-amber-500'
: 'bg-green-500'
return (
<div
className={cn(
'p-2 rounded-md',
isOverridden
? 'bg-amber-500/5 ring-1 ring-amber-500/30'
: 'bg-gray-50 dark:bg-gray-700/50'
)}
>
<div className='flex items-center justify-between gap-1'>
<p className='text-xs text-gray-500 dark:text-gray-400'>{label}</p>
{isOverridden && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center gap-0.5 rounded-full border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400 cursor-default'>
<Sparkles className='h-2.5 w-2.5' />
Custom
</span>
</TooltipTrigger>
<TooltipContent>
<p className='max-w-[220px]'>
Custom limit set for your account by the textbee team.
{planName
? ` Standard ${planName} plan limit: ${formatLimit(
planValue
)} ${tooltipUnit}.`
: ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className='mt-0.5 flex items-baseline gap-1.5'>
<p className='text-sm font-semibold text-gray-900 dark:text-white'>
{formatLimit(effectiveValue)}
</p>
{isOverridden && (
<span className='text-xs text-gray-400 dark:text-gray-500 line-through'>
{formatLimit(planValue)}
</span>
)}
{isUnlimited && unlimitedNote && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-3.5 w-3.5 text-gray-400 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{unlimitedNote}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{meter && !meter.unlimited && (
<div className='mt-1.5'>
<div className='h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700'>
<div
className={cn('h-full rounded-full transition-all', meterColor)}
style={{ width: `${Math.min(Math.max(meter.percentage, 0), 100)}%` }}
/>
</div>
<div className='mt-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400'>
<span>
{meter.used.toLocaleString()} {meter.usedLabel}
</span>
<span>{Math.max(meter.remaining, 0).toLocaleString()} left</span>
</div>
</div>
)}
{meter && meter.unlimited && (
<p className='mt-1.5 text-[10px] text-gray-500 dark:text-gray-400'>
{meter.used.toLocaleString()} {meter.usedLabel}
</p>
)}
</div>
)
}
export default function SubscriptionInfo() {
const { toast } = useToast()
@@ -92,15 +221,102 @@ export default function SubscriptionInfo() {
</p>
)
const plan = currentSubscription?.plan
const usage = currentSubscription?.usage
const planName = plan?.name || 'Free'
const isOverridden = (
custom: number | null | undefined,
planValue: number | null | undefined
) => custom != null && custom !== planValue
const limitTiles: LimitTileProps[] = [
{
label: 'Daily',
effectiveValue: usage?.dailyLimit ?? plan?.dailyLimit,
planValue: plan?.dailyLimit,
isOverridden: isOverridden(
currentSubscription?.customDailyLimit,
plan?.dailyLimit
),
planName,
tooltipUnit: 'per day',
unlimitedNote: 'Unlimited (within monthly limit)',
meter: {
used: usage?.processedSmsToday ?? 0,
remaining: usage?.dailyRemaining ?? 0,
percentage: usage?.dailyUsagePercentage ?? 0,
usedLabel: 'used today',
unlimited: (usage?.dailyLimit ?? plan?.dailyLimit) === -1,
},
},
{
label: 'Monthly',
effectiveValue: usage?.monthlyLimit ?? plan?.monthlyLimit,
planValue: plan?.monthlyLimit,
isOverridden: isOverridden(
currentSubscription?.customMonthlyLimit,
plan?.monthlyLimit
),
planName,
tooltipUnit: 'per month',
unlimitedNote: 'Unlimited (within fair usage)',
meter: {
used: usage?.processedSmsLastMonth ?? 0,
remaining: usage?.monthlyRemaining ?? 0,
percentage: usage?.monthlyUsagePercentage ?? 0,
usedLabel: 'used this month',
unlimited: (usage?.monthlyLimit ?? plan?.monthlyLimit) === -1,
},
},
{
label: 'Bulk send',
effectiveValue: usage?.bulkSendLimit ?? plan?.bulkSendLimit,
planValue: plan?.bulkSendLimit,
isOverridden: isOverridden(
currentSubscription?.customBulkSendLimit,
plan?.bulkSendLimit
),
planName,
tooltipUnit: 'per bulk send',
unlimitedNote: 'Unlimited (within monthly limit)',
},
{
label: 'Devices',
effectiveValue: usage?.deviceLimit ?? plan?.deviceLimit,
planValue: plan?.deviceLimit,
isOverridden: isOverridden(
currentSubscription?.customDeviceLimit,
plan?.deviceLimit
),
planName,
tooltipUnit: 'devices',
unlimitedNote: 'Unlimited devices',
},
]
const hasCustomLimits = limitTiles.some((tile) => tile.isOverridden)
return (
<div className='bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border rounded-lg shadow p-5'>
<div className='flex items-center justify-between mb-5'>
<div className='bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border rounded-lg shadow p-4'>
<div className='flex items-center justify-between mb-4'>
<div>
<h3 className='text-xl font-bold text-gray-900 dark:text-white'>
{currentSubscription?.plan?.name || 'Free Plan'}
</h3>
<div className='flex items-center gap-2 flex-wrap'>
<h3 className='text-lg font-bold text-gray-900 dark:text-white'>
{plan?.name || 'Free Plan'}
</h3>
{hasCustomLimits && (
<Badge
variant='outline'
className='gap-1 border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] font-medium'
>
<Sparkles className='h-3 w-3' />
Custom limits
</Badge>
)}
</div>
<div className='flex items-center gap-2'>
<p className='text-sm text-gray-500 dark:text-gray-400'>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Current subscription
</p>
{currentSubscription?.amount > 0 && (
@@ -156,19 +372,19 @@ export default function SubscriptionInfo() {
</div>
</div>
<div className='grid grid-cols-2 gap-3 mb-5'>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-3 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div className='grid grid-cols-2 gap-2.5 mb-4'>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-2.5 rounded-md shadow-sm'>
<Calendar className='h-3.5 w-3.5 text-brand-600 dark:text-brand-400 flex-none' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
<p className='text-[11px] text-gray-500 dark:text-gray-400'>
Start Date
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
<p className='text-xs font-medium text-gray-900 dark:text-white'>
{currentSubscription?.subscriptionStartDate
? new Date(
currentSubscription?.subscriptionStartDate
).toLocaleDateString('en-US', {
month: 'long',
month: 'short',
day: 'numeric',
year: 'numeric',
})
@@ -177,18 +393,18 @@ export default function SubscriptionInfo() {
</div>
</div>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-3 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-2.5 rounded-md shadow-sm'>
<Calendar className='h-3.5 w-3.5 text-brand-600 dark:text-brand-400 flex-none' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
<p className='text-[11px] text-gray-500 dark:text-gray-400'>
Next Payment
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
<p className='text-xs font-medium text-gray-900 dark:text-white'>
{currentSubscription?.currentPeriodEnd
? new Date(
currentSubscription?.currentPeriodEnd
).toLocaleDateString('en-US', {
month: 'long',
month: 'short',
day: 'numeric',
year: 'numeric',
})
@@ -198,78 +414,41 @@ export default function SubscriptionInfo() {
</div>
</div>
<div className='bg-white dark:bg-gray-800 p-4 rounded-md shadow-sm mb-5'>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-3 font-medium'>
Usage Limits
</p>
<div className='grid grid-cols-3 gap-3'>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Daily</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.dailyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.dailyLimit || '0'}
{currentSubscription?.plan?.dailyLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within monthly limit)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className='bg-white dark:bg-gray-800 p-3 rounded-md shadow-sm mb-4'>
<div className='flex items-center justify-between mb-2.5'>
<div className='flex items-center gap-1'>
<p className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
Usage Limits
</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-3.5 w-3.5 text-gray-400 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p className='max-w-[240px]'>
SMS usage is measured on a rolling window, not your billing
cycle. Daily usage resets at 00:00 UTC and monthly usage
covers a rolling 30-day window. The Start Date and Next
Payment above are just your subscription start and renewal
dates.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Monthly</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.monthlyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.monthlyLimit?.toLocaleString() ||
'0'}
{currentSubscription?.plan?.monthlyLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within fair usage)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasCustomLimits && (
<p className='text-[10px] text-amber-600 dark:text-amber-400'>
Custom values set for your account
</p>
</div>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Bulk</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.bulkSendLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.bulkSendLimit || '0'}
{currentSubscription?.plan?.bulkSendLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within monthly limit)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
)}
</div>
<div className='grid grid-cols-2 gap-2.5'>
{limitTiles.map((tile) => (
<LimitTile key={tile.label} {...tile} />
))}
</div>
</div>
@@ -278,7 +457,7 @@ export default function SubscriptionInfo() {
currentSubscription?.plan?.name?.toLowerCase() === 'free') ? (
<Link
href='/checkout/pro'
className='text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 px-4 py-2 rounded-md transition-colors'
className='text-xs font-medium text-white bg-brand-600 hover:bg-brand-700 px-3 py-1.5 rounded-md transition-colors'
>
Upgrade to Pro
</Link>
@@ -286,13 +465,13 @@ export default function SubscriptionInfo() {
<>
<Link
href='/checkout/scale'
className='text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 px-4 py-2 rounded-md transition-colors'
className='text-xs font-medium text-white bg-teal-600 hover:bg-teal-700 px-3 py-1.5 rounded-md transition-colors'
>
Upgrade to Scale
</Link>
<Link
href={polarCustomerPortalRequestUrl(currentUser?.email)}
className='text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 px-4 py-2 rounded-md transition-colors'
className='text-xs font-medium text-white bg-brand-600 hover:bg-brand-700 px-3 py-1.5 rounded-md transition-colors'
>
Manage Subscription
</Link>
@@ -300,7 +479,7 @@ export default function SubscriptionInfo() {
) : (
<Link
href={polarCustomerPortalRequestUrl(currentUser?.email)}
className='text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 px-4 py-2 rounded-md transition-colors'
className='text-xs font-medium text-white bg-brand-600 hover:bg-brand-700 px-3 py-1.5 rounded-md transition-colors'
>
Manage Subscription
</Link>