From bbca0847feec10ce3d00b3b8b8a84f4f78a534dd Mon Sep 17 00:00:00 2001 From: EDOHWARES Date: Fri, 27 Mar 2026 12:43:14 +0100 Subject: [PATCH] feat: add system rate limit graceful banner --- src/app/layout.tsx | 2 + src/components/providers/Providers.tsx | 21 +++++++++- src/components/ui/RateLimitBanner.tsx | 56 ++++++++++++++++++++++++++ src/services/api.ts | 19 ++++++++- src/store/slices/uiSlice.ts | 11 +++-- 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/components/ui/RateLimitBanner.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fba4548..312060f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Manrope } from "next/font/google"; import "./globals.css"; import { Providers } from "@/components/providers/Providers"; +import { RateLimitBanner } from "@/components/ui/RateLimitBanner"; const manrope = Manrope({ subsets: ["latin"], @@ -24,6 +25,7 @@ export default function RootLayout({ + {children} diff --git a/src/components/providers/Providers.tsx b/src/components/providers/Providers.tsx index 7f53956..9fda342 100644 --- a/src/components/providers/Providers.tsx +++ b/src/components/providers/Providers.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import { Toaster } from 'react-hot-toast'; import { store } from '@/store'; import { initializeAuth } from '@/store/slices/authSlice'; -import { initializeUI } from '@/store/slices/uiSlice'; +import { initializeUI, setRateLimited } from '@/store/slices/uiSlice'; interface ProvidersProps { children: React.ReactNode; @@ -17,6 +17,25 @@ export function Providers({ children }: ProvidersProps) { store.dispatch(initializeAuth()); // Initialize UI state (theme, etc.) store.dispatch(initializeUI()); + + // Global API event listeners + const handleRateLimit = () => { + store.dispatch(setRateLimited(true)); + }; + + const handleSuccess = () => { + // Auto-clear rate limit on successful request? + // Or maybe let the user dismiss it. + // For now, let's keep it until manual dismissal or timeout. + }; + + window.addEventListener('api-rate-limit' as any, handleRateLimit); + window.addEventListener('api-success' as any, handleSuccess); + + return () => { + window.removeEventListener('api-rate-limit' as any, handleRateLimit); + window.removeEventListener('api-success' as any, handleSuccess); + }; }, []); return ( diff --git a/src/components/ui/RateLimitBanner.tsx b/src/components/ui/RateLimitBanner.tsx new file mode 100644 index 0000000..b875181 --- /dev/null +++ b/src/components/ui/RateLimitBanner.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; +import { useAppDispatch, useAppSelector } from '@/store'; +import { setRateLimited } from '@/store/slices/uiSlice'; +import { AlertCircle, X, Clock } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +export const RateLimitBanner: React.FC = () => { + const dispatch = useAppDispatch(); + const isRateLimited = useAppSelector((state) => state.ui.isRateLimited); + + const handleDismiss = () => { + dispatch(setRateLimited(false)); + }; + + return ( + + {isRateLimited && ( + +
+
+
+
+ +
+
+

System Rate Limit Active

+

+ We've received too many requests from your IP. Please wait a moment before trying again. +

+
+
+ + +
+
+
+ )} +
+ ); +}; + +export default RateLimitBanner; diff --git a/src/services/api.ts b/src/services/api.ts index 8bc72b7..531e0b2 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -45,14 +45,29 @@ class ApiService { // Response interceptor for error handling this.api.interceptors.response.use( - (response) => response, - (error) => { + (response) => { + // Successful response - potentially clear rate limit if it was set + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('api-success')); + } + return response; + }, + (error: any) => { if (error.response?.status === 401) { this.clearToken(); // Redirect to login or dispatch logout action if (typeof window !== 'undefined') { window.location.href = '/auth/login'; } + } else if (error.response?.status === 429) { + // Rate limited + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('api-rate-limit', { + detail: { + message: error.response?.data?.message || 'You are being rate limited. Please slow down.' + } + })); + } } return Promise.reject(error); } diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index d68c550..17bc000 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -1,11 +1,13 @@ import { createSlice, PayloadAction, createAction } from '@reduxjs/toolkit'; -export interface ThemeState { +export interface UIState { mode: 'light' | 'dark'; + isRateLimited: boolean; } -const initialState: ThemeState = { +const initialState: UIState = { mode: 'dark', // Default to dark mode + isRateLimited: false, }; // Create action for initialization @@ -38,6 +40,9 @@ export const uiSlice = createSlice({ localStorage.setItem('theme', state.mode); } }, + setRateLimited: (state, action: PayloadAction) => { + state.isRateLimited = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(initializeUI, (state, action) => { @@ -46,5 +51,5 @@ export const uiSlice = createSlice({ }, }); -export const { toggleTheme, setTheme } = uiSlice.actions; +export const { toggleTheme, setTheme, setRateLimited } = uiSlice.actions; export default uiSlice.reducer; \ No newline at end of file