mirror of
https://github.com/vernu/textbee.git
synced 2026-03-03 02:27:00 +00:00
chore(web): improve error message ui
This commit is contained in:
@@ -27,6 +27,8 @@ import { ApiEndpoints } from '@/config/api'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { RateLimitError } from '@/components/shared/rate-limit-error'
|
||||
|
||||
const DEFAULT_MAX_FILE_SIZE = 1024 * 1024 // 1 MB
|
||||
const DEFAULT_MAX_ROWS = 50
|
||||
@@ -321,15 +323,27 @@ export default function BulkSMSSend() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sendingBulkSMSError && (
|
||||
<Alert variant='destructive' className='mt-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{sendingBulkSMSError?.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{sendingBulkSMSError && (() => {
|
||||
const formattedError = formatError(sendingBulkSMSError)
|
||||
if (formattedError.isRateLimit) {
|
||||
return (
|
||||
<RateLimitError
|
||||
errorData={formattedError.rateLimitData}
|
||||
variant="alert"
|
||||
className="mt-4"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Alert variant='destructive' className='mt-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{formattedError.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
})()}
|
||||
|
||||
{isSendingBulkSMSuccess && (
|
||||
<Alert variant='default' className='mt-4'>
|
||||
|
||||
@@ -44,6 +44,8 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { formatRateLimitMessageForToast } from '@/components/shared/rate-limit-error'
|
||||
|
||||
|
||||
// Helper function to format timestamps
|
||||
@@ -115,9 +117,14 @@ function ReplyDialog({ sms, onClose, open, onOpenChange }: { sms: any; onClose?:
|
||||
}, 1500)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const formattedError = formatError(error)
|
||||
const description = formattedError.isRateLimit
|
||||
? formatRateLimitMessageForToast(formattedError.rateLimitData)
|
||||
: formattedError.message || 'Please try again.'
|
||||
toast({
|
||||
title: 'Failed to send SMS.',
|
||||
description: error.response?.data?.message || 'Please try again.',
|
||||
description,
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -264,9 +271,14 @@ function FollowUpDialog({ message, onClose, open, onOpenChange }: { message: any
|
||||
}, 1500)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const formattedError = formatError(error)
|
||||
const description = formattedError.isRateLimit
|
||||
? formatRateLimitMessageForToast(formattedError.rateLimitData)
|
||||
: formattedError.message || 'Please try again.'
|
||||
toast({
|
||||
title: 'Failed to send follow-up SMS.',
|
||||
description: error.response?.data?.message || 'Please try again.',
|
||||
description,
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,6 +27,8 @@ import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { RateLimitError } from '@/components/shared/rate-limit-error'
|
||||
|
||||
export default function SendSms() {
|
||||
const { data: devices, isLoading: isLoadingDevices } = useQuery({
|
||||
@@ -180,12 +182,23 @@ export default function SendSms() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sendSmsError && (
|
||||
<div className='flex items-center gap-2 text-destructive'>
|
||||
<p>Error sending SMS: {sendSmsError.message}</p>
|
||||
<X className='h-5 w-5' />
|
||||
</div>
|
||||
)}
|
||||
{sendSmsError && (() => {
|
||||
const formattedError = formatError(sendSmsError)
|
||||
if (formattedError.isRateLimit) {
|
||||
return (
|
||||
<RateLimitError
|
||||
errorData={formattedError.rateLimitData}
|
||||
variant="inline"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='flex items-center gap-2 text-destructive'>
|
||||
<p>Error sending SMS: {formattedError.message}</p>
|
||||
<X className='h-5 w-5' />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{isSendSmsSuccess && (
|
||||
<div className='flex items-center gap-2'>
|
||||
|
||||
69
web/components/shared/rate-limit-error.tsx
Normal file
69
web/components/shared/rate-limit-error.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import type { RateLimitErrorData } from '@/lib/utils/errorHandler'
|
||||
|
||||
interface RateLimitErrorProps {
|
||||
errorData?: RateLimitErrorData
|
||||
variant?: 'alert' | 'inline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying rate limit (429) errors with upgrade option
|
||||
*/
|
||||
export function RateLimitError({
|
||||
errorData,
|
||||
variant = 'alert',
|
||||
className,
|
||||
}: RateLimitErrorProps) {
|
||||
const message = errorData?.message || 'You have reached your usage limit.'
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className || ''}`}>
|
||||
<p className="text-sm text-destructive">{message}</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button asChild variant="default" size="sm">
|
||||
<Link href="/checkout/pro">Upgrade Plan</Link>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground flex items-center">
|
||||
or wait for your limit to reset
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className={className}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Limit Reached</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 mt-2">
|
||||
<p>{message}</p>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/checkout/pro">Upgrade Plan</Link>
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
or wait for your limit to reset
|
||||
</span>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a rate limit error message for use in toast notifications
|
||||
* Since toasts can't contain interactive components, this returns a plain message
|
||||
*/
|
||||
export function formatRateLimitMessageForToast(
|
||||
errorData?: RateLimitErrorData
|
||||
): string {
|
||||
const baseMessage = errorData?.message || 'You have reached your usage limit.'
|
||||
return `${baseMessage} Please upgrade your plan or wait for your limit to reset.`
|
||||
}
|
||||
85
web/lib/utils/errorHandler.ts
Normal file
85
web/lib/utils/errorHandler.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
export interface RateLimitErrorData {
|
||||
message: string
|
||||
hasReachedLimit: boolean
|
||||
dailyLimit?: number
|
||||
dailyRemaining?: number
|
||||
monthlyRemaining?: number
|
||||
bulkSendLimit?: number
|
||||
monthlyLimit?: number
|
||||
}
|
||||
|
||||
export interface FormattedError {
|
||||
message: string
|
||||
isRateLimit: boolean
|
||||
rateLimitData?: RateLimitErrorData
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats axios errors into user-friendly messages
|
||||
* Special handling for 429 (rate limit) errors
|
||||
*/
|
||||
export function formatError(error: unknown): FormattedError {
|
||||
if (!error) {
|
||||
return {
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
isRateLimit: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an axios error
|
||||
const axiosError = error as AxiosError
|
||||
if (axiosError.response) {
|
||||
const status = axiosError.response.status
|
||||
const data = axiosError.response.data as any
|
||||
|
||||
// Handle 429 rate limit errors
|
||||
if (status === 429) {
|
||||
const rateLimitData: RateLimitErrorData = {
|
||||
message: data?.message || 'Rate limit reached',
|
||||
hasReachedLimit: data?.hasReachedLimit ?? true,
|
||||
dailyLimit: data?.dailyLimit,
|
||||
dailyRemaining: data?.dailyRemaining,
|
||||
monthlyRemaining: data?.monthlyRemaining,
|
||||
bulkSendLimit: data?.bulkSendLimit,
|
||||
monthlyLimit: data?.monthlyLimit,
|
||||
}
|
||||
|
||||
return {
|
||||
message: rateLimitData.message,
|
||||
isRateLimit: true,
|
||||
rateLimitData,
|
||||
}
|
||||
}
|
||||
|
||||
// For other HTTP errors, use the message from the response
|
||||
if (data?.message) {
|
||||
return {
|
||||
message: data.message,
|
||||
isRateLimit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-axios errors or errors without a response
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred. Please try again.',
|
||||
isRateLimit: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
isRateLimit: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is a rate limit (429) error
|
||||
*/
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
const formatted = formatError(error)
|
||||
return formatted.isRateLimit
|
||||
}
|
||||
Reference in New Issue
Block a user