2058 lines
52 KiB
Markdown
2058 lines
52 KiB
Markdown
# React Frontend Architecture Guide
|
|
## Building Enterprise Applications with Three-Layer Service Architecture
|
|
|
|
**Version:** 2.0.0
|
|
**React:** 19.1.0
|
|
**Build Tool:** Vite 7.1.2
|
|
**Styling:** Tailwind CSS 4.1.12
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Project Overview](#project-overview)
|
|
2. [Technology Stack](#technology-stack)
|
|
3. [Project Structure](#project-structure)
|
|
4. [Three-Layer Service Architecture](#three-layer-service-architecture)
|
|
5. [Dependency Injection System](#dependency-injection-system)
|
|
6. [Constants Management](#constants-management)
|
|
7. [Component Architecture](#component-architecture)
|
|
8. [Authentication & Authorization](#authentication--authorization)
|
|
9. [Routing & Navigation](#routing--navigation)
|
|
10. [State Management & Caching](#state-management--caching)
|
|
11. [Styling & Theming](#styling--theming)
|
|
12. [Build Configuration](#build-configuration)
|
|
13. [Complete Setup Guide](#complete-setup-guide)
|
|
14. [Best Practices & Patterns](#best-practices--patterns)
|
|
|
|
---
|
|
|
|
## Project Overview
|
|
|
|
This architecture guide documents a production-grade React application built for enterprise use. The application follows a strict **three-layer service architecture** (API, Storage, Manager) with custom dependency injection, comprehensive constants management, and zero tolerance for magic values.
|
|
|
|
### Core Principles
|
|
|
|
1. **No Magic Values**: Every number, string, or configuration value must be defined as a constant
|
|
2. **Service Layer Separation**: Clear boundaries between API, Storage, and Manager layers
|
|
3. **Dependency Injection**: Custom DI system without third-party libraries
|
|
4. **Component Reusability**: Extensive component library organized by purpose
|
|
5. **Type Safety**: PropTypes for runtime type checking
|
|
6. **Performance**: Code splitting, caching, and optimization strategies
|
|
|
|
---
|
|
|
|
## Technology Stack
|
|
|
|
### Core Dependencies
|
|
|
|
```json
|
|
{
|
|
"react": "^19.1.0",
|
|
"react-dom": "^19.1.0",
|
|
"react-router-dom": "^7.8.0",
|
|
"vite": "^7.1.2",
|
|
"tailwindcss": "^4.1.12"
|
|
}
|
|
```
|
|
|
|
### Key Libraries
|
|
|
|
- **HTTP Client**: `axios` (^1.11.0) - API communication with interceptors
|
|
- **Date Handling**: `luxon` (^3.7.1) - Date manipulation and formatting
|
|
- **Case Conversion**: `humps` (^2.0.1) - camelCase ↔ snake_case conversion
|
|
- **Icons**: `@heroicons/react` (^2.2.0) - SVG icon library
|
|
- **QR Codes**: `qrcode.react` (^4.2.0) - 2FA QR code generation
|
|
- **Utility**: `clsx` (^2.1.1) - Conditional className utility
|
|
|
|
### Development Tools
|
|
|
|
- **Linting**: ESLint 9 with React plugins
|
|
- **Build Optimization**: Terser for minification
|
|
- **CSS Processing**: PostCSS with Autoprefixer
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
web/frontend/
|
|
├── index.html # Entry HTML file
|
|
├── package.json # Dependencies and scripts
|
|
├── vite.config.js # Vite build configuration
|
|
├── tailwind.config.js # Tailwind CSS configuration
|
|
├── eslint.config.js # ESLint configuration
|
|
│
|
|
├── public/ # Static assets
|
|
│ ├── favicon.ico
|
|
│ └── assets/
|
|
│
|
|
└── src/
|
|
├── main.jsx # Application entry point
|
|
├── AppRouter.jsx # Main routing configuration
|
|
│
|
|
├── components/ # Reusable components
|
|
│ ├── UI/ # Pure UI components
|
|
│ │ ├── Button/
|
|
│ │ ├── Input/
|
|
│ │ ├── Modal/
|
|
│ │ ├── Table/
|
|
│ │ └── ...
|
|
│ ├── UIX/ # Enhanced UI components
|
|
│ │ ├── EntityListPage/
|
|
│ │ ├── EntityDetailPage/
|
|
│ │ └── ...
|
|
│ ├── business/ # Domain-aware components
|
|
│ │ ├── CustomerSelect/
|
|
│ │ ├── OrderDisplay/
|
|
│ │ └── ...
|
|
│ └── Layout/ # Layout components
|
|
│ ├── Sidebar.jsx
|
|
│ ├── Header.jsx
|
|
│ └── Footer.jsx
|
|
│
|
|
├── pages/ # Page components (route handlers)
|
|
│ ├── Admin/
|
|
│ │ ├── Customer/
|
|
│ │ │ ├── List/
|
|
│ │ │ │ └── Page.jsx
|
|
│ │ │ ├── Detail/
|
|
│ │ │ │ └── Page.jsx
|
|
│ │ │ ├── Create/
|
|
│ │ │ │ └── Page.jsx
|
|
│ │ │ └── Update/
|
|
│ │ │ └── Page.jsx
|
|
│ │ └── ...
|
|
│ ├── Dashboard/
|
|
│ └── Auth/
|
|
│
|
|
├── services/ # Service layer (CRITICAL)
|
|
│ ├── Services.jsx # DI container
|
|
│ ├── Config/
|
|
│ │ └── APIConfig.js
|
|
│ ├── API/ # HTTP communication layer
|
|
│ │ ├── CustomerAPI.js
|
|
│ │ ├── AuthAPI.js
|
|
│ │ └── ...
|
|
│ ├── Storage/ # Local persistence layer
|
|
│ │ ├── CustomerStorage.js
|
|
│ │ ├── TokenStorage.js
|
|
│ │ └── ...
|
|
│ ├── Manager/ # Business logic layer
|
|
│ │ ├── CustomerManager.js
|
|
│ │ ├── AuthManager.js
|
|
│ │ └── ...
|
|
│ └── Helpers/
|
|
│ └── AuthenticatedAxios.js
|
|
│
|
|
├── constants/ # Constants (NO MAGIC VALUES!)
|
|
│ ├── Customer.js
|
|
│ ├── Authentication.js
|
|
│ ├── UI.js
|
|
│ ├── Roles.js
|
|
│ └── ...
|
|
│
|
|
├── utils/ # Utility functions
|
|
│ └── serviceUtils.js
|
|
│
|
|
├── styles/ # Global styles
|
|
│ └── app.css
|
|
│
|
|
└── assets/ # Static assets (images, fonts)
|
|
```
|
|
|
|
---
|
|
|
|
## Three-Layer Service Architecture
|
|
|
|
The most critical aspect of this architecture is the **three-layer service pattern**. This pattern ensures:
|
|
- Clear separation of concerns
|
|
- Testability
|
|
- Maintainability
|
|
- Consistent error handling
|
|
- Caching strategies
|
|
|
|
### Layer 1: API Layer (`src/services/API/`)
|
|
|
|
**Purpose**: HTTP communication with backend services
|
|
|
|
**Responsibilities**:
|
|
- Making HTTP requests using Axios
|
|
- Request/response transformation (camelCase ↔ snake_case)
|
|
- Error handling and formatting
|
|
- Endpoint configuration
|
|
|
|
**Example: CustomerAPI.js**
|
|
|
|
```javascript
|
|
// src/services/API/CustomerAPI.js
|
|
import { camelizeKeys, decamelizeKeys, decamelize } from "humps";
|
|
import { createAuthenticatedAxios } from "../Helpers/AuthenticatedAxios";
|
|
import { DateTime } from "luxon";
|
|
|
|
export class CustomerAPI {
|
|
constructor(baseURL, endpoints, tokenStorage) {
|
|
this.baseURL = baseURL;
|
|
this.endpoints = endpoints;
|
|
this.tokenStorage = tokenStorage;
|
|
}
|
|
|
|
/**
|
|
* Gets list of customers with filtering and pagination
|
|
*/
|
|
async getCustomers(params = {}, onUnauthorizedCallback = null) {
|
|
try {
|
|
// Create authenticated axios instance
|
|
const authenticatedAxios = createAuthenticatedAxios(
|
|
this.baseURL,
|
|
this.tokenStorage,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
// Build query parameters
|
|
const queryParams = new URLSearchParams();
|
|
if (params.page) queryParams.append("page", params.page);
|
|
if (params.limit) queryParams.append("page_size", params.limit);
|
|
if (params.search) queryParams.append("search", params.search);
|
|
|
|
// Make API call
|
|
const response = await authenticatedAxios.get(
|
|
`${this.endpoints.CUSTOMERS}?${queryParams.toString()}`
|
|
);
|
|
|
|
// Convert snake_case to camelCase
|
|
const data = camelizeKeys(response.data);
|
|
|
|
// Format dates
|
|
if (data.results) {
|
|
data.results.forEach((item) => {
|
|
if (item.createdAt) {
|
|
item.createdAt = DateTime.fromISO(item.createdAt)
|
|
.toLocaleString(DateTime.DATETIME_MED);
|
|
}
|
|
});
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
throw this._formatError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new customer
|
|
*/
|
|
async createCustomer(customerData, onUnauthorizedCallback = null) {
|
|
try {
|
|
const authenticatedAxios = createAuthenticatedAxios(
|
|
this.baseURL,
|
|
this.tokenStorage,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
// Convert camelCase to snake_case for backend
|
|
let decamelizedData = decamelizeKeys(customerData);
|
|
|
|
const response = await authenticatedAxios.post(
|
|
this.endpoints.CUSTOMERS,
|
|
decamelizedData
|
|
);
|
|
|
|
return camelizeKeys(response.data);
|
|
} catch (error) {
|
|
throw this._formatError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets customer detail by ID
|
|
*/
|
|
async getCustomerDetail(customerId, onUnauthorizedCallback = null) {
|
|
try {
|
|
if (!customerId) {
|
|
throw { customerId: "Valid customer ID is required" };
|
|
}
|
|
|
|
const authenticatedAxios = createAuthenticatedAxios(
|
|
this.baseURL,
|
|
this.tokenStorage,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
const url = this.endpoints.CUSTOMER_DETAIL.replace("{id}", customerId);
|
|
const response = await authenticatedAxios.get(url);
|
|
|
|
return camelizeKeys(response.data);
|
|
} catch (error) {
|
|
throw this._formatError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates a customer
|
|
*/
|
|
async updateCustomer(customerId, customerData, onUnauthorizedCallback = null) {
|
|
try {
|
|
const authenticatedAxios = createAuthenticatedAxios(
|
|
this.baseURL,
|
|
this.tokenStorage,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
let decamelizedData = decamelizeKeys(customerData);
|
|
decamelizedData.id = customerId;
|
|
|
|
const url = this.endpoints.CUSTOMER_DETAIL.replace("{id}", customerId);
|
|
const response = await authenticatedAxios.put(url, decamelizedData);
|
|
|
|
return camelizeKeys(response.data);
|
|
} catch (error) {
|
|
throw this._formatError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a customer
|
|
*/
|
|
async deleteCustomer(customerId, onUnauthorizedCallback = null) {
|
|
try {
|
|
const authenticatedAxios = createAuthenticatedAxios(
|
|
this.baseURL,
|
|
this.tokenStorage,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
const url = this.endpoints.CUSTOMER_DETAIL.replace("{id}", customerId);
|
|
const response = await authenticatedAxios.delete(url);
|
|
|
|
return camelizeKeys(response.data);
|
|
} catch (error) {
|
|
throw this._formatError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats errors consistently
|
|
*/
|
|
_formatError(error) {
|
|
let errorData = error.response?.data || error.response || error;
|
|
return camelizeKeys(errorData);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Layer 2: Storage Layer (`src/services/Storage/`)
|
|
|
|
**Purpose**: Local data persistence, caching, and state management
|
|
|
|
**Responsibilities**:
|
|
- localStorage operations
|
|
- sessionStorage operations
|
|
- In-memory caching
|
|
- Cache invalidation
|
|
- Data persistence strategies
|
|
|
|
**Example: CustomerStorage.js**
|
|
|
|
```javascript
|
|
// src/services/Storage/CustomerStorage.js
|
|
|
|
export class CustomerStorage {
|
|
constructor() {
|
|
this.CUSTOMERS_CACHE_KEY = "MAPLEPRESS_CUSTOMERS_CACHE";
|
|
this.CUSTOMERS_TIMESTAMP_KEY = "MAPLEPRESS_CUSTOMERS_TIMESTAMP";
|
|
this.DEFAULT_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// In-memory cache for current session
|
|
this.memoryCache = {
|
|
customers: null,
|
|
customersTimestamp: null,
|
|
isCustomersLoading: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets customers from cache (memory first, then localStorage)
|
|
*/
|
|
getCustomersFromCache(maxAge = this.DEFAULT_CACHE_DURATION) {
|
|
// Check memory cache first (fastest)
|
|
if (this._isMemoryCacheValid(maxAge)) {
|
|
console.log("Using memory cache for customers");
|
|
return this.memoryCache.customers;
|
|
}
|
|
|
|
// Check localStorage cache
|
|
try {
|
|
const cachedCustomers = localStorage.getItem(this.CUSTOMERS_CACHE_KEY);
|
|
const cachedTimestamp = localStorage.getItem(this.CUSTOMERS_TIMESTAMP_KEY);
|
|
|
|
if (cachedCustomers && cachedTimestamp) {
|
|
const timestamp = parseInt(cachedTimestamp);
|
|
const age = Date.now() - timestamp;
|
|
|
|
if (age < maxAge) {
|
|
const customersData = JSON.parse(cachedCustomers);
|
|
|
|
// Update memory cache
|
|
this.memoryCache.customers = customersData;
|
|
this.memoryCache.customersTimestamp = timestamp;
|
|
|
|
console.log("Using localStorage cache for customers");
|
|
return customersData;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error reading from localStorage", error);
|
|
this._clearLocalStorageCache();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Saves customers to cache (both memory and localStorage)
|
|
*/
|
|
saveCustomersToCache(customersData) {
|
|
if (!customersData) {
|
|
console.warn("Attempted to save null/undefined data");
|
|
return;
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
|
|
// Save to memory cache
|
|
this.memoryCache.customers = customersData;
|
|
this.memoryCache.customersTimestamp = timestamp;
|
|
this.memoryCache.isCustomersLoading = false;
|
|
|
|
// Save to localStorage
|
|
try {
|
|
localStorage.setItem(
|
|
this.CUSTOMERS_CACHE_KEY,
|
|
JSON.stringify(customersData)
|
|
);
|
|
localStorage.setItem(
|
|
this.CUSTOMERS_TIMESTAMP_KEY,
|
|
timestamp.toString()
|
|
);
|
|
|
|
console.log("Customers cached successfully", {
|
|
timestamp: new Date(timestamp).toISOString(),
|
|
count: customersData.results ? customersData.results.length : 0,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error saving to localStorage", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears customers cache
|
|
*/
|
|
clearCustomersCache() {
|
|
this.memoryCache.customers = null;
|
|
this.memoryCache.customersTimestamp = null;
|
|
this.memoryCache.isCustomersLoading = false;
|
|
this._clearLocalStorageCache();
|
|
console.log("Customers cache cleared");
|
|
}
|
|
|
|
/**
|
|
* Sets loading state
|
|
*/
|
|
setCustomersCacheLoading(isLoading) {
|
|
this.memoryCache.isCustomersLoading = isLoading;
|
|
}
|
|
|
|
/**
|
|
* Gets loading state
|
|
*/
|
|
isCustomersCacheLoading() {
|
|
return this.memoryCache.isCustomersLoading;
|
|
}
|
|
|
|
/**
|
|
* Private helper methods
|
|
*/
|
|
_isMemoryCacheValid(maxAge) {
|
|
if (!this.memoryCache.customers || !this.memoryCache.customersTimestamp) {
|
|
return false;
|
|
}
|
|
const age = Date.now() - this.memoryCache.customersTimestamp;
|
|
return age < maxAge;
|
|
}
|
|
|
|
_clearLocalStorageCache() {
|
|
localStorage.removeItem(this.CUSTOMERS_CACHE_KEY);
|
|
localStorage.removeItem(this.CUSTOMERS_TIMESTAMP_KEY);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Layer 3: Manager Layer (`src/services/Manager/`)
|
|
|
|
**Purpose**: Business logic and orchestration
|
|
|
|
**Responsibilities**:
|
|
- Combining API and Storage services
|
|
- Business rule implementation
|
|
- Validation logic
|
|
- Cache coordination
|
|
- Providing clean interface to components
|
|
|
|
**Example: CustomerManager.js**
|
|
|
|
```javascript
|
|
// src/services/Manager/CustomerManager.js
|
|
|
|
export class CustomerManager {
|
|
constructor(customerAPI, customerStorage) {
|
|
this.customerAPI = customerAPI;
|
|
this.customerStorage = customerStorage;
|
|
}
|
|
|
|
/**
|
|
* Gets list of customers with caching
|
|
*/
|
|
async getCustomers(
|
|
params = {},
|
|
onUnauthorizedCallback = null,
|
|
forceRefresh = false
|
|
) {
|
|
try {
|
|
// Check cache first unless force refresh
|
|
if (!forceRefresh) {
|
|
const cachedCustomers = this.customerStorage.getCustomersFromCache();
|
|
if (cachedCustomers) {
|
|
return cachedCustomers;
|
|
}
|
|
}
|
|
|
|
// Prevent multiple simultaneous requests
|
|
if (this.customerStorage.isCustomersCacheLoading()) {
|
|
console.log("Customers request already in progress");
|
|
return this._waitForCurrentRequest();
|
|
}
|
|
|
|
this.customerStorage.setCustomersCacheLoading(true);
|
|
|
|
try {
|
|
// Validate parameters
|
|
const validatedParams = this._validateCustomersParams(params);
|
|
|
|
// Fetch from API
|
|
const customersData = await this.customerAPI.getCustomers(
|
|
validatedParams,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
// Save to cache
|
|
this.customerStorage.saveCustomersToCache(customersData);
|
|
|
|
return customersData;
|
|
} finally {
|
|
this.customerStorage.setCustomersCacheLoading(false);
|
|
}
|
|
} catch (error) {
|
|
this.customerStorage.setCustomersCacheLoading(false);
|
|
console.error("Failed to get customers", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new customer with validation
|
|
*/
|
|
async createCustomer(customerData, onUnauthorizedCallback = null) {
|
|
try {
|
|
// Validate data
|
|
const validationErrors = this._validateCustomerData(customerData, true);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
throw validationErrors;
|
|
}
|
|
|
|
// Call API
|
|
const createdCustomer = await this.customerAPI.createCustomer(
|
|
customerData,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
// Clear cache since data changed
|
|
this.customerStorage.clearCustomersCache();
|
|
|
|
return createdCustomer;
|
|
} catch (error) {
|
|
console.error("Failed to create customer", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets customer detail
|
|
*/
|
|
async getCustomerDetail(customerId, onUnauthorizedCallback = null) {
|
|
try {
|
|
const validationError = this._validateCustomerId(customerId);
|
|
if (validationError) {
|
|
throw validationError;
|
|
}
|
|
|
|
const customerData = await this.customerAPI.getCustomerDetail(
|
|
customerId,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
return customerData;
|
|
} catch (error) {
|
|
console.error("Failed to get customer detail", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates a customer
|
|
*/
|
|
async updateCustomer(
|
|
customerId,
|
|
customerData,
|
|
onUnauthorizedCallback = null
|
|
) {
|
|
try {
|
|
// Validate ID
|
|
const idError = this._validateCustomerId(customerId);
|
|
if (idError) throw idError;
|
|
|
|
// Validate data
|
|
const validationErrors = this._validateCustomerData(customerData);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
throw validationErrors;
|
|
}
|
|
|
|
// Call API
|
|
const updatedCustomer = await this.customerAPI.updateCustomer(
|
|
customerId,
|
|
customerData,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
// Clear cache
|
|
this.customerStorage.clearCustomersCache();
|
|
|
|
return updatedCustomer;
|
|
} catch (error) {
|
|
console.error("Failed to update customer", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a customer
|
|
*/
|
|
async deleteCustomer(customerId, onUnauthorizedCallback = null) {
|
|
try {
|
|
const validationError = this._validateCustomerId(customerId);
|
|
if (validationError) throw validationError;
|
|
|
|
const deleteResponse = await this.customerAPI.deleteCustomer(
|
|
customerId,
|
|
onUnauthorizedCallback
|
|
);
|
|
|
|
// Clear cache
|
|
this.customerStorage.clearCustomersCache();
|
|
|
|
return deleteResponse;
|
|
} catch (error) {
|
|
console.error("Failed to delete customer", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears cache
|
|
*/
|
|
clearCustomersCache() {
|
|
this.customerStorage.clearCustomersCache();
|
|
}
|
|
|
|
/**
|
|
* Private validation methods
|
|
*/
|
|
_validateCustomerId(customerId) {
|
|
if (!customerId ||
|
|
(typeof customerId !== "string" && typeof customerId !== "number")) {
|
|
return { customerId: "Valid customer ID is required" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_validateCustomersParams(params) {
|
|
const validatedParams = {};
|
|
|
|
// Validate pagination
|
|
if (params.page && typeof params.page === "number" && params.page > 0) {
|
|
validatedParams.page = params.page;
|
|
}
|
|
|
|
if (params.limit && typeof params.limit === "number" &&
|
|
params.limit > 0 && params.limit <= 1000) {
|
|
validatedParams.limit = params.limit;
|
|
}
|
|
|
|
// Validate search
|
|
if (params.search && typeof params.search === "string" &&
|
|
params.search.trim()) {
|
|
validatedParams.search = params.search.trim();
|
|
}
|
|
|
|
return validatedParams;
|
|
}
|
|
|
|
_validateCustomerData(customerData, isCreate = false) {
|
|
const errors = {};
|
|
|
|
if (!customerData || typeof customerData !== "object") {
|
|
errors.general = "Customer data is required";
|
|
return errors;
|
|
}
|
|
|
|
// Validate first name
|
|
if (!customerData.firstName || !customerData.firstName.trim()) {
|
|
errors.firstName = "First name is required";
|
|
}
|
|
|
|
// Validate last name
|
|
if (!customerData.lastName || !customerData.lastName.trim()) {
|
|
errors.lastName = "Last name is required";
|
|
}
|
|
|
|
// Validate email
|
|
if (!customerData.email || !customerData.email.trim()) {
|
|
errors.email = "Email is required";
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerData.email)) {
|
|
errors.email = "Invalid email format";
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
_waitForCurrentRequest() {
|
|
return new Promise((resolve, reject) => {
|
|
const checkInterval = setInterval(() => {
|
|
if (!this.customerStorage.isCustomersCacheLoading()) {
|
|
clearInterval(checkInterval);
|
|
const cachedData = this.customerStorage.getCustomersFromCache();
|
|
if (cachedData) {
|
|
resolve(cachedData);
|
|
} else {
|
|
reject(new Error("Request failed"));
|
|
}
|
|
}
|
|
}, 100);
|
|
|
|
setTimeout(() => {
|
|
clearInterval(checkInterval);
|
|
reject(new Error("Request timeout"));
|
|
}, 30000);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Dependency Injection System
|
|
|
|
The application uses a custom dependency injection container without third-party libraries.
|
|
|
|
### Service Registration (`src/services/Services.jsx`)
|
|
|
|
```javascript
|
|
// src/services/Services.jsx
|
|
import React, { createContext, useContext, useMemo } from "react";
|
|
import { getAPIBaseURL, API_ENDPOINTS } from "./Config/APIConfig";
|
|
|
|
// Import all Storage services
|
|
import { TokenStorage } from "./Storage/TokenStorage";
|
|
import { CustomerStorage } from "./Storage/CustomerStorage";
|
|
|
|
// Import all API services
|
|
import { AuthAPI } from "./API/AuthAPI";
|
|
import { CustomerAPI } from "./API/CustomerAPI";
|
|
|
|
// Import all Manager services
|
|
import { AuthManager } from "./Manager/AuthManager";
|
|
import { CustomerManager } from "./Manager/CustomerManager";
|
|
|
|
/**
|
|
* Service container class that manages all service instances
|
|
*/
|
|
class Services {
|
|
constructor() {
|
|
const baseURL = getAPIBaseURL();
|
|
|
|
// Storage Services
|
|
this.tokenStorage = new TokenStorage();
|
|
this.customerStorage = new CustomerStorage();
|
|
|
|
// API Services (depend on tokenStorage)
|
|
this.authAPI = new AuthAPI(baseURL, API_ENDPOINTS, this.tokenStorage);
|
|
this.customerAPI = new CustomerAPI(
|
|
baseURL,
|
|
API_ENDPOINTS,
|
|
this.tokenStorage
|
|
);
|
|
|
|
// Manager Services (depend on API and Storage)
|
|
this.authManager = new AuthManager(this.authAPI, this.tokenStorage);
|
|
this.customerManager = new CustomerManager(
|
|
this.customerAPI,
|
|
this.customerStorage
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create React Context for DI
|
|
const ServiceContext = createContext(null);
|
|
|
|
/**
|
|
* ServiceProvider component wraps the application
|
|
*/
|
|
export function ServiceProvider({ children }) {
|
|
const services = useMemo(() => new Services(), []);
|
|
|
|
return (
|
|
<ServiceContext.Provider value={services}>
|
|
{children}
|
|
</ServiceContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hook to access services in components
|
|
*/
|
|
export function useServices() {
|
|
const context = useContext(ServiceContext);
|
|
if (!context) {
|
|
throw new Error("useServices must be used within ServiceProvider");
|
|
}
|
|
return context;
|
|
}
|
|
```
|
|
|
|
### Using Services in Components
|
|
|
|
```javascript
|
|
// Example: CustomerListPage.jsx
|
|
import React, { useState, useEffect } from "react";
|
|
import { useServices } from "../services/Services";
|
|
|
|
function CustomerListPage() {
|
|
const { customerManager } = useServices();
|
|
const [customers, setCustomers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetchCustomers();
|
|
}, []);
|
|
|
|
async function fetchCustomers() {
|
|
try {
|
|
setLoading(true);
|
|
const data = await customerManager.getCustomers(
|
|
{ page: 1, limit: 20 },
|
|
handleUnauthorized
|
|
);
|
|
setCustomers(data.results);
|
|
} catch (error) {
|
|
console.error("Failed to fetch customers:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleUnauthorized() {
|
|
// Handle unauthorized access (redirect to login)
|
|
window.location.href = "/login";
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h1>Customers</h1>
|
|
{loading ? (
|
|
<p>Loading...</p>
|
|
) : (
|
|
<ul>
|
|
{customers.map((customer) => (
|
|
<li key={customer.id}>
|
|
{customer.firstName} {customer.lastName}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Constants Management
|
|
|
|
**CRITICAL RULE**: The application has ZERO TOLERANCE for magic numbers or strings. Every value must be defined as a constant.
|
|
|
|
### Constants Structure
|
|
|
|
```
|
|
src/constants/
|
|
├── Customer.js # Customer-related constants
|
|
├── Authentication.js # Auth constants
|
|
├── Roles.js # User role constants
|
|
├── UI.js # UI-related constants
|
|
├── Order.js # Order constants
|
|
├── Event.js # Event constants
|
|
├── Financial.js # Financial constants
|
|
├── Storage.js # Storage key constants
|
|
└── ...
|
|
```
|
|
|
|
### Example: Customer Constants (`src/constants/Customer.js`)
|
|
|
|
```javascript
|
|
// src/constants/Customer.js
|
|
|
|
// Customer Status
|
|
export const CUSTOMER_STATUS = {
|
|
ACTIVE: 1,
|
|
ARCHIVED: 2,
|
|
};
|
|
|
|
// Customer Status Labels
|
|
export const CUSTOMER_STATUS_LABELS = {
|
|
[CUSTOMER_STATUS.ACTIVE]: "Active",
|
|
[CUSTOMER_STATUS.ARCHIVED]: "Archived",
|
|
};
|
|
|
|
// Customer Types
|
|
export const CUSTOMER_TYPE_VALUES = {
|
|
EDUCATIONAL: "educational",
|
|
CORPORATE: "corporate",
|
|
NON_PROFIT: "non_profit",
|
|
GOVERNMENT: "government",
|
|
};
|
|
|
|
// Customer Type Labels
|
|
export const CUSTOMER_TYPES = {
|
|
EDUCATIONAL: "Educational",
|
|
CORPORATE: "Corporate",
|
|
NON_PROFIT: "Non-Profit",
|
|
GOVERNMENT: "Government",
|
|
};
|
|
|
|
// Customer Type Options for Dropdowns
|
|
export const CUSTOMER_TYPE_OPTIONS = [
|
|
{ value: CUSTOMER_TYPE_VALUES.EDUCATIONAL, label: CUSTOMER_TYPES.EDUCATIONAL },
|
|
{ value: CUSTOMER_TYPE_VALUES.CORPORATE, label: CUSTOMER_TYPES.CORPORATE },
|
|
{ value: CUSTOMER_TYPE_VALUES.NON_PROFIT, label: CUSTOMER_TYPES.NON_PROFIT },
|
|
{ value: CUSTOMER_TYPE_VALUES.GOVERNMENT, label: CUSTOMER_TYPES.GOVERNMENT },
|
|
];
|
|
|
|
// Phone Types
|
|
export const CUSTOMER_PHONE_TYPES = {
|
|
MOBILE: 1,
|
|
WORK: 2,
|
|
HOME: 3,
|
|
};
|
|
|
|
export const CUSTOMER_PHONE_TYPE_LABELS = {
|
|
[CUSTOMER_PHONE_TYPES.MOBILE]: "Mobile",
|
|
[CUSTOMER_PHONE_TYPES.WORK]: "Work",
|
|
[CUSTOMER_PHONE_TYPES.HOME]: "Home",
|
|
};
|
|
|
|
// Pagination
|
|
export const CUSTOMERS_PER_PAGE = 20;
|
|
export const DEFAULT_PAGE = 1;
|
|
|
|
// Form Validation
|
|
export const CUSTOMER_VALIDATION = {
|
|
NAME_MIN_LENGTH: 2,
|
|
NAME_MAX_LENGTH: 100,
|
|
EMAIL_MAX_LENGTH: 255,
|
|
PHONE_MAX_LENGTH: 20,
|
|
ADDRESS_MAX_LENGTH: 500,
|
|
COMMENT_MAX_LENGTH: 638,
|
|
};
|
|
|
|
// Cache Configuration
|
|
export const CUSTOMER_CACHE_EXPIRY = {
|
|
LIST: 5 * 60 * 1000, // 5 minutes
|
|
DETAIL: 10 * 60 * 1000, // 10 minutes
|
|
SEARCH: 3 * 60 * 1000, // 3 minutes
|
|
};
|
|
|
|
// Countries
|
|
export const CUSTOMER_COUNTRIES = {
|
|
CA: "CA",
|
|
US: "US",
|
|
MX: "MX",
|
|
};
|
|
|
|
export const CUSTOMER_COUNTRY_LABELS = {
|
|
[CUSTOMER_COUNTRIES.CA]: "Canada",
|
|
[CUSTOMER_COUNTRIES.US]: "United States",
|
|
[CUSTOMER_COUNTRIES.MX]: "Mexico",
|
|
};
|
|
```
|
|
|
|
### Example: Authentication Constants (`src/constants/Authentication.js`)
|
|
|
|
```javascript
|
|
// src/constants/Authentication.js
|
|
|
|
// HTTP Status Codes
|
|
export const HTTP_STATUS = {
|
|
OK: 200,
|
|
CREATED: 201,
|
|
NO_CONTENT: 204,
|
|
BAD_REQUEST: 400,
|
|
UNAUTHORIZED: 401,
|
|
FORBIDDEN: 403,
|
|
NOT_FOUND: 404,
|
|
INTERNAL_SERVER_ERROR: 500,
|
|
};
|
|
|
|
// Token Types
|
|
export const AUTH_TOKEN_TYPE = {
|
|
JWT: "JWT",
|
|
BEARER: "Bearer",
|
|
};
|
|
|
|
// HTTP Headers
|
|
export const HTTP_HEADERS = {
|
|
CONTENT_TYPE_JSON: "application/json",
|
|
ACCEPT_JSON: "application/json",
|
|
CONTENT_TYPE_MULTIPART: "multipart/form-data",
|
|
};
|
|
|
|
// Token Storage Keys
|
|
export const TOKEN_STORAGE_KEYS = {
|
|
ACCESS_TOKEN: "MAPLEPRESS_ACCESS_TOKEN",
|
|
REFRESH_TOKEN: "MAPLEPRESS_REFRESH_TOKEN",
|
|
USER_DATA: "MAPLEPRESS_USER_DATA",
|
|
};
|
|
|
|
// Session Timeout
|
|
export const SESSION_TIMEOUT = {
|
|
WARNING: 5 * 60 * 1000, // 5 minutes warning
|
|
LOGOUT: 15 * 60 * 1000, // 15 minutes auto logout
|
|
};
|
|
```
|
|
|
|
### Using Constants in Components
|
|
|
|
```javascript
|
|
import { CUSTOMER_STATUS, CUSTOMER_STATUS_LABELS } from "@constants/Customer";
|
|
|
|
function CustomerBadge({ status }) {
|
|
const label = CUSTOMER_STATUS_LABELS[status];
|
|
const isActive = status === CUSTOMER_STATUS.ACTIVE;
|
|
|
|
return (
|
|
<span className={isActive ? "badge-green" : "badge-gray"}>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Component Architecture
|
|
|
|
Components are organized into three main categories:
|
|
|
|
### 1. UI Components (`src/components/UI/`)
|
|
|
|
Pure, stateless, reusable components with no business logic.
|
|
|
|
```
|
|
UI/
|
|
├── Button/
|
|
│ └── Button.jsx
|
|
├── Input/
|
|
│ └── Input.jsx
|
|
├── Modal/
|
|
│ └── Modal.jsx
|
|
├── Table/
|
|
│ └── Table.jsx
|
|
├── Card/
|
|
│ └── Card.jsx
|
|
├── Alert/
|
|
│ └── Alert.jsx
|
|
└── ...
|
|
```
|
|
|
|
**Example: Button Component**
|
|
|
|
```javascript
|
|
// src/components/UI/Button/Button.jsx
|
|
import React from "react";
|
|
import PropTypes from "prop-types";
|
|
import clsx from "clsx";
|
|
|
|
export function Button({
|
|
children,
|
|
variant = "primary",
|
|
size = "md",
|
|
disabled = false,
|
|
loading = false,
|
|
onClick,
|
|
type = "button",
|
|
className,
|
|
...rest
|
|
}) {
|
|
const baseStyles = "inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
|
|
|
|
const variantStyles = {
|
|
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
|
secondary: "bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500",
|
|
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
|
outline: "border-2 border-gray-300 text-gray-700 hover:bg-gray-50",
|
|
};
|
|
|
|
const sizeStyles = {
|
|
sm: "px-3 py-1.5 text-sm",
|
|
md: "px-4 py-2 text-base",
|
|
lg: "px-6 py-3 text-lg",
|
|
};
|
|
|
|
const disabledStyles = "opacity-50 cursor-not-allowed";
|
|
|
|
return (
|
|
<button
|
|
type={type}
|
|
disabled={disabled || loading}
|
|
onClick={onClick}
|
|
className={clsx(
|
|
baseStyles,
|
|
variantStyles[variant],
|
|
sizeStyles[size],
|
|
(disabled || loading) && disabledStyles,
|
|
className
|
|
)}
|
|
{...rest}
|
|
>
|
|
{loading && <Spinner className="mr-2" />}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
Button.propTypes = {
|
|
children: PropTypes.node.isRequired,
|
|
variant: PropTypes.oneOf(["primary", "secondary", "danger", "outline"]),
|
|
size: PropTypes.oneOf(["sm", "md", "lg"]),
|
|
disabled: PropTypes.bool,
|
|
loading: PropTypes.bool,
|
|
onClick: PropTypes.func,
|
|
type: PropTypes.oneOf(["button", "submit", "reset"]),
|
|
className: PropTypes.string,
|
|
};
|
|
```
|
|
|
|
### 2. Business Components (`src/components/business/`)
|
|
|
|
Domain-aware components with service integration.
|
|
|
|
```javascript
|
|
// src/components/business/CustomerSelect.jsx
|
|
import React, { useState, useEffect } from "react";
|
|
import PropTypes from "prop-types";
|
|
import { useServices } from "@services/Services";
|
|
|
|
export function CustomerSelect({ value, onChange, placeholder = "Select customer" }) {
|
|
const { customerManager } = useServices();
|
|
const [customers, setCustomers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetchCustomers();
|
|
}, []);
|
|
|
|
async function fetchCustomers() {
|
|
try {
|
|
setLoading(true);
|
|
const data = await customerManager.getCustomers({ limit: 100 });
|
|
setCustomers(data.results || []);
|
|
} catch (error) {
|
|
console.error("Failed to fetch customers:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="form-select"
|
|
disabled={loading}
|
|
>
|
|
<option value="">{loading ? "Loading..." : placeholder}</option>
|
|
{customers.map((customer) => (
|
|
<option key={customer.id} value={customer.id}>
|
|
{customer.firstName} {customer.lastName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
CustomerSelect.propTypes = {
|
|
value: PropTypes.string,
|
|
onChange: PropTypes.func.isRequired,
|
|
placeholder: PropTypes.string,
|
|
};
|
|
```
|
|
|
|
### 3. Layout Components (`src/components/Layout/`)
|
|
|
|
Application structure components.
|
|
|
|
```javascript
|
|
// src/components/Layout/Sidebar.jsx
|
|
import React from "react";
|
|
import { Link, useLocation } from "react-router-dom";
|
|
import { HomeIcon, UsersIcon, DocumentIcon } from "@heroicons/react/24/outline";
|
|
|
|
export function Sidebar() {
|
|
const location = useLocation();
|
|
|
|
const navigation = [
|
|
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon },
|
|
{ name: "Customers", href: "/customers", icon: UsersIcon },
|
|
{ name: "Orders", href: "/orders", icon: DocumentIcon },
|
|
];
|
|
|
|
return (
|
|
<aside className="w-64 bg-gray-800 text-white min-h-screen">
|
|
<nav className="p-4 space-y-2">
|
|
{navigation.map((item) => {
|
|
const isActive = location.pathname.startsWith(item.href);
|
|
return (
|
|
<Link
|
|
key={item.name}
|
|
to={item.href}
|
|
className={clsx(
|
|
"flex items-center px-4 py-2 rounded-md",
|
|
isActive
|
|
? "bg-gray-900 text-white"
|
|
: "text-gray-300 hover:bg-gray-700"
|
|
)}
|
|
>
|
|
<item.icon className="w-5 h-5 mr-3" />
|
|
{item.name}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Authentication & Authorization
|
|
|
|
### Authenticated Axios Instance
|
|
|
|
```javascript
|
|
// src/services/Helpers/AuthenticatedAxios.js
|
|
import axios from "axios";
|
|
import { camelizeKeys } from "humps";
|
|
import { API_ENDPOINTS } from "../Config/APIConfig";
|
|
import {
|
|
AUTH_TOKEN_TYPE,
|
|
HTTP_HEADERS,
|
|
HTTP_STATUS,
|
|
} from "@constants/Authentication";
|
|
|
|
export function createAuthenticatedAxios(
|
|
baseURL,
|
|
tokenStorage,
|
|
onUnauthorizedCallback = null
|
|
) {
|
|
const accessToken = tokenStorage.getAccessToken();
|
|
|
|
const authenticatedAxios = axios.create({
|
|
baseURL: baseURL,
|
|
headers: {
|
|
Authorization: `${AUTH_TOKEN_TYPE.JWT} ${accessToken}`,
|
|
"Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON,
|
|
Accept: HTTP_HEADERS.ACCEPT_JSON,
|
|
},
|
|
});
|
|
|
|
// Add response interceptor for automatic token refresh
|
|
authenticatedAxios.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalConfig = error.config;
|
|
|
|
// Handle 401 unauthorized errors
|
|
if (error.response?.status === HTTP_STATUS.UNAUTHORIZED) {
|
|
const refreshToken = tokenStorage.getRefreshToken();
|
|
|
|
if (refreshToken) {
|
|
try {
|
|
// Attempt token refresh
|
|
const refreshResponse = await handleTokenRefresh(
|
|
baseURL,
|
|
refreshToken
|
|
);
|
|
|
|
if (refreshResponse?.status === HTTP_STATUS.OK) {
|
|
// Save new tokens
|
|
const newAccessToken = refreshResponse.data.access_token;
|
|
const newRefreshToken = refreshResponse.data.refresh_token;
|
|
|
|
tokenStorage.setAccessToken(newAccessToken);
|
|
tokenStorage.setRefreshToken(newRefreshToken);
|
|
|
|
// Retry original request with new token
|
|
const retryConfig = {
|
|
...originalConfig,
|
|
headers: {
|
|
...originalConfig.headers,
|
|
Authorization: `${AUTH_TOKEN_TYPE.JWT} ${newAccessToken}`,
|
|
},
|
|
};
|
|
|
|
return authenticatedAxios(retryConfig);
|
|
}
|
|
} catch (refreshError) {
|
|
console.error("Token refresh failed:", refreshError);
|
|
if (onUnauthorizedCallback) {
|
|
onUnauthorizedCallback();
|
|
}
|
|
}
|
|
} else if (onUnauthorizedCallback) {
|
|
onUnauthorizedCallback();
|
|
}
|
|
}
|
|
|
|
return Promise.reject(error.response?.data || error);
|
|
}
|
|
);
|
|
|
|
return authenticatedAxios;
|
|
}
|
|
|
|
async function handleTokenRefresh(baseURL, refreshToken) {
|
|
const refreshAxios = axios.create({
|
|
baseURL: baseURL,
|
|
headers: {
|
|
"Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON,
|
|
Accept: HTTP_HEADERS.ACCEPT_JSON,
|
|
Authorization: `${AUTH_TOKEN_TYPE.BEARER} ${refreshToken}`,
|
|
},
|
|
});
|
|
|
|
const refreshData = { value: refreshToken };
|
|
|
|
return await refreshAxios.post(API_ENDPOINTS.REFRESH_TOKEN, refreshData);
|
|
}
|
|
```
|
|
|
|
### Token Storage
|
|
|
|
```javascript
|
|
// src/services/Storage/TokenStorage.js
|
|
import { TOKEN_STORAGE_KEYS } from "@constants/Authentication";
|
|
|
|
export class TokenStorage {
|
|
/**
|
|
* Gets access token from localStorage
|
|
*/
|
|
getAccessToken() {
|
|
return localStorage.getItem(TOKEN_STORAGE_KEYS.ACCESS_TOKEN);
|
|
}
|
|
|
|
/**
|
|
* Sets access token in localStorage
|
|
*/
|
|
setAccessToken(token) {
|
|
if (token) {
|
|
localStorage.setItem(TOKEN_STORAGE_KEYS.ACCESS_TOKEN, token);
|
|
} else {
|
|
localStorage.removeItem(TOKEN_STORAGE_KEYS.ACCESS_TOKEN);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets refresh token from localStorage
|
|
*/
|
|
getRefreshToken() {
|
|
return localStorage.getItem(TOKEN_STORAGE_KEYS.REFRESH_TOKEN);
|
|
}
|
|
|
|
/**
|
|
* Sets refresh token in localStorage
|
|
*/
|
|
setRefreshToken(token) {
|
|
if (token) {
|
|
localStorage.setItem(TOKEN_STORAGE_KEYS.REFRESH_TOKEN, token);
|
|
} else {
|
|
localStorage.removeItem(TOKEN_STORAGE_KEYS.REFRESH_TOKEN);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears all tokens
|
|
*/
|
|
clearTokens() {
|
|
localStorage.removeItem(TOKEN_STORAGE_KEYS.ACCESS_TOKEN);
|
|
localStorage.removeItem(TOKEN_STORAGE_KEYS.REFRESH_TOKEN);
|
|
localStorage.removeItem(TOKEN_STORAGE_KEYS.USER_DATA);
|
|
}
|
|
|
|
/**
|
|
* Checks if user is authenticated
|
|
*/
|
|
isAuthenticated() {
|
|
return !!this.getAccessToken();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Routing & Navigation
|
|
|
|
### Main Router (`src/AppRouter.jsx`)
|
|
|
|
```javascript
|
|
// src/AppRouter.jsx
|
|
import React from "react";
|
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
|
import { useServices } from "./services/Services";
|
|
|
|
// Pages
|
|
import LoginPage from "./pages/Auth/Login/Page";
|
|
import DashboardPage from "./pages/Dashboard/Page";
|
|
import CustomerListPage from "./pages/Admin/Customer/List/Page";
|
|
import CustomerDetailPage from "./pages/Admin/Customer/Detail/Page";
|
|
import CustomerCreatePage from "./pages/Admin/Customer/Create/Page";
|
|
import CustomerUpdatePage from "./pages/Admin/Customer/Update/Page";
|
|
|
|
function AppRouter() {
|
|
return (
|
|
<BrowserRouter>
|
|
<Routes>
|
|
{/* Public routes */}
|
|
<Route path="/login" element={<LoginPage />} />
|
|
|
|
{/* Protected routes */}
|
|
<Route
|
|
path="/dashboard"
|
|
element={
|
|
<ProtectedRoute>
|
|
<DashboardPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
|
|
{/* Customer routes */}
|
|
<Route
|
|
path="/customers"
|
|
element={
|
|
<ProtectedRoute>
|
|
<CustomerListPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/customers/create"
|
|
element={
|
|
<ProtectedRoute>
|
|
<CustomerCreatePage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/customers/:id"
|
|
element={
|
|
<ProtectedRoute>
|
|
<CustomerDetailPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/customers/:id/update"
|
|
element={
|
|
<ProtectedRoute>
|
|
<CustomerUpdatePage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
|
|
{/* Default redirect */}
|
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
</Routes>
|
|
</BrowserRouter>
|
|
);
|
|
}
|
|
|
|
function ProtectedRoute({ children }) {
|
|
const { authManager } = useServices();
|
|
|
|
if (!authManager.isAuthenticated()) {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
return children;
|
|
}
|
|
|
|
export default AppRouter;
|
|
```
|
|
|
|
---
|
|
|
|
## State Management & Caching
|
|
|
|
### Caching Strategy
|
|
|
|
The application uses a two-tier caching strategy:
|
|
|
|
1. **Memory Cache**: Fastest, cleared on page reload
|
|
2. **localStorage Cache**: Persistent across sessions
|
|
|
|
### Example: List Page with Caching
|
|
|
|
```javascript
|
|
// src/pages/Admin/Customer/List/Page.jsx
|
|
import React, { useState, useEffect } from "react";
|
|
import { useServices } from "@services/Services";
|
|
import { CUSTOMERS_PER_PAGE } from "@constants/Customer";
|
|
|
|
export default function CustomerListPage() {
|
|
const { customerManager } = useServices();
|
|
const [customers, setCustomers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
fetchCustomers();
|
|
}, [page]);
|
|
|
|
async function fetchCustomers(forceRefresh = false) {
|
|
try {
|
|
setLoading(true);
|
|
|
|
const data = await customerManager.getCustomers(
|
|
{
|
|
page: page,
|
|
limit: CUSTOMERS_PER_PAGE,
|
|
},
|
|
handleUnauthorized,
|
|
forceRefresh
|
|
);
|
|
|
|
setCustomers(data.results || []);
|
|
setTotalCount(data.count || 0);
|
|
} catch (error) {
|
|
console.error("Failed to fetch customers:", error);
|
|
// Show error toast
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleUnauthorized() {
|
|
window.location.href = "/login";
|
|
}
|
|
|
|
function handleRefresh() {
|
|
fetchCustomers(true); // Force refresh
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h1 className="text-3xl font-bold">Customers</h1>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="btn-secondary"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<LoadingSpinner />
|
|
) : (
|
|
<>
|
|
<CustomerTable customers={customers} />
|
|
<Pagination
|
|
currentPage={page}
|
|
totalCount={totalCount}
|
|
pageSize={CUSTOMERS_PER_PAGE}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Styling & Theming
|
|
|
|
### Tailwind CSS Configuration
|
|
|
|
```javascript
|
|
// tailwind.config.js
|
|
export default {
|
|
content: [
|
|
"./index.html",
|
|
"./src/**/*.{js,jsx}",
|
|
],
|
|
theme: {
|
|
extend: {
|
|
screens: {
|
|
'xs': '400px',
|
|
},
|
|
colors: {
|
|
primary: {
|
|
50: '#eff6ff',
|
|
100: '#dbeafe',
|
|
500: '#3b82f6',
|
|
600: '#2563eb',
|
|
700: '#1d4ed8',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
plugins: [],
|
|
}
|
|
```
|
|
|
|
### Global Styles (`src/styles/app.css`)
|
|
|
|
```css
|
|
@import "tailwindcss";
|
|
|
|
/* Base styles */
|
|
body {
|
|
@apply bg-gray-50 text-gray-900;
|
|
}
|
|
|
|
/* Utility classes */
|
|
.btn {
|
|
@apply px-4 py-2 rounded-md font-medium transition-colors;
|
|
}
|
|
|
|
.btn-primary {
|
|
@apply btn bg-blue-600 text-white hover:bg-blue-700;
|
|
}
|
|
|
|
.btn-secondary {
|
|
@apply btn bg-gray-600 text-white hover:bg-gray-700;
|
|
}
|
|
|
|
.form-input {
|
|
@apply block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm;
|
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
|
}
|
|
|
|
.form-select {
|
|
@apply form-input;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Build Configuration
|
|
|
|
### Vite Configuration (`vite.config.js`)
|
|
|
|
```javascript
|
|
// vite.config.js
|
|
import { defineConfig } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import path from "path";
|
|
|
|
export default defineConfig(({ mode }) => ({
|
|
plugins: [
|
|
react(),
|
|
tailwindcss(),
|
|
],
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
"@components": path.resolve(__dirname, "./src/components"),
|
|
"@pages": path.resolve(__dirname, "./src/pages"),
|
|
"@services": path.resolve(__dirname, "./src/services"),
|
|
"@constants": path.resolve(__dirname, "./src/constants"),
|
|
"@utils": path.resolve(__dirname, "./src/utils"),
|
|
"@styles": path.resolve(__dirname, "./src/styles"),
|
|
},
|
|
},
|
|
server: {
|
|
port: 3000,
|
|
open: true,
|
|
cors: true,
|
|
proxy: {
|
|
"/api": {
|
|
target: process.env.VITE_API_URL || "http://localhost:8080",
|
|
changeOrigin: true,
|
|
secure: false,
|
|
},
|
|
},
|
|
},
|
|
build: {
|
|
outDir: "dist",
|
|
sourcemap: mode === "development" || mode === "staging",
|
|
minify: "terser",
|
|
terserOptions: {
|
|
compress: {
|
|
drop_console: mode === "production" || mode === "staging",
|
|
drop_debugger: true,
|
|
},
|
|
},
|
|
rollupOptions: {
|
|
output: {
|
|
manualChunks: {
|
|
"react-vendor": ["react", "react-dom", "react-router-dom"],
|
|
"ui-vendor": ["@heroicons/react", "clsx"],
|
|
"utils-vendor": ["axios", "luxon", "humps"],
|
|
},
|
|
},
|
|
},
|
|
chunkSizeWarningLimit: 1000,
|
|
},
|
|
optimizeDeps: {
|
|
include: [
|
|
"react",
|
|
"react-dom",
|
|
"react-router-dom",
|
|
"@heroicons/react/24/outline",
|
|
"@heroicons/react/24/solid",
|
|
],
|
|
},
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Setup Guide
|
|
|
|
### Step 1: Initialize Project
|
|
|
|
```bash
|
|
# Create new React project with Vite
|
|
npm create vite@latest my-app -- --template react
|
|
|
|
cd my-app
|
|
|
|
# Install dependencies
|
|
npm install react@^19.1.0 react-dom@^19.1.0
|
|
npm install react-router-dom@^7.8.0
|
|
npm install axios@^1.11.0
|
|
npm install luxon@^3.7.1
|
|
npm install humps@^2.0.1
|
|
npm install @heroicons/react@^2.2.0
|
|
npm install clsx@^2.1.1
|
|
npm install prop-types@^15.8.1
|
|
|
|
# Install dev dependencies
|
|
npm install -D tailwindcss@^4.1.12
|
|
npm install -D @tailwindcss/vite@^4.1.11
|
|
npm install -D @tailwindcss/postcss@^4.1.12
|
|
npm install -D vite@^7.1.2
|
|
npm install -D @vitejs/plugin-react@^4.7.0
|
|
npm install -D terser@^5.44.0
|
|
npm install -D eslint@^9.30.1
|
|
npm install -D @eslint/js@^9.30.1
|
|
npm install -D eslint-plugin-react-hooks@^5.2.0
|
|
```
|
|
|
|
### Step 2: Configure Vite
|
|
|
|
Create `vite.config.js` as shown in Build Configuration section.
|
|
|
|
### Step 3: Configure Tailwind
|
|
|
|
Create `tailwind.config.js` as shown in Styling section.
|
|
|
|
### Step 4: Create Directory Structure
|
|
|
|
```bash
|
|
mkdir -p src/{components/{UI,UIX,business,Layout},pages,services/{API,Storage,Manager,Config,Helpers},constants,utils,styles,assets}
|
|
```
|
|
|
|
### Step 5: Create Service Layer
|
|
|
|
1. **Create API Config**
|
|
|
|
```javascript
|
|
// src/services/Config/APIConfig.js
|
|
export function getAPIBaseURL() {
|
|
return import.meta.env.VITE_API_URL || "http://localhost:8080";
|
|
}
|
|
|
|
export const API_ENDPOINTS = {
|
|
// Auth
|
|
LOGIN: "/api/v1/auth/login",
|
|
LOGOUT: "/api/v1/auth/logout",
|
|
REFRESH_TOKEN: "/api/v1/auth/refresh",
|
|
|
|
// Customers
|
|
CUSTOMERS: "/api/v1/customers",
|
|
CUSTOMER_DETAIL: "/api/v1/customers/{id}",
|
|
CUSTOMER_COUNT: "/api/v1/customers/count",
|
|
};
|
|
|
|
export const ENV_CONFIG = {
|
|
API_URL: import.meta.env.VITE_API_URL,
|
|
ENV: import.meta.env.MODE,
|
|
};
|
|
```
|
|
|
|
2. **Create Storage Services** (TokenStorage, CustomerStorage, etc.)
|
|
3. **Create API Services** (AuthAPI, CustomerAPI, etc.)
|
|
4. **Create Manager Services** (AuthManager, CustomerManager, etc.)
|
|
5. **Create Services Container** (`src/services/Services.jsx`)
|
|
|
|
### Step 6: Create Constants
|
|
|
|
Create constant files for each domain (Customer, Auth, UI, etc.) as shown in Constants Management section.
|
|
|
|
### Step 7: Create Components
|
|
|
|
1. Create UI components (Button, Input, Modal, etc.)
|
|
2. Create Layout components (Sidebar, Header, Footer)
|
|
3. Create business components (CustomerSelect, etc.)
|
|
|
|
### Step 8: Create Pages
|
|
|
|
Create page components following the structure:
|
|
```
|
|
pages/
|
|
└── Admin/
|
|
└── Customer/
|
|
├── List/
|
|
│ └── Page.jsx
|
|
├── Detail/
|
|
│ └── Page.jsx
|
|
├── Create/
|
|
│ └── Page.jsx
|
|
└── Update/
|
|
└── Page.jsx
|
|
```
|
|
|
|
### Step 9: Setup Router
|
|
|
|
Create `AppRouter.jsx` as shown in Routing section.
|
|
|
|
### Step 10: Setup Main Entry
|
|
|
|
```javascript
|
|
// src/main.jsx
|
|
import React from "react";
|
|
import ReactDOM from "react-dom/client";
|
|
import App from "./AppRouter";
|
|
import { ServiceProvider } from "./services/Services";
|
|
import "./styles/app.css";
|
|
|
|
// Error Boundary
|
|
class ErrorBoundary extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = { hasError: false, error: null };
|
|
}
|
|
|
|
static getDerivedStateFromError(error) {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error, errorInfo) {
|
|
console.error("Error:", error, errorInfo);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="bg-white p-6 rounded-lg shadow-lg">
|
|
<h1 className="text-2xl font-bold text-red-600 mb-4">
|
|
Something went wrong
|
|
</h1>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="btn-primary"
|
|
>
|
|
Reload Page
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById("root"));
|
|
|
|
root.render(
|
|
<React.StrictMode>
|
|
<ErrorBoundary>
|
|
<ServiceProvider>
|
|
<App />
|
|
</ServiceProvider>
|
|
</ErrorBoundary>
|
|
</React.StrictMode>
|
|
);
|
|
```
|
|
|
|
### Step 11: Environment Variables
|
|
|
|
Create `.env` files:
|
|
|
|
```bash
|
|
# .env.development
|
|
VITE_API_URL=http://localhost:8080
|
|
|
|
# .env.production
|
|
VITE_API_URL=https://api.production.com
|
|
|
|
# .env.staging
|
|
VITE_API_URL=https://api.staging.com
|
|
```
|
|
|
|
### Step 12: Package.json Scripts
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "vite build",
|
|
"build:production": "vite build --mode production",
|
|
"build:staging": "vite build --mode staging",
|
|
"preview": "vite preview",
|
|
"lint": "eslint .",
|
|
"clean": "rm -rf node_modules dist",
|
|
"clean:install": "npm run clean && npm install"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices & Patterns
|
|
|
|
### 1. Service Layer Rules
|
|
|
|
- **Components ONLY interact with Manager services**
|
|
- **Never call API services directly from components**
|
|
- **Never access Storage services directly from components**
|
|
- **Managers orchestrate API and Storage layers**
|
|
|
|
### 2. Constants Rules
|
|
|
|
- **ZERO TOLERANCE for magic values**
|
|
- **Every number, string, or config value must be a constant**
|
|
- **Check `src/constants/` before writing ANY code**
|
|
- **Create new constants if they don't exist**
|
|
|
|
### 3. Component Rules
|
|
|
|
- **UI components are pure and stateless**
|
|
- **Business components use the service layer**
|
|
- **Page components handle routing logic**
|
|
- **Always use PropTypes**
|
|
|
|
### 4. Error Handling
|
|
|
|
```javascript
|
|
// Consistent error handling pattern
|
|
try {
|
|
const data = await manager.getData();
|
|
setData(data);
|
|
} catch (error) {
|
|
console.error("Operation failed:", error);
|
|
// Show user-friendly error message
|
|
showToast("Failed to load data", "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
```
|
|
|
|
### 5. Caching Strategy
|
|
|
|
- **Use memory cache first (fastest)**
|
|
- **Fall back to localStorage**
|
|
- **Clear cache on mutations**
|
|
- **Implement loading flags to prevent duplicate requests**
|
|
|
|
### 6. Code Organization
|
|
|
|
```javascript
|
|
// Component structure order:
|
|
// 1. Imports
|
|
// 2. Component definition
|
|
// 3. State hooks
|
|
// 4. Effect hooks
|
|
// 5. Event handlers
|
|
// 6. Helper functions
|
|
// 7. Render logic
|
|
// 8. PropTypes
|
|
```
|
|
|
|
### 7. Naming Conventions
|
|
|
|
- **Components**: PascalCase (CustomerList)
|
|
- **Functions**: camelCase (fetchCustomers)
|
|
- **Constants**: UPPER_SNAKE_CASE (CUSTOMER_STATUS)
|
|
- **CSS Classes**: kebab-case (customer-list)
|
|
- **Files**: PascalCase for components, camelCase for utilities
|
|
|
|
### 8. Testing Checklist
|
|
|
|
Before deployment, verify:
|
|
- [ ] All magic values replaced with constants
|
|
- [ ] Components use Manager services only
|
|
- [ ] Error handling implemented
|
|
- [ ] Loading states handled
|
|
- [ ] Caching working correctly
|
|
- [ ] Authentication flow tested
|
|
- [ ] Token refresh working
|
|
- [ ] PropTypes defined
|
|
- [ ] Console logs removed (in production)
|
|
- [ ] Build succeeds without warnings
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
This architecture provides:
|
|
- **Scalability**: Clear separation of concerns allows teams to work independently
|
|
- **Maintainability**: Consistent patterns make code easy to understand and modify
|
|
- **Testability**: Service layer isolation enables comprehensive testing
|
|
- **Performance**: Multi-tier caching reduces API calls
|
|
- **Reliability**: Automatic token refresh and error handling
|
|
- **Developer Experience**: Path aliases, constants, and clear patterns
|
|
|
|
Follow these patterns strictly to maintain consistency and quality across the entire application.
|
|
|
|
---
|
|
|
|
**Document Version**: 1.0.0
|
|
**Last Updated**: 2025-01-30
|
|
**Maintained By**: Development Team
|