diff --git a/docs/adr/ADR-001-notification-architecture.md b/docs/adr/ADR-001-notification-architecture.md new file mode 100644 index 0000000..207fed2 --- /dev/null +++ b/docs/adr/ADR-001-notification-architecture.md @@ -0,0 +1,164 @@ +# ADR-001: Notification Architecture + +## Status + +Accepted + +## Date + +2025 + +## Context + +The application required a notification system capable of displaying toast messages across all feature modules. The initial implementation used scattered legacy helpers (`showSuccess`, `showError`, `showWarning`, `showInfo`) imported directly from internal toast service files. This created tight coupling between feature modules and the notification infrastructure, made future extension difficult, and produced inconsistent error handling across the codebase. + +A migration was undertaken to replace this with a domain-agnostic, extensible notification infrastructure. + +## Decision + +The notification system is implemented as a domain-agnostic infrastructure service with a single stable public API. + +### Ownership boundaries + +**Feature modules own:** +- Business messages (title, description, variant) +- The decision of when to notify + +**Notification infrastructure owns:** +- Rendering (Toast components, icons, progress bar, animations) +- Lifecycle management (auto-close, dismiss, update) +- Promise handling (loading → success/error transitions) +- Error normalization (Axios, Fetch, AbortError, unknown) +- Accessibility (aria-live, aria-label, role) +- Event dispatching (registry) +- Queue management (max visible toasts, suppression rules) + +### Constraints + +- The notification infrastructure must not contain business-specific concepts such as Product, Category, Asset, Workflow, Role, or any domain entity. +- All notifications enter the system through the `notify` facade — no feature module may import from internal toast files. +- The public API is frozen. New methods must not be added without a deliberate cross-cutting justification. + +### Public API (frozen at v1.0.0) + +```ts +notify.success(content, options?, metadata?) +notify.error(content, options?, metadata?) +notify.warning(content, options?, metadata?) +notify.info(content, options?, metadata?) +notify.loading(content, options?) +notify.promise(promise, config) +notify.update(id, variant, content, options?) +notify.dismiss(id) +notify.dismissAll() +``` + +### Allowed imports in feature modules + +```ts +import { notify } from '@/services/toast'; +// or +const { notify } = useToast(); +``` + +Nothing else from the toast infrastructure is part of the public contract. + +## Event flow + +``` +Feature Module + │ + ▼ +notify.success(...) / notify.error(...) / notify.promise(...) + │ + ▼ +Notification Service (toast.service.ts) + │ + ▼ +Notification Registry (toast.registry.ts) + │ + ┌────┴────────────────────┐ + ▼ ▼ +Toast Renderer Future Subscribers +(react-toastify) │ + ┌────────┼──────────────┐ + ▼ ▼ ▼ + Header Bell Notification Analytics / + Store Telemetry +``` + +## Rationale + +This design decouples the notification contract from its consumers. The `notify` facade is the only surface feature modules depend on. Everything behind it — the toast renderer today, a notification store tomorrow, analytics later — can be added, replaced, or extended without touching a single feature module. + +The registry (`toast.registry.ts`) is the mechanism that makes this possible. It is an internal event dispatcher. Any infrastructure component can subscribe to it: + +```ts +// Future: notification store (infrastructure only) +import { subscribe } from '@/services/toast/toast.registry'; + +subscribe((notification) => notificationStore.add(notification)); +``` + +```ts +// Future: header bell (infrastructure only) +import { subscribe } from '@/services/toast/toast.registry'; + +subscribe((notification) => { + if (notification.priority === 'high' || notification.priority === 'critical') { + bellStore.increment(); + } +}); +``` + +Feature modules are never aware these consumers exist. + +Every notification already carries `timestamp`, `module`, `entity`, `entityId`, `action`, `correlationId`, and `userId` through `ToastMetadata` — all the fields a Notification History panel needs without any future API changes. + +## Consequences + +### Positive +- All 22 feature modules use a single, consistent notification API. +- Error normalization is centralized — Axios errors, network failures, and unknown errors are handled uniformly without feature modules needing to know the error shape. +- Future consumers (Notification Center, Header Bell, Analytics, WebSocket queue) can be added with zero changes to feature code. +- The public API is small, stable, and easy to audit. + +### Constraints going forward +- Do not add business-specific helpers (`notify.product.created()`). +- Do not add domain message catalogs to the toast infrastructure layer. +- Do not expand the public API unless there is a compelling cross-cutting need that cannot be handled in a feature module. +- New notification consumers must subscribe through the registry — they must not intercept or wrap the `notify` facade. + +## Infrastructure file map + +``` +src/services/toast/ +├── index.ts ← public API surface (frozen) +├── toast.service.ts ← notify facade implementation +├── toast.provider.tsx ← ToastContainer initialization +├── toast.registry.ts ← internal event dispatcher +├── toast.internal.ts ← low-level ops (loading, update) +├── toast.promise.ts ← promise lifecycle +├── toast.errors.ts ← error normalization +├── toast.factory.ts ← message builders +├── toast.config.ts ← durations, positions, suppression +├── toast.types.ts ← all shared types +├── toast.utils.ts ← variant tokens, payload helpers +├── toast.icons.tsx ← animated SVG icons +├── components/ +│ ├── Toast.tsx ← ToastBody + variant exports +│ ├── Toast.css ← all animations +│ ├── ProgressBar.tsx ← auto-close progress bar +│ ├── CloseButton.tsx ← dismiss button +│ ├── ToastContainer.tsx ← re-export of react-toastify container +│ └── ToastIcons.tsx ← re-export of toast.icons +├── hooks/ +│ └── useToast.ts ← React hook returning notify +└── messages/ + ├── index.ts ← exports SystemMessages + └── system.messages.ts ← infrastructure-level error messages only +``` + +## Version + +`v1.0.0 — feat(toast): complete enterprise notification system migration` diff --git a/generate-messages.cjs b/generate-messages.cjs new file mode 100644 index 0000000..79640ec --- /dev/null +++ b/generate-messages.cjs @@ -0,0 +1,109 @@ +const fs = require('fs'); +const path = require('path'); + +const dir = 'c:/Users/Lenovo/Desktop/product_catalogue/productcatalogue_frontend/src/services/toast/messages'; +if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + +function toPascal(str) { + return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''); +} + +function toTitle(str) { + return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); +} + +const entities = [ + 'product', 'category', 'family', 'variant', 'attribute', 'attribute-group', + 'brand', 'unit', 'channel', 'channel-type', 'asset', 'asset-family', + 'workflow', 'role', 'permission', 'tenant', 'integration', 'report', + 'settings', 'theme', 'auth', 'user', 'system' +]; + +entities.forEach(entity => { + const isSystem = ['system', 'auth', 'settings', 'theme', 'workflow'].includes(entity); + const PascalName = toPascal(entity); + const TitleName = toTitle(entity); + + let content = `import { createSuccess, createError, createWarning, createInfo } from '../toast.factory';\n\n`; + content += `export const ${PascalName}Messages = {\n`; + + if (entity === 'system') { + content += ` networkError: () => createError("Network Error", "Unable to connect to the server. Please check your internet connection."), + validationError: (msg?: string) => createError("Validation Failed", msg || "Please check the form for errors."), + duplicateRecord: () => createError("Duplicate Record", "A record with this information already exists."), + permissionDenied: () => createError("Permission Denied", "You do not have permission to perform this action."), + unauthorized: () => createError("Unauthorized", "Please log in to continue."), + conflict: () => createError("Conflict", "The resource has been modified by another user."), + notFound: () => createError("Not Found", "The requested resource could not be found."), + timeout: () => createError("Timeout", "The request took too long to complete."), + internalServerError: () => createError("Server Error", "An unexpected error occurred. Please try again later."), + unknownError: (msg?: string) => createError("Unknown Error", msg || "An unexpected error occurred."),\n`; + } else if (entity === 'auth') { + content += ` login: () => createSuccess("Logged In", "You have been logged in successfully."), + logout: () => createSuccess("Logged Out", "You have been logged out successfully."), + sessionExpired: () => createWarning("Session Expired", "Your session has expired. Please log in again."),\n`; + } else if (entity === 'settings') { + content += ` saved: () => createSuccess("Settings Saved", "Application settings have been saved successfully."), + reset: () => createSuccess("Settings Reset", "Application settings have been restored to defaults."),\n`; + } else if (entity === 'theme') { + content += ` applied: () => createSuccess("Theme Applied", "The selected theme has been applied."), + reset: () => createSuccess("Theme Reset", "The theme has been restored to default."),\n`; + } else if (entity === 'workflow') { + content += ` submitted: () => createSuccess("Workflow Submitted", "The record has been submitted for review."), + approved: () => createSuccess("Workflow Approved", "The record has been approved."), + rejected: () => createError("Workflow Rejected", "The submission has been returned for revision."), + returned: () => createWarning("Workflow Returned", "The workflow has been returned."), + published: () => createSuccess("Workflow Published", "The workflow has been published."), + unpublished: () => createSuccess("Workflow Unpublished", "The workflow has been unpublished."),\n`; + } else if (entity === 'asset') { + content += ` uploaded: () => createSuccess("Asset Uploaded", "The asset has been uploaded successfully."), + replaced: () => createSuccess("Asset Replaced", "The asset has been replaced successfully."), + linked: () => createSuccess("Asset Linked", "The asset has been linked successfully."), + unlinked: () => createSuccess("Asset Unlinked", "The asset has been unlinked successfully."), + archived: () => createSuccess("Asset Archived", "The asset has been archived."), + deleted: () => createSuccess("Asset Deleted", "The asset has been removed from the system."),\n`; + } else if (entity === 'role') { + content += ` created: () => createSuccess("Role Created", "The role has been added successfully."), + updated: () => createSuccess("Role Updated", "The role information has been updated successfully."), + permissionsChanged: () => createSuccess("Permissions Updated", "Role permissions have been updated."), + deleted: () => createSuccess("Role Deleted", "The role has been removed from the system."),\n`; + } else if (entity === 'user') { + content += ` created: () => createSuccess("User Created", "The user has been added to the system."), + updated: () => createSuccess("User Updated", "The user information has been updated successfully."), + disabled: () => createWarning("User Disabled", "The user has been disabled."), + enabled: () => createSuccess("User Enabled", "The user has been enabled."), + passwordReset: () => createSuccess("Password Reset", "The user's password has been reset."), + deleted: () => createSuccess("User Deleted", "The user has been removed from the system."),\n`; + } else if (entity === 'category') { + content += ` created: () => createSuccess("Category Created", "The category has been added to the catalogue."), + updated: () => createSuccess("Category Updated", "The category information has been updated successfully."), + deleted: () => createSuccess("Category Deleted", "The category has been removed from the system."), + moved: () => createSuccess("Category Moved", "The category has been moved successfully."), + merged: () => createSuccess("Category Merged", "The categories have been merged successfully."), + split: () => createSuccess("Category Split", "The category has been split successfully."),\n`; + } else { + // Standard CRUD + content += ` created: () => createSuccess("${TitleName} Created", "The ${TitleName.toLowerCase()} has been added to the catalogue."), + updated: () => createSuccess("${TitleName} Updated", "The ${TitleName.toLowerCase()} information has been updated successfully."), + deleted: () => createSuccess("${TitleName} Deleted", "The ${TitleName.toLowerCase()} has been removed from the system."), + archived: () => createSuccess("${TitleName} Archived", "The ${TitleName.toLowerCase()} has been archived."), + restored: () => createSuccess("${TitleName} Restored", "The ${TitleName.toLowerCase()} has been restored."), + published: () => createSuccess("${TitleName} Published", "The ${TitleName.toLowerCase()} has been published."), + unpublished: () => createSuccess("${TitleName} Unpublished", "The ${TitleName.toLowerCase()} has been unpublished."), + cloned: () => createSuccess("${TitleName} Cloned", "The ${TitleName.toLowerCase()} has been cloned successfully."), + imported: () => createSuccess("${TitleName} Imported", "The ${TitleName.toLowerCase()}s have been imported successfully."), + exported: () => createSuccess("${TitleName} Exported", "The ${TitleName.toLowerCase()}s have been exported successfully."),\n`; + if (entity === 'product') { + content += ` variantGenerated: () => createSuccess("Variant Generated", "The product variants have been generated successfully."),\n`; + } + } + + content += `};\n`; + + fs.writeFileSync(path.join(dir, `${entity}.messages.ts`), content); +}); + +// Create index.ts +let indexContent = entities.map(entity => `export * from './${entity}.messages';`).join('\n'); +fs.writeFileSync(path.join(dir, 'index.ts'), indexContent + '\n'); +console.log('Done generating messages'); diff --git a/package-lock.json b/package-lock.json index ecb8763..915743c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-router-dom": "^7.18.0", "react-toastify": "^11.1.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", "yup": "^1.7.1" @@ -1395,6 +1396,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2613,6 +2620,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -4304,6 +4333,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5037,6 +5094,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 8cc9293..2720460 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-router-dom": "^7.18.0", "react-toastify": "^11.1.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", "yup": "^1.7.1" diff --git a/src/App.tsx b/src/App.tsx index 72ad1f2..a9e05b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,13 @@ // src/App.tsx import { useEffect, useRef, useState } from 'react'; import AppRoutes from './routes/AppRoutes'; -import { ToastContainer } from 'react-toastify'; import { LanguageProvider } from './contexts/LanguageContext'; import { HeaderProvider } from './contexts/HeaderContext'; import { ThemeProvider } from './contexts/ThemeContext'; -import { Loader } from "./components/customs/Loader"; +import { Loader } from './components/customs/Loader'; +import { ToastProvider } from './services/toast'; -function App() { +function AppShell() { const bootstrappedRef = useRef(false); const [bootstrapped, setBootstrapped] = useState(false); const [progress, setProgress] = useState(0); @@ -17,14 +17,12 @@ function App() { bootstrappedRef.current = true; const bootstrap = async () => { - const steps = [15, 35, 55, 75, 92, 100]; - + const steps = [12, 28, 48, 65, 82, 95, 100]; for (const step of steps) { - await new Promise(resolve => setTimeout(resolve, 160)); + await new Promise(resolve => setTimeout(resolve, 150)); setProgress(step); } - - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise(resolve => setTimeout(resolve, 250)); setBootstrapped(true); }; @@ -43,16 +41,22 @@ function App() { ); } + return ( + + + + + + + ); +} + +function App() { return ( - - - - - - + ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index a762593..17b7673 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -35,7 +35,7 @@ axiosInstance.interceptors.response.use( ); const apiClient = { - get: async (url: string, params?: any): Promise => { + get: async (url: string, params?: Record): Promise => { const response = await axiosInstance.get(url, { params }); return response.data; }, @@ -53,7 +53,11 @@ const apiClient = { const response = await axiosInstance.put(url, data, config); return response.data; }, - delete: async (url: string, params?: any): Promise => { + patch: async (url: string, data?: unknown): Promise => { + const response = await axiosInstance.patch(url, data); + return response.data; + }, + delete: async (url: string, params?: Record): Promise => { const response = await axiosInstance.delete(url, { params }); return response.data; }, diff --git a/src/assets/error_page_images/404.svg b/src/assets/error_page_images/404.svg new file mode 100644 index 0000000..6bbe284 --- /dev/null +++ b/src/assets/error_page_images/404.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/customs/Loader.tsx b/src/components/customs/Loader.tsx index b60961e..eeb1c4c 100644 --- a/src/components/customs/Loader.tsx +++ b/src/components/customs/Loader.tsx @@ -1,7 +1,39 @@ // src/components/customs/Loader.tsx -import { Package } from "lucide-react"; import { cn } from "../../lib/utils"; +// Generate the 12-peak wavy ring path mathematically for SVG viewBox 0 0 100 100 +const { WAVY_PATH, TOTAL_LENGTH } = (() => { + const points = 360; // 360 points for perfect vector smoothness + const r0 = 38; + const amplitude = 3; // soft curves + const wavesCount = 12; + const pathParts = []; + let length = 0; + let prevX = 0; + let prevY = 0; + + for (let i = 0; i <= points; i++) { + const theta = (i / points) * Math.PI * 2; + const r = r0 + amplitude * Math.cos(wavesCount * theta); + const x = 50 + r * Math.cos(theta); + const y = 50 + r * Math.sin(theta); + + pathParts.push(`${i === 0 ? "M" : "L"} ${x.toFixed(3)} ${y.toFixed(3)}`); + + if (i > 0) { + const dx = x - prevX; + const dy = y - prevY; + length += Math.sqrt(dx * dx + dy * dy); + } + prevX = x; + prevY = y; + } + return { + WAVY_PATH: pathParts.join(" ") + " Z", + TOTAL_LENGTH: length + }; +})(); + export interface LoaderProps { size?: "sm" | "md" | "lg" | "xl"; message?: string; @@ -9,6 +41,7 @@ export interface LoaderProps { progress?: number; fullScreen?: boolean; className?: string; + color?: string; // Optional custom solid color (e.g. #7C3AED) } export function Loader({ @@ -18,51 +51,132 @@ export function Loader({ progress, fullScreen = false, className, + color, }: LoaderProps) { + const iconSize = size === "xl" ? 56 : size === "lg" ? 44 : size === "md" ? 32 : 24; + const outerSize = iconSize * 2.2; + const barWidth = size === "xl" ? 220 : size === "lg" ? 180 : size === "md" ? 140 : 100; + const barHeight = size === "xl" ? 4 : size === "lg" ? 4 : 3; + const strokeColor = color || "var(--color-primary, #7C3AED)"; + return (
-
-
- -
+ {/* Inline styles for local animation */} +