From 4ef65b08ff3e049cfe1e021e1f03cf1af83174f9 Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 20 Jan 2026 20:32:24 +0300 Subject: [PATCH] chore(web): improve error message ui --- .../dashboard/(components)/bulk-sms-send.tsx | 32 +++++-- .../(components)/message-history.tsx | 16 +++- .../(app)/dashboard/(components)/send-sms.tsx | 25 ++++-- web/components/shared/rate-limit-error.tsx | 69 +++++++++++++++ web/lib/utils/errorHandler.ts | 85 +++++++++++++++++++ 5 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 web/components/shared/rate-limit-error.tsx create mode 100644 web/lib/utils/errorHandler.ts diff --git a/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx b/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx index f6312b9..dd5aed7 100644 --- a/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx +++ b/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx @@ -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() { - {sendingBulkSMSError && ( - - - Error - - {sendingBulkSMSError?.message} - - - )} + {sendingBulkSMSError && (() => { + const formattedError = formatError(sendingBulkSMSError) + if (formattedError.isRateLimit) { + return ( + + ) + } + return ( + + + Error + + {formattedError.message} + + + ) + })()} {isSendingBulkSMSuccess && ( diff --git a/web/app/(app)/dashboard/(components)/message-history.tsx b/web/app/(app)/dashboard/(components)/message-history.tsx index 1ca05e3..28bc63f 100644 --- a/web/app/(app)/dashboard/(components)/message-history.tsx +++ b/web/app/(app)/dashboard/(components)/message-history.tsx @@ -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', }) }, }) diff --git a/web/app/(app)/dashboard/(components)/send-sms.tsx b/web/app/(app)/dashboard/(components)/send-sms.tsx index 1d2f4b9..cb215bc 100644 --- a/web/app/(app)/dashboard/(components)/send-sms.tsx +++ b/web/app/(app)/dashboard/(components)/send-sms.tsx @@ -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() { )} - {sendSmsError && ( -
-

Error sending SMS: {sendSmsError.message}

- -
- )} + {sendSmsError && (() => { + const formattedError = formatError(sendSmsError) + if (formattedError.isRateLimit) { + return ( + + ) + } + return ( +
+

Error sending SMS: {formattedError.message}

+ +
+ ) + })()} {isSendSmsSuccess && (
diff --git a/web/components/shared/rate-limit-error.tsx b/web/components/shared/rate-limit-error.tsx new file mode 100644 index 0000000..8a04203 --- /dev/null +++ b/web/components/shared/rate-limit-error.tsx @@ -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 ( +
+

{message}

+
+ +

+ or wait for your limit to reset +

+
+
+ ) + } + + return ( + + + Limit Reached + +

{message}

+
+ + + or wait for your limit to reset + +
+
+
+ ) +} + +/** + * 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.` +} diff --git a/web/lib/utils/errorHandler.ts b/web/lib/utils/errorHandler.ts new file mode 100644 index 0000000..2a78388 --- /dev/null +++ b/web/lib/utils/errorHandler.ts @@ -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 +}