mirror of
https://github.com/vernu/textbee.git
synced 2026-07-31 01:17:52 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user