diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index bfa1cda..feb26ba 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -1,3 +1,5 @@ +import time + from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token, create_refresh_token from app import db @@ -76,7 +78,12 @@ def get_current_user(): @api_bp.route("/products", methods=["GET"]) def get_products(): """Get all products""" + + + # time.sleep(5) # This adds a 5 second delay + products = Product.query.filter_by(is_active=True).all() + return jsonify([product.to_dict() for product in products]), 200 diff --git a/docs/usage_rules_frontend.md b/docs/usage_rules_frontend.md index 824fbef..4d23693 100644 --- a/docs/usage_rules_frontend.md +++ b/docs/usage_rules_frontend.md @@ -54,6 +54,145 @@ function Products() { } ``` +### API Integration with Loaders and Toasts +- **ALWAYS** create custom hooks for API operations that integrate loader and toast logic +- **NEVER** handle errors in components when the hook already shows toasts +- Custom hooks should use `useLoader` and `useToast` for consistent UX +- Hooks should return data, refetch function, and optionally error state for debugging +- Components should NOT display error UI when toasts are already shown + +```jsx +// ✅ CORRECT - Custom hook with loader and toast integration +import { useState, useEffect } from "react" +import useApi from "./useApi" +import { useLoader } from "../context/loaders/useLoader" +import { useToast } from "../context/toasts/useToast" + +function useProducts() { + const [products, setProducts] = useState([]) + const [error, setError] = useState(null) + + const { getProducts } = useApi() + const { withLoader } = useLoader() + const { addNotification } = useToast() + + const fetchProducts = async () => { + try { + setError(null) + + // Use withLoader to show loading state + const data = await withLoader( + () => getProducts(), + 'Loading products...' + ) + + setProducts(data) + + // Show success toast + addNotification({ + type: 'success', + title: 'Products Loaded', + message: `Successfully loaded ${data.length} products.`, + duration: 3000, + }) + + return data + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load products' + setError(errorMessage) + + // Show error toast + addNotification({ + type: 'error', + title: 'Error Loading Products', + message: errorMessage, + duration: 5000, + }) + + return [] + } + } + + useEffect(() => { + fetchProducts() + }, []) + + return { + products, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + refetch: fetchProducts, + } +} + +export default useProducts +``` + +```jsx +// ✅ CORRECT - Component using the custom hook +function Products() { + const { products, refetch } = useProducts() + + // Note: No error UI here - toast is already shown by the hook + + return ( +
+

Products

+ + {products.map(product => ( + + ))} +
+ ) +} +``` + +```jsx +// ❌ WRONG - Handling errors in component when toast already shown +function Products() { + const { products, error, refetch } = useProducts() + + // BAD PRACTICE: This duplicates the error message already shown in toast + if (error) { + return ( +
+

Failed to load products

+ +
+ ) + } + + return ( +
+ {products.map(product => ( + + ))} +
+ ) +} +``` + +**Why this is bad practice:** +1. **Duplicate UI**: Users see the same error message twice (toast + component error UI) +2. **Inconsistent UX**: Some operations show toasts, others show component error messages +3. **Confusing Experience**: Users don't know which error message to act on +4. **Redundant Code**: You're writing error handling twice (in hook and component) +5. **Maintenance Issue**: If you change error handling in hook, component UI doesn't update + +**The error state in hooks is for:** +- Debugging purposes +- Conditional rendering based on error type (rare cases) +- Logging or analytics +- NOT for displaying error UI to users + +**Benefits of this pattern:** +1. **Consistent UX**: All errors shown via toasts in the same place (top-right) +2. **Separation of Concerns**: Hook handles data fetching, component handles rendering +3. **Global Loading**: Loader covers entire app, preventing double-submissions +4. **Auto-Cleanup**: `withLoader` ensures loader is hidden even on errors +5. **Clean Components**: Components focus on displaying data, not handling errors +``` + ## Code Style ### Formatting diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d0428d..a539c36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,8 @@ import { ModalProvider } from './context/modals/useModal' import { ModalRoot } from './context/modals/ModalRoot' import { ToastProvider } from './context/toasts/useToast' import { ToastRoot } from './context/toasts/ToastRoot' +import { LoaderProvider } from './context/loaders/useLoader' +import { LoaderRoot } from './context/loaders/LoaderRoot' import Cart from './pages/Cart' import { Navbar } from './components/Navbar' import { Home } from './pages/Home' @@ -13,25 +15,29 @@ import { Orders } from './pages/Orders' const App = () => { return ( - - -
- -
- - } /> - } /> - } /> - } /> - } /> - } /> - -
- - -
-
-
+ + + +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+ {/* Order matters for Z-Index: Loader (70) > Toast (60) > Modal (50) */} + + + +
+
+
+
) } diff --git a/frontend/src/context/loaders/LoaderExample.tsx b/frontend/src/context/loaders/LoaderExample.tsx new file mode 100644 index 0000000..1a580ed --- /dev/null +++ b/frontend/src/context/loaders/LoaderExample.tsx @@ -0,0 +1,192 @@ +import { useLoader } from './useLoader'; +import { useToast } from '../toasts/useToast'; + +export const LoaderExample = () => { + const { showLoader, hideLoader, withLoader } = useLoader(); + const { addNotification } = useToast(); + + // Pattern A: Manual Control + const handleManualLoad = async () => { + showLoader("Processing manual task..."); + + try { + // Simulate an async operation + await new Promise(resolve => setTimeout(resolve, 2000)); + + addNotification({ + type: 'success', + title: 'Manual Task Complete', + message: 'The manual loading task finished successfully.', + duration: 3000, + }); + } catch (err) { + addNotification({ + type: 'error', + title: 'Manual Task Failed', + message: 'Something went wrong during manual loading.', + duration: 5000, + }); + } finally { + hideLoader(); + } + }; + + // Pattern B: withLoader Helper (Cleanest) + const handleWithLoader = async () => { + try { + await withLoader( + async () => { + // Simulate an async operation + await new Promise(resolve => setTimeout(resolve, 1500)); + return 'Success!'; + }, + "Processing with withLoader..." + ); + + addNotification({ + type: 'success', + title: 'withLoader Task Complete', + message: 'The task wrapped in withLoader finished successfully.', + duration: 3000, + }); + } catch (err) { + addNotification({ + type: 'error', + title: 'withLoader Task Failed', + message: 'Something went wrong during withLoader.', + duration: 5000, + }); + } + }; + + // Pattern C: Long-running task + const handleLongLoad = async () => { + showLoader("Processing long-running task..."); + + try { + // Simulate a longer async operation + await new Promise(resolve => setTimeout(resolve, 4000)); + + addNotification({ + type: 'success', + title: 'Long Task Complete', + message: 'The long-running task finished successfully.', + duration: 3000, + }); + } catch (err) { + addNotification({ + type: 'error', + title: 'Long Task Failed', + message: 'Something went wrong during long loading.', + duration: 5000, + }); + } finally { + hideLoader(); + } + }; + + // Pattern D: Error simulation + const handleError = async () => { + showLoader("Processing task that will fail..."); + + try { + // Simulate an error + await new Promise((_, reject) => + setTimeout(() => reject(new Error('Simulated error')), 1500) + ); + + addNotification({ + type: 'success', + title: 'Task Complete', + message: 'This should not appear.', + duration: 3000, + }); + } catch (err) { + addNotification({ + type: 'error', + title: 'Task Failed', + message: 'Simulated error occurred as expected.', + duration: 5000, + }); + } finally { + hideLoader(); + } + }; + + // Pattern E: Without message + const handleNoMessage = async () => { + showLoader(); + + try { + await new Promise(resolve => setTimeout(resolve, 2000)); + + addNotification({ + type: 'success', + title: 'No Message Task Complete', + message: 'Loader shown without custom message.', + duration: 3000, + }); + } catch (err) { + addNotification({ + type: 'error', + title: 'Task Failed', + message: 'Something went wrong.', + duration: 5000, + }); + } finally { + hideLoader(); + } + }; + + return ( +
+

Loader System Examples

+

+ Click the buttons below to see different loader patterns in action. The loader uses React Context for state management. +

+ +
+ + + + + +
+ +
+

Usage Tips:

+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/context/loaders/LoaderRoot.tsx b/frontend/src/context/loaders/LoaderRoot.tsx new file mode 100644 index 0000000..5ae6fee --- /dev/null +++ b/frontend/src/context/loaders/LoaderRoot.tsx @@ -0,0 +1,33 @@ +import { createPortal } from 'react-dom'; +import { useLoader } from './useLoader'; + +export const LoaderRoot = () => { + const { isLoading, message } = useLoader(); + + if (!isLoading) return null; + + return createPortal( +
+ {/* Custom CSS Spinner (No external libs) */} +
+
+ {/* Optional inner circle for style */} +
+
+
+
+ + {message && ( +

+ {message} +

+ )} +
, + document.body + ); +}; \ No newline at end of file diff --git a/frontend/src/context/loaders/index.ts b/frontend/src/context/loaders/index.ts new file mode 100644 index 0000000..9967013 --- /dev/null +++ b/frontend/src/context/loaders/index.ts @@ -0,0 +1,2 @@ +export { LoaderProvider, useLoader } from './useLoader'; +export { LoaderRoot } from './LoaderRoot'; \ No newline at end of file diff --git a/frontend/src/context/loaders/useLoader.tsx b/frontend/src/context/loaders/useLoader.tsx new file mode 100644 index 0000000..fa291ca --- /dev/null +++ b/frontend/src/context/loaders/useLoader.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, useState, useCallback, ReactNode, FC } from 'react'; + +interface LoaderState { + isLoading: boolean; + message?: string; +} + +interface LoaderContextType extends LoaderState { + showLoader: (message?: string) => void; + hideLoader: () => void; + // Optional: Helper to wrap async functions automatically + withLoader: (fn: () => Promise, message?: string) => Promise; +} + +const LoaderContext = createContext(undefined); + +interface LoaderProviderProps { + children: ReactNode; +} + +export const LoaderProvider: FC = ({ children }) => { + const [state, setState] = useState({ isLoading: false }); + + const showLoader = useCallback((message?: string) => { + setState({ isLoading: true, message }); + }, []); + + const hideLoader = useCallback(() => { + setState({ isLoading: false, message: undefined }); + }, []); + + // Helper to avoid try/finally blocks everywhere + const withLoader = useCallback(async ( + fn: () => Promise, + message?: string + ): Promise => { + showLoader(message); + try { + return await fn(); + } finally { + hideLoader(); + } + }, [showLoader, hideLoader]); + + return ( + + {children} + + ); +}; + +export const useLoader = () => { + const context = useContext(LoaderContext); + if (!context) throw new Error('useLoader must be used within a LoaderProvider'); + return context; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useProducts.ts b/frontend/src/hooks/useProducts.ts new file mode 100644 index 0000000..6e1f250 --- /dev/null +++ b/frontend/src/hooks/useProducts.ts @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import { ProductData } from '../types'; + +export function useProducts() { + const [products, setProducts] = useState([]); + const [error, setError] = useState(null); + + const { getProducts } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchProducts = async () => { + try { + setError(null); + + // Use withLoader to show loading state and handle errors + const data = await withLoader( + () => getProducts(), + 'Loading products...' + ); + + setProducts(data); + + // // Show success toast + // addNotification({ + // type: 'success', + // title: 'Products Loaded', + // message: `Successfully loaded ${data.length} products.`, + // duration: 3000, + // }); + + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load products'; + setError(errorMessage); + + // Show error toast + addNotification({ + type: 'error', + title: 'Error Loading Products', + message: errorMessage, + duration: 5000, + }); + + return []; + } + }; + + // Optionally auto-fetch on mount + useEffect(() => { + fetchProducts(); + }, []); + + return { + products, + error, + loading: false, // Loading is handled by the global loader + refetch: fetchProducts, + }; +} \ No newline at end of file diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 507c24c..f017b7a 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router-dom' import { ModalExample } from '../context/modals/ModalExample' import { ToastExample } from '../context/toasts/ToastExample' +import { LoaderExample } from '../context/loaders/LoaderExample' export function Home() { return ( @@ -50,6 +51,14 @@ export function Home() {

+ +
+

Loader System Demo

+

+ Test our global loading system with this interactive example. The loader uses React Context for state management. +

+ +
) } diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx index a77e66d..757b515 100644 --- a/frontend/src/pages/Products.tsx +++ b/frontend/src/pages/Products.tsx @@ -1,40 +1,22 @@ -import { useEffect, useState } from 'react' import { useApp } from '../context/AppContext' -import { useApi } from '../hooks/useApi' -import { ProductData, CartItem } from '../types' +import { useProducts } from '../hooks/useProducts' +import { CartItem } from '../types' export function Products() { - const [products, setProducts] = useState([]) - const [loading, setLoading] = useState(true) + const { products, refetch } = useProducts() const { addToCart } = useApp() - const { getProducts } = useApi() - - useEffect(() => { - fetchProducts() - }, []) - - const fetchProducts = async () => { - try { - const data = await getProducts() - setProducts(data) - } catch (error) { - console.error('Error fetching products:', error) - } finally { - setLoading(false) - } - } - - if (loading) { - return ( -
-
Loading products...
-
- ) - } return (
-

Products

+
+

Products

+ +
{products.map((product) => (