notifiction,auditlog,themes

This commit is contained in:
MohamedHasan07
2026-07-14 12:45:22 +05:30
parent e07491c5e9
commit 1a73a8f6c8
85 changed files with 4613 additions and 993 deletions
@@ -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`
+109
View File
@@ -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');
+86
View File
@@ -28,6 +28,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"
@@ -1663,6 +1664,12 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"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",
@@ -2879,6 +2886,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",
@@ -4583,6 +4612,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",
@@ -5294,6 +5351,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",
+1
View File
@@ -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"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.4 MiB

+19 -15
View File
@@ -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 (
<LanguageProvider>
<HeaderProvider>
<AppRoutes />
<ToastProvider />
</HeaderProvider>
</LanguageProvider>
);
}
function App() {
return (
<ThemeProvider>
<LanguageProvider>
<HeaderProvider>
<AppRoutes />
<ToastContainer position="top-right" autoClose={3000} />
</HeaderProvider>
</LanguageProvider>
<AppShell />
</ThemeProvider>
);
}
export default App;
export default App;
+6 -2
View File
@@ -35,7 +35,7 @@ axiosInstance.interceptors.response.use(
);
const apiClient = {
get: async <T>(url: string, params?: any): Promise<T> => {
get: async <T>(url: string, params?: Record<string, unknown>): Promise<T> => {
const response = await axiosInstance.get<T>(url, { params });
return response.data;
},
@@ -47,7 +47,11 @@ const apiClient = {
const response = await axiosInstance.put<T>(url, data);
return response.data;
},
delete: async <T>(url: string, params?: any): Promise<T> => {
patch: async <T>(url: string, data?: unknown): Promise<T> => {
const response = await axiosInstance.patch<T>(url, data);
return response.data;
},
delete: async <T>(url: string, params?: Record<string, unknown>): Promise<T> => {
const response = await axiosInstance.delete<T>(url, { params });
return response.data;
},
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.4 MiB

+144 -30
View File
@@ -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 (
<div
className={cn(
"flex flex-col items-center justify-center gap-6 z-50",
fullScreen
? "fixed inset-0 bg-gradient-to-br from-primary/5/95 via-white/95 to-primary/5/95 backdrop-blur-md"
: "min-h-[300px]",
"flex flex-col items-center justify-center gap-7",
fullScreen ? "fixed inset-0 bg-background z-[9999]" : "min-h-[300px]",
className
)}
>
<div className="relative flex items-center justify-center">
<div className={cn(
"absolute border-4 border-primary/20 rounded-full animate-spin",
size === "xl" ? "w-20 h-20" : size === "lg" ? "w-16 h-16" : "w-10 h-10"
)} />
<div
className={cn(
"absolute border-4 border-transparent border-t-purple-600 border-r-purple-600 rounded-full animate-spin",
size === "xl" ? "w-20 h-20" : size === "lg" ? "w-16 h-16" : "w-10 h-10"
)}
style={{ animationDuration: "1.2s", animationDirection: "reverse" }}
/>
{/* Inline styles for local animation */}
<style dangerouslySetInnerHTML={{ __html: `
@keyframes wavy-flow-dash {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -${TOTAL_LENGTH.toFixed(3)};
}
}
`}} />
<div className="absolute bg-white rounded-2xl p-3 shadow-xl shadow-purple-500/10">
<div className="bg-gradient-to-br from-primary to-primary-hover text-white rounded-xl flex items-center justify-center">
<Package size={size === "xl" ? 52 : size === "lg" ? 42 : 28} strokeWidth={2.25} />
</div>
{/* Brand logo container with flowing outer wavy ring */}
<div
className="relative flex items-center justify-center animate-fade-in"
style={{
width: outerSize,
height: outerSize,
}}
>
{/* Outer Wavy Spinner */}
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={WAVY_PATH}
stroke={strokeColor}
strokeWidth="8"
strokeLinecap="round"
strokeLinejoin="round"
style={{
strokeDasharray: `${(TOTAL_LENGTH * 0.78).toFixed(2)} ${(TOTAL_LENGTH * 0.22).toFixed(2)}`,
animation: "wavy-flow-dash 4.5s linear infinite",
}}
/>
</svg>
{/* Central Logo Box */}
<div
className="rounded-2xl flex items-center justify-center shadow-lg relative z-10"
style={{
width: iconSize,
height: iconSize,
background: "linear-gradient(135deg, var(--color-primary), var(--color-primary-hover))",
}}
>
<svg
width={iconSize * 0.55}
height={iconSize * 0.55}
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
</div>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-gray-900">{message}</p>
{subMessage && <p className="text-sm text-gray-500 mt-1">{subMessage}</p>}
{/* Text */}
<div className="text-center space-y-1.5">
<p className="text-base font-semibold text-foreground tracking-tight">{message}</p>
{subMessage && <p className="text-sm text-muted-foreground">{subMessage}</p>}
</div>
{typeof progress === "number" && (
<div className="w-64 bg-gray-100 h-1 rounded-full overflow-hidden">
{/* Gmail-style indeterminate bar — shown when no numeric progress */}
{typeof progress !== "number" && (
<div
className="relative overflow-hidden rounded-full"
style={{
width: barWidth,
height: barHeight,
backgroundColor: "var(--color-primary-light)",
}}
>
{/* Primary segment */}
<div
className="h-full bg-gradient-to-r from-primary to-primary-hover transition-all duration-300"
style={{ width: `${Math.max(5, Math.min(100, progress))}%` }}
className="bar-primary absolute top-0 bottom-0 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
{/* Secondary segment — chases the first */}
<div
className="bar-secondary absolute top-0 bottom-0 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</div>
)}
{/* Determinate progress bar — shown when numeric progress is passed */}
{typeof progress === "number" && (
<div
className="rounded-full overflow-hidden"
style={{ width: barWidth, height: barHeight, backgroundColor: "var(--color-primary-light)" }}
>
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${Math.max(4, Math.min(100, progress))}%`,
background: "linear-gradient(90deg, var(--color-primary), var(--color-primary-hover))",
}}
/>
</div>
)}
</div>
);
}
}
+125
View File
@@ -0,0 +1,125 @@
// src/components/customs/PageLoader.tsx
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
// 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
};
})();
/**
* Fullscreen route-change progress indicator.
* Displays a large, slow-spinning wavy ring around the brand logo
* with a subtle backdrop blur on page transitions.
*/
export function PageLoader({ color }: { color?: string }) {
const location = useLocation();
const [visible, setVisible] = useState(false);
const [fading, setFading] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Show loader on route change
if (timerRef.current) clearTimeout(timerRef.current);
setFading(false);
setVisible(true);
// Keep it visible for 850ms to allow smooth page transitions, then fade out
const fadeTimer = setTimeout(() => {
setFading(true);
const hideTimer = setTimeout(() => {
setVisible(false);
setFading(false);
}, 300); // matches the transition-opacity duration-300 CSS
timerRef.current = hideTimer;
}, 850);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
clearTimeout(fadeTimer);
};
}, [location.pathname]);
if (!visible) return null;
const spinnerSize = 72; // Normal size of the ring (72px)
const strokeColor = color || "var(--color-primary, #7C3AED)";
return (
<div
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/85 backdrop-blur-[1px] transition-opacity duration-300 ease-in-out"
style={{ opacity: fading ? 0 : 1 }}
>
{/* Inline styles for local animation */}
<style dangerouslySetInnerHTML={{ __html: `
@keyframes page-wavy-flow-dash {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -${TOTAL_LENGTH.toFixed(3)};
}
}
`}} />
<div
className="relative flex items-center justify-center"
style={{
width: spinnerSize,
height: spinnerSize,
}}
>
{/* Outer Wavy Spinner */}
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={WAVY_PATH}
stroke={strokeColor}
strokeWidth="8"
strokeLinecap="round"
strokeLinejoin="round"
style={{
strokeDasharray: `${(TOTAL_LENGTH * 0.78).toFixed(2)} ${(TOTAL_LENGTH * 0.22).toFixed(2)}`,
animation: "page-wavy-flow-dash 4.5s linear infinite",
}}
/>
</svg>
</div>
</div>
);
}
View File
+41 -2
View File
@@ -5,6 +5,7 @@ import { useHeader } from "../../contexts/HeaderContext";
import { useAppDispatch, useAppSelector } from "../../store";
import { logout } from "../../store/slices/authSlice";
import { useState, useRef, useEffect } from "react";
import { notificationService } from "../../features/notifications";
@@ -16,8 +17,38 @@ export function Header() {
const { user } = useAppSelector((state) => state.auth);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [headerUnread, setHeaderUnread] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let mounted = true;
const sync = async () => {
const count = await notificationService.getUnreadCount();
if (mounted) setHeaderUnread(count);
};
sync();
// Listen to socket for real-time updates
const handleUnreadCount = (data: { unreadCount: number }) => {
if (mounted) setHeaderUnread(data.unreadCount);
};
// The socket might not be immediately connected here depending on when Headers renders vs useNotifications
// But socketService caches listeners and binds them when connected.
import("../../services/socket.service").then(({ socketService }) => {
socketService.on("notification:unread-count", handleUnreadCount);
});
const id = setInterval(sync, 60_000); // Polling fallback
return () => {
mounted = false;
clearInterval(id);
import("../../services/socket.service").then(({ socketService }) => {
socketService.off("notification:unread-count", handleUnreadCount);
});
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
@@ -72,9 +103,17 @@ export function Header() {
</button>
<div className="relative">
<button className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted">
<button
onClick={() => navigate("/notifications")}
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted"
aria-label="View notifications"
>
<Bell className="w-4 h-4" />
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-white text-[10px] rounded-full flex items-center justify-center">2</span>
{headerUnread > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-white text-[10px] rounded-full flex items-center justify-center">
{headerUnread > 9 ? "9+" : headerUnread}
</span>
)}
</button>
</div>
+3 -6
View File
@@ -1,19 +1,16 @@
// src/components/layouts/AppLayout.tsx
import { Outlet } from 'react-router-dom';
import { Header } from './Headers';
import { Sidebar } from './Sidebar';
import { PageLoader } from '../customs/PageLoader';
const MainLayout = () => {
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */}
<Sidebar />
{/* Main Content Area */}
<div className="flex flex-col flex-1 overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-background">
<main className="flex-1 overflow-y-auto bg-background relative">
<PageLoader />
<Outlet />
</main>
</div>
+111
View File
@@ -0,0 +1,111 @@
import type { Theme } from "../types/theme.types";
export const RoyalPurpleTheme: Theme = {
id: "royal-purple",
name: "Royal Purple",
mode: "light",
isDefault: true,
palette: {
primary: "#7C3AED",
primaryHover: "#6D28D9",
primaryLight: "#DDD6FE",
primaryDark: "#4C1D95",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#EDE9FE",
tableHeaderText: "#4C1D95",
tableHeaderBorder: "#C4B5FD",
},
};
export const ForestGreenTheme: Theme = {
id: "forest-green",
name: "Forest Green",
mode: "light",
isDefault: false,
palette: {
primary: "#16A34A",
primaryHover: "#15803D",
primaryLight: "#BBF7D0",
primaryDark: "#166534",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#DCFCE7",
tableHeaderText: "#166534",
tableHeaderBorder: "#86EFAC",
},
};
export const OceanBlueTheme: Theme = {
id: "ocean-blue",
name: "Ocean Blue",
mode: "light",
isDefault: false,
palette: {
primary: "#2563EB",
primaryHover: "#1D4ED8",
primaryLight: "#BFDBFE",
primaryDark: "#1E3A8A",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#DBEAFE",
tableHeaderText: "#1E3A8A",
tableHeaderBorder: "#93C5FD",
},
};
export const SunsetOrangeTheme: Theme = {
id: "sunset-orange",
name: "Sunset Orange",
mode: "light",
isDefault: false,
palette: {
primary: "#EA580C",
primaryHover: "#C2410C",
primaryLight: "#FED7AA",
primaryDark: "#7C2D12",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#FFEDD5",
tableHeaderText: "#7C2D12",
tableHeaderBorder: "#FDBA74",
},
};
export const DarkTheme: Theme = {
id: "dark-mode",
name: "Dark",
mode: "dark",
isDefault: false,
palette: {
primary: "#1F2937",
primaryHover: "#374151",
primaryLight: "#4B5563",
primaryDark: "#111827",
background: "#111827",
surface: "#1F2937",
foreground: "#F9FAFB",
border: "#374151",
tableHeaderBg: "#111827",
tableHeaderText: "#D1D5DB",
tableHeaderBorder: "#4B5563",
},
};
export const DEFAULT_THEME = RoyalPurpleTheme;
export const AVAILABLE_THEMES = [
RoyalPurpleTheme,
ForestGreenTheme,
OceanBlueTheme,
SunsetOrangeTheme,
DarkTheme,
];
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { assetFamiliesService } from '../services/asset-families.service';
import type { AssetFamily, AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useAssetFamily = () => {
const [items, setItems] = useState<AssetFamily[]>([]);
@@ -12,8 +12,8 @@ export const useAssetFamily = () => {
try {
const data = await assetFamiliesService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useAssetFamily = () => {
try {
const created = await assetFamiliesService.create(req);
setItems((prev) => [...prev, created]);
toast.success('AssetFamily created successfully!');
notify.success('Asset family created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useAssetFamily = () => {
try {
const updated = await assetFamiliesService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('AssetFamily updated successfully!');
notify.success('Asset family updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useAssetFamily = () => {
try {
await assetFamiliesService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('AssetFamily deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Asset family deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { assetTypesService } from '../services/asset-types.service';
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useAssetType = () => {
const [items, setItems] = useState<AssetType[]>([]);
@@ -12,8 +12,8 @@ export const useAssetType = () => {
try {
const data = await assetTypesService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useAssetType = () => {
try {
const created = await assetTypesService.create(req);
setItems((prev) => [...prev, created]);
toast.success('AssetType created successfully!');
notify.success('Asset type created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useAssetType = () => {
try {
const updated = await assetTypesService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('AssetType updated successfully!');
notify.success('Asset type updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useAssetType = () => {
try {
await assetTypesService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('AssetType deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Asset type deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { assetsService } from '../services/assets.service';
import type { Asset, AssetCreateRequest, AssetUpdateRequest } from '../types/assets.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useAsset = () => {
const [items, setItems] = useState<Asset[]>([]);
@@ -12,8 +12,8 @@ export const useAsset = () => {
try {
const data = await assetsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useAsset = () => {
try {
const created = await assetsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Asset created successfully!');
notify.success('Asset created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useAsset = () => {
try {
const updated = await assetsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Asset updated successfully!');
notify.success('Asset updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useAsset = () => {
try {
await assetsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Asset deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Asset deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { attributeGroupsService } from '../services/attribute-groups.service';
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useAttributeGroup = () => {
const [items, setItems] = useState<AttributeGroup[]>([]);
@@ -12,8 +12,8 @@ export const useAttributeGroup = () => {
try {
const data = await attributeGroupsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useAttributeGroup = () => {
try {
const created = await attributeGroupsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('AttributeGroup created successfully!');
notify.success('Attribute group created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useAttributeGroup = () => {
try {
const updated = await attributeGroupsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('AttributeGroup updated successfully!');
notify.success('Attribute group updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useAttributeGroup = () => {
try {
await attributeGroupsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('AttributeGroup deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Attribute group deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+14 -13
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { attributeService } from '../services/attribute.service';
import type { Attribute, AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useAttribute = () => {
const [attributes, setAttributes] = useState<Attribute[]>([]);
@@ -14,9 +14,10 @@ export const useAttribute = () => {
try {
const data = await attributeService.getAll();
setAttributes(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch attributes');
toast.error(err.message || 'Failed to fetch attributes');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch attributes';
setError(msg);
notify.error(err);
} finally {
setLoading(false);
}
@@ -27,10 +28,10 @@ export const useAttribute = () => {
try {
const created = await attributeService.create(req);
setAttributes((prev) => [...prev, created]);
toast.success('Attribute created successfully!');
notify.success('Attribute created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create attribute');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -42,10 +43,10 @@ export const useAttribute = () => {
try {
const updated = await attributeService.update(id, req);
setAttributes((prev) => prev.map((item) => (item.id === id ? updated : item)));
toast.success('Attribute updated successfully!');
notify.success('Attribute updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update attribute');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -57,10 +58,10 @@ export const useAttribute = () => {
try {
await attributeService.delete(id);
setAttributes((prev) => prev.filter((item) => item.id !== id));
toast.success('Attribute deleted successfully!');
notify.success('Attribute deleted successfully!');
return true;
} catch (err: any) {
toast.error(err.message || 'Failed to delete attribute');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+13 -13
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { brandService } from '../services/brand.service';
import type { Brand, BrandCreateRequest, BrandUpdateRequest } from '../types/brand.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useBrand = () => {
const [brands, setBrands] = useState<Brand[]>([]);
@@ -14,9 +14,9 @@ export const useBrand = () => {
try {
const data = await brandService.getAll();
setBrands(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch brands');
toast.error(err.message || 'Failed to fetch brands');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch brands');
notify.error(err);
} finally {
setLoading(false);
}
@@ -27,10 +27,10 @@ export const useBrand = () => {
try {
const created = await brandService.create(req);
setBrands((prev) => [...prev, created]);
toast.success('Brand created successfully!');
notify.success('Brand created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create brand');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -42,10 +42,10 @@ export const useBrand = () => {
try {
const updated = await brandService.update(id, req);
setBrands((prev) => prev.map((item) => (item.id === id ? updated : item)));
toast.success('Brand updated successfully!');
notify.success('Brand updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update brand');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -57,10 +57,10 @@ export const useBrand = () => {
try {
await brandService.delete(id);
setBrands((prev) => prev.filter((item) => item.id !== id));
toast.success('Brand deleted successfully!');
notify.success('Brand deleted successfully!');
return true;
} catch (err: any) {
toast.error(err.message || 'Failed to delete brand');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+10 -10
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { categoryService } from '../services/category.service';
import type { Category, CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
import { MOCK_CATEGORIES } from '../components/category.mock';
export const useCategory = () => {
@@ -29,10 +29,10 @@ export const useCategory = () => {
try {
const created = await categoryService.create(req);
setCategories((prev) => [...prev, created]);
toast.success('Category created successfully!');
notify.success('Category created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create category');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -44,10 +44,10 @@ export const useCategory = () => {
try {
const updated = await categoryService.update(id, req);
setCategories((prev) => prev.map((item) => (item.id === id ? updated : item)));
toast.success('Category updated successfully!');
notify.success('Category updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update category');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -59,10 +59,10 @@ export const useCategory = () => {
try {
await categoryService.delete(id);
setCategories((prev) => prev.filter((item) => item.id !== id));
toast.success('Category deleted successfully!');
notify.success('Category deleted successfully!');
return true;
} catch (err: any) {
toast.error(err.message || 'Failed to delete category');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { channelTypesService } from '../services/channel-types.service';
import type { ChannelType, ChannelTypeCreateRequest, ChannelTypeUpdateRequest } from '../types/channel-types.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useChannelType = () => {
const [items, setItems] = useState<ChannelType[]>([]);
@@ -12,8 +12,8 @@ export const useChannelType = () => {
try {
const data = await channelTypesService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch channel types');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useChannelType = () => {
try {
const created = await channelTypesService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Channel type created successfully!');
notify.success('Channel type created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create channel type');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useChannelType = () => {
try {
const updated = await channelTypesService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Channel type updated successfully!');
notify.success('Channel type updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update channel type');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useChannelType = () => {
try {
await channelTypesService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Channel type deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete channel type');
notify.success('Channel type deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { channelsService } from '../services/channels.service';
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useChannel = () => {
const [items, setItems] = useState<Channel[]>([]);
@@ -12,8 +12,8 @@ export const useChannel = () => {
try {
const data = await channelsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useChannel = () => {
try {
const created = await channelsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Channel created successfully!');
notify.success('Channel created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useChannel = () => {
try {
const updated = await channelsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Channel updated successfully!');
notify.success('Channel updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useChannel = () => {
try {
await channelsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Channel deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Channel deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+7 -7
View File
@@ -46,8 +46,8 @@ export default function ChannelList() {
<Icon className={`w-4 h-4 ${meta.typeColor}`} />
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.description || 'No description provided'}</div>
<div className="font-medium text-foreground">{row.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.description || 'No description provided'}</div>
</div>
</div>
);
@@ -56,7 +56,7 @@ export default function ChannelList() {
{
key: "code",
label: "Code",
render: (val: string) => <span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{val}</span>,
render: (val: string) => <span className="text-xs font-mono bg-surface-muted text-muted-foreground px-2 py-1 rounded">{val}</span>,
},
{
key: "channelType",
@@ -84,8 +84,8 @@ export default function ChannelList() {
label: "Updated",
render: (val: any, row: Channel) => (
<div>
<div className="text-gray-900 font-medium text-sm">{new Date(val).toLocaleDateString()}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.author || 'Admin'}</div>
<div className="text-foreground font-medium text-sm">{new Date(val).toLocaleDateString()}</div>
<div className="text-muted-foreground text-xs mt-0.5">{row.author || 'Admin'}</div>
</div>
),
},
@@ -113,7 +113,7 @@ export default function ChannelList() {
items={[{ label: "Home" }, { label: "Channel Registry" }]}
actions={
<>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50" onClick={fetchItems}>
<Button variant="outline" className="bg-white border-border text-muted-foreground hover:bg-surface-muted" onClick={fetchItems}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
@@ -157,7 +157,7 @@ export default function ChannelList() {
</div>
{/* Main Content Area */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="bg-white rounded-xl shadow-sm border border-border overflow-hidden">
{/* Table */}
<DataTable
+19 -19
View File
@@ -42,7 +42,7 @@ const channelSchema = Yup.object({
allowPublishing:Yup.boolean(),
});
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const labelClass = "block text-sm font-medium text-foreground mb-1.5";
function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
@@ -224,7 +224,7 @@ export default function NewChannel() {
placeholder="e.g., shopify_main"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
<p className="text-[11px] text-gray-400 mt-1">Auto-generated from name</p>
<p className="text-[11px] text-subtle-foreground mt-1">Auto-generated from name</p>
</div>
</div>
@@ -240,13 +240,13 @@ export default function NewChannel() {
type="button"
onClick={() => { formik.setFieldValue("channelType", t.id); setSelectedType(t.id); }}
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all ${
isSelected ? "border-primary bg-primary/5/50" : "border-gray-100 hover:border-primary/20 hover:bg-primary/5/20"
isSelected ? "border-primary bg-primary/5/50" : "border-border hover:border-primary/20 hover:bg-primary/5/20"
}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${t.bg}`}>
<Icon className={`w-4 h-4 ${t.color}`} />
</div>
<span className={`text-xs font-medium ${isSelected ? "text-primary-dark" : "text-gray-600"}`}>{t.label}</span>
<span className={`text-xs font-medium ${isSelected ? "text-primary-dark" : "text-muted-foreground"}`}>{t.label}</span>
</button>
);
})}
@@ -301,15 +301,15 @@ export default function NewChannel() {
<Zap className="w-5 h-5 text-primary" />
</div>
<div>
<p className="font-medium text-gray-900">Allow Product Publishing</p>
<p className="text-sm text-gray-500">When enabled, products can be published and syndicated to this channel</p>
<p className="font-medium text-foreground">Allow Product Publishing</p>
<p className="text-sm text-muted-foreground">When enabled, products can be published and syndicated to this channel</p>
</div>
</div>
<button
type="button"
onClick={() => formik.setFieldValue("allowPublishing", !formik.values.allowPublishing)}
className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
formik.values.allowPublishing ? "bg-primary" : "bg-gray-200"
formik.values.allowPublishing ? "bg-primary" : "bg-border"
}`}
>
<span className={`inline-block h-6 w-6 rounded-full bg-white shadow transform transition-transform duration-200 ${
@@ -347,8 +347,8 @@ export default function NewChannel() {
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-4">Type</p>
<div className="rounded-xl border border-border bg-surface-muted p-4">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-4">Type</p>
{selectedType ? (() => {
const t = CHANNEL_TYPES.find(c => c.id === selectedType);
if (!t) return null;
@@ -358,32 +358,32 @@ export default function NewChannel() {
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${t.bg}`}>
<Icon className={`w-4 h-4 ${t.color}`} />
</div>
<span className="font-medium text-gray-900">{t.label}</span>
<span className="font-medium text-foreground">{t.label}</span>
</div>
);
})() : <span className="text-sm text-gray-500">Not selected</span>}
})() : <span className="text-sm text-muted-foreground">Not selected</span>}
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-3">Assigned Families</p>
<div className="rounded-xl border border-border bg-white p-4">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-3">Assigned Families</p>
<div className="flex items-center gap-2 mb-1">
<div className="w-4 h-4 rounded-full bg-blue-100 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
</div>
<p className="text-sm font-medium text-gray-900">No families assigned yet</p>
<p className="text-sm font-medium text-foreground">No families assigned yet</p>
</div>
<p className="text-xs text-gray-500 ml-6">Families are assigned from the Product Family module</p>
<p className="text-xs text-muted-foreground ml-6">Families are assigned from the Product Family module</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-3">Assigned Products</p>
<div className="rounded-xl border border-border bg-white p-4">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-3">Assigned Products</p>
<div className="flex items-center gap-2 mb-1">
<div className="w-4 h-4 rounded-full bg-orange-100 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
</div>
<p className="text-sm font-medium text-gray-900">No products published yet</p>
<p className="text-sm font-medium text-foreground">No products published yet</p>
</div>
<p className="text-xs text-gray-500 ml-6">{formik.values.allowPublishing ? "Publishing enabled" : "Publishing disabled"}</p>
<p className="text-xs text-muted-foreground ml-6">{formik.values.allowPublishing ? "Publishing enabled" : "Publishing disabled"}</p>
</div>
</div>
</div>
+14 -13
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { familyService } from '../services/family.service';
import type { Family, FamilyCreateRequest, FamilyUpdateRequest } from '../types/family.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useFamily = () => {
const [families, setFamilies] = useState<Family[]>([]);
@@ -14,9 +14,10 @@ export const useFamily = () => {
try {
const data = await familyService.getAll();
setFamilies(data);
} catch (err: any) {
setError(err.message || 'Failed to fetch families');
toast.error(err.message || 'Failed to fetch families');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch families';
setError(msg);
notify.error(err);
} finally {
setLoading(false);
}
@@ -27,10 +28,10 @@ export const useFamily = () => {
try {
const created = await familyService.create(req);
setFamilies((prev) => [...prev, created]);
toast.success('Family created successfully!');
notify.success('Family created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create family');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -42,10 +43,10 @@ export const useFamily = () => {
try {
const updated = await familyService.update(id, req);
setFamilies((prev) => prev.map((item) => (item.id === id ? updated : item)));
toast.success('Family updated successfully!');
notify.success('Family updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update family');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -57,10 +58,10 @@ export const useFamily = () => {
try {
await familyService.delete(id);
setFamilies((prev) => prev.filter((item) => item.id !== id));
toast.success('Family deleted successfully!');
notify.success('Family deleted successfully!');
return true;
} catch (err: any) {
toast.error(err.message || 'Failed to delete family');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { integrationsService } from '../services/integrations.service';
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useIntegration = () => {
const [items, setItems] = useState<Integration[]>([]);
@@ -12,8 +12,8 @@ export const useIntegration = () => {
try {
const data = await integrationsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useIntegration = () => {
try {
const created = await integrationsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Integration created successfully!');
notify.success('Integration created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useIntegration = () => {
try {
const updated = await integrationsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Integration updated successfully!');
notify.success('Integration updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useIntegration = () => {
try {
await integrationsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Integration deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Integration deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -0,0 +1,145 @@
// src/features/notifications/components/NotificationCard.tsx
import { useNavigate } from "react-router-dom";
import {
Package, Tag, Award, FolderTree, Layers, Store,
Users, Workflow, Settings,
Plus, Pencil, Trash2, Globe, CheckCircle, XCircle,
} from "lucide-react";
import type { NotificationItem, NotificationVariant, NotificationAction } from "../types/notification.types";
import { VARIANT_LABELS, ACTION_LABELS } from "../constants/notification.constants";
// ─── Variant visual config (labels live in constants, not here) ───────────────
interface VariantConfig {
icon: React.ElementType;
bg: string;
color: string;
}
const VARIANT_CONFIG: Record<NotificationVariant, VariantConfig> = {
product: { icon: Package, bg: "bg-blue-50", color: "text-blue-600" },
category: { icon: Tag, bg: "bg-amber-50", color: "text-amber-600" },
brand: { icon: Award, bg: "bg-purple-50", color: "text-purple-600" },
family: { icon: FolderTree, bg: "bg-teal-50", color: "text-teal-600" },
attribute: { icon: Layers, bg: "bg-indigo-50", color: "text-indigo-600" },
store: { icon: Store, bg: "bg-green-50", color: "text-green-600" },
role: { icon: Users, bg: "bg-rose-50", color: "text-rose-600" },
workflow: { icon: Workflow, bg: "bg-orange-50", color: "text-orange-600" },
system: { icon: Settings, bg: "bg-gray-50", color: "text-gray-600" },
};
// ─── Action visual config (labels live in constants, not here) ────────────────
interface ActionConfig {
icon: React.ElementType;
bg: string;
color: string;
}
const ACTION_CONFIG: Record<NotificationAction, ActionConfig> = {
created: { icon: Plus, bg: "bg-green-100", color: "text-green-700" },
updated: { icon: Pencil, bg: "bg-blue-100", color: "text-blue-700" },
deleted: { icon: Trash2, bg: "bg-red-100", color: "text-red-700" },
published: { icon: Globe, bg: "bg-teal-100", color: "text-teal-700" },
approved: { icon: CheckCircle, bg: "bg-green-100", color: "text-green-700" },
rejected: { icon: XCircle, bg: "bg-red-100", color: "text-red-700" },
};
// ─── Relative time helper ─────────────────────────────────────────────────────
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
// ─── Component ────────────────────────────────────────────────────────────────
interface NotificationCardProps {
notification: NotificationItem;
onMarkRead: (id: string) => void;
}
export function NotificationCard({ notification, onMarkRead }: NotificationCardProps) {
const navigate = useNavigate();
const variant = VARIANT_CONFIG[notification.variant];
const action = ACTION_CONFIG[notification.action];
const Icon = variant.icon;
const ActionIcon = action.icon;
const handleClick = () => {
if (!notification.isRead) onMarkRead(notification.id);
if (notification.link) navigate(notification.link);
};
return (
<div
onClick={handleClick}
className={[
"group relative flex items-start gap-4 px-5 py-4 rounded-xl border transition-all duration-150",
"hover:shadow-sm hover:-translate-y-px cursor-pointer",
notification.isRead
? "bg-surface border-border"
: "bg-primary/[0.03] border-primary/20",
].join(" ")}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && handleClick()}
aria-label={notification.title}
>
{/* Unread dot */}
{!notification.isRead && (
<span
className="absolute top-4 right-4 w-2 h-2 rounded-full bg-primary flex-shrink-0"
aria-label="Unread"
/>
)}
{/* Variant icon */}
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${variant.bg}`}>
<Icon className={`w-5 h-5 ${variant.color}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-foreground leading-snug">
{notification.title}
</span>
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[11px] font-medium ${action.bg} ${action.color}`}>
<ActionIcon className="w-3 h-3" />
{ACTION_LABELS[notification.action]}
</span>
</div>
<p className="text-sm text-muted-foreground leading-snug line-clamp-2 mb-2">
{notification.description}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="w-5 h-5 rounded-full bg-primary/10 text-primary text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{notification.actor.substring(0, 2).toUpperCase()}
</span>
{notification.actor}
</span>
<span className="text-border">·</span>
<time dateTime={notification.createdAt}>
{relativeTime(notification.createdAt)}
</time>
<span className="text-border">·</span>
<span className={`px-1.5 py-0.5 rounded text-[11px] font-medium ${variant.bg} ${variant.color}`}>
{VARIANT_LABELS[notification.variant]}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
// src/features/notifications/components/NotificationEmptyState.tsx
import { Bell } from "lucide-react";
interface NotificationEmptyStateProps {
hasFilters: boolean;
}
export function NotificationEmptyState({ hasFilters }: NotificationEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-surface-muted flex items-center justify-center mb-4">
<Bell className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1">
{hasFilters ? "No matching notifications" : "You're all caught up"}
</h3>
<p className="text-sm text-muted-foreground max-w-xs">
{hasFilters
? "Try adjusting your search or filters to find what you're looking for."
: "New activity across your catalog, workflows, and team will appear here."}
</p>
</div>
);
}
@@ -0,0 +1,65 @@
// src/features/notifications/components/NotificationFilterBar.tsx
import { READ_FILTER_OPTIONS, VARIANT_FILTER_OPTIONS } from "../constants/notification.constants";
import type { NotificationVariant, ReadFilter } from "../types/notification.types";
interface NotificationFilterBarProps {
variantFilter: NotificationVariant | "all";
readFilter: ReadFilter;
onVariantChange: (v: NotificationVariant | "all") => void;
onReadChange: (v: ReadFilter) => void;
unreadCount: number;
}
export function NotificationFilterBar({
variantFilter,
readFilter,
onVariantChange,
onReadChange,
unreadCount,
}: NotificationFilterBarProps) {
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Read status pills */}
<div className="flex items-center gap-1 p-1 bg-surface-muted rounded-lg">
{READ_FILTER_OPTIONS.map((f) => (
<button
key={f.value}
onClick={() => onReadChange(f.value)}
className={[
"px-3 py-1 rounded-md text-xs font-medium transition-all",
readFilter === f.value
? "bg-surface text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
].join(" ")}
>
{f.label}
{f.value === "unread" && unreadCount > 0 && (
<span className="ml-1.5 px-1.5 py-0.5 rounded-full bg-primary text-white text-[10px] font-bold">
{unreadCount}
</span>
)}
</button>
))}
</div>
{/* Variant type select */}
<select
value={variantFilter}
onChange={(e) => onVariantChange(e.target.value as NotificationVariant | "all")}
className={[
"px-3 py-1.5 text-xs font-medium rounded-lg border border-border bg-surface",
"text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary",
"cursor-pointer transition-colors",
].join(" ")}
aria-label="Filter by type"
>
{VARIANT_FILTER_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
);
}
@@ -0,0 +1,51 @@
// src/features/notifications/components/NotificationPageHeader.tsx
import { Bell, CheckCheck } from "lucide-react";
interface NotificationPageHeaderProps {
unreadCount: number;
onMarkAllRead: () => void;
isMarkingAll: boolean;
}
export function NotificationPageHeader({
unreadCount,
onMarkAllRead,
isMarkingAll,
}: NotificationPageHeaderProps) {
return (
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<Bell className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground leading-tight">
Notifications
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount !== 1 ? "s" : ""}`
: "All caught up — no unread notifications"}
</p>
</div>
</div>
{unreadCount > 0 && (
<button
onClick={onMarkAllRead}
disabled={isMarkingAll}
className={[
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
"border border-border bg-surface hover:bg-surface-muted",
"text-foreground disabled:opacity-50 disabled:cursor-not-allowed",
].join(" ")}
aria-label="Mark all notifications as read"
>
<CheckCheck className="w-4 h-4" />
{isMarkingAll ? "Marking…" : "Mark all read"}
</button>
)}
</div>
);
}
@@ -0,0 +1,38 @@
// src/features/notifications/components/NotificationSearchBar.tsx
import { Search, X } from "lucide-react";
interface NotificationSearchBarProps {
value: string;
onChange: (value: string) => void;
}
export function NotificationSearchBar({ value, onChange }: NotificationSearchBarProps) {
return (
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search notifications…"
className={[
"w-full pl-9 pr-9 py-2 text-sm rounded-lg border border-border bg-surface",
"placeholder-muted-foreground text-foreground",
"focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary",
"transition-colors",
].join(" ")}
aria-label="Search notifications"
/>
{value && (
<button
onClick={() => onChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
@@ -0,0 +1,35 @@
// src/features/notifications/components/NotificationSkeleton.tsx
export function NotificationSkeleton() {
return (
<div className="flex items-start gap-4 px-5 py-4 rounded-xl border border-border bg-surface animate-pulse">
{/* Icon placeholder */}
<div className="w-10 h-10 rounded-xl bg-surface-muted flex-shrink-0" />
{/* Content placeholder */}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<div className="h-4 w-40 rounded bg-surface-muted" />
<div className="h-4 w-16 rounded bg-surface-muted" />
</div>
<div className="h-3 w-full rounded bg-surface-muted" />
<div className="h-3 w-3/4 rounded bg-surface-muted" />
<div className="flex items-center gap-3 pt-1">
<div className="h-3 w-20 rounded bg-surface-muted" />
<div className="h-3 w-12 rounded bg-surface-muted" />
<div className="h-3 w-14 rounded bg-surface-muted" />
</div>
</div>
</div>
);
}
export function NotificationSkeletonList({ count = 5 }: { count?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<NotificationSkeleton key={i} />
))}
</div>
);
}
@@ -0,0 +1,86 @@
// src/features/notifications/components/NotificationTimeline.tsx
import { useMemo } from "react";
import { NotificationCard } from "./NotificationCard";
import type { NotificationItem } from "../types/notification.types";
// ─── Timeline grouping ────────────────────────────────────────────────────────
export type TimelineGroup = "Today" | "Yesterday" | "Last 7 Days" | "Older";
function getGroup(iso: string): TimelineGroup {
const now = new Date();
const date = new Date(iso);
const diffMs = now.getTime() - date.getTime();
const diffDays = diffMs / 86_400_000;
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
if (isToday) return "Today";
if (isYesterday) return "Yesterday";
if (diffDays < 7) return "Last 7 Days";
return "Older";
}
const GROUP_ORDER: TimelineGroup[] = ["Today", "Yesterday", "Last 7 Days", "Older"];
export function groupNotifications(
notifications: NotificationItem[]
): Array<{ group: TimelineGroup; items: NotificationItem[] }> {
const map = new Map<TimelineGroup, NotificationItem[]>();
for (const n of notifications) {
const g = getGroup(n.createdAt);
if (!map.has(g)) map.set(g, []);
map.get(g)!.push(n);
}
return GROUP_ORDER
.filter((g) => map.has(g))
.map((g) => ({ group: g, items: map.get(g)! }));
}
// ─── Section header ───────────────────────────────────────────────────────────
function TimelineSectionHeader({ group, count }: { group: TimelineGroup; count: number }) {
return (
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{group}
</span>
<span className="text-xs text-muted-foreground bg-surface-muted px-2 py-0.5 rounded-full">
{count}
</span>
<div className="flex-1 h-px bg-border" />
</div>
);
}
// ─── Timeline ─────────────────────────────────────────────────────────────────
interface NotificationTimelineProps {
notifications: NotificationItem[];
onMarkRead: (id: string) => void;
}
export function NotificationTimeline({ notifications, onMarkRead }: NotificationTimelineProps) {
const groups = useMemo(() => groupNotifications(notifications), [notifications]);
return (
<div className="space-y-8">
{groups.map(({ group, items }) => (
<section key={group} aria-labelledby={`group-${group}`}>
<TimelineSectionHeader group={group} count={items.length} />
<div className="space-y-3">
{items.map((n) => (
<NotificationCard key={n.id} notification={n} onMarkRead={onMarkRead} />
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,44 @@
// src/features/notifications/constants/notification.constants.ts
import type { NotificationVariant, NotificationAction, ReadFilter } from "../types/notification.types";
export const NOTIFICATION_PAGE_SIZE = 20;
export const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "unread", label: "Unread" },
{ value: "read", label: "Read" },
];
export const VARIANT_FILTER_OPTIONS: { value: NotificationVariant | "all"; label: string }[] = [
{ value: "all", label: "All Types" },
{ value: "product", label: "Products" },
{ value: "category", label: "Categories" },
{ value: "brand", label: "Brands" },
{ value: "family", label: "Families" },
{ value: "attribute", label: "Attributes" },
{ value: "store", label: "Stores" },
{ value: "role", label: "Roles" },
{ value: "workflow", label: "Workflow" },
];
export const VARIANT_LABELS: Record<NotificationVariant, string> = {
product: "Product",
category: "Category",
brand: "Brand",
family: "Family",
attribute: "Attribute",
store: "Store",
role: "Role",
workflow: "Workflow",
system: "System",
};
export const ACTION_LABELS: Record<NotificationAction, string> = {
created: "Created",
updated: "Updated",
deleted: "Deleted",
published: "Published",
approved: "Approved",
rejected: "Rejected",
};
@@ -0,0 +1,211 @@
// src/features/notifications/data/notifications.mock.ts
//
// Static fixture data — mock-phase only. Replaced by real API in Step 5.
// Types are imported from the canonical types file; no local re-declarations.
import type { NotificationItem } from "../types/notification.types";
const now = new Date();
const mins = (n: number) => new Date(now.getTime() - n * 60_000).toISOString();
const hours = (n: number) => new Date(now.getTime() - n * 3_600_000).toISOString();
const days = (n: number) => new Date(now.getTime() - n * 86_400_000).toISOString();
export const MOCK_NOTIFICATIONS: NotificationItem[] = [
// ── Today ──────────────────────────────────────────────────────────────────
{
id: "n-001",
variant: "product",
action: "created",
title: "New product created",
description: "\"Wireless Noise-Cancelling Headphones\" was added to the catalog.",
entity: "Wireless Noise-Cancelling Headphones",
entityId: "prod-881",
actor: "Sarah Chen",
isRead: false,
createdAt: mins(8),
link: "/products/prod-881",
},
{
id: "n-002",
variant: "category",
action: "updated",
title: "Category updated",
description: "\"Electronics > Audio\" category tree was restructured.",
entity: "Electronics > Audio",
entityId: "cat-44",
actor: "James Okafor",
isRead: false,
createdAt: mins(22),
link: "/categories/cat-44",
},
{
id: "n-003",
variant: "workflow",
action: "approved",
title: "Product approved",
description: "\"Smart Watch Series 9\" passed the QA review stage.",
entity: "Smart Watch Series 9",
entityId: "prod-774",
actor: "Priya Nair",
isRead: false,
createdAt: hours(1),
link: "/products/prod-774",
},
{
id: "n-004",
variant: "brand",
action: "created",
title: "New brand added",
description: "Brand \"Luminary\" was registered in the master data.",
entity: "Luminary",
entityId: "brand-29",
actor: "Carlos Mendez",
isRead: true,
createdAt: hours(3),
link: "/brands/brand-29",
},
{
id: "n-005",
variant: "product",
action: "deleted",
title: "Product deleted",
description: "\"Legacy USB Hub v1\" was permanently removed from the catalog.",
entity: "Legacy USB Hub v1",
entityId: "prod-102",
actor: "Sarah Chen",
isRead: true,
createdAt: hours(5),
},
// ── Yesterday ──────────────────────────────────────────────────────────────
{
id: "n-006",
variant: "attribute",
action: "created",
title: "Attribute created",
description: "New attribute \"Battery Capacity (mAh)\" was added to the registry.",
entity: "Battery Capacity (mAh)",
entityId: "attr-67",
actor: "Mei Lin",
isRead: true,
createdAt: days(1),
link: "/attributes/attr-67",
},
{
id: "n-007",
variant: "family",
action: "updated",
title: "Product family updated",
description: "\"Consumer Electronics\" family had 3 attributes reassigned.",
entity: "Consumer Electronics",
entityId: "fam-12",
actor: "James Okafor",
isRead: false,
createdAt: days(1),
link: "/families/fam-12",
},
{
id: "n-008",
variant: "role",
action: "updated",
title: "Role permissions changed",
description: "\"Content Editor\" role was granted export permissions on Products.",
entity: "Content Editor",
entityId: "role-5",
actor: "Admin",
isRead: true,
createdAt: days(1),
link: "/users/roles/role-5",
},
{
id: "n-009",
variant: "product",
action: "published",
title: "Product published",
description: "\"4K OLED Monitor 27\"\" is now live on the Shopify channel.",
entity: "4K OLED Monitor 27\"",
entityId: "prod-553",
actor: "Priya Nair",
isRead: true,
createdAt: days(1),
link: "/products/prod-553",
},
// ── Last 7 Days ────────────────────────────────────────────────────────────
{
id: "n-010",
variant: "store",
action: "created",
title: "Store created",
description: "New store \"APAC Marketplace\" was configured and activated.",
entity: "APAC Marketplace",
entityId: "store-8",
actor: "Carlos Mendez",
isRead: true,
createdAt: days(3),
link: "/channels/store-8",
},
{
id: "n-011",
variant: "category",
action: "deleted",
title: "Category deleted",
description: "\"Discontinued > Legacy\" category was removed from the taxonomy.",
entity: "Discontinued > Legacy",
entityId: "cat-91",
actor: "Mei Lin",
isRead: true,
createdAt: days(4),
},
{
id: "n-012",
variant: "brand",
action: "updated",
title: "Brand updated",
description: "\"TechCore\" brand logo and description were refreshed.",
entity: "TechCore",
entityId: "brand-7",
actor: "Sarah Chen",
isRead: true,
createdAt: days(5),
link: "/brands/brand-7",
},
{
id: "n-013",
variant: "workflow",
action: "rejected",
title: "Product rejected",
description: "\"Portable Bluetooth Speaker\" failed the compliance check.",
entity: "Portable Bluetooth Speaker",
entityId: "prod-340",
actor: "James Okafor",
isRead: true,
createdAt: days(6),
link: "/products/prod-340",
},
// ── Older ──────────────────────────────────────────────────────────────────
{
id: "n-014",
variant: "attribute",
action: "updated",
title: "Attribute updated",
description: "\"Color\" attribute options were expanded with 12 new values.",
entity: "Color",
entityId: "attr-3",
actor: "Priya Nair",
isRead: true,
createdAt: days(10),
link: "/attributes/attr-3",
},
{
id: "n-015",
variant: "family",
action: "created",
title: "Product family created",
description: "\"Home Appliances\" family was created with 24 base attributes.",
entity: "Home Appliances",
entityId: "fam-18",
actor: "Carlos Mendez",
isRead: true,
createdAt: days(14),
link: "/families/fam-18",
},
];
@@ -0,0 +1,181 @@
// src/features/notifications/hooks/useNotifications.ts
import { useState, useEffect, useCallback, useMemo } from "react";
import { notificationService, mapNotification } from "../services/notification.service";
import { NOTIFICATION_PAGE_SIZE } from "../constants/notification.constants";
import { socketService } from "../../../services/socket.service";
import { useAppSelector } from "../../../store";
import type {
NotificationItem,
NotificationVariant,
NotificationResponse,
ReadFilter,
} from "../types/notification.types";
interface UseNotificationsState {
items: NotificationItem[];
isLoading: boolean;
isMarkingAll: boolean;
error: string | null;
page: number;
totalPages: number;
total: number;
hasNextPage: boolean;
hasPrevPage: boolean;
unreadCount: number;
hasFilters: boolean;
search: string;
variantFilter: NotificationVariant | "all";
readFilter: ReadFilter;
selectedId: string | null;
}
interface UseNotificationsActions {
setSearch: (v: string) => void;
setVariantFilter: (v: NotificationVariant | "all") => void;
setReadFilter: (v: ReadFilter) => void;
setPage: (p: number) => void;
setSelectedId: (id: string | null) => void;
markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>;
refresh: () => void;
}
export type UseNotificationsReturn = UseNotificationsState & UseNotificationsActions;
export function useNotifications(): UseNotificationsReturn {
const [items, setItems] = useState<NotificationItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isMarkingAll, setIsMarkingAll] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [meta, setMeta] = useState<NotificationResponse["meta"] | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [search, setSearchRaw] = useState("");
const [variantFilter, setVariantFilter] = useState<NotificationVariant | "all">("all");
const [readFilter, setReadFilter] = useState<ReadFilter>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const user = useAppSelector(state => state.auth.user);
// Reset to page 1 when filters change
const setSearch = useCallback((v: string) => { setSearchRaw(v); setPage(1); }, []);
const handleVariantFilter = useCallback((v: NotificationVariant | "all") => { setVariantFilter(v); setPage(1); }, []);
const handleReadFilter = useCallback((v: ReadFilter) => { setReadFilter(v); setPage(1); }, []);
const refresh = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
notificationService
.getNotifications({
page,
limit: NOTIFICATION_PAGE_SIZE,
search,
variant: variantFilter,
read: readFilter,
})
.then((res) => {
if (cancelled) return;
setItems(res.data);
setMeta(res.meta);
setUnreadCount(res.unreadCount ?? 0);
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load notifications");
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => { cancelled = true; };
}, [page, search, variantFilter, readFilter, tick]);
useEffect(() => {
if (!user) return;
const userId = user.id || user.user_id;
if (!userId) return;
socketService.connect(userId, user.tenant_id);
const handleNewNotification = (rawNotification: NotificationItem) => {
const notification = mapNotification(rawNotification);
// Only append if it matches current filters
let matches = true;
if (variantFilter !== "all" && notification.variant !== variantFilter) matches = false;
if (readFilter === "read" && !notification.isRead) matches = false;
if (readFilter === "unread" && notification.isRead) matches = false;
if (matches) {
setItems((prev) => [notification, ...prev]);
}
};
const handleUnreadCount = (data: { unreadCount: number }) => {
setUnreadCount(data.unreadCount);
};
socketService.on("notification:created", handleNewNotification);
socketService.on("notification:unread-count", handleUnreadCount);
return () => {
socketService.off("notification:created", handleNewNotification);
socketService.off("notification:unread-count", handleUnreadCount);
// Let the socket service handle its own singleton connection, no disconnect here so other components can share it.
};
}, [user, variantFilter, readFilter]);
const markRead = useCallback(async (id: string) => {
await notificationService.markRead(id);
setItems((prev) => prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)));
// the socket event will update unread count, but we can do it optimistically:
setUnreadCount((c) => Math.max(0, c - 1));
}, []);
const markAllRead = useCallback(async () => {
setIsMarkingAll(true);
try {
await notificationService.markAllRead();
setItems((prev) => prev.map((n) => ({ ...n, isRead: true })));
setUnreadCount(0);
} finally {
setIsMarkingAll(false);
}
}, []);
const hasFilters = useMemo(
() => search !== "" || variantFilter !== "all" || readFilter !== "all",
[search, variantFilter, readFilter]
);
return {
items,
isLoading,
isMarkingAll,
error,
page,
totalPages: meta?.totalPages ?? 1,
total: meta?.total ?? 0,
hasNextPage: meta?.hasNextPage ?? false,
hasPrevPage: meta?.hasPrevPage ?? false,
unreadCount,
hasFilters,
search,
variantFilter,
readFilter,
selectedId,
setSearch,
setVariantFilter: handleVariantFilter,
setReadFilter: handleReadFilter,
setPage,
setSelectedId,
markRead,
markAllRead,
refresh,
};
}
+10
View File
@@ -0,0 +1,10 @@
// src/features/notifications/index.ts
//
// Public API of the notifications feature.
// The service is intentionally NOT exported — consumers must go through the hook.
export * from "./types/notification.types";
export * from "./constants/notification.constants";
export * from "./hooks/useNotifications";
export { NotificationRoutes } from "./routes/notifications.routes";
export { notificationService } from "./services/notification.service";
@@ -0,0 +1,70 @@
// src/features/notifications/pages/NotificationsPage.tsx
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { NotificationPageHeader } from "../components/NotificationPageHeader";
import { NotificationSearchBar } from "../components/NotificationSearchBar";
import { NotificationFilterBar } from "../components/NotificationFilterBar";
import { NotificationTimeline } from "../components/NotificationTimeline";
import { NotificationSkeletonList } from "../components/NotificationSkeleton";
import { NotificationEmptyState } from "../components/NotificationEmptyState";
import { useNotifications } from "../hooks/useNotifications";
export default function NotificationsPage() {
const {
items,
isLoading,
isMarkingAll,
error,
unreadCount,
hasFilters,
search,
variantFilter,
readFilter,
setSearch,
setVariantFilter,
setReadFilter,
markRead,
markAllRead,
} = useNotifications();
return (
<PageWrapper>
<Breadcrumb items={[{ label: "Home" }, { label: "Notifications" }]} />
<NotificationPageHeader
unreadCount={unreadCount}
onMarkAllRead={markAllRead}
isMarkingAll={isMarkingAll}
/>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap mb-6">
<NotificationSearchBar value={search} onChange={setSearch} />
<NotificationFilterBar
variantFilter={variantFilter}
readFilter={readFilter}
onVariantChange={setVariantFilter}
onReadChange={setReadFilter}
unreadCount={unreadCount}
/>
</div>
{/* Error */}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 mb-6">
{error}
</div>
)}
{/* Content */}
{isLoading ? (
<NotificationSkeletonList count={6} />
) : items.length === 0 ? (
<NotificationEmptyState hasFilters={hasFilters} />
) : (
<NotificationTimeline notifications={items} onMarkRead={markRead} />
)}
</PageWrapper>
);
}
@@ -0,0 +1,12 @@
// src/features/notifications/routes/notifications.routes.tsx
import { Routes, Route } from "react-router-dom";
import NotificationsPage from "../pages/NotificationsPage";
export const NotificationRoutes = () => (
<Routes>
<Route index element={<NotificationsPage />} />
</Routes>
);
export default NotificationRoutes;
@@ -0,0 +1,103 @@
import apiClient from "../../../api/axiosInstance";
import { NOTIFICATION_PAGE_SIZE } from "../constants/notification.constants";
import type {
NotificationItem,
NotificationQuery,
NotificationResponse,
NotificationVariant,
} from "../types/notification.types";
export function generateLink(variant: NotificationVariant, entityId: string | undefined): string | undefined {
if (!entityId) return undefined;
switch (variant) {
case 'product': return `/products/${entityId}`;
case 'category': return `/categories/${entityId}`;
case 'brand': return `/brands/${entityId}`;
case 'family': return `/families/${entityId}`;
case 'attribute': return `/attributes/${entityId}`;
case 'store': return `/channels/${entityId}`;
case 'role': return `/users/roles/${entityId}`;
default: return undefined;
}
}
export function mapNotification(item: NotificationItem): NotificationItem {
return {
...item,
link: generateLink(item.variant, item.entityId),
};
}
// Backend response envelope shape
interface BackendNotificationResponse {
success: boolean;
message?: string;
data: NotificationItem[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
meta: {
unreadCount: number;
};
}
export const notificationService = {
async getNotifications(query: NotificationQuery): Promise<NotificationResponse> {
const params: Record<string, any> = {};
params.page = query.page;
params.limit = query.limit ?? NOTIFICATION_PAGE_SIZE;
if (query.search?.trim()) params.search = query.search.trim();
if (query.variant && query.variant !== 'all') params.variant = query.variant;
if (query.read && query.read !== 'all') params.isRead = query.read === 'read' ? 'true' : 'false';
const raw = await apiClient.get<BackendNotificationResponse>('/api/v1/notifications', params);
// Normalize backend shape → frontend NotificationResponse
return {
data: (raw.data ?? []).map(mapNotification),
meta: {
page: raw.pagination?.page ?? query.page,
limit: raw.pagination?.limit ?? (query.limit ?? NOTIFICATION_PAGE_SIZE),
total: raw.pagination?.total ?? 0,
totalPages: raw.pagination?.totalPages ?? 1,
hasNextPage: raw.pagination?.hasNextPage ?? false,
hasPrevPage: raw.pagination?.hasPreviousPage ?? false,
unreadCount: raw.meta?.unreadCount ?? 0,
} as any,
unreadCount: raw.meta?.unreadCount ?? 0,
};
},
async getById(id: string): Promise<NotificationItem | null> {
try {
const response = await apiClient.get<{ success: boolean; data: NotificationItem }>(`/api/v1/notifications/${id}`);
return response.data ? mapNotification(response.data) : null;
} catch (e) {
return null;
}
},
async markRead(id: string): Promise<void> {
await apiClient.patch(`/api/v1/notifications/${id}/read`);
},
async markAllRead(): Promise<void> {
await apiClient.patch('/api/v1/notifications/read-all');
},
async getUnreadCount(): Promise<number> {
try {
const raw = await apiClient.get<BackendNotificationResponse>('/api/v1/notifications', { limit: 1, isRead: 'false' });
return raw.meta?.unreadCount ?? 0;
} catch (e) {
return 0;
}
}
};
@@ -0,0 +1,69 @@
// src/features/notifications/types/notification.types.ts
export type NotificationVariant =
| "product"
| "category"
| "brand"
| "family"
| "attribute"
| "store"
| "role"
| "workflow"
| "system";
export type NotificationAction =
| "created"
| "updated"
| "deleted"
| "published"
| "approved"
| "rejected";
export type ReadFilter = "all" | "unread" | "read";
// ─── Core domain model ────────────────────────────────────────────────────────
export interface NotificationItem {
id: string;
variant: NotificationVariant;
action: NotificationAction;
title: string;
description: string;
entity: string;
entityId: string;
actor: string;
actorAvatar?: string;
isRead: boolean;
createdAt: string; // ISO 8601
link?: string;
}
// ─── Query / filter contract ──────────────────────────────────────────────────
export interface NotificationFilter {
variant?: NotificationVariant | "all";
read?: ReadFilter;
search?: string;
}
export interface NotificationQuery extends NotificationFilter {
page: number;
limit: number;
}
// ─── API response envelope ────────────────────────────────────────────────────
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
export interface NotificationResponse {
data: NotificationItem[];
meta: PaginationMeta;
unreadCount: number;
}
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { productService } from '../services/product.service';
import type { Product, ProductCreateRequest, ProductUpdateRequest } from '../types/product.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useProduct = () => {
const [products, setProducts] = useState<Product[]>([]);
@@ -12,8 +12,8 @@ export const useProduct = () => {
try {
const data = await productService.getAll();
setProducts(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch products');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useProduct = () => {
try {
const created = await productService.create(req);
setProducts((prev) => [...prev, created]);
toast.success('Product created successfully!');
notify.success('Product created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create product');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useProduct = () => {
try {
const updated = await productService.update(id, req);
setProducts((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Product updated successfully!');
notify.success('Product updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update product');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useProduct = () => {
try {
await productService.delete(id);
setProducts((prev) => prev.filter((p) => p.id !== id));
toast.success('Product deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete product');
notify.success('Product deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { reportsService } from '../services/reports.service';
import type { Report, ReportCreateRequest, ReportUpdateRequest } from '../types/reports.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useReport = () => {
const [items, setItems] = useState<Report[]>([]);
@@ -12,8 +12,8 @@ export const useReport = () => {
try {
const data = await reportsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useReport = () => {
try {
const created = await reportsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Report created successfully!');
notify.success('Report created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useReport = () => {
try {
const updated = await reportsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Report updated successfully!');
notify.success('Report updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useReport = () => {
try {
await reportsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Report deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Report deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+27 -15
View File
@@ -1,12 +1,14 @@
import { useState, useCallback } from 'react';
import { roleService } from '../services/role.service';
import type { Role, CreateRoleDTO, UpdateRoleDTO, PermissionNode } from '../types/role.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export function useRole() {
const [roles, setRoles] = useState<Role[]>([]);
const [nodes, setNodes] = useState<PermissionNode[]>([]);
const [loading, setLoading] = useState(false);
const [nodesLoading, setNodesLoading] = useState(false);
const [nodesError, setNodesError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchRoles = useCallback(async () => {
@@ -15,9 +17,10 @@ export function useRole() {
setError(null);
const data = await roleService.getAll();
setRoles(data);
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to fetch roles');
toast.error('Failed to load roles');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to fetch roles';
setError(msg);
notify.error(err);
} finally {
setLoading(false);
}
@@ -25,10 +28,16 @@ export function useRole() {
const fetchNodes = useCallback(async () => {
try {
setNodesLoading(true);
setNodesError(null);
const data = await roleService.getPermissionNodes();
setNodes(data);
} catch (err) {
console.error(err);
const msg = err instanceof Error ? err.message : 'Failed to load permission modules';
setNodesError(msg);
notify.error(err);
} finally {
setNodesLoading(false);
}
}, []);
@@ -41,10 +50,10 @@ export function useRole() {
setLoading(true);
const newRole = await roleService.create(data);
setRoles(prev => [...prev, newRole]);
toast.success('Role created successfully');
notify.success('Role created successfully');
return newRole;
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to create role');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -56,10 +65,10 @@ export function useRole() {
setLoading(true);
const updated = await roleService.update(id, data);
setRoles(prev => prev.map(r => r.id === id ? updated : r));
toast.success('Role updated successfully');
notify.success('Role updated successfully');
return updated;
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to update role');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -71,9 +80,9 @@ export function useRole() {
setLoading(true);
await roleService.delete(id);
setRoles(prev => prev.filter(r => r.id !== id));
toast.success('Role deleted successfully');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to delete role');
notify.success('Role deleted successfully');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -84,12 +93,15 @@ export function useRole() {
roles,
nodes,
loading,
nodesLoading,
nodesError,
error,
fetchRoles,
fetchNodes,
getRole,
createRole,
updateRole,
deleteRole
deleteRole,
};
}
-296
View File
@@ -1,296 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { roleService } from "../services/role.service";
import type { RolePermission, PermissionNode } from "../types/role.types";
import { toast } from "react-toastify";
export default function EditRoleForm() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [nodes, setNodes] = useState<PermissionNode[]>([]);
const [formData, setFormData] = useState({ role_name: "", description: "", status: true });
const [permissions, setPermissions] = useState<Record<string, RolePermission>>({});
useEffect(() => {
if (!id) {
navigate("/users/roles");
return;
}
let cancelled = false;
const load = async () => {
try {
// Fetch both in parallel
const [allNodes, role] = await Promise.all([
roleService.getPermissionNodes(),
roleService.getById(id),
]);
if (cancelled) return;
setNodes(allNodes);
if (role) {
setFormData({
role_name: role.role_name || "",
description: role.description || "",
status: role.status === undefined ? true : Boolean(role.status),
});
// Build permission map from role's existing permissions
const existingPerms: Record<string, RolePermission> = {};
if (role.permissions && Array.isArray(role.permissions)) {
role.permissions.forEach((p: any) => {
const rp = p.RolePermission || p;
const nodeId = String(p.id || p.node_id);
existingPerms[nodeId] = {
node_id: nodeId,
can_view: rp?.can_view || false,
can_create: rp?.can_create || false,
can_edit: rp?.can_edit || false,
can_delete: rp?.can_delete || false,
can_alter: rp?.can_alter || false,
can_export: rp?.can_export || false,
can_import: rp?.can_import || false,
};
});
}
// Merge with all nodes — nodes not in existing get all-false defaults
const merged: Record<string, RolePermission> = {};
allNodes.forEach((node) => {
merged[node.id] = existingPerms[node.id] || {
node_id: node.id,
can_view: false,
can_create: false,
can_edit: false,
can_delete: false,
can_alter: false,
can_export: false,
can_import: false,
};
});
setPermissions(merged);
}
} catch (err) {
console.error("Failed to load role for editing:", err);
toast.error("Failed to load role data. Redirecting...");
if (!cancelled) navigate("/users/roles");
} finally {
if (!cancelled) setFetching(false);
}
};
load();
return () => {
cancelled = true;
};
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const togglePermission = (nodeId: string, action: keyof RolePermission) => {
setPermissions((prev) => ({
...prev,
[nodeId]: { ...prev[nodeId], [action]: !prev[nodeId]?.[action] },
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) return;
setLoading(true);
try {
await roleService.update(id, {
...formData,
permissions: Object.values(permissions).filter(
(p) =>
p.can_view ||
p.can_create ||
p.can_edit ||
p.can_delete ||
p.can_alter ||
p.can_export ||
p.can_import
),
});
toast.success("Role updated successfully");
navigate("/users/roles");
} catch (err: any) {
toast.error(err?.response?.data?.message || "Failed to update role");
} finally {
setLoading(false);
}
};
return (
<ProtectedRoute node="settings.roles">
<PageWrapper>
<Breadcrumb
items={[
{ label: "Home" },
{ label: "Users & Roles" },
{ label: "Roles", href: "/users/roles" },
{ label: "Edit Role" },
]}
/>
{fetching ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary" />
<p className="text-sm text-gray-500">Loading role data...</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Role Details */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Role Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role Name *
</label>
<input
type="text"
required
value={formData.role_name}
onChange={(e) => setFormData({ ...formData, role_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="e.g. Product Manager"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<div className="flex items-center gap-3 mt-2">
<span
className={`text-sm ${formData.status ? "text-gray-900 font-medium" : "text-gray-500"}`}
>
Active
</span>
<button
type="button"
onClick={() => setFormData({ ...formData, status: !formData.status })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.status ? "bg-primary" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.status ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span
className={`text-sm ${!formData.status ? "text-gray-900 font-medium" : "text-gray-500"}`}
>
Inactive
</span>
</div>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
rows={3}
placeholder="Brief description of the role responsibilities"
/>
</div>
</div>
{/* Permissions Matrix */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Permissions Matrix</h3>
<p className="text-sm text-gray-500">
Configure granular access levels for each module.
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{["Module", "View", "Create", "Edit", "Delete", "Alter", "Export", "Import"].map(
(h) => (
<th
key={h}
className={`px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${
h === "Module" ? "text-left" : "text-center"
}`}
>
{h}
</th>
)
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{nodes.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-sm text-gray-400">
No permission modules found.
</td>
</tr>
) : (
nodes.map((node) => (
<tr key={node.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{node.node_name}</div>
<div className="text-xs text-gray-500">{node.module}</div>
</td>
{["can_view", "can_create", "can_edit", "can_delete", "can_alter", "can_export", "can_import"].map(
(action) => (
<td
key={action}
className="px-6 py-4 whitespace-nowrap text-center"
>
<input
type="checkbox"
checked={
(permissions[node.id]?.[action as keyof RolePermission] as boolean) ||
false
}
onChange={() =>
togglePermission(node.id, action as keyof RolePermission)
}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded cursor-pointer"
/>
</td>
)
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => navigate("/users/roles")}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={loading}>
Save Changes
</Button>
</div>
</form>
)}
</PageWrapper>
</ProtectedRoute>
);
}
+502 -214
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import { Check, Save } from "lucide-react";
@@ -8,9 +8,12 @@ import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useRole } from "../hooks/useRole";
import type { RolePermission } from "../types/role.types";
import { roleService } from "../services/role.service";
import type { RolePermission, PermissionNode } from "../types/role.types";
import { getSupportedActions, PERMISSION_LABELS } from "../types/role.types";
import { tenantService } from "../../tenants/services/tenant.service";
import type { Tenant } from "../../tenants/types/tenant.types";
import { notify } from "../../../services/toast";
const roleSchema = Yup.object().shape({
role_name: Yup.string().required("Role name is required"),
@@ -19,22 +22,22 @@ const roleSchema = Yup.object().shape({
});
const STEPS = [
{ id: 'details', label: 'Role Details', step: 1 },
{ id: 'permissions', label: 'Permissions Matrix', step: 2 },
{ id: "details", label: "Role Details", step: 1 },
{ id: "permissions", label: "Permissions Matrix", step: 2 },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-400 focus:ring-red-400' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white placeholder-gray-400`;
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const errorClass = 'text-xs text-red-500 mt-1';
`w-full border ${error ? "border-red-400 focus:ring-red-400" : "border-primary/10 focus:ring-primary-light"} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface placeholder-subtle-foreground`;
const labelClass = "block text-sm font-medium text-foreground mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5 to-surface flex items-center gap-3">
<div className="w-1 h-5 bg-primary rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">{title}</h3>
{subtitle && <p className="text-xs text-primary-light mt-0.5">{subtitle}</p>}
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
);
@@ -42,242 +45,527 @@ function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
export default function NewRoleForm() {
const navigate = useNavigate();
const { nodes, fetchNodes, createRole } = useRole();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { nodes, fetchNodes, createRole, updateRole, nodesLoading, nodesError } = useRole();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [permissions, setPermissions] = useState<Record<string, RolePermission>>({});
const [activeStep, setActiveStep] = useState('details');
const [activeStep, setActiveStep] = useState("details");
const [fetching, setFetching] = useState(isEdit);
const [fetchError, setFetchError] = useState<string | null>(null);
useEffect(() => {
fetchNodes();
tenantService.getAll().then(setTenants).catch(console.error);
}, [fetchNodes]);
useEffect(() => {
if (nodes.length > 0) {
const initialPerms: Record<string, RolePermission> = {};
nodes.forEach(node => {
initialPerms[node.id] = {
node_id: node.id,
can_view: false, can_create: false, can_edit: false,
can_delete: false, can_alter: false, can_export: false, can_import: false
};
});
setPermissions(initialPerms);
}
}, [nodes]);
const togglePermission = (nodeId: string, action: keyof RolePermission) => {
setPermissions(prev => ({
...prev,
[nodeId]: { ...prev[nodeId], [action]: !prev[nodeId][action] }
}));
};
// ─── Stable ref for formik.setValues ────────────────────────────────────────
// Declared as a plain MutableRefObject<any> to avoid referencing `formik`
// before it is defined. Kept in sync on every render below.
const setValuesRef = useRef<((values: any) => void) | null>(null);
const formik = useFormik({
initialValues: { role_name: "", description: "", tenant_id: "" },
validationSchema: roleSchema,
onSubmit: async (values, { setSubmitting }) => {
const permList = Object.values(permissions).filter(
(p) =>
p.can_view || p.can_create || p.can_edit ||
p.can_delete || p.can_alter || p.can_export || p.can_import
);
// Normalize tenant_id to null for both create and update
const payload = { ...values, tenant_id: values.tenant_id || null };
try {
await createRole({
...values,
permissions: Object.values(permissions).filter(p =>
p.can_view || p.can_create || p.can_edit || p.can_delete || p.can_alter || p.can_export || p.can_import
),
tenant_id: values.tenant_id || null
});
if (isEdit && id) {
await updateRole(id, { ...payload, permissions: permList });
} else {
await createRole({ ...payload, permissions: permList });
}
navigate("/users/roles");
} catch {
// Error handled by hook
// errors surfaced by hook via toast
} finally {
setSubmitting(false);
}
},
});
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
// Keep ref current on every render — no stale closure in loadEditData
setValuesRef.current = formik.setValues;
// ─── loadEditData ────────────────────────────────────────────────────────────
// Depends only on [id, navigate]. Uses setValuesRef so formik is never a dep.
const loadEditData = useCallback(
async (allNodes: PermissionNode[]) => {
if (!id) return;
setFetchError(null);
setFetching(true);
try {
const role = await roleService.getById(id);
// Guard: null, undefined, or missing id means the role doesn't exist
if (!role || !role.id) {
const msg = "Role not found or has been deleted.";
setFetchError(msg);
notify.error(msg);
setTimeout(() => navigate("/users/roles"), 2000);
return;
}
// Sync Formik values via ref — no stale closure, no extra re-renders
setValuesRef.current!({
role_name: role.role_name || "",
description: role.description || "",
tenant_id: role.tenant_id || "",
});
// Build existing permissions map from junction data
const existingPerms: Record<string, RolePermission> = {};
if (Array.isArray(role.permissions)) {
role.permissions.forEach((p: any) => {
const junction = p.RolePermission ?? p;
const nodeId = String(p.id ?? p.node_id);
existingPerms[nodeId] = {
node_id: nodeId,
can_view: Boolean(junction.can_view),
can_create: Boolean(junction.can_create),
can_edit: Boolean(junction.can_edit),
can_delete: Boolean(junction.can_delete),
can_alter: Boolean(junction.can_alter),
can_export: Boolean(junction.can_export),
can_import: Boolean(junction.can_import),
};
});
}
// Merge: every node gets a row; existing perms pre-fill, rest default false
const merged: Record<string, RolePermission> = {};
allNodes.forEach((node) => {
merged[node.id] = existingPerms[node.id] ?? {
node_id: node.id,
can_view: false,
can_create: false,
can_edit: false,
can_delete: false,
can_alter: false,
can_export: false,
can_import: false,
};
});
setPermissions(merged);
} catch (err: any) {
const status = err?.response?.status;
const msg =
status === 404
? "Role not found. It may have been deleted."
: err?.response?.data?.message || "Failed to load role data";
setFetchError(msg);
notify.error(msg);
// Redirect on 404 — role no longer exists
if (status === 404) {
setTimeout(() => navigate("/users/roles"), 2000);
}
} finally {
setFetching(false);
}
},
[id, navigate]
);
// ─── Stop spinner if fetchNodes fails in edit mode ───────────────────────────
// Runs whenever nodesError changes. If nodes failed, surface the error and
// stop the full-page spinner so the error UI + Retry button become visible.
// Also clears fetchError when nodesError is cleared (i.e. after a successful
// retry of fetchNodes), so the form renders instead of staying on error UI.
useEffect(() => {
if (!isEdit) return;
if (nodesError) {
setFetching(false);
setFetchError(nodesError);
} else {
// nodesError cleared → nodes loaded successfully; clear any prior node error
// Only clear if the current fetchError was the nodes error (not a role error)
setFetchError((prev) => (prev === nodesError ? null : prev));
}
}, [isEdit, nodesError]);
// ─── Initial data load ───────────────────────────────────────────────────────
useEffect(() => {
Promise.all([
fetchNodes(),
tenantService.getAll().then(setTenants).catch(console.error),
]);
}, [fetchNodes]);
// ─── Seed permissions once nodes are available ───────────────────────────────
// In edit mode: loadEditData is called here (not in handleRetry) to avoid
// double-calling. handleRetry only re-fetches nodes; this effect handles the rest.
useEffect(() => {
if (nodes.length === 0) return;
if (isEdit) {
// Only run when fetching is still true (initial load or after a retry that
// cleared fetchError). Prevents re-running on unrelated nodes reference changes.
if (fetching) {
loadEditData(nodes);
}
} else {
const initialPerms: Record<string, RolePermission> = {};
nodes.forEach((node) => {
initialPerms[node.id] = {
node_id: node.id,
can_view: false,
can_create: false,
can_edit: false,
can_delete: false,
can_alter: false,
can_export: false,
can_import: false,
};
});
setPermissions(initialPerms);
}
}, [nodes, isEdit, loadEditData, fetching]);
// ─── Permission toggle ───────────────────────────────────────────────────────
const togglePermission = (nodeId: string, action: keyof RolePermission) => {
setPermissions((prev) => ({
...prev,
[nodeId]: { ...prev[nodeId], [action]: !prev[nodeId][action] },
}));
};
// ─── Step validation gate ────────────────────────────────────────────────────
// Validates Step 1 fields and returns true if navigation to a later step is
// allowed. Touches role_name so the inline error becomes visible on failure.
const validateStep1 = async (): Promise<boolean> => {
const errors = await formik.validateForm();
if (errors.role_name) {
formik.setTouched({ role_name: true });
return false;
}
return true;
};
// Navigate to a specific step, enforcing Step 1 validation when moving forward
const navigateToStep = async (targetId: string) => {
const targetIndex = STEPS.findIndex((s) => s.id === targetId);
const currentIndex = STEPS.findIndex((s) => s.id === activeStep);
// Moving forward from Step 1 requires validation
if (currentIndex === 0 && targetIndex > 0) {
const valid = await validateStep1();
if (!valid) return;
}
setActiveStep(targetId);
};
// ─── Retry ───────────────────────────────────────────────────────────────────
// If nodes failed: re-fetch nodes. The useEffect above will call loadEditData
// once nodes arrive (fetching will be true at that point).
// If nodes are fine but role fetch failed: call loadEditData directly.
const handleRetry = useCallback(async () => {
setFetchError(null);
if (!isEdit) return;
if (nodesError || nodes.length === 0) {
// Nodes failed — set fetching so the spinner shows, then re-fetch nodes.
// The nodes useEffect will trigger loadEditData once nodes load.
setFetching(true);
await fetchNodes();
// If fetchNodes failed again, the nodesError useEffect will stop the spinner.
// If it succeeded, nodes useEffect fires loadEditData (fetching is still true).
} else {
// Nodes are fine — only the role fetch failed; retry it directly.
await loadEditData(nodes);
}
}, [isEdit, nodesError, nodes, fetchNodes, loadEditData]);
const activeIndex = STEPS.findIndex((s) => s.id === activeStep);
return (
<ProtectedRoute node="settings.roles">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Users & Roles" }, { label: "Roles", href: "/users/roles" }, { label: "New Role" }]}
items={[
{ label: "Home" },
{ label: "Users & Roles" },
{ label: "Roles", href: "/users/roles" },
{ label: isEdit ? "Edit Role" : "New Role" },
]}
backTo="/users/roles"
actions={
<>
<Button type="button" variant="outline" onClick={() => navigate("/users/roles")} disabled={formik.isSubmitting}>Cancel</Button>
<Button type="submit" form="role-form" variant="primary" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Create Role</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate("/users/roles")}
disabled={formik.isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
form="role-form"
variant="primary"
icon={<Save className="w-4 h-4" />}
loading={formik.isSubmitting}
>
{isEdit ? "Save Changes" : "Create Role"}
</Button>
</>
}
/>
<form id="role-form" onSubmit={formik.handleSubmit} className="flex gap-5">
{/* Timeline Sidebar */}
<aside className="w-52 shrink-0 self-start bg-white border border-primary/10 rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-primary/5 bg-primary/5/40">
<p className="text-[11px] font-semibold text-primary uppercase tracking-widest">Configuration</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-gray-400">Step {activeIndex + 1} of {STEPS.length}</p>
<span className="text-[10px] font-medium text-primary bg-primary-light px-2 py-0.5 rounded-full">
{Math.round(((activeIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="mt-2 h-1 bg-primary-light rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${((activeIndex + 1) / STEPS.length) * 100}%` }}
/>
</div>
</div>
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? 'bg-primary ring-2 ring-primary/20' :
isDone ? 'bg-emerald-500' :
'bg-white border-2 border-gray-200 hover:border-primary/30'
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
: <span className={`text-[9px] font-bold ${isActive ? 'text-white' : 'text-gray-400'}`}>{s.step}</span>
}
</button>
{!isLast && (
<div className={`w-px flex-1 my-0.5 ${isDone ? 'bg-emerald-300' : 'bg-gray-200'}`} style={{ minHeight: 14 }} />
)}
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? 'text-primary-dark' : isDone ? 'text-gray-600' : 'text-gray-400 hover:text-gray-600'
}`}>{s.label}</span>
</button>
</div>
);
})}
</nav>
</aside>
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
{/* Step 1 — Role Details */}
{activeStep === 'details' && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Role Details" subtitle="Define the role name, tenant and description" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Role Name <span className="text-red-400">*</span></label>
<input
type="text"
name="role_name"
value={formik.values.role_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Product Manager"
className={inputClass(formik.touched.role_name && Boolean(formik.errors.role_name))}
/>
{formik.touched.role_name && formik.errors.role_name && <p className={errorClass}>{formik.errors.role_name}</p>}
</div>
<div>
<label className={labelClass}>Assign to Tenant</label>
<select
name="tenant_id"
value={formik.values.tenant_id}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="">No Tenant (Platform Role)</option>
{tenants.map(t => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
</select>
<p className="mt-1 text-xs text-gray-400">Leave empty for a global platform role.</p>
</div>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
rows={3}
placeholder="Brief description of the role responsibilities"
className={inputClass() + ' resize-y'}
/>
</div>
</div>
</div>
)}
{/* Step 2 — Permissions Matrix */}
{activeStep === 'permissions' && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Permissions Matrix" subtitle="Configure granular access levels for each module" />
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100">
<thead className="bg-primary/5/40">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Module</th>
{['View', 'Create', 'Edit', 'Delete', 'Alter', 'Export', 'Import'].map(h => (
<th key={h} className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{nodes.map(node => (
<tr key={node.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{node.node_name}</div>
<div className="text-xs text-gray-500">{node.module}</div>
</td>
{['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_export', 'can_import'].map((action) => (
<td key={action} className="px-6 py-4 whitespace-nowrap text-center">
<input
type="checkbox"
checked={permissions[node.id]?.[action as keyof RolePermission] as boolean || false}
onChange={() => togglePermission(node.id, action as keyof RolePermission)}
className="h-4 w-4 text-primary focus:ring-primary-light border-gray-300 rounded cursor-pointer"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
{/* Full-page loading — edit mode only */}
{fetching && (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">Loading role data...</p>
</div>
</div>
)}
</form>
{/* Full-page error — edit mode only */}
{!fetching && fetchError && (
<div className="flex flex-col items-center justify-center h-64 gap-3">
<p className="text-sm text-danger">{fetchError}</p>
<button
type="button"
onClick={handleRetry}
className="px-4 py-2 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
Retry
</button>
</div>
)}
{!fetching && !fetchError && (
<form id="role-form" onSubmit={formik.handleSubmit} className="flex gap-5">
{/* ── Timeline Sidebar ── */}
<aside className="w-52 shrink-0 self-start bg-surface border border-primary/10 rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-primary/5 bg-primary/5">
<p className="text-[11px] font-semibold text-primary uppercase tracking-widest">Configuration</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-subtle-foreground">Step {activeIndex + 1} of {STEPS.length}</p>
<span className="text-[10px] font-medium text-primary bg-primary-light px-2 py-0.5 rounded-full">
{Math.round(((activeIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="mt-2 h-1 bg-primary-light rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${((activeIndex + 1) / STEPS.length) * 100}%` }}
/>
</div>
</div>
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => navigateToStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-success" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
: <span className={`text-[9px] font-bold ${isActive ? "text-white" : "text-subtle-foreground"}`}>{s.step}</span>
}
</button>
{!isLast && (
<div
className={`w-px flex-1 my-0.5 ${isDone ? "bg-success/50" : "bg-border"}`}
style={{ minHeight: 14 }}
/>
)}
</div>
<button
type="button"
onClick={() => navigateToStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" :
isDone ? "text-muted-foreground" :
"text-subtle-foreground hover:text-muted-foreground"
}`}>
{s.label}
</span>
</button>
</div>
);
})}
</nav>
</aside>
{/* ── Main Content ── */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
{/* Step 1 — Role Details */}
{activeStep === "details" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Role Details" subtitle="Define the role name, tenant and description" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Role Name <span className="text-red-400">*</span></label>
<input
type="text"
name="role_name"
value={formik.values.role_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Product Manager"
className={inputClass(formik.touched.role_name && Boolean(formik.errors.role_name))}
/>
{formik.touched.role_name && formik.errors.role_name && (
<p className={errorClass}>{formik.errors.role_name}</p>
)}
</div>
<div>
<label className={labelClass}>Assign to Tenant</label>
<select
name="tenant_id"
value={formik.values.tenant_id}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="">No Tenant (Platform Role)</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
</select>
<p className="mt-1 text-xs text-subtle-foreground">Leave empty for a global platform role.</p>
</div>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
rows={3}
placeholder="Brief description of the role responsibilities"
className={inputClass() + " resize-y"}
/>
</div>
</div>
</div>
)}
{/* Step 2 — Permissions Matrix */}
{activeStep === "permissions" && (
<div
className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden flex flex-col"
style={{ height: "520px" }}
>
<div className="shrink-0">
<CardHeader title="Permissions Matrix" subtitle="Configure granular access levels for each module" />
</div>
{nodesLoading && (
<div className="flex items-center justify-center flex-1 gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<span className="text-sm text-muted-foreground">Loading permission modules...</span>
</div>
)}
{!nodesLoading && nodesError && (
<div className="flex flex-col items-center justify-center flex-1 gap-3">
<p className="text-sm text-red-500">{nodesError}</p>
<button
type="button"
onClick={fetchNodes}
className="px-4 py-2 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
Retry
</button>
</div>
)}
{!nodesLoading && !nodesError && (
<div
className="flex-1 overflow-y-auto divide-y divide-border"
style={{ scrollbarWidth: "none" }}
>
{nodes.map((node) => {
const perm = permissions[node.id];
const supportedActions = getSupportedActions(node);
return (
<div
key={node.id}
className="flex items-center gap-4 px-6 py-4 hover:bg-primary-light transition-colors"
>
<div className="w-40 flex items-center gap-2.5 shrink-0">
<span className="text-sm font-semibold text-foreground">{node.node_name}</span>
</div>
<div className="flex flex-wrap gap-2">
{supportedActions.map((key) => {
const checked = (perm?.[key] as boolean) || false;
return (
<button
key={key}
type="button"
onClick={() => togglePermission(node.id, key)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-xs font-medium transition-all cursor-pointer ${
checked
? "bg-primary-light border-primary text-primary-dark"
: "bg-surface border-border text-muted-foreground hover:border-primary hover:text-primary-dark"
}`}
>
<span className={`w-3.5 h-3.5 rounded-sm border flex items-center justify-center shrink-0 ${checked ? "bg-primary border-primary" : "border-border"}`}>
{checked && <Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />}
</span>
{PERMISSION_LABELS[key]}
</button>
);
})}
{supportedActions.length === 0 && (
<span className="text-xs text-muted-foreground italic">No actions configured</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
<button
type="button"
onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-surface-muted transition-colors"
>
Back
</button>
)}
{activeIndex < STEPS.length - 1 && (
<button
type="button"
onClick={() => navigateToStep(STEPS[activeIndex + 1].id)}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors"
>
Next
</button>
)}
</div>
</div>
</form>
)}
</PageWrapper>
</ProtectedRoute>
);
}
+1 -2
View File
@@ -1,14 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import RoleList from '../pages/RoleList';
import NewRoleForm from '../pages/NewRoleForm';
import EditRoleForm from '../pages/EditRoleForm';
export const RoleRoutes = () => {
return (
<Routes>
<Route path="/" element={<RoleList />} />
<Route path="/new" element={<NewRoleForm />} />
<Route path="/:id/edit" element={<EditRoleForm />} />
<Route path="/:id/edit" element={<NewRoleForm />} />
</Routes>
);
};
+22 -30
View File
@@ -1,45 +1,37 @@
import api from '../../../api/axiosInstance';
import type { CreateRoleDTO, UpdateRoleDTO, PermissionNode } from '../types/role.types';
import type { CreateRoleDTO, UpdateRoleDTO, PermissionNode, Role } from '../types/role.types';
// axiosInstance already unwraps response.data (returns the axios response.data).
// Backend wraps all responses as { success: true, data: ... }
// So api.get(...) returns { success, data } → we need .data to get the actual payload.
export const roleService = {
getAll: async () => {
const response = await api.get('/api/v1/roles');
return (response as any).data;
getAll: async (): Promise<Role[]> => {
const res = await api.get<{ success: boolean; data: Role[] }>('/api/v1/roles');
return (res as any).data;
},
getById: async (id: string) => {
const response = await api.get(`/api/v1/roles/${id}`);
return (response as any).data;
getById: async (id: string): Promise<Role> => {
const res = await api.get<{ success: boolean; data: Role }>(`/api/v1/roles/${id}`);
return (res as any).data;
},
create: async (data: CreateRoleDTO) => {
const response = await api.post('/api/v1/roles', data);
return (response as any).data;
create: async (data: CreateRoleDTO): Promise<Role> => {
const res = await api.post<{ success: boolean; data: Role }>('/api/v1/roles', data);
return (res as any).data;
},
update: async (id: string, data: UpdateRoleDTO) => {
const response = await api.put(`/api/v1/roles/${id}`, data);
return (response as any).data;
update: async (id: string, data: UpdateRoleDTO): Promise<Role> => {
const res = await api.put<{ success: boolean; data: Role }>(`/api/v1/roles/${id}`, data);
return (res as any).data;
},
delete: async (id: string) => {
const response = await api.delete(`/api/v1/roles/${id}`);
return (response as any).data;
delete: async (id: string): Promise<void> => {
await api.delete(`/api/v1/roles/${id}`);
},
getPermissionNodes: async (): Promise<PermissionNode[]> => {
// Attempting to hit an endpoint that returns all available permission nodes
try {
const response = await api.get('/api/v1/roles/permissions');
return (response as any).data;
} catch (e) {
// Mocking nodes if endpoint doesn't exist yet
return [
{ id: "1", node_code: "products", node_name: "Products Module", module: "PIM" },
{ id: "2", node_code: "inventory", node_name: "Inventory Module", module: "PIM" },
{ id: "3", node_code: "users", node_name: "User Management", module: "Settings" },
{ id: "4", node_code: "roles", node_name: "Roles & Permissions", module: "Settings" }
];
}
}
const res = await api.get<{ success: boolean; data: PermissionNode[] }>('/api/v1/roles/permissions');
return (res as any).data;
},
};
+66 -2
View File
@@ -1,9 +1,72 @@
// All permission action keys that exist in the database
export type PermissionKey =
| 'can_view'
| 'can_create'
| 'can_edit'
| 'can_delete'
| 'can_alter'
| 'can_export'
| 'can_import';
// Maps each DB permission key to its display label in the UI
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
can_view: 'View',
can_create: 'Create',
can_edit: 'Edit',
can_delete: 'Delete',
can_alter: 'Publish',
can_export: 'Export',
can_import: 'Upload',
};
// The ordered list of all possible actions — controls display order in the matrix
export const PERMISSION_ORDER: PermissionKey[] = [
'can_view',
'can_create',
'can_import',
'can_edit',
'can_delete',
'can_alter',
'can_export',
];
/**
* PermissionNode — returned by GET /api/v1/roles/permissions
*
* The boolean capability fields (can_view, can_create, …) on the node itself
* define which actions THIS module supports. They are NOT the user's granted
* permissions — they are the module's capability flags stored in permission_nodes.
*
* Example:
* PIM_SYSTEM: can_view=true, can_create=false → only "View" pill shown
* USERS_MANAGEMENT: can_view=true, can_create=true, can_edit=true, can_delete=true
* → "View", "Create", "Edit", "Delete" pills shown
*/
export interface PermissionNode {
id: string;
node_code: string;
node_name: string;
node_type?: string;
parent_id?: string | null;
node_level?: number;
display_order?: number;
description?: string;
module?: string;
// Capability flags — true means this module supports that action
can_view: boolean;
can_create: boolean;
can_edit: boolean;
can_delete: boolean;
can_alter: boolean;
can_export: boolean;
can_import: boolean;
}
/**
* Returns the ordered list of permission keys that a given node supports,
* derived entirely from the node's own capability flags.
*/
export function getSupportedActions(node: PermissionNode): PermissionKey[] {
return PERMISSION_ORDER.filter((key) => node[key] === true);
}
export interface RolePermission {
@@ -26,7 +89,7 @@ export interface Role {
role_type: string;
is_system_role: boolean;
status: boolean;
permissions?: RolePermission[];
permissions?: any[];
created_at: string;
updated_at: string;
}
@@ -43,5 +106,6 @@ export interface UpdateRoleDTO {
role_name?: string;
description?: string;
status?: boolean;
tenant_id?: string | null;
permissions?: RolePermission[];
}
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { settingsService } from '../services/settings.service';
import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useSetting = () => {
const [items, setItems] = useState<Setting[]>([]);
@@ -12,8 +12,8 @@ export const useSetting = () => {
try {
const data = await settingsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useSetting = () => {
try {
const created = await settingsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Setting created successfully!');
notify.success('Setting created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useSetting = () => {
try {
const updated = await settingsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Setting updated successfully!');
notify.success('Setting updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useSetting = () => {
try {
await settingsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Setting deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Setting deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -1,7 +1,7 @@
// src/features/settings/pages/ThemeSettings.tsx
import { useState, useEffect } from "react";
import { Check, RotateCcw, Save } from "lucide-react";
import { toast } from "react-toastify";
import { notify } from "../../../services/toast";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
@@ -275,7 +275,7 @@ export default function ThemeSettings() {
const handleSave = () => {
serviceSave(previewTheme);
setSavedTheme(previewTheme);
toast.success("Theme updated successfully.");
notify.success("Theme updated successfully.");
};
const isDirty = previewTheme.id !== savedTheme.id;
@@ -360,3 +360,4 @@ export default function ThemeSettings() {
</PageWrapper>
);
}
+13 -13
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { tenantService } from '../services/tenant.service';
import type { Tenant, CreateTenantDTO, UpdateTenantDTO } from '../types/tenant.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export function useTenant() {
const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -14,9 +14,9 @@ export function useTenant() {
setError(null);
const data = await tenantService.getAll();
setTenants(data);
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to fetch tenants');
toast.error('Failed to load tenants');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tenants');
notify.error(err);
} finally {
setLoading(false);
}
@@ -31,10 +31,10 @@ export function useTenant() {
setLoading(true);
const newTenant = await tenantService.create(data);
setTenants(prev => [...prev, newTenant]);
toast.success('Tenant created successfully');
notify.success('Tenant created successfully');
return newTenant;
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to create tenant');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -46,10 +46,10 @@ export function useTenant() {
setLoading(true);
const updated = await tenantService.update(id, data);
setTenants(prev => prev.map(t => t.id === id ? updated : t));
toast.success('Tenant updated successfully');
notify.success('Tenant updated successfully');
return updated;
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to update tenant');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -61,9 +61,9 @@ export function useTenant() {
setLoading(true);
await tenantService.delete(id);
setTenants(prev => prev.filter(t => t.id !== id));
toast.success('Tenant deleted successfully');
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to delete tenant');
notify.success('Tenant deleted successfully');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { unitService } from '../services/unit.service';
import type { Unit, UnitCreateRequest, UnitUpdateRequest } from '../types/unit.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useUnit = () => {
const [units, setUnits] = useState<Unit[]>([]);
@@ -12,8 +12,8 @@ export const useUnit = () => {
try {
const data = await unitService.getAll();
setUnits(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch units');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useUnit = () => {
try {
const created = await unitService.create(req);
setUnits((prev) => [...prev, created]);
toast.success('Unit created successfully!');
notify.success('Unit created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create unit');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useUnit = () => {
try {
const updated = await unitService.update(id, req);
setUnits((prev) => prev.map((u) => (u.id === id ? updated : u)));
toast.success('Unit updated successfully!');
notify.success('Unit updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update unit');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useUnit = () => {
try {
await unitService.delete(id);
setUnits((prev) => prev.filter((u) => u.id !== id));
toast.success('Unit deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete unit');
notify.success('Unit deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { usersService } from '../services/users.service';
import type { User, UserCreateRequest, UserUpdateRequest } from '../types/users.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useUser = () => {
const [items, setItems] = useState<User[]>([]);
@@ -12,8 +12,8 @@ export const useUser = () => {
try {
const data = await usersService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useUser = () => {
try {
const created = await usersService.create(req);
setItems((prev) => [...prev, created]);
toast.success('User created successfully!');
notify.success('User created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useUser = () => {
try {
const updated = await usersService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('User updated successfully!');
notify.success('User updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useUser = () => {
try {
await usersService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('User deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('User deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { variantService } from '../services/variant.service';
import type { Variant, VariantCreateRequest, VariantUpdateRequest } from '../types/variant.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useVariant = () => {
const [variants, setVariants] = useState<Variant[]>([]);
@@ -12,8 +12,8 @@ export const useVariant = () => {
try {
const data = await variantService.getAll();
setVariants(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch variants');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useVariant = () => {
try {
const created = await variantService.create(req);
setVariants((prev) => [...prev, created]);
toast.success('Variant created successfully!');
notify.success('Variant created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create variant');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useVariant = () => {
try {
const updated = await variantService.update(id, req);
setVariants((prev) => prev.map((v) => (v.id === id ? updated : v)));
toast.success('Variant updated successfully!');
notify.success('Variant updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update variant');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useVariant = () => {
try {
await variantService.delete(id);
setVariants((prev) => prev.filter((v) => v.id !== id));
toast.success('Variant deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete variant');
notify.success('Variant deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+12 -12
View File
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { workflowService } from '../services/workflow.service';
import type { Workflow, WorkflowCreateRequest, WorkflowUpdateRequest } from '../types/workflow.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useWorkflow = () => {
const [items, setItems] = useState<Workflow[]>([]);
@@ -12,8 +12,8 @@ export const useWorkflow = () => {
try {
const data = await workflowService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} catch (err) {
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useWorkflow = () => {
try {
const created = await workflowService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Workflow created successfully!');
notify.success('Workflow created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useWorkflow = () => {
try {
const updated = await workflowService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Workflow updated successfully!');
notify.success('Workflow updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,9 +54,9 @@ export const useWorkflow = () => {
try {
await workflowService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Workflow deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
notify.success('Workflow deleted successfully!');
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
+28 -5
View File
@@ -9,11 +9,11 @@
============================================ */
@theme {
/* --- Brand / Primary (Green Theme for Testing) --- */
--color-primary: #16A34A; /* Green 600 */
--color-primary-hover: #15803D; /* Green 700 */
--color-primary-light: #BBF7D0; /* Green 200 */
--color-primary-dark: #166534; /* Green 800 */
/* --- Brand / Primary (Royal Purple — matches DEFAULT_THEME in theme.service.ts) --- */
--color-primary: #7C3AED; /* violet-600 */
--color-primary-hover: #6D28D9; /* violet-700 */
--color-primary-light: #DDD6FE; /* violet-200 */
--color-primary-dark: #4C1D95; /* violet-900 */
/* --- Semantic --- */
--color-success: #10B981; /* emerald-500 */
@@ -59,6 +59,7 @@
--header-height: 64px;
/* Table header — overridden at runtime by theme engine via setProperty() */
/* Default values match DEFAULT_THEME.palette.tableHeader* in theme.service.ts */
--color-table-header-bg: #EDE9FE;
--color-table-header-text: #4C1D95;
--color-table-header-border: #C4B5FD;
@@ -89,4 +90,26 @@ body {
*::-webkit-scrollbar {
display: none;
}
/* ============================================
INDETERMINATE BAR — Gmail-style loader
============================================ */
@keyframes bar-primary {
0% { left: -35%; right: 100%; }
30% { left: 0%; right: 60%; }
60% { left: 40%; right: 0%; }
100% { left: 100%; right: -35%; }
}
@keyframes bar-secondary {
0% { left: -200%; right: 100%; }
40% { left: 107%; right: -8%; }
100% { left: 107%; right: -8%; }
}
.bar-primary {
animation: bar-primary 2s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.bar-secondary {
animation: bar-secondary 2s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;
animation-delay: 1.15s;
}
+4
View File
@@ -30,6 +30,7 @@ import { ReportRoutes } from '../features/reports/routes/reports.routes';
import { SettingRoutes } from '../features/settings/routes/settings.routes';
import { TenantRoutes } from '../features/tenants/routes/tenant.routes';
import { RoleRoutes } from '../features/roles/routes/role.routes';
import { NotificationRoutes } from '../features/notifications/routes/notifications.routes';
const AppRoutes = () => {
return (
@@ -77,6 +78,9 @@ const AppRoutes = () => {
<Route path="/users/roles/*" element={<RoleRoutes />} />
<Route path="/users/*" element={<UserRoutes />} />
{/* Notifications */}
<Route path="/notifications/*" element={<NotificationRoutes />} />
{/* Admin */}
<Route path="/reports/*" element={<ReportRoutes />} />
<Route path="/settings/*" element={<SettingRoutes />} />
+3
View File
@@ -98,6 +98,9 @@ export const protectedRoutes: RouteConfig[] = [
// Settings
{ path: '/settings', title: 'Settings', description: 'System configurations and preferences' },
{ path: '/settings/new', title: 'New Setting', description: 'Add a new system configuration', sidebarRoot: '/settings' },
// Notifications
{ path: '/notifications', title: 'Notifications', description: 'Activity feed across your catalog, workflows, and team' },
];
export const publicRoutes: RouteConfig[] = [
+7 -1
View File
@@ -18,7 +18,8 @@ import {
BarChart,
Settings,
List,
Layers2
Layers2,
Bell
} from 'lucide-react';
import React from 'react';
@@ -132,4 +133,9 @@ export const sidebarConfig: SidebarItem[] = [
href: '/settings',
icon: Settings
},
{
label: 'Notifications',
href: '/notifications',
icon: Bell,
},
];
+74
View File
@@ -0,0 +1,74 @@
import { io, Socket } from 'socket.io-client';
const API_BASE_URL = 'http://localhost:5000';
class SocketServiceClass {
private socket: Socket | null = null;
private isConnected = false;
connect(userId: string, tenantId?: string) {
if (this.socket) {
if (this.socket.connected) return;
this.socket.connect();
return;
}
const query: Record<string, string> = { userId };
if (tenantId) query.tenantId = tenantId;
this.socket = io(API_BASE_URL, {
query,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 20000,
});
this.socket.on('connect', () => {
this.isConnected = true;
console.log('Socket connected:', this.socket?.id);
});
this.socket.on('disconnect', (reason) => {
this.isConnected = false;
console.log('Socket disconnected:', reason);
if (reason === 'io server disconnect') {
// the disconnection was initiated by the server, you need to reconnect manually
this.socket?.connect();
}
});
this.socket.on('connect_error', (error) => {
console.error('Socket connection error:', error);
});
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
this.isConnected = false;
}
}
joinUser(userId: string) {
if (this.socket && this.isConnected) {
this.socket.emit('join_user', userId);
}
}
on(event: string, callback: (...args: any[]) => void) {
if (this.socket) {
this.socket.on(event, callback);
}
}
off(event: string, callback?: (...args: any[]) => void) {
if (this.socket) {
this.socket.off(event, callback);
}
}
}
export const socketService = new SocketServiceClass();
+5 -115
View File
@@ -26,120 +26,10 @@ import {
saveTheme as storageSave,
clearTheme as storageClear,
} from "../storage/theme.storage";
import {
applyTheme as applyThemeToDom,
} from "../utils/applyTheme";
import { applyTheme as applyThemeToDom } from "../utils/applyTheme";
import { DEFAULT_THEME, AVAILABLE_THEMES } from "../constants/themes";
// ─────────────────────────────────────────────────────────────────────────────
// DEFAULT THEME
// The canonical fallback. Matches the @theme block in src/index.css exactly.
// This is the single source of truth for the default palette values.
// ─────────────────────────────────────────────────────────────────────────────
export const DEFAULT_THEME: Theme = {
id: "royal-purple",
name: "Royal Purple",
mode: "light",
isDefault: true,
palette: {
primary: "#7C3AED",
primaryHover: "#6D28D9",
primaryLight: "#DDD6FE",
primaryDark: "#4C1D95",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#EDE9FE",
tableHeaderText: "#4C1D95",
tableHeaderBorder: "#C4B5FD",
},
};
// ─────────────────────────────────────────────────────────────────────────────
// AVAILABLE THEMES REGISTRY
// All themes the application ships with.
// Future themes (tenant, dark mode, white-label) are added here.
// ThemeContext reads this list to populate availableThemes.
// ─────────────────────────────────────────────────────────────────────────────
const AVAILABLE_THEMES: Theme[] = [
DEFAULT_THEME,
{
id: "ocean-blue",
name: "Ocean Blue",
mode: "light",
isDefault: false,
palette: {
primary: "#2563EB",
primaryHover: "#1D4ED8",
primaryLight: "#BFDBFE",
primaryDark: "#1E3A8A",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#DBEAFE",
tableHeaderText: "#1E3A8A",
tableHeaderBorder: "#93C5FD",
},
},
{
id: "sunset-orange",
name: "Sunset Orange",
mode: "light",
isDefault: false,
palette: {
primary: "#EA580C",
primaryHover: "#C2410C",
primaryLight: "#FED7AA",
primaryDark: "#7C2D12",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#FFEDD5",
tableHeaderText: "#7C2D12",
tableHeaderBorder: "#FDBA74",
},
},
{
id: "dark-mode",
name: "Dark",
mode: "dark",
isDefault: false,
palette: {
primary: "#1F2937",
primaryHover: "#374151",
primaryLight: "#4B5563",
primaryDark: "#111827",
background: "#111827",
surface: "#1F2937",
foreground: "#F9FAFB",
border: "#374151",
tableHeaderBg: "#111827",
tableHeaderText: "#D1D5DB",
tableHeaderBorder: "#4B5563",
},
},
{
id: "default-green",
name: "Forest Green",
mode: "light",
isDefault: false,
palette: {
primary: "#16A34A",
primaryHover: "#15803D",
primaryLight: "#BBF7D0",
primaryDark: "#166534",
background: "#f9fafb",
surface: "#ffffff",
foreground: "#111827",
border: "#e5e7eb",
tableHeaderBg: "#DCFCE7",
tableHeaderText: "#166534",
tableHeaderBorder: "#86EFAC",
},
},
];
export { DEFAULT_THEME, AVAILABLE_THEMES };
// ─────────────────────────────────────────────────────────────────────────────
// PUBLIC SERVICE METHODS
@@ -186,8 +76,8 @@ export function saveTheme(theme: Theme): void {
*/
export function resetTheme(): Theme {
storageClear();
// Apply the new default explicitly — removeThemeOverrides() would fall back
// to the static index.css values (green), which is no longer the default.
// Apply explicitly so the DOM reflects DEFAULT_THEME (Royal Purple),
// matching the static @theme values in index.css.
applyThemeToDom(DEFAULT_THEME.palette);
return DEFAULT_THEME;
}
@@ -0,0 +1,23 @@
// src/services/toast/components/CloseButton.tsx
//
// Dismiss button rendered in the top-right corner of every non-loading toast.
// Hover and focus styles are defined in Toast.css (.pim-toast-close).
interface CloseButtonProps {
onClose: () => void;
}
export function CloseButton({ onClose }: CloseButtonProps) {
return (
<button
type="button"
className="pim-toast-close"
onClick={onClose}
aria-label="Dismiss notification"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
);
}
@@ -0,0 +1,38 @@
// src/services/toast/components/ProgressBar.tsx
//
// Animated progress bar rendered at the bottom of every auto-closing toast.
// Duration is passed from toastProps.autoClose so it always matches the
// actual dismiss timer set in toast.service.ts / toast.config.ts.
// isPaused is passed from toastProps so the animation stays in sync with
// the react-toastify JS timer — both pause and resume together.
//
// Layout and structural styles live in Toast.css (.pim-toast-progress-track,
// .pim-toast-progress). Only dynamic values (color, duration, playState)
// are applied via inline style.
interface ProgressBarProps {
/** Auto-close duration in ms — drives the CSS animation length. */
duration: number;
/** CSS variable string from VARIANT_TOKENS.accent */
color: string;
/** Mirrors react-toastify's isPaused — pauses the animation when the timer is paused. */
isPaused?: boolean;
}
export function ProgressBar({ duration, color, isPaused = false }: ProgressBarProps) {
return (
<div
className="pim-toast-progress-track"
style={{ backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }}
>
<div
className="pim-toast-progress"
style={{
backgroundColor: color,
animationDuration: `${duration}ms`,
animationPlayState: isPaused ? "paused" : "running",
}}
/>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
/* ─── Toast enter / exit animations ──────────────────────────────────────── */
@keyframes toast-in {
0% { opacity: 0; transform: translateX(calc(100% + 1rem)) scale(0.96); }
60% { opacity: 1; transform: translateX(-4px) scale(1.01); }
100% { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes toast-out {
0% { opacity: 1; transform: translateX(0) scale(1); }
100% { opacity: 0; transform: translateX(calc(100% + 1rem)) scale(0.96); }
}
/* ─── Toast shell ─────────────────────────────────────────────────────────── */
.pim-toast {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 40px 14px 14px;
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
background-color: var(--color-surface, #ffffff);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08), 0 2px 6px -1px rgba(0, 0, 0, 0.05);
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
min-width: 300px;
max-width: 400px;
/* border-left color is set via inline style (dynamic token) */
border-left-width: 3.5px;
border-left-style: solid;
}
.pim-toast:hover {
transform: translateY(-2px);
box-shadow:
0 20px 40px -8px rgba(0, 0, 0, 0.14),
0 8px 16px -4px rgba(0, 0, 0, 0.08);
}
/* ─── Icon container ──────────────────────────────────────────────────────── */
.pim-toast-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
/* background-color is set via inline style (dynamic token) */
}
/* ─── Text content ────────────────────────────────────────────────────────── */
.pim-toast-content {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.pim-toast-title {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
color: var(--color-foreground, #111827);
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pim-toast-description {
margin: 3px 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-muted-foreground, #6b7280);
font-weight: 400;
}
/* ─── HTTP status badge ───────────────────────────────────────────────────── */
.pim-toast-status-badge {
display: inline-flex;
align-items: center;
font-size: 0.6875rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.85;
flex-shrink: 0;
/* color and border-color are set via inline style (dynamic token) */
}
/* ─── Action button ───────────────────────────────────────────────────────── */
.pim-toast-action {
display: inline-flex;
align-items: center;
margin-top: 6px;
padding: 2px 0;
font-size: 0.75rem;
font-weight: 600;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
opacity: 0.9;
transition: opacity 0.15s;
/* color is set via inline style (dynamic token) */
}
.pim-toast-action:hover {
opacity: 1;
}
.pim-toast-action:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
border-radius: 2px;
}
/* ─── Progress bar ────────────────────────────────────────────────────────── */
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.pim-toast-progress-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 0 0 12px 12px;
overflow: hidden;
/* background-color is set via inline style (dynamic token) */
}
.pim-toast-progress {
height: 100%;
width: 100%;
border-radius: 0 0 12px 12px;
transform-origin: left;
animation: toast-progress linear forwards;
/* background-color, animationDuration, animationPlayState via inline style */
}
/* ─── Animated checkmark (SVG stroke-dashoffset draw) ────────────────────── */
@keyframes check-draw {
from { stroke-dashoffset: 24; }
to { stroke-dashoffset: 0; }
}
@keyframes check-circle {
from { stroke-dashoffset: 66; opacity: 0; }
to { stroke-dashoffset: 0; opacity: 1; }
}
.pim-check-circle {
stroke-dasharray: 66;
stroke-dashoffset: 66;
animation: check-circle 0.45s cubic-bezier(0.4, 0, 0.2, 1) 0.05s forwards;
}
.pim-check-mark {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: check-draw 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.35s forwards;
}
/* ─── X mark (error icon) ─────────────────────────────────────────────────── */
@keyframes x-draw {
from { stroke-dashoffset: 20; opacity: 0; }
to { stroke-dashoffset: 0; opacity: 1; }
}
.pim-x-line {
stroke-dasharray: 20;
stroke-dashoffset: 20;
animation: x-draw 0.25s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards;
}
/* ─── Warning / Info icon pop ─────────────────────────────────────────────── */
@keyframes icon-pop {
0% { transform: scale(0.5); opacity: 0; }
70% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); }
}
.pim-icon-pop {
animation: icon-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s both;
}
/* ─── Loading spinner ─────────────────────────────────────────────────────── */
@keyframes toast-spin {
to { transform: rotate(360deg); }
}
.pim-spinner {
animation: toast-spin 0.75s linear infinite;
}
/* ─── Close button ────────────────────────────────────────────────────────── */
.pim-toast-close {
position: absolute;
top: 10px;
right: 10px;
width: 22px;
height: 22px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground, #6b7280);
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
padding: 0;
flex-shrink: 0;
}
.pim-toast-close:hover {
opacity: 1;
background: color-mix(in srgb, var(--color-foreground, #111827) 8%, transparent);
}
.pim-toast-close:focus-visible {
outline: 2px solid var(--color-primary, #7C3AED);
outline-offset: 2px;
}
/* ─── Toastify container overrides ───────────────────────────────────────── */
/* Strip ALL default toastify chrome — our component owns 100% of the visual */
.pim-toast-container {
padding: 0 !important;
width: auto !important;
min-width: 340px;
max-width: 420px;
}
.pim-toast-container .Toastify__toast {
padding: 0 !important;
margin: 0 0 0.625rem !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
min-height: unset !important;
font-family: inherit !important;
cursor: default !important;
animation: toast-in 0.42s cubic-bezier(0.34, 1.2, 0.64, 1) both !important;
}
.pim-toast-container .Toastify__toast--default,
.pim-toast-container .Toastify__toast--success,
.pim-toast-container .Toastify__toast--error,
.pim-toast-container .Toastify__toast--warning,
.pim-toast-container .Toastify__toast--info {
background: transparent !important;
}
/* Hide toastify's own progress bar visually — keep animation running so animationend fires */
.pim-toast-container .Toastify__progress-bar--wrp {
opacity: 0 !important;
pointer-events: none !important;
}
/* Hide toastify's own close button — we render our own */
.pim-toast-container .Toastify__close-button {
display: none !important;
}
.pim-toast-container .Toastify__toast-body {
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
}
/* Slide-out on dismiss */
.pim-toast-container .Toastify__toast--close-on-click,
.pim-toast-container .Toastify__slide-exit {
animation: toast-out 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
}
+148
View File
@@ -0,0 +1,148 @@
// src/services/toast/components/Toast.tsx
import "./Toast.css";
import type { ToastContentProps } from "react-toastify";
import { ProgressBar } from "./ProgressBar";
import { CloseButton } from "./CloseButton";
import { SuccessIcon, ErrorIcon, WarningIcon, InfoIcon, LoadingSpinner } from "../toast.icons";
import { VARIANT_TOKENS, resolvePayload } from "../toast.utils";
import type { ToastVariant, ToastPayload } from "../toast.types";
// ─── ARIA role mapping ────────────────────────────────────────────────────────
// error / warning → role="alert" aria-live="assertive" (interrupts the user)
// success / info → role="status" aria-live="polite" (non-disruptive)
// loading → role="status" aria-live="polite" (non-disruptive)
const ARIA_ROLE: Record<ToastVariant, "alert" | "status"> = {
error: "alert",
warning: "alert",
success: "status",
info: "status",
loading: "status",
};
const ARIA_LIVE: Record<ToastVariant, "assertive" | "polite"> = {
error: "assertive",
warning: "assertive",
success: "polite",
info: "polite",
loading: "polite",
};
// ─── HTTP status badge ────────────────────────────────────────────────────────
function StatusBadge({ status, color }: { status: number; color: string }) {
return (
<span
className="pim-toast-status-badge"
style={{ color, borderColor: color }}
aria-label={`HTTP ${status}`}
>
{status}
</span>
);
}
// ─── Action button ────────────────────────────────────────────────────────────
function ActionButton({ label, onClick, color }: { label: string; onClick: () => void; color: string }) {
return (
<button
type="button"
className="pim-toast-action"
style={{ color }}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
{label}
</button>
);
}
// ─── Toast body ───────────────────────────────────────────────────────────────
interface ToastBodyProps {
variant: ToastVariant;
payload: string | ToastPayload;
closeToast?: () => void;
autoClose?: number | false;
isPaused?: boolean;
}
function ToastBody({ variant, payload, closeToast, autoClose, isPaused }: ToastBodyProps) {
const tokens = VARIANT_TOKENS[variant];
const isLoading = variant === "loading";
const { title, description } = resolvePayload(payload, tokens.label);
// httpStatus and action are only present when payload is a full BusinessNotification.
const httpStatus = typeof payload === "object" ? payload.httpStatus : undefined;
const action = typeof payload === "object" ? payload.action : undefined;
// For loading toasts autoClose is always false — never derive a numeric duration from it.
// For all other variants autoClose must be a number; fall back to 3000 only as a safety net.
const duration = (!isLoading && typeof autoClose === "number") ? autoClose : 3000;
return (
<div
className="pim-toast"
role={ARIA_ROLE[variant]}
aria-live={ARIA_LIVE[variant]}
aria-atomic="true"
style={{ borderLeftColor: tokens.accent }}
>
{/* Variant icon */}
<div
className="pim-toast-icon"
style={{ backgroundColor: tokens.accentBg }}
aria-hidden="true"
>
{variant === "success" && <SuccessIcon color={tokens.accentText} />}
{variant === "error" && <ErrorIcon color={tokens.accentText} />}
{variant === "warning" && <WarningIcon color={tokens.accentText} />}
{variant === "info" && <InfoIcon color={tokens.accentText} />}
{variant === "loading" && <LoadingSpinner color={tokens.accentText} />}
</div>
{/* Content */}
<div className="pim-toast-content">
<p className="pim-toast-title">
{httpStatus !== undefined && (
<StatusBadge status={httpStatus} color={tokens.accentText} />
)}
{title}
</p>
{description && (
<p className="pim-toast-description">{description}</p>
)}
{action && (
<ActionButton label={action.label} onClick={action.onClick} color={tokens.accentText} />
)}
</div>
{!isLoading && closeToast && <CloseButton onClose={closeToast} />}
{!isLoading && autoClose !== false && (
<ProgressBar duration={duration} color={tokens.accent} isPaused={isPaused} />
)}
</div>
);
}
type RenderProps = ToastContentProps<string | ToastPayload>;
export function SuccessToast(props: RenderProps) {
return <ToastBody variant="success" payload={props.data ?? ""} closeToast={props.closeToast} autoClose={props.toastProps.autoClose} isPaused={props.isPaused} />;
}
export function ErrorToast(props: RenderProps) {
return <ToastBody variant="error" payload={props.data ?? ""} closeToast={props.closeToast} autoClose={props.toastProps.autoClose} isPaused={props.isPaused} />;
}
export function WarningToast(props: RenderProps) {
return <ToastBody variant="warning" payload={props.data ?? ""} closeToast={props.closeToast} autoClose={props.toastProps.autoClose} isPaused={props.isPaused} />;
}
export function InfoToast(props: RenderProps) {
return <ToastBody variant="info" payload={props.data ?? ""} closeToast={props.closeToast} autoClose={props.toastProps.autoClose} isPaused={props.isPaused} />;
}
export function LoadingToast(props: RenderProps) {
return <ToastBody variant="loading" payload={props.data ?? ""} closeToast={props.closeToast} autoClose={false} isPaused={false} />;
}
@@ -0,0 +1,8 @@
// src/services/toast/components/ToastContainer.tsx
//
// Re-exports react-toastify's ToastContainer so that toast.provider.tsx
// does not import directly from react-toastify. All react-toastify imports
// are consolidated in toast.service.ts (runtime) and here (container).
export { ToastContainer } from "react-toastify";
export type { ToastContainerProps } from "react-toastify";
@@ -0,0 +1,12 @@
// src/services/toast/components/ToastIcons.tsx
//
// Re-exports all animated SVG icons from toast.icons.tsx.
// Keeps the components/ folder self-contained.
export {
SuccessIcon,
ErrorIcon,
WarningIcon,
InfoIcon,
LoadingSpinner,
} from "../toast.icons";
+10
View File
@@ -0,0 +1,10 @@
// src/services/toast/hooks/useToast.ts
//
// Returns the notify API for use inside React components.
// No additional logic — notify is already a stable singleton.
import { notify } from "../toast.service";
export function useToast() {
return { notify };
}
@@ -0,0 +1,33 @@
// src/services/toast/hooks/useToastRouteCleanup.ts
//
// Dismisses all active loading toasts when the user navigates to a new route.
// Prevents orphan loading notifications that would otherwise persist forever.
//
// Usage: rendered once inside ToastProvider (which is inside the Router).
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { toast } from "react-toastify";
/**
* Tracks the set of loading toast IDs created via notify.loading().
* toast.service.ts registers IDs here; this hook cleans them up on navigation.
*/
export const loadingToastIds = new Set<string | number>();
export function useToastRouteCleanup(): void {
const location = useLocation();
const prevPath = useRef<string>(location.pathname);
useEffect(() => {
// Only act on actual route changes, not on the initial mount.
if (prevPath.current === location.pathname) return;
prevPath.current = location.pathname;
// Dismiss every tracked loading toast.
for (const id of loadingToastIds) {
toast.dismiss(id);
}
loadingToastIds.clear();
}, [location.pathname]);
}
+29
View File
@@ -0,0 +1,29 @@
// src/services/toast/index.ts
//
// ─── PUBLIC API — FROZEN ──────────────────────────────────────────────────────
//
// This is the only surface feature modules should ever import from.
// Nothing else from the toast infrastructure is public.
//
// Implementation details that are NOT exported:
// toast.registry — internal dispatcher
// toast.errors — internal error normalization
// toast.internal — internal low-level ops
// toast.promise — internal promise lifecycle
// toast.utils — internal UI helpers
// toast.config — internal configuration
// toast.factory — internal message factory
// toast.types (internal types) — BusinessNotification, ToastContent, etc.
// ─── Runtime API ─────────────────────────────────────────────────────────────
export { notify } from "./toast.service";
// ─── Provider ─────────────────────────────────────────────────────────────────
export { ToastProvider } from "./toast.provider";
// ─── Hook ─────────────────────────────────────────────────────────────────────
export { useToast } from "./hooks/useToast";
// ─── Public types ─────────────────────────────────────────────────────────────
// Only the types feature modules need to annotate their own code.
export type { ToastOptions, ToastAction, ToastMetadata, PromiseToastOptions } from "./toast.types";
+7
View File
@@ -0,0 +1,7 @@
// src/services/toast/messages/index.ts
//
// The toast infrastructure layer only owns system-level messages.
// Domain messages (Product, Category, Asset, etc.) belong in their
// respective feature modules.
export { SystemMessages } from "./system.messages";
@@ -0,0 +1,45 @@
// src/services/toast/messages/system.messages.ts
//
// Infrastructure-level system messages only.
// No domain entities (Product, Category, Asset, etc.) belong here.
import { createError, createWarning, createInfo } from "../toast.factory";
import type { BusinessNotification } from "../toast.types";
export const SystemMessages = {
networkError: (): BusinessNotification =>
createError("Network Error", "Unable to connect. Please check your internet connection."),
timeout: (): BusinessNotification =>
createError("Request Timed Out", "The server took too long to respond. Please try again."),
internalServerError: (httpStatus?: number): BusinessNotification =>
createError("Server Error", "An internal server error occurred. Please try again later.", { httpStatus }),
unauthorized: (httpStatus = 401): BusinessNotification =>
createWarning("Unauthorized", "Your session has expired. Please log in again.", { httpStatus }),
permissionDenied: (httpStatus = 403): BusinessNotification =>
createWarning("Permission Denied", "You do not have permission to perform this action.", { httpStatus }),
notFound: (msg?: string, httpStatus = 404): BusinessNotification =>
createError("Not Found", msg ?? "The requested resource could not be found.", { httpStatus }),
conflict: (msg?: string, httpStatus = 409): BusinessNotification =>
createError("Conflict", msg ?? "This record already exists or conflicts with existing data.", { httpStatus }),
duplicateRecord: (): BusinessNotification =>
createError("Duplicate Record", "A record with the same details already exists."),
validationError: (msg?: string, httpStatus?: number): BusinessNotification =>
createError("Validation Error", msg ?? "Please check the form fields and try again.", { httpStatus }),
tooManyRequests: (httpStatus = 429): BusinessNotification =>
createError("Too Many Requests", "You have made too many requests. Please wait and try again.", { httpStatus }),
unknownError: (msg?: string, httpStatus?: number): BusinessNotification =>
createError("Something Went Wrong", msg ?? "An unexpected error occurred. Please try again.", { httpStatus }),
info: (msg: string): BusinessNotification =>
createInfo("Info", msg),
};
+73
View File
@@ -0,0 +1,73 @@
// src/services/toast/toast.config.ts
//
// All global configuration and constants for the toast system.
// Single source of truth for durations, positions, limits, and suppression rules.
import type { ToastOptions } from "./toast.types";
// ─── Default options ──────────────────────────────────────────────────────────
export const DEFAULT_TOAST_OPTIONS: Required<Pick<ToastOptions, "autoClose">> = {
autoClose: 3000,
};
// ─── Durations ────────────────────────────────────────────────────────────────
export const TOAST_DURATIONS = {
SHORT: 2000,
MEDIUM: 4000,
LONG: 7000,
PERSISTENT: false as const,
} as const;
// ─── Priority ─────────────────────────────────────────────────────────────────
export const TOAST_PRIORITY = {
LOW: "low",
NORMAL: "normal",
HIGH: "high",
CRITICAL: "critical",
} as const;
// ─── Positions ────────────────────────────────────────────────────────────────
export const TOAST_POSITIONS = {
TOP_RIGHT: "top-right",
TOP_CENTER: "top-center",
BOTTOM_RIGHT: "bottom-right",
BOTTOM_CENTER: "bottom-center",
} as const;
// ─── Limits ───────────────────────────────────────────────────────────────────
export const MAX_VISIBLE_TOASTS = 5;
// ─── Animation timings ───────────────────────────────────────────────────────
export const ANIMATION_TIMINGS = {
ENTER: 300,
EXIT: 300,
} as const;
// ─── Suppression ─────────────────────────────────────────────────────────────
// Network / server error strings that should be silently suppressed.
// Exact-match strings (case-insensitive full-word or full-phrase checks).
// Do NOT use bare HTTP status codes like "500" — they match substrings such as
// "Error code 5001". Status-code suppression is handled by isAxiosError guards
// in toast.errors.ts before a toast is ever created.
export const SUPPRESSED_ERROR_PATTERNS: ReadonlyArray<string> = [
"Network Error",
"Network connection failed",
"ERR_NETWORK",
"Failed to fetch",
"server connection",
];
export function isSuppressedError(message: string): boolean {
if ((window as Window & { __hasConnectionError?: boolean }).__hasConnectionError) return true;
const lower = message.toLowerCase();
return SUPPRESSED_ERROR_PATTERNS.some((pattern) =>
lower.includes(pattern.toLowerCase())
);
}
+103
View File
@@ -0,0 +1,103 @@
// src/services/toast/toast.errors.ts
//
// Centralized error normalization.
// Parses all error types into standard BusinessNotification payloads.
// Supports: Axios, Fetch, Network, Timeout, Abort, Validation, Backend, Unknown.
//
// Separation of concerns:
// isAxiosError() — type guard (parsing)
// normalizeError() — maps parsed error to BusinessNotification (mapping)
import { SystemMessages } from "./messages/system.messages";
import type { BusinessNotification } from "./toast.types";
// ─── Axios error shape ────────────────────────────────────────────────────────
interface AxiosErrorResponse {
status: number;
data?: {
message?: string;
errors?: unknown[];
code?: string;
};
}
interface AxiosErrorShape {
isAxiosError: true;
code?: string;
message?: string;
name?: string;
response?: AxiosErrorResponse;
}
function isAxiosError(error: unknown): error is AxiosErrorShape {
return (
typeof error === "object" &&
error !== null &&
(error as Record<string, unknown>)["isAxiosError"] === true
);
}
// ─── Normalizer ───────────────────────────────────────────────────────────────
export function normalizeError(error: unknown): BusinessNotification {
// Axios errors
if (isAxiosError(error)) {
if (
error.code === "ECONNABORTED" ||
error.message?.toLowerCase().includes("timeout")
) {
return SystemMessages.timeout();
}
if (error.message === "Network Error") {
return SystemMessages.networkError();
}
if (error.response) {
const { status, data } = error.response;
const backendMessage = data?.message;
switch (status) {
case 400:
return SystemMessages.validationError(backendMessage, status);
case 401:
return SystemMessages.unauthorized(status);
case 403:
return SystemMessages.permissionDenied(status);
case 404:
return SystemMessages.notFound(backendMessage, status);
case 409:
return SystemMessages.conflict(backendMessage, status);
case 422:
return SystemMessages.validationError(backendMessage, status);
case 429:
return SystemMessages.tooManyRequests(status);
case 500:
case 502:
case 503:
case 504:
return SystemMessages.internalServerError(status);
default:
return SystemMessages.unknownError(backendMessage, status);
}
}
}
// Fetch AbortError
if (error instanceof Error && error.name === "AbortError") {
return SystemMessages.unknownError("The request was cancelled.");
}
// Standard JS errors
if (error instanceof Error) {
return SystemMessages.unknownError(error.message);
}
// Plain strings
if (typeof error === "string") {
return SystemMessages.unknownError(error);
}
return SystemMessages.unknownError();
}
+55
View File
@@ -0,0 +1,55 @@
// src/services/toast/toast.factory.ts
import { TOAST_DURATIONS, TOAST_PRIORITY } from "./toast.config";
import type { BusinessNotification, ToastVariant } from "./toast.types";
/**
* Message Factory
* Prevents duplicated message objects and ensures consistency.
*/
function createMessage(
variant: ToastVariant,
title: string,
description?: string,
overrides?: Partial<BusinessNotification>
): BusinessNotification {
return {
title,
description,
variant,
duration: TOAST_DURATIONS.MEDIUM,
priority: TOAST_PRIORITY.NORMAL,
dismissible: true,
retryable: false,
...overrides,
};
}
export function createSuccess(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("success", title, description, overrides);
}
export function createError(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("error", title, description, {
duration: TOAST_DURATIONS.LONG,
priority: TOAST_PRIORITY.HIGH,
...overrides,
});
}
export function createWarning(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("warning", title, description, overrides);
}
export function createInfo(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("info", title, description, overrides);
}
export function createLoading(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("loading", title, description, {
duration: TOAST_DURATIONS.PERSISTENT,
dismissible: false,
...overrides,
});
}
+83
View File
@@ -0,0 +1,83 @@
// src/services/toast/toast.icons.tsx
//
// Animated SVG icons for each toast variant.
// Pure presentational components — no state, no side effects.
// Colors are passed as props (CSS variable strings from VARIANT_TOKENS).
// ─── Success — animated circle + checkmark draw ───────────────────────────────
export function SuccessIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle
className="pim-check-circle"
cx="12" cy="12" r="10"
stroke={color} strokeWidth="2"
/>
<polyline
className="pim-check-mark"
points="7,12.5 10.5,16 17,9"
stroke={color} strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
// ─── Error — animated circle + X lines draw ───────────────────────────────────
export function ErrorIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle
className="pim-check-circle"
cx="12" cy="12" r="10"
stroke={color} strokeWidth="2"
/>
<line className="pim-x-line" x1="8" y1="8" x2="16" y2="16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
<line className="pim-x-line" x1="16" y1="8" x2="8" y2="16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
// ─── Warning — spring-scale triangle ─────────────────────────────────────────
export function WarningIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="pim-icon-pop" aria-hidden="true">
<path
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
stroke={color} strokeWidth="2" strokeLinejoin="round"
/>
<line x1="12" y1="9" x2="12" y2="13" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
<circle cx="12" cy="17" r="1" fill={color} />
</svg>
);
}
// ─── Info — spring-scale circle with dot + line ───────────────────────────────
export function InfoIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="pim-icon-pop" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke={color} strokeWidth="2" />
<line x1="12" y1="8" x2="12" y2="8" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<line x1="12" y1="12" x2="12" y2="16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
// ─── Loading — continuous rotation spinner ────────────────────────────────────
export function LoadingSpinner({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="pim-spinner" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke={color} strokeWidth="2" strokeOpacity="0.2" />
<path
d="M12 2a10 10 0 0110 10"
stroke={color} strokeWidth="2.2" strokeLinecap="round"
/>
</svg>
);
}
+59
View File
@@ -0,0 +1,59 @@
// src/services/toast/toast.internal.ts
//
// Internal low-level toast operations shared between toast.service.ts and
// toast.promise.ts. Not part of the public API — do not export from index.ts.
//
// Exists solely to break the circular dependency:
// toast.service.ts → toast.promise.ts → toast.service.ts
import { toast, type Id } from "react-toastify";
import { SuccessToast, ErrorToast, WarningToast, InfoToast, LoadingToast } from "./components/Toast";
import { DEFAULT_TOAST_OPTIONS } from "./toast.config";
import { loadingToastIds } from "./hooks/useToastRouteCleanup";
import type { ToastContent, ToastOptions, LoadingHandle } from "./toast.types";
function baseOptions(content: ToastContent, options?: ToastOptions) {
return { ...DEFAULT_TOAST_OPTIONS, ...options, data: content };
}
export function showLoading(content: ToastContent, options?: ToastOptions): LoadingHandle {
const id = toast(LoadingToast, {
...baseOptions(content, options),
autoClose: false,
closeOnClick: false,
draggable: false,
});
loadingToastIds.add(id);
return { id };
}
export function updateToast(
id: Id,
variant: "success" | "error" | "warning" | "info",
content: ToastContent,
options?: ToastOptions,
duration?: number | false
): void {
// When a loading toast transitions to a final state, remove it from tracking.
loadingToastIds.delete(id);
const renderers = { success: SuccessToast, error: ErrorToast, warning: WarningToast, info: InfoToast };
// Duration precedence: explicit duration arg → options.autoClose → global default.
// This ensures BusinessNotification.duration (e.g. LONG=7000 for errors) is respected
// when a loading toast transitions to an error state via notify.promise().
const autoClose =
options?.autoClose !== undefined
? options.autoClose
: duration !== undefined
? duration
: DEFAULT_TOAST_OPTIONS.autoClose;
toast.update(id, {
...DEFAULT_TOAST_OPTIONS,
...options,
type: variant,
render: renderers[variant],
data: content,
isLoading: false,
autoClose,
});
}
+78
View File
@@ -0,0 +1,78 @@
// src/services/toast/toast.promise.ts
//
// Owns the Loading → Success → Error promise lifecycle.
// Internal module — not exported from index.ts.
// Exposed only through notify.promise().
import { showLoading, updateToast } from "./toast.internal";
import { normalizeError } from "./toast.errors";
import { dispatchNotification } from "./toast.registry";
import { TOAST_DURATIONS } from "./toast.config";
import type { BusinessNotification, PromiseToastOptions } from "./toast.types";
function toNotification(
payload: { title: string; description?: string },
variant: BusinessNotification["variant"]
): BusinessNotification {
return { title: payload.title, description: payload.description, variant };
}
/**
* Manages the Loading → Success / Error lifecycle using a single toast instance.
* Called internally by notify.promise() — not exported from index.ts.
*/
export async function notifyPromise<T>(
promise: Promise<T> | (() => Promise<T>),
config: PromiseToastOptions<T>
): Promise<T> {
const p = typeof promise === "function" ? promise() : promise;
const loadingNotif = toNotification(config.loading, "loading");
const loadingMsg = dispatchNotification(loadingNotif, config.metadata);
const handle = showLoading(
{ title: loadingMsg.title, description: loadingMsg.description },
config.options
);
try {
const result = await p;
const successRaw =
typeof config.success === "function"
? config.success(result)
: config.success;
const successNotif = toNotification(successRaw, "success");
const successMsg = dispatchNotification(successNotif, config.metadata);
// Use the resolved duration from the notification (MEDIUM=4000 for success).
const successDuration = successMsg.duration ?? TOAST_DURATIONS.MEDIUM;
updateToast(
handle.id,
"success",
{ title: successMsg.title, description: successMsg.description },
config.options,
successDuration
);
return result;
} catch (err) {
const errorRaw = config.error
? (typeof config.error === "function" ? config.error(err) : config.error)
: null;
let errorMsg: BusinessNotification;
if (errorRaw) {
errorMsg = dispatchNotification(toNotification(errorRaw, "error"), config.metadata);
} else {
errorMsg = dispatchNotification(normalizeError(err), config.metadata);
}
// Use the resolved duration from the notification (LONG=7000 for errors).
const errorDuration = errorMsg.duration ?? TOAST_DURATIONS.LONG;
updateToast(
handle.id,
"error",
{ title: errorMsg.title, description: errorMsg.description, httpStatus: errorMsg.httpStatus },
config.options,
errorDuration
);
throw err;
}
}
+36
View File
@@ -0,0 +1,36 @@
// src/services/toast/toast.provider.tsx
//
// Responsible only for:
// - Rendering ToastContainer
// - Registering global configuration
// - Initializing the notification system
// - Cleaning up orphan loading toasts on route change
//
// No business logic. Rendered once in App.tsx (inside the Router).
import { ToastContainer } from "./components/ToastContainer";
import { useToastRouteCleanup } from "./hooks/useToastRouteCleanup";
import "./components/Toast.css";
export function ToastProvider() {
// Dismiss orphan loading toasts when the user navigates to a new route.
useToastRouteCleanup();
return (
<ToastContainer
position="top-right"
autoClose={3000}
closeButton={false}
icon={false}
newestOnTop
pauseOnFocusLoss={false}
pauseOnHover
draggable={false}
limit={5}
className="pim-toast-container"
toastClassName={() => ""}
toastStyle={{ background: "transparent", boxShadow: "none", padding: 0 }}
style={{ width: "auto", minWidth: "340px", maxWidth: "420px" }}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
// src/services/toast/toast.registry.ts
//
// Notification Dispatcher.
//
// Responsibilities:
// - Receive notification events
// - Attach metadata (timestamp, correlation)
// - Dispatch to all registered handlers
//
// Does NOT store notifications.
// Does NOT implement Notification Center or Header Notifications.
//
// Future subscribers can register here without changing any feature code:
// registry.subscribe(headerNotificationBell)
// registry.subscribe(analyticsTracker)
// registry.subscribe(activityLog)
import type { BusinessNotification, ToastMetadata } from "./toast.types";
export type NotificationHandler = (notification: BusinessNotification) => void;
const handlers: NotificationHandler[] = [];
/**
* Register a handler to receive all dispatched notifications.
* Returns an unsubscribe function.
*
* Duplicate handlers are silently ignored — registering the same function
* reference twice will not cause double-firing.
* Always call the returned unsubscribe in useEffect cleanup to prevent leaks.
*/
export function subscribe(handler: NotificationHandler): () => void {
// Guard: prevent duplicate registrations of the same function reference.
if (handlers.includes(handler)) {
return () => {
const idx = handlers.indexOf(handler);
if (idx !== -1) handlers.splice(idx, 1);
};
}
handlers.push(handler);
return () => {
const idx = handlers.indexOf(handler);
if (idx !== -1) handlers.splice(idx, 1);
};
}
/**
* Enrich a notification with metadata and dispatch it to all registered handlers.
* Returns the enriched notification for the caller (toast renderer) to use.
*/
export function dispatchNotification(
message: BusinessNotification,
metadata?: ToastMetadata
): BusinessNotification {
const enrichedMetadata: ToastMetadata = {
timestamp: Date.now(),
...message.metadata,
...metadata,
};
const enriched: BusinessNotification = { ...message, metadata: enrichedMetadata };
// Dispatch to all registered subscribers.
// Subscriber errors must never break the toast render path.
for (const handler of handlers) {
try {
handler(enriched);
} catch {
// intentionally swallowed
}
}
return enriched;
}
+195
View File
@@ -0,0 +1,195 @@
// src/services/toast/toast.service.ts
//
// Assembles and exports the single public `notify` API.
// All implementation details are in toast.internal.ts / toast.promise.ts.
// Nothing from this file except `notify` should be re-exported from index.ts.
import { toast, type Id } from "react-toastify";
import { SuccessToast, ErrorToast, WarningToast, InfoToast, LoadingToast } from "./components/Toast";
import { DEFAULT_TOAST_OPTIONS, isSuppressedError, TOAST_PRIORITY } from "./toast.config";
import { normalizeError } from "./toast.errors";
import { dispatchNotification } from "./toast.registry";
import { showLoading, updateToast } from "./toast.internal";
import { notifyPromise } from "./toast.promise";
import { resolveToastId, isRecentlySuppressed, createToastHash } from "./toast.utils";
import type {
ToastContent,
ToastOptions,
LoadingHandle,
BusinessNotification,
ToastMetadata,
PromiseToastOptions,
} from "./toast.types";
// ─── Internal render helpers ──────────────────────────────────────────────────
function resolveMessage(content: ToastContent): string {
return typeof content === "string" ? content : content.title;
}
function isBusinessNotification(value: unknown): value is BusinessNotification {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>)["title"] === "string"
);
}
function baseOptions(
content: ToastContent,
options?: ToastOptions,
duration?: number | false,
variant?: string,
metadata?: ToastMetadata
) {
const autoClose = options?.autoClose !== undefined ? options.autoClose : (duration ?? DEFAULT_TOAST_OPTIONS.autoClose);
const title = typeof content === "string" ? content : content.title;
const description = typeof content === "object" ? content.description : undefined;
const toastId = resolveToastId(options, metadata, variant ?? "info", title, description);
// Strip `id` and `toastId` from the spread — react-toastify only understands `toastId`.
const { id: _id, toastId: _toastId, ...restOptions } = options ?? {};
return {
...DEFAULT_TOAST_OPTIONS,
...restOptions,
autoClose,
...(toastId !== undefined ? { toastId } : {}),
data: content,
};
}
function _showSuccess(content: ToastContent, options?: ToastOptions, duration?: number | false, metadata?: ToastMetadata): Id {
return toast(SuccessToast, { ...baseOptions(content, options, duration, "success", metadata), type: "success" });
}
function _showError(content: ToastContent, options?: ToastOptions, duration?: number | false, metadata?: ToastMetadata): Id | "" {
const message = resolveMessage(content);
if (isSuppressedError(message)) {
console.warn("Toast suppressed (network/server error):", message.replace(/[\r\n]/g, " "));
return "";
}
// 2-second suppression window: prevent rapid-fire duplicate error toasts.
const hash = createToastHash("error", metadata?.feature, metadata?.action, message,
typeof content === "object" ? content.description : undefined);
if (isRecentlySuppressed(hash)) return "";
return toast(ErrorToast, { ...baseOptions(content, options, duration, "error", metadata), type: "error" });
}
function _showWarning(content: ToastContent, options?: ToastOptions, duration?: number | false, metadata?: ToastMetadata): Id {
return toast(WarningToast, { ...baseOptions(content, options, duration, "warning", metadata), type: "warning" });
}
function _showInfo(content: ToastContent, options?: ToastOptions, duration?: number | false, metadata?: ToastMetadata): Id {
return toast(InfoToast, { ...baseOptions(content, options, duration, "info", metadata), type: "info" });
}
function trigger(
message: BusinessNotification,
options?: ToastOptions,
metadata?: ToastMetadata
) {
const final = dispatchNotification(message, metadata);
// Priority enforcement: critical notifications are never suppressed by the
// 2-second deduplication window. They always fire regardless of recency.
const isCritical = final.priority === TOAST_PRIORITY.CRITICAL;
// Pass the full enriched BusinessNotification as data so Toast.tsx can access
// httpStatus, action, priority, retryable — not just title and description.
const payload: BusinessNotification = {
title: final.title,
description: final.description,
httpStatus: final.httpStatus,
action: final.action,
priority: final.priority,
retryable: final.retryable,
dismissible: final.dismissible,
metadata: final.metadata,
};
const duration = final.duration;
const meta = final.metadata;
// For critical notifications, bypass the suppression window by temporarily
// clearing the hash from the recent-hashes map before calling _show*.
// We achieve this by passing a unique options.id so resolveToastId uses
// Priority 1 (explicit id) and the hash-based suppression is never checked.
const criticalOptions: ToastOptions | undefined = isCritical
? { ...options, id: `critical-${Date.now()}` }
: options;
switch (final.variant) {
case "success": return _showSuccess(payload, criticalOptions, duration, meta);
case "error": return _showError(payload, criticalOptions, duration, meta);
case "warning": return _showWarning(payload, criticalOptions, duration, meta);
case "info": return _showInfo(payload, criticalOptions, duration, meta);
case "loading": return showLoading(payload, options);
default: return _showInfo(payload, criticalOptions, duration, meta);
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
//
// This is the frozen contract. Do not rename methods, change parameter shapes,
// or add new entries without a deliberate versioning decision.
export const notify = {
success(content: ToastContent, options?: ToastOptions, metadata?: ToastMetadata) {
const message: BusinessNotification = isBusinessNotification(content)
? { ...content, variant: "success" }
: { title: content, variant: "success" };
return trigger(message, options, metadata);
},
error(content: ToastContent | unknown, options?: ToastOptions, metadata?: ToastMetadata) {
const message: BusinessNotification = isBusinessNotification(content)
? { ...content, variant: "error" }
: typeof content === "string"
? { title: content, variant: "error", duration: DEFAULT_TOAST_OPTIONS.autoClose }
: normalizeError(content);
return trigger(message, options, metadata);
},
warning(content: ToastContent, options?: ToastOptions, metadata?: ToastMetadata) {
const message: BusinessNotification = isBusinessNotification(content)
? { ...content, variant: "warning" }
: { title: content, variant: "warning" };
return trigger(message, options, metadata);
},
info(content: ToastContent, options?: ToastOptions, metadata?: ToastMetadata) {
const message: BusinessNotification = isBusinessNotification(content)
? { ...content, variant: "info" }
: { title: content, variant: "info" };
return trigger(message, options, metadata);
},
loading(content: ToastContent, options?: ToastOptions): LoadingHandle {
return showLoading(content, options);
},
promise<T>(
promise: Promise<T> | (() => Promise<T>),
config: PromiseToastOptions<T>
): Promise<T> {
return notifyPromise(promise, config);
},
update(
id: Id,
variant: "success" | "error" | "warning" | "info",
content: ToastContent,
options?: ToastOptions
): void {
updateToast(id, variant, content, options);
},
dismiss(id: Id): void {
toast.dismiss(id);
},
dismissAll(): void {
toast.dismiss();
},
};
// Internal re-export for Toast.tsx which needs LoadingToast for its render map
export { LoadingToast };
+93
View File
@@ -0,0 +1,93 @@
// src/services/toast/toast.types.ts
export type ToastVariant = "success" | "error" | "warning" | "info" | "loading";
export type ToastPriority = "low" | "normal" | "high" | "critical";
export interface ToastMetadata {
entity?: string;
entityId?: string | number;
feature?: string;
module?: string;
action?: string;
tenantId?: string;
userId?: string;
requestId?: string;
correlationId?: string;
source?: string;
timestamp?: number;
[key: string]: unknown;
}
/**
* Optional action button rendered inside a toast (Retry, Undo, View Details, etc.).
*/
export interface ToastAction {
label: string;
onClick: () => void;
}
/**
* The standard structure of all notifications passed through the system.
*/
export interface BusinessNotification {
title: string;
description?: string;
variant?: ToastVariant;
duration?: number | false;
priority?: ToastPriority;
dismissible?: boolean;
retryable?: boolean;
metadata?: ToastMetadata;
/** HTTP status code preserved from the original error response (e.g. 404, 500). */
httpStatus?: number;
/** Optional action button rendered inside the toast. */
action?: ToastAction;
}
// ToastPayload is an alias for BusinessNotification — kept for named clarity
export type ToastPayload = BusinessNotification;
export type ToastContent = string | ToastPayload;
/**
* Per-call overrides merged on top of global defaults from toast.config.ts.
*
* Use `id` to supply an explicit notification identity.
* The infrastructure maps `id` → react-toastify's `toastId` internally.
* Feature modules must never reference `toastId` directly.
*/
export interface ToastOptions {
/** Explicit notification identity. Takes Priority 1 in identity resolution. */
id?: string | number;
autoClose?: number | false;
/** @internal Kept for backward compatibility. Prefer `id`. */
toastId?: string | number;
closeOnClick?: boolean;
draggable?: boolean;
onClose?: () => void;
onClick?: () => void;
}
/**
* Handle returned by notify.loading() — used to update or dismiss the toast.
* Internal — not exported from index.ts.
*/
export interface LoadingHandle {
id: string | number;
}
// ToastAction is defined above BusinessNotification — kept here as a comment
// so existing imports of ToastAction from this file continue to resolve.
/**
* Public config type for notify.promise().
* Feature modules import this when they need to type their promise config.
*/
export interface PromiseToastOptions<T = unknown> {
loading: { title: string; description?: string };
success: { title: string; description?: string } | ((result: T) => { title: string; description?: string });
error?: { title: string; description?: string } | ((err: unknown) => { title: string; description?: string });
options?: ToastOptions;
metadata?: ToastMetadata;
}
+149
View File
@@ -0,0 +1,149 @@
// src/services/toast/toast.utils.ts
//
// Generic reusable helpers only.
// No business logic. No HTTP parsing. No rendering logic.
import type { ToastVariant, ToastPayload, ToastOptions, ToastMetadata } from "./toast.types";
// ─── Variant token map (used by UI components) ────────────────────────────────
export interface VariantTokens {
accent: string;
accentBg: string;
accentText: string;
label: string;
}
export const VARIANT_TOKENS: Record<ToastVariant, VariantTokens> = {
success: {
accent: "var(--color-success, #10B981)",
accentBg: "color-mix(in srgb, var(--color-success, #10B981) 12%, transparent)",
accentText: "var(--color-success, #10B981)",
label: "Success",
},
error: {
accent: "var(--color-danger, #EF4444)",
accentBg: "color-mix(in srgb, var(--color-danger, #EF4444) 12%, transparent)",
accentText: "var(--color-danger, #EF4444)",
label: "Error",
},
warning: {
accent: "var(--color-warning, #F59E0B)",
accentBg: "color-mix(in srgb, var(--color-warning, #F59E0B) 12%, transparent)",
accentText: "var(--color-warning, #F59E0B)",
label: "Warning",
},
info: {
accent: "var(--color-info, #3B82F6)",
accentBg: "color-mix(in srgb, var(--color-info, #3B82F6) 12%, transparent)",
accentText: "var(--color-info, #3B82F6)",
label: "Info",
},
loading: {
accent: "var(--color-primary, #7C3AED)",
accentBg: "color-mix(in srgb, var(--color-primary, #7C3AED) 12%, transparent)",
accentText: "var(--color-primary, #7C3AED)",
label: "Loading",
},
};
// ─── Generic utilities ────────────────────────────────────────────────────────
export function resolvePayload(
payload: string | ToastPayload,
fallbackLabel: string
): { title: string; description?: string } {
if (typeof payload === "string") {
return { title: payload || fallbackLabel };
}
return { title: payload.title, description: payload.description };
}
// ─── Toast identity ───────────────────────────────────────────────────────────
/**
* djb2 hash — deterministic, zero-dependency, collision-resistant enough for
* toast deduplication. Always produces the same output for the same input.
*/
function djb2(str: string): string {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) + h) ^ str.charCodeAt(i);
h = h >>> 0; // keep unsigned 32-bit
}
return h.toString(36);
}
/**
* Produces a deterministic hash from the notification's semantic identity.
* Different entities with the same title (e.g. Product "Not Found" vs
* Category "Not Found") produce different hashes because feature/action differ.
*/
export function createToastHash(
variant: string,
feature: string | undefined,
action: string | undefined,
title: string,
description: string | undefined
): string {
const key = [variant, feature ?? "", action ?? "", title, description ?? ""].join("|");
return `t-${djb2(key)}`;
}
// ─── Suppression window ───────────────────────────────────────────────────────
// Prevents the same notification from firing repeatedly within a short window
// even after the previous toast has already been dismissed.
const SUPPRESSION_WINDOW_MS = 2000;
const recentHashes = new Map<string, number>();
/**
* Returns true if this hash was already shown within the suppression window.
* Records the hash if it was not suppressed.
*/
export function isRecentlySuppressed(
hash: string,
nowMs: number = Date.now()
): boolean {
const last = recentHashes.get(hash);
if (last !== undefined && nowMs - last < SUPPRESSION_WINDOW_MS) {
return true;
}
recentHashes.set(hash, nowMs);
// Prune stale entries to prevent unbounded growth.
for (const [key, ts] of recentHashes) {
if (nowMs - ts >= SUPPRESSION_WINDOW_MS) recentHashes.delete(key);
}
return false;
}
/**
* Resolves the toast identity following the 4-priority chain:
* 1. options.id (public API explicit override — always wins)
* options.toastId (internal backward-compat alias, same priority)
* 2. metadata.correlationId (request lifecycle identity)
* 3. Deterministic hash (variant + feature + action + title + description)
* 4. undefined (let react-toastify assign its own id)
*/
export function resolveToastId(
options: ToastOptions | undefined,
metadata: ToastMetadata | undefined,
variant: string,
title: string,
description: string | undefined
): string | number | undefined {
// Priority 1 — explicit caller identity (public `id` field, or legacy `toastId`)
if (options?.id !== undefined) return options.id;
if (options?.toastId !== undefined) return options.toastId;
// Priority 2 — request lifecycle identity
if (metadata?.correlationId) return metadata.correlationId;
// Priority 3 — deterministic semantic hash
if (title) return createToastHash(variant, metadata?.feature, metadata?.action, title, description);
// Priority 4 — let react-toastify assign its own id
return undefined;
}
/** @deprecated Use resolveToastId instead. Will be removed in a future release. */
export function generateToastId(prefix?: string): string {
return resolveToastId(undefined, undefined, prefix ?? "toast", String(Date.now()), undefined) as string;
}