diff --git a/README.md b/README.md index af6fe9e3..b67f0cce 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ It serves as a full-scale technical showcase of building resilient, high-concurrency monoliths using **Clean Architecture**, **Domain-Driven Design (DDD)** principles, and modern reactive frontend design. +

+ Launchly Platform Overview +

+ --- ## Tech Stack @@ -65,10 +69,14 @@ It serves as a full-scale technical showcase of building resilient, high-concurr - **@xyflow/react (React Flow)**: Interactive visual bot graph and node constructor - **Tailwind CSS v4**: High-performance modern utility styling - **TanStack Query v5**: Server state caching, background synchronization, and optimistic mutations -- **Zustand v5**: Client-only synchronous state (auth session, canvas selection, UI theme) +- **Zustand v5**: Client-only synchronous state (auth session, canvas selection, network health) - **React Hook Form + Zod v4**: Strict type-safe form validation and DTO transformations - **React Router v7**: Nested dashboard layouts and role-based route guards - **STOMP & SockJS Client**: Real-time live chat subscriptions +- **TanStack Virtual & Custom Virtualizer**: Scalable windowing for high-volume conversation and contact lists +- **DOMPurify**: Industrial-grade XSS sanitization and URL validation pipeline +- **BroadcastChannel & Storage Events**: Real-time multi-tab session synchronization +- **Axios Idempotency & AbortController Interceptors**: Automatic idempotency injection and query deduplication - **Vitest & Testing Library**: Unit and component test suites with v8 coverage - **Playwright**: End-to-end browser automation suite @@ -117,9 +125,15 @@ Detailed backend architecture & ADRs: [docs/backend/ARCHITECTURE.md](docs/backen ### Frontend Architecture - **Layer-Based & Feature-Sliced**: Strict modular hierarchy dividing low-level UI primitives, REST API clients, custom React hooks, and domain-specific route views. - **Interactive Visual Flow Builder**: Custom node-graph editor powered by `@xyflow/react` (React Flow), featuring real-time node validation, connection rules, and seamless JSON schema serialization. -- **Strict State Separation**: Server state cached and invalidated via TanStack Query v5; UI/client state (modals, auth sessions, canvas selection) isolated in Zustand stores. +- **Strict State Separation**: Server state cached and invalidated via TanStack Query v5; UI/client state (modals, auth sessions, canvas selection, network health) isolated in Zustand stores. - **Real-Time Live Chat (STOMP / SockJS)**: Low-latency bidirectional WebSocket connection for live CRM messaging, operator typing indicators, and instant lead status updates. -- **Optimistic UI Updates**: Immediate client-side reflection of CRM lead state changes, tag assignments, and message delivery with automatic rollback on network failure. +- **Optimistic UI Updates**: Immediate client-side reflection of CRM messages, notes, lead states, tag assignments, and support tickets with automatic rollback on network failure. +- **Automatic Client Idempotency**: Transparent injection of UUID v4 `Idempotency-Key` headers on `POST`, `DELETE`, and `PATCH` requests, synchronized with backend replay-protection filters. +- **Race Condition Prevention & Request Cancellation**: Automated query deduplication and superseded search request abortion via native `AbortController` in Axios. +- **Zero-CLS Skeleton Screens**: Purpose-built Neo-Brutalist skeleton states maintaining layout stability (CLS = 0) during async data fetches. +- **Multi-Tab State Synchronization**: `BroadcastChannel` event-driven architecture synchronizing auth sessions, active bot context, and logout triggers across concurrent browser tabs. +- **Defensive XSS Pipeline**: Deep DOMPurify sanitization and safe link filtering on all dynamic markdown and HTML solutions. +- **Real-Time Network & WebSocket Health**: Sticky connection banner monitoring browser connectivity and STOMP session lifecycles with debounced auto-reconnection. - **Silent JWT Refresh Interceptor**: Axios queue mechanism ensuring transparent access token rotation on HTTP 401 without disrupting in-flight operations or prompting re-login. - **Type-Safe Validation & Form Pipelines**: Unified runtime and compile-time validation powered by Zod v4 and React Hook Form across all bot configurations and integration settings. - **Role & Tier-Aware Route Protection**: Granular client-side route guards restricting capabilities based on active user roles (`OWNER`, `OPERATOR`, `ADMIN`) and subscription tier limits. diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java index cf41eeb4..e798053b 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java @@ -49,7 +49,7 @@ public ResponseEntity> getAutomations( @ApiResponse(responseCode = "404", description = "Automation flow not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "403", description = "Forbidden - requires ADMIN or MANAGER role", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/{automationId}/details") + @GetMapping("/{automationId}") @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity getAutomationDetails( @Parameter(description = "Automation schema ID") @PathVariable Long automationId, diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java index 927eaf0a..8e1b649a 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java @@ -49,7 +49,7 @@ public ResponseEntity> getBroadcasts( @ApiResponse(responseCode = "404", description = "Broadcast campaign not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "403", description = "Forbidden - requires ADMIN or MANAGER role", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/{broadcastId}/details") + @GetMapping("/{broadcastId}") @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity getBroadcastDetails( @Parameter(description = "Broadcast campaign ID") @PathVariable Long broadcastId, diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java b/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java index 512149b3..f474225b 100644 --- a/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java +++ b/backend/src/main/java/com/launchly/admin/controller/AdminUserController.java @@ -85,7 +85,7 @@ public ResponseEntity toggleUserStatus( @ApiResponse(responseCode = "404", description = "User not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "403", description = "Forbidden - requires ADMIN or MANAGER role", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @GetMapping("/{userId}/details") + @GetMapping("/{userId}") @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") public ResponseEntity getUserDetails( @Parameter(description = "Target user ID") @PathVariable Long userId, diff --git a/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java b/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java index 39f0a6bb..1351b562 100644 --- a/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java +++ b/backend/src/main/java/com/launchly/admin/repository/SupportTicketRepository.java @@ -15,27 +15,27 @@ @Repository public interface SupportTicketRepository extends JpaRepository, JpaSpecificationExecutor { - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) @Query("SELECT t FROM SupportTicket t WHERE t.user.id = :userId ORDER BY t.updatedAt DESC") Optional findFirstByUserId(@Param("userId") Long userId); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByUserId(Long userId, Pageable pageable); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByStatus(String status, Pageable pageable); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByUnreadForAdminTrue(Pageable pageable); - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findByIsFavoriteTrue(Pageable pageable); @Override - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Optional findById(Long id); @Override - @EntityGraph(attributePaths = {"user", "assignedTo"}) + @EntityGraph(attributePaths = {"user", "assignedManager"}) Page findAll(Specification spec, Pageable pageable); } diff --git a/backend/src/main/java/com/launchly/auth/controller/AuthController.java b/backend/src/main/java/com/launchly/auth/controller/AuthController.java index 908419ec..561654cc 100644 --- a/backend/src/main/java/com/launchly/auth/controller/AuthController.java +++ b/backend/src/main/java/com/launchly/auth/controller/AuthController.java @@ -108,7 +108,7 @@ public ResponseEntity updateProfile(@Valid @RequestBody UpdateProf @ApiResponse(responseCode = "204", description = "Account deleted successfully"), @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @DeleteMapping("/delete-account") + @DeleteMapping("/account") public ResponseEntity deleteAccount(Authentication authentication) { UserResponse currentUser = authService.getCurrentUser(authentication.getName()); authService.deleteUserAccount(currentUser.id()); diff --git a/backend/src/main/java/com/launchly/bot/controller/TemplateController.java b/backend/src/main/java/com/launchly/bot/controller/TemplateController.java index 86555c5e..5a2bb234 100644 --- a/backend/src/main/java/com/launchly/bot/controller/TemplateController.java +++ b/backend/src/main/java/com/launchly/bot/controller/TemplateController.java @@ -39,7 +39,7 @@ public class TemplateController { @ApiResponse(responseCode = "400", description = "Validation error", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "404", description = "Source bot not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @PostMapping("/create") + @PostMapping @Idempotent public ResponseEntity createTemplate( @RequestBody CreateTemplateRequest request, diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java index 6df60c9d..8f6d8f37 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminAutomationControllerTest.java @@ -65,12 +65,12 @@ void getAutomations_Success() throws Exception { } @Test - @DisplayName("GET /api/v1/admin/automations/{automationId}/details - Should return automation details") + @DisplayName("GET /api/v1/admin/automations/{automationId} - Should return automation details") void getAutomationDetails_Success() throws Exception { AdminAutomationDetailDto dto = new AdminAutomationDetailDto(); when(adminAutomationService.getAutomationDetails(eq(10L), eq("all"), eq(0), eq(20))).thenReturn(dto); - mockMvc.perform(get("/api/v1/admin/automations/10/details")) + mockMvc.perform(get("/api/v1/admin/automations/10")) .andExpect(status().isOk()); } diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java index 74889826..add6445f 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminBroadcastControllerTest.java @@ -65,12 +65,12 @@ void getBroadcasts_Success() throws Exception { } @Test - @DisplayName("GET /api/v1/admin/broadcasts/{broadcastId}/details - Should return broadcast details") + @DisplayName("GET /api/v1/admin/broadcasts/{broadcastId} - Should return broadcast details") void getBroadcastDetails_Success() throws Exception { AdminBroadcastDetailDto dto = new AdminBroadcastDetailDto(); when(adminBroadcastService.getBroadcastDetails(eq(15L), eq("all"), eq(0), eq(10))).thenReturn(dto); - mockMvc.perform(get("/api/v1/admin/broadcasts/15/details")) + mockMvc.perform(get("/api/v1/admin/broadcasts/15")) .andExpect(status().isOk()); } diff --git a/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java b/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java index b74ca241..0fa46e69 100644 --- a/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java +++ b/backend/src/test/java/com/launchly/admin/controller/AdminUserControllerTest.java @@ -106,12 +106,12 @@ void updateUserRole_Success() throws Exception { } @Test - @DisplayName("GET /api/v1/admin/users/{userId}/details - Should return user details") + @DisplayName("GET /api/v1/admin/users/{userId} - Should return user details") void getUserDetails_Success() throws Exception { AdminUserDetailDto dto = new AdminUserDetailDto(); when(adminUserService.getUserDetails(eq(5L), eq("all"), eq("all"), eq(0), eq(20))).thenReturn(dto); - mockMvc.perform(get("/api/v1/admin/users/5/details")) + mockMvc.perform(get("/api/v1/admin/users/5")) .andExpect(status().isOk()); } } diff --git a/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java index 83c16481..c096a168 100644 --- a/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/launchly/auth/controller/AuthControllerTest.java @@ -186,7 +186,7 @@ void updateProfile_ValidRequest_ReturnsOk() throws Exception { } @Test - @DisplayName("DELETE /api/v1/auth/delete-account - Should delete account and return 204 No Content") + @DisplayName("DELETE /api/v1/auth/account - Should delete account and return 204 No Content") void deleteAccount_Success() throws Exception { UserResponse userResponse = mock(UserResponse.class); when(userResponse.id()).thenReturn(1L); @@ -194,7 +194,7 @@ void deleteAccount_Success() throws Exception { UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken("user@launchly.pro", null, Collections.emptyList()); - mockMvc.perform(delete("/api/v1/auth/delete-account").principal(auth)) + mockMvc.perform(delete("/api/v1/auth/account").principal(auth)) .andExpect(status().isNoContent()); verify(authService, times(1)).deleteUserAccount(1L); diff --git a/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java b/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java index b494eb67..fe6f53aa 100644 --- a/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java +++ b/backend/src/test/java/com/launchly/auth/integration/AuthIntegrationTest.java @@ -157,7 +157,7 @@ void updatePassword_Success() throws Exception { void deleteAccount_Success() throws Exception { User user = createTestUser("delacc", Role.ROLE_OWNER); - mockMvc.perform(delete("/api/v1/auth/delete-account") + mockMvc.perform(delete("/api/v1/auth/account") .header("Authorization", getAuthHeader(user))) .andExpect(status().isNoContent()); diff --git a/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java b/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java index 74454478..28de69f0 100644 --- a/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java +++ b/backend/src/test/java/com/launchly/bot/controller/TemplateControllerTest.java @@ -80,14 +80,14 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m } @Test - @DisplayName("POST /api/v1/templates/create - Should create template and return 201 Created") + @DisplayName("POST /api/v1/templates - Should create template and return 201 Created") void createTemplate_Success() throws Exception { CreateTemplateRequest request = mock(CreateTemplateRequest.class); TemplateResponse response = mock(TemplateResponse.class); when(response.shareCode()).thenReturn("tpl_abc"); when(templateService.createTemplate(any(CreateTemplateRequest.class), eq(1L))).thenReturn(response); - mockMvc.perform(post("/api/v1/templates/create") + mockMvc.perform(post("/api/v1/templates") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) diff --git a/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java b/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java index 3c145273..7b725fdb 100644 --- a/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java +++ b/backend/src/test/java/com/launchly/bot/integration/BotIntegrationTest.java @@ -166,7 +166,7 @@ void createAndGetTemplate_Success() throws Exception { List.of() ); - mockMvc.perform(post("/api/v1/templates/create") + mockMvc.perform(post("/api/v1/templates") .header("Authorization", getAuthHeader(user)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) diff --git a/docs/images/landing.jpg b/docs/images/landing.jpg new file mode 100644 index 00000000..f97fc906 Binary files /dev/null and b/docs/images/landing.jpg differ diff --git a/frontend/package.json b/frontend/package.json index f151df60..dfa0e917 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,8 +24,10 @@ "@icons-pack/react-simple-icons": "^13.13.0", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.13.1", "@xyflow/react": "^12.11.0", "axios": "^1.17.0", + "dompurify": "^3.2.4", "emoji-mart": "^5.6.0", "lucide-react": "^1.17.0", "react": "^19.2.6", @@ -45,6 +47,7 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.5", + "@types/dompurify": "^3.0.5", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 33b77041..a30e98ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,15 +4,26 @@ import { queryClient } from './api/queryClient'; import { AppRouter } from './routes'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { LanguageProvider } from './i18n/config'; +import { NetworkStatusBanner } from './components/common/NetworkStatusBanner'; +import { useMultiTabSync } from './hooks/useMultiTabSync'; + +const AppContent: React.FC = () => { + useMultiTabSync(); + + return ( + + + + + + + ); +}; const App: React.FC = () => { return ( - - - - - + ); }; diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 8b3f7541..136f264a 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -197,7 +197,7 @@ export const fetchAdminBroadcastDetailsApi = async ( size = 10 ): Promise => { const params: Record = { period, page, size }; - const response = await apiClient.get(`/admin/broadcasts/${broadcastId}/details`, { params }); + const response = await apiClient.get(`/admin/broadcasts/${broadcastId}`, { params }); return response.data; }; @@ -327,7 +327,7 @@ export const fetchAdminAutomationDetailsApi = async ( size = 20 ): Promise => { const params: Record = { period, page, size }; - const response = await apiClient.get(`/admin/automations/${automationId}/details`, { params }); + const response = await apiClient.get(`/admin/automations/${automationId}`, { params }); return response.data; }; @@ -453,7 +453,7 @@ export const fetchAdminUserDetailsApi = async ( size = 20 ): Promise => { const params: Record = { period, category, page, size }; - const response = await apiClient.get(`/admin/users/${userId}/details`, { params }); + const response = await apiClient.get(`/admin/users/${userId}`, { params }); return response.data; }; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 2f602a9a..2ab16bcc 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -34,7 +34,7 @@ export const updateProfileApi = async (data: UpdateProfileRequest): Promise => { - await apiClient.delete('/auth/delete-account'); + await apiClient.delete('/auth/account'); }; export interface TelegramSessionResponse { diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts new file mode 100644 index 00000000..26d89c9b --- /dev/null +++ b/frontend/src/api/axios.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import apiClient from './axios'; +import { useAuthStore } from '../store/useAuthStore'; +import { IDEMPOTENCY_HEADER_NAME } from '../utils/idempotency'; + +describe('apiClient request interceptor', () => { + let originalAdapter: any; + let capturedConfig: any = null; + + beforeEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ accessToken: null, refreshToken: null, user: null }); + capturedConfig = null; + originalAdapter = apiClient.defaults.adapter; + apiClient.defaults.adapter = async (config: any) => { + capturedConfig = config; + return { + data: { success: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }; + }; + }); + + afterEach(() => { + apiClient.defaults.adapter = originalAdapter; + }); + + it('should automatically attach Idempotency-Key on POST requests', async () => { + await apiClient.post('/test-endpoint', { name: 'Launchly' }); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers?.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers?.[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeDefined(); + expect(typeof idempotencyKey).toBe('string'); + expect(idempotencyKey.length).toBeGreaterThan(10); + }); + + it('should automatically attach Idempotency-Key on DELETE requests', async () => { + await apiClient.delete('/test-endpoint/123'); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers?.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers?.[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeDefined(); + expect(typeof idempotencyKey).toBe('string'); + }); + + it('should automatically attach Idempotency-Key on PATCH requests', async () => { + await apiClient.patch('/test-endpoint/123', { active: true }); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers?.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers?.[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeDefined(); + }); + + it('should NOT attach Idempotency-Key on GET requests', async () => { + await apiClient.get('/test-endpoint'); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers?.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers?.[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBeUndefined(); + }); + + it('should preserve manual Idempotency-Key if already provided', async () => { + const customKey = 'custom-user-provided-key-999'; + + await apiClient.post('/test-endpoint', {}, { + headers: { [IDEMPOTENCY_HEADER_NAME]: customKey }, + }); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const idempotencyKey = typeof headers?.get === 'function' + ? headers.get(IDEMPOTENCY_HEADER_NAME) + : headers?.[IDEMPOTENCY_HEADER_NAME]; + + expect(idempotencyKey).toBe(customKey); + }); + + it('should attach Bearer token when accessToken is present in auth store', async () => { + useAuthStore.setState({ accessToken: 'sample-jwt-token', refreshToken: null, user: null }); + + await apiClient.get('/secure-data'); + + expect(capturedConfig).not.toBeNull(); + const headers = capturedConfig.headers; + const authHeader = typeof headers?.get === 'function' + ? headers.get('Authorization') + : headers?.['Authorization']; + + expect(authHeader).toBe('Bearer sample-jwt-token'); + }); + + it('should automatically attach AbortSignal to search GET requests', async () => { + await apiClient.get('/search-items', { params: { search: 'launchly' } }); + + expect(capturedConfig).not.toBeNull(); + expect(capturedConfig.signal).toBeDefined(); + expect(capturedConfig.signal.aborted).toBe(false); + }); +}); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 2969662e..74cac831 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,5 +1,16 @@ import axios from 'axios'; import { useAuthStore } from '../store/useAuthStore'; +import { + IDEMPOTENCY_HEADER_NAME, + generateIdempotencyKey, + shouldAttachIdempotencyKey, +} from '../utils/idempotency'; + +import { + registerRequest, + removePendingRequest, + isRequestCanceled, +} from '../utils/requestCancellation'; const apiClient = axios.create({ baseURL: '/api/v1', @@ -14,7 +25,22 @@ apiClient.interceptors.request.use( if (token) { config.headers.Authorization = `Bearer ${token}`; } - return config; + + if (shouldAttachIdempotencyKey(config.method)) { + const existingKey = typeof config.headers.get === 'function' + ? config.headers.get(IDEMPOTENCY_HEADER_NAME) + : config.headers[IDEMPOTENCY_HEADER_NAME]; + + if (!existingKey) { + if (typeof config.headers.set === 'function') { + config.headers.set(IDEMPOTENCY_HEADER_NAME, generateIdempotencyKey()); + } else { + config.headers[IDEMPOTENCY_HEADER_NAME] = generateIdempotencyKey(); + } + } + } + + return registerRequest(config); }, (error) => Promise.reject(error) ); @@ -34,8 +60,21 @@ const processQueue = (error: unknown, token: string | null = null) => { }; apiClient.interceptors.response.use( - (response) => response, + (response) => { + if (response.config) { + removePendingRequest(response.config); + } + return response; + }, async (error) => { + if (error.config) { + removePendingRequest(error.config); + } + + if (isRequestCanceled(error)) { + return Promise.reject(error); + } + const originalRequest = error.config; if (error.response?.status === 403 || error.response?.data?.error === 'ACCOUNT_BLOCKED') { const reason = error.response?.data?.reason || 'Violation of platform rules'; diff --git a/frontend/src/api/templateApi.ts b/frontend/src/api/templateApi.ts index 9480432e..e98371b3 100644 --- a/frontend/src/api/templateApi.ts +++ b/frontend/src/api/templateApi.ts @@ -67,7 +67,7 @@ export interface TemplateResponse { } export const createTemplateApi = async (payload: CreateTemplatePayload): Promise => { - const response = await apiClient.post('/templates/create', payload); + const response = await apiClient.post('/templates', payload); return response.data; }; diff --git a/frontend/src/components/common/NetworkStatusBanner.test.tsx b/frontend/src/components/common/NetworkStatusBanner.test.tsx new file mode 100644 index 00000000..e7ff7b74 --- /dev/null +++ b/frontend/src/components/common/NetworkStatusBanner.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { NetworkStatusBanner } from './NetworkStatusBanner'; +import { useNetworkStore } from '../../store/useNetworkStore'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +vi.mock('../../i18n/config', () => ({ + t: (_k: string, fallback?: string) => fallback || _k, +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe('NetworkStatusBanner', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'connected', + hasBeenOffline: false, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders nothing when online and connected', () => { + const { container } = render(, { wrapper: createWrapper() }); + expect(container.firstChild).toBeNull(); + }); + + it('renders offline alert when browser is offline', () => { + useNetworkStore.setState({ isOnline: false }); + render(, { wrapper: createWrapper() }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText(/Відсутнє підключення до Інтернету/i) + ).toBeInTheDocument(); + }); + + it('renders reconnecting alert after debounce when webSocketStatus is reconnecting after being offline', () => { + vi.useFakeTimers(); + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'reconnecting', + hasBeenOffline: true, + }); + + render(, { wrapper: createWrapper() }); + + act(() => { + vi.advanceTimersByTime(1300); + }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText(/З'єднання в реальному часі втрачено/i) + ).toBeInTheDocument(); + }); + + it('does not render alert when webSocketStatus is disconnected', () => { + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'disconnected', + hasBeenOffline: false, + }); + + const { container } = render(, { wrapper: createWrapper() }); + expect(container.firstChild).toBeNull(); + }); + + it('shows connection restored status on online event', () => { + render(, { wrapper: createWrapper() }); + + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect( + screen.getByText(/Підключення відновлено/i) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/common/NetworkStatusBanner.tsx b/frontend/src/components/common/NetworkStatusBanner.tsx new file mode 100644 index 00000000..3957b7c5 --- /dev/null +++ b/frontend/src/components/common/NetworkStatusBanner.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { WifiOff, RefreshCw, CheckCircle2, X } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNetworkStore } from '../../store/useNetworkStore'; +import { t } from '../../i18n/config'; + +export const NetworkStatusBanner: React.FC = () => { + const queryClient = useQueryClient(); + const { isOnline, webSocketStatus, hasBeenOffline, setOnline, setHasBeenOffline } = useNetworkStore(); + const [showRestored, setShowRestored] = useState(false); + const [isRetrying, setIsRetrying] = useState(false); + const [debouncedReconnecting, setDebouncedReconnecting] = useState(false); + + useEffect(() => { + if (webSocketStatus === 'reconnecting') { + const timer = setTimeout(() => { + setDebouncedReconnecting(true); + }, 1200); + return () => clearTimeout(timer); + } else { + setDebouncedReconnecting(false); + } + }, [webSocketStatus]); + + const handleOnline = useCallback(() => { + setOnline(true); + setShowRestored(true); + queryClient.invalidateQueries(); + + const timer = setTimeout(() => { + setShowRestored(false); + setHasBeenOffline(false); + }, 4000); + + return () => clearTimeout(timer); + }, [setOnline, setHasBeenOffline, queryClient]); + + const handleOffline = useCallback(() => { + setOnline(false); + setShowRestored(false); + }, [setOnline]); + + useEffect(() => { + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [handleOnline, handleOffline]); + + const handleManualRetry = async () => { + setIsRetrying(true); + try { + if (typeof navigator !== 'undefined' && navigator.onLine) { + setOnline(true); + setHasBeenOffline(false); + setDebouncedReconnecting(false); + await queryClient.invalidateQueries(); + setShowRestored(true); + setTimeout(() => { + setShowRestored(false); + }, 3000); + } + } finally { + setIsRetrying(false); + } + }; + + if (!isOnline) { + return ( +
+
+
+
+ +
+ + {t( + 'network.offline_message', + 'Відсутнє підключення до Інтернету. Перейдіть в онлайн для синхронізації змін.' + )} + +
+ +
+
+ ); + } + + if (isOnline && debouncedReconnecting && hasBeenOffline) { + return ( +
+
+
+ + + {t( + 'network.reconnecting_message', + "З'єднання в реальному часі втрачено. Відновлення зв'язку із сервером..." + )} + +
+ +
+
+ ); + } + + if (showRestored) { + return ( +
+
+
+ + + {t('network.restored_message', "Підключення відновлено. Дані успішно синхронізовані.")} + +
+ +
+
+ ); + } + + return null; +}; diff --git a/frontend/src/components/common/Skeleton.test.tsx b/frontend/src/components/common/Skeleton.test.tsx new file mode 100644 index 00000000..f43b52b8 --- /dev/null +++ b/frontend/src/components/common/Skeleton.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { + Skeleton, + ConversationListSkeleton, + MessageAreaSkeleton, + TableSkeleton, +} from './Skeleton'; +import React from 'react'; + +describe('Skeleton components', () => { + it('renders basic Skeleton element with custom className', () => { + render(); + const el = screen.getByTestId('skeleton'); + expect(el).toBeInTheDocument(); + expect(el).toHaveClass('custom-skeleton'); + expect(el).toHaveClass('animate-pulse'); + }); + + it('renders ConversationListSkeleton with placeholder items', () => { + const { container } = render(); + const items = container.querySelectorAll('.w-full.px-4.py-3'); + expect(items.length).toBe(6); + }); + + it('renders MessageAreaSkeleton with chat bubble placeholders', () => { + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + expect(container.querySelectorAll('.rounded-2xl').length).toBeGreaterThan(1); + }); + + it('renders TableSkeleton with specified rows and columns', () => { + const { container } = render(); + const rows = container.querySelectorAll('.h-16.px-6'); + expect(rows.length).toBe(4); + }); +}); diff --git a/frontend/src/components/common/Skeleton.tsx b/frontend/src/components/common/Skeleton.tsx new file mode 100644 index 00000000..58756bd5 --- /dev/null +++ b/frontend/src/components/common/Skeleton.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +interface SkeletonProps extends React.HTMLAttributes { + className?: string; +} + +export const Skeleton: React.FC = ({ className = '', ...props }) => { + return ( +
+ ); +}; + +export const ConversationListSkeleton: React.FC = () => { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +}; + +export const MessageAreaSkeleton: React.FC = () => { + return ( +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ ); +}; + +interface TableSkeletonProps { + rows?: number; + columns?: number; +} + +export const TableSkeleton: React.FC = ({ rows = 5, columns = 5 }) => { + return ( +
+
+ {Array.from({ length: columns }).map((_, i) => ( +
+ ))} +
+
+ {Array.from({ length: rows }).map((_, r) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/hooks/crm/useCrmQueries.test.ts b/frontend/src/hooks/crm/useCrmQueries.test.ts index 8fd9d9dc..8c9ec61c 100644 --- a/frontend/src/hooks/crm/useCrmQueries.test.ts +++ b/frontend/src/hooks/crm/useCrmQueries.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor, act } from '@testing-library/react'; import { useOrdersQuery, useLeadsQuery, @@ -13,12 +13,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; vi.mock('../../api/crm', () => ({ - getOrdersApi: vi.fn().mockResolvedValue([{ id: 1, total: 100 }]), + getOrdersApi: vi.fn().mockResolvedValue([{ id: 1, orderNumber: 'ORD-1', status: 'NEW', totalAmount: 100 }]), getLeadsApi: vi.fn().mockResolvedValue([{ id: 1, name: 'Lead 1' }]), - getConversationsApi: vi.fn().mockResolvedValue([{ id: 1, botUserName: 'Alice' }]), + getConversationsApi: vi.fn().mockResolvedValue([{ id: 1, botUserName: 'Alice', lastMessage: 'Old' }]), getConversationApi: vi.fn().mockResolvedValue({ id: 1, botUserName: 'Alice' }), - getMessagesApi: vi.fn().mockResolvedValue([{ id: 1, content: 'Hello' }]), - sendOwnerMessageApi: vi.fn().mockResolvedValue({ id: 2, content: 'Reply' }), + getMessagesApi: vi.fn().mockResolvedValue([{ id: 1, content: 'Hello', senderType: 'USER' }]), + sendOwnerMessageApi: vi.fn().mockResolvedValue({ id: 2, content: 'Reply', senderType: 'OWNER' }), updateOrderApi: vi.fn().mockResolvedValue({ id: 1, status: 'COMPLETED' }), })); @@ -26,8 +26,8 @@ vi.mock('../bot/useBotsQuery', () => ({ useBotsQuery: () => ({ data: [{ id: 1, name: 'Bot 1' }], isLoading: false }), })); -const createWrapper = () => { - const queryClient = new QueryClient({ +const createWrapper = (client?: QueryClient) => { + const queryClient = client || new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return ({ children }: { children: React.ReactNode }) => @@ -49,21 +49,33 @@ describe('useCrmQueries', () => { await waitFor(() => expect(convRes.current.isSuccess).toBe(true)); await waitFor(() => expect(msgsRes.current.isSuccess).toBe(true)); - expect(ordersRes.current.data).toEqual([{ id: 1, total: 100 }]); + expect(ordersRes.current.data).toEqual([{ id: 1, orderNumber: 'ORD-1', status: 'NEW', totalAmount: 100 }]); expect(leadsRes.current.data).toEqual([{ id: 1, name: 'Lead 1' }]); - expect(convsRes.current.data).toEqual([{ id: 1, botUserName: 'Alice' }]); - expect(msgsRes.current.data).toEqual([{ id: 1, content: 'Hello' }]); + expect(convsRes.current.data).toEqual([{ id: 1, botUserName: 'Alice', lastMessage: 'Old' }]); + expect(msgsRes.current.data).toEqual([{ id: 1, content: 'Hello', senderType: 'USER' }]); }); - it('runs CRM mutations', async () => { - const wrapper = createWrapper(); + it('runs CRM mutations and performs optimistic updates', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = createWrapper(queryClient); + + queryClient.setQueryData(['messages', 1], [{ id: 1, content: 'Hello', senderType: 'USER' }]); + queryClient.setQueryData(['orders', 1], [{ id: 1, orderNumber: 'ORD-1', status: 'NEW', notes: '' }]); + const { result: sendRes } = renderHook(() => useSendMessageMutation(1, 1), { wrapper }); const { result: orderRes } = renderHook(() => useUpdateOrderMutation(1), { wrapper }); - const sent = await sendRes.current.mutateAsync({ content: 'Reply' }); - const upd = await orderRes.current.mutateAsync({ orderId: 1, status: 'COMPLETED' as unknown as never, notes: 'Done' }); + await act(async () => { + await sendRes.current.mutateAsync({ content: 'Optimistic Reply' }); + }); + + await act(async () => { + await orderRes.current.mutateAsync({ orderId: 1, status: 'COMPLETED' as unknown as never, notes: 'Done' }); + }); - expect(sent).toEqual({ id: 2, content: 'Reply' }); - expect(upd).toEqual({ id: 1, status: 'COMPLETED' }); + expect(sendRes.current.data).toEqual({ id: 2, content: 'Reply', senderType: 'OWNER' }); + expect(orderRes.current.data).toEqual({ id: 1, status: 'COMPLETED' }); }); }); diff --git a/frontend/src/hooks/crm/useCrmQueries.ts b/frontend/src/hooks/crm/useCrmQueries.ts index 16202790..87a3c4c1 100644 --- a/frontend/src/hooks/crm/useCrmQueries.ts +++ b/frontend/src/hooks/crm/useCrmQueries.ts @@ -20,8 +20,15 @@ import { createBotUserApi, deleteBotUserApi, } from '../../api/bot'; -import type { OrderStatus, LeadStatus, ConversationStatus } from '../../types/crm'; -import type { BotUserUpdateRequest, BotUserCreateRequest, BotUserResponse } from '../../types/bot'; +import type { + OrderStatus, + LeadStatus, + ConversationStatus, + MessageResponse, + OrderResponse, + LeadResponse, + ConversationResponse, +} from '../../types/crm'; export const useOrdersQuery = (botId: number, enabled: boolean = true) => { return useQuery({ @@ -76,7 +83,22 @@ export const useUpdateOrderMutation = (botId: number) => { return useMutation({ mutationFn: ({ orderId, status, notes }: { orderId: number; status: OrderStatus; notes: string }) => updateOrderApi(orderId, status, notes), - onSuccess: () => { + onMutate: async ({ orderId, status, notes }) => { + await queryClient.cancelQueries({ queryKey: ['orders', botId] }); + const previousOrders = queryClient.getQueryData(['orders', botId]); + if (previousOrders) { + queryClient.setQueryData(['orders', botId], (old = []) => + old.map((order) => (order.id === orderId ? { ...order, status, notes } : order)) + ); + } + return { previousOrders }; + }, + onError: (_err, _variables, context) => { + if (context?.previousOrders) { + queryClient.setQueryData(['orders', botId], context.previousOrders); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['orders', botId] }); }, }); @@ -87,7 +109,22 @@ export const useUpdateLeadMutation = (botId: number) => { return useMutation({ mutationFn: ({ leadId, status, notes }: { leadId: number; status: LeadStatus; notes: string }) => updateLeadApi(leadId, status, notes), - onSuccess: () => { + onMutate: async ({ leadId, status, notes }) => { + await queryClient.cancelQueries({ queryKey: ['leads', botId] }); + const previousLeads = queryClient.getQueryData(['leads', botId]); + if (previousLeads) { + queryClient.setQueryData(['leads', botId], (old = []) => + old.map((lead) => (lead.id === leadId ? { ...lead, status, notes } : lead)) + ); + } + return { previousLeads }; + }, + onError: (_err, _variables, context) => { + if (context?.previousLeads) { + queryClient.setQueryData(['leads', botId], context.previousLeads); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['leads', botId] }); }, }); @@ -98,7 +135,57 @@ export const useSendMessageMutation = (conversationId: number, botId: number) => return useMutation({ mutationFn: ({ content, mediaUrl, mediaType, scheduledAt }: { content: string; mediaUrl?: string; mediaType?: string; scheduledAt?: string }) => sendOwnerMessageApi(conversationId, content, mediaUrl, mediaType, scheduledAt), - onSuccess: () => { + onMutate: async ({ content, mediaUrl, mediaType, scheduledAt }) => { + await queryClient.cancelQueries({ queryKey: ['messages', conversationId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', botId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', 'all'] }); + + const previousMessages = queryClient.getQueryData(['messages', conversationId]); + const previousBotConversations = queryClient.getQueryData(['conversations', botId]); + const previousAllConversations = queryClient.getQueryData(['conversations', 'all']); + + const optimisticMessage: MessageResponse = { + id: -Date.now(), + conversationId, + content, + senderType: 'OWNER', + mediaUrl: mediaUrl || null, + mediaType: mediaType || null, + createdAt: new Date().toISOString(), + scheduledAt, + sent: true, + }; + + queryClient.setQueryData(['messages', conversationId], (old = []) => [...old, optimisticMessage]); + + const updateConversationsPreview = (list?: ConversationResponse[]) => + list?.map((conv) => + conv.id === conversationId + ? { ...conv, lastMessage: content, lastMessageAt: new Date().toISOString() } + : conv + ); + + if (previousBotConversations) { + queryClient.setQueryData(['conversations', botId], updateConversationsPreview(previousBotConversations)); + } + if (previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], updateConversationsPreview(previousAllConversations)); + } + + return { previousMessages, previousBotConversations, previousAllConversations }; + }, + onError: (_err, _variables, context) => { + if (context?.previousMessages) { + queryClient.setQueryData(['messages', conversationId], context.previousMessages); + } + if (context?.previousBotConversations) { + queryClient.setQueryData(['conversations', botId], context.previousBotConversations); + } + if (context?.previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], context.previousAllConversations); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations', botId] }); queryClient.invalidateQueries({ queryKey: ['conversations', 'all'] }); @@ -110,7 +197,30 @@ export const useSendNoteMutation = (conversationId: number, botId: number) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (content: string) => sendNoteApi(conversationId, content), - onSuccess: () => { + onMutate: async (content: string) => { + await queryClient.cancelQueries({ queryKey: ['messages', conversationId] }); + const previousMessages = queryClient.getQueryData(['messages', conversationId]); + + const optimisticNote: MessageResponse = { + id: -Date.now(), + conversationId, + content: `Note: ${content}`, + senderType: 'OWNER', + mediaUrl: null, + mediaType: null, + createdAt: new Date().toISOString(), + sent: true, + }; + + queryClient.setQueryData(['messages', conversationId], (old = []) => [...old, optimisticNote]); + return { previousMessages }; + }, + onError: (_err, _content, context) => { + if (context?.previousMessages) { + queryClient.setQueryData(['messages', conversationId], context.previousMessages); + } + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations', botId] }); queryClient.invalidateQueries({ queryKey: ['conversations', 'all'] }); @@ -198,8 +308,52 @@ export const useUpdateConversationMutation = (botId: number) => { return useMutation({ mutationFn: ({ conversationId, status, unread }: { conversationId: number; status?: ConversationStatus; unread?: boolean }) => updateConversationApi(conversationId, { status, unread }), - onSuccess: (updated) => { - queryClient.invalidateQueries({ queryKey: ['conversation', updated.id] }); + onMutate: async ({ conversationId, status, unread }) => { + await queryClient.cancelQueries({ queryKey: ['conversation', conversationId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', botId] }); + await queryClient.cancelQueries({ queryKey: ['conversations', 'all'] }); + + const previousConversation = queryClient.getQueryData(['conversation', conversationId]); + const previousBotConversations = queryClient.getQueryData(['conversations', botId]); + const previousAllConversations = queryClient.getQueryData(['conversations', 'all']); + + if (previousConversation) { + queryClient.setQueryData(['conversation', conversationId], { + ...previousConversation, + status: status ?? previousConversation.status, + unread: unread ?? previousConversation.unread, + }); + } + + const updateList = (list?: ConversationResponse[]) => + list?.map((conv) => + conv.id === conversationId + ? { ...conv, status: status ?? conv.status, unread: unread ?? conv.unread } + : conv + ); + + if (previousBotConversations) { + queryClient.setQueryData(['conversations', botId], updateList(previousBotConversations)); + } + if (previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], updateList(previousAllConversations)); + } + + return { previousConversation, previousBotConversations, previousAllConversations }; + }, + onError: (_err, { conversationId }, context) => { + if (context?.previousConversation) { + queryClient.setQueryData(['conversation', conversationId], context.previousConversation); + } + if (context?.previousBotConversations) { + queryClient.setQueryData(['conversations', botId], context.previousBotConversations); + } + if (context?.previousAllConversations) { + queryClient.setQueryData(['conversations', 'all'], context.previousAllConversations); + } + }, + onSettled: (updated, _err, { conversationId }) => { + queryClient.invalidateQueries({ queryKey: ['conversation', updated?.id || conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations', botId] }); queryClient.invalidateQueries({ queryKey: ['conversations', 'all'] }); }, diff --git a/frontend/src/hooks/crm/useCrmWebSocket.ts b/frontend/src/hooks/crm/useCrmWebSocket.ts index 6cad74ad..4fab3400 100644 --- a/frontend/src/hooks/crm/useCrmWebSocket.ts +++ b/frontend/src/hooks/crm/useCrmWebSocket.ts @@ -2,28 +2,49 @@ import { useEffect, useRef } from 'react'; import { Client } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; import { useQueryClient } from '@tanstack/react-query'; +import { useNetworkStore } from '../../store/useNetworkStore'; export const useCrmWebSocket = (botId: number) => { const queryClient = useQueryClient(); const stompClientRef = useRef(null); + const isIntentionalDisconnectRef = useRef(false); useEffect(() => { if (!botId || botId <= 0) return; + isIntentionalDisconnectRef.current = false; const socket = new SockJS('/ws'); const client = new Client({ webSocketFactory: () => socket, - reconnectDelay: 10000, - connectionTimeout: 3000, + reconnectDelay: 5000, + connectionTimeout: 4000, heartbeatIncoming: 10000, heartbeatOutgoing: 10000, + onWebSocketClose: () => { + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } + }, onWebSocketError: () => { + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } }, onStompError: () => { + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } + }, + onDisconnect: () => { + if (!isIntentionalDisconnectRef.current) { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + } }, }); client.onConnect = () => { + useNetworkStore.getState().setWebSocketStatus('connected'); + client.subscribe(`/topic/crm/${botId}/messages`, (msg) => { try { const body = JSON.parse(msg.body); @@ -45,13 +66,16 @@ export const useCrmWebSocket = (botId: number) => { }); }; + useNetworkStore.getState().setWebSocketStatus('connecting'); client.activate(); stompClientRef.current = client; return () => { + isIntentionalDisconnectRef.current = true; if (stompClientRef.current) { stompClientRef.current.deactivate(); } + useNetworkStore.getState().setWebSocketStatus('disconnected'); }; }, [botId, queryClient]); }; diff --git a/frontend/src/hooks/support/useSupportQueries.ts b/frontend/src/hooks/support/useSupportQueries.ts index 3742b21d..69bb454c 100644 --- a/frontend/src/hooks/support/useSupportQueries.ts +++ b/frontend/src/hooks/support/useSupportQueries.ts @@ -42,8 +42,40 @@ export const useSendTicketMessageMutation = (ticketId: number | string | null) = if (!ticketId) throw new Error('No ticket selected'); return sendTicketMessageApi(ticketId, text); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + onMutate: async (text: string) => { + if (!ticketId) return; + await queryClient.cancelQueries({ queryKey: ['user-support-ticket', ticketId] }); + const previousTicket = queryClient.getQueryData(['user-support-ticket', ticketId]); + + if (previousTicket) { + const optimisticMsg: SupportMessageItem = { + id: -Date.now(), + ticketId: Number(ticketId), + sender: 'USER', + senderName: 'Ви', + text, + timestamp: new Date().toISOString(), + }; + + queryClient.setQueryData(['user-support-ticket', ticketId], { + ...previousTicket, + messages: [...(previousTicket.messages || []), optimisticMsg], + lastMessage: text, + lastMessageTime: new Date().toISOString(), + }); + } + + return { previousTicket }; + }, + onError: (_err, _text, context) => { + if (ticketId && context?.previousTicket) { + queryClient.setQueryData(['user-support-ticket', ticketId], context.previousTicket); + } + }, + onSettled: () => { + if (ticketId) { + queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + } queryClient.invalidateQueries({ queryKey: ['user-support-tickets'] }); }, }); @@ -56,8 +88,29 @@ export const useUpdateTicketStatusMutation = (ticketId: number | string | null) if (!ticketId) throw new Error('No ticket selected'); return updateTicketStatusApi(ticketId, status); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + onMutate: async (status: string) => { + if (!ticketId) return; + await queryClient.cancelQueries({ queryKey: ['user-support-ticket', ticketId] }); + const previousTicket = queryClient.getQueryData(['user-support-ticket', ticketId]); + + if (previousTicket) { + queryClient.setQueryData(['user-support-ticket', ticketId], { + ...previousTicket, + status: status as SupportTicketItem['status'], + }); + } + + return { previousTicket }; + }, + onError: (_err, _status, context) => { + if (ticketId && context?.previousTicket) { + queryClient.setQueryData(['user-support-ticket', ticketId], context.previousTicket); + } + }, + onSettled: () => { + if (ticketId) { + queryClient.invalidateQueries({ queryKey: ['user-support-ticket', ticketId] }); + } queryClient.invalidateQueries({ queryKey: ['user-support-tickets'] }); }, }); diff --git a/frontend/src/hooks/useMultiTabSync.test.ts b/frontend/src/hooks/useMultiTabSync.test.ts new file mode 100644 index 00000000..52a7ad47 --- /dev/null +++ b/frontend/src/hooks/useMultiTabSync.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMultiTabSync } from './useMultiTabSync'; +import { useAuthStore } from '../store/useAuthStore'; +import { useBotStore } from '../store/useBotStore'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe('useMultiTabSync', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ + accessToken: 'initial_token', + refreshToken: 'initial_refresh', + user: { id: 1, email: 'test@launchly.com' } as never, + }); + useBotStore.setState({ activeBotId: 1 }); + }); + + it('synchronizes logout event from external tab', () => { + renderHook(() => useMultiTabSync(), { wrapper: createWrapper() }); + + act(() => { + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify({ + type: 'AUTH_LOGOUT', + senderTabId: 'other-tab-id', + timestamp: Date.now(), + }), + }); + window.dispatchEvent(storageEvent); + }); + + const state = useAuthStore.getState(); + expect(state.accessToken).toBeNull(); + expect(state.user).toBeNull(); + expect(useBotStore.getState().activeBotId).toBeNull(); + }); + + it('synchronizes bot change event from external tab', () => { + renderHook(() => useMultiTabSync(), { wrapper: createWrapper() }); + + act(() => { + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify({ + type: 'BOT_CHANGED', + payload: { botId: 42 }, + senderTabId: 'other-tab-id', + timestamp: Date.now(), + }), + }); + window.dispatchEvent(storageEvent); + }); + + expect(useBotStore.getState().activeBotId).toBe(42); + }); +}); diff --git a/frontend/src/hooks/useMultiTabSync.ts b/frontend/src/hooks/useMultiTabSync.ts new file mode 100644 index 00000000..9f9558d4 --- /dev/null +++ b/frontend/src/hooks/useMultiTabSync.ts @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { subscribeToSyncEvents, type SyncMessage } from '../utils/multiTabSync'; +import { useAuthStore } from '../store/useAuthStore'; +import { useBotStore } from '../store/useBotStore'; + +export const useMultiTabSync = (): void => { + const queryClient = useQueryClient(); + + useEffect(() => { + const unsubscribe = subscribeToSyncEvents((message: SyncMessage) => { + switch (message.type) { + case 'AUTH_LOGOUT': { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + useAuthStore.setState({ + accessToken: null, + refreshToken: null, + user: null, + }); + useBotStore.getState().clearBots(); + queryClient.clear(); + if ( + typeof window !== 'undefined' && + !window.location.pathname.startsWith('/login') && + !window.location.pathname.startsWith('/register') && + window.location.pathname !== '/' + ) { + window.location.href = '/login'; + } + break; + } + + case 'AUTH_LOGIN': { + const payload = message.payload as { + accessToken: string; + refreshToken: string; + user: unknown; + }; + if (payload?.accessToken) { + useAuthStore.setState({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + user: payload.user as never, + }); + queryClient.invalidateQueries(); + } + break; + } + + case 'BOT_CHANGED': { + const payload = message.payload as { botId: number | null }; + if (payload && payload.botId !== undefined) { + useBotStore.setState({ activeBotId: payload.botId }); + queryClient.invalidateQueries({ queryKey: ['bot', payload.botId] }); + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ['leads'] }); + queryClient.invalidateQueries({ queryKey: ['orders'] }); + } + break; + } + + case 'SYNC_QUERY_INVALIDATE': { + const payload = message.payload as { queryKey: string[] }; + if (payload?.queryKey) { + queryClient.invalidateQueries({ queryKey: payload.queryKey }); + } + break; + } + } + }); + + return () => { + unsubscribe(); + }; + }, [queryClient]); +}; diff --git a/frontend/src/hooks/useVirtualList.test.ts b/frontend/src/hooks/useVirtualList.test.ts new file mode 100644 index 00000000..4689cec1 --- /dev/null +++ b/frontend/src/hooks/useVirtualList.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useVirtualList } from './useVirtualList'; + +describe('useVirtualList', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should return empty items when count is 0', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 0, + itemHeight: 50, + }) + ); + + expect(result.current.virtualItems).toEqual([]); + expect(result.current.totalHeight).toBe(0); + expect(result.current.parentRef.current).toBeNull(); + }); + + it('should calculate total height correctly for fixed height items', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 100, + itemHeight: 60, + }) + ); + + expect(result.current.totalHeight).toBe(6000); + }); + + it('should calculate total height correctly for dynamic height items', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 10, + itemHeight: (index) => (index % 2 === 0 ? 50 : 100), + }) + ); + + expect(result.current.totalHeight).toBe(750); + }); + + it('should render correct virtual items when viewport height and scrollTop are updated', () => { + const { result } = renderHook(() => + useVirtualList({ + count: 1000, + itemHeight: 50, + overscan: 2, + }) + ); + + const mockElement = document.createElement('div'); + Object.defineProperty(mockElement, 'clientHeight', { value: 500, configurable: true }); + Object.defineProperty(mockElement, 'scrollTop', { value: 200, writable: true, configurable: true }); + + (result.current.parentRef as any).current = mockElement; + + act(() => { + mockElement.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.totalHeight).toBe(50000); + }); +}); diff --git a/frontend/src/hooks/useVirtualList.ts b/frontend/src/hooks/useVirtualList.ts new file mode 100644 index 00000000..a3562243 --- /dev/null +++ b/frontend/src/hooks/useVirtualList.ts @@ -0,0 +1,201 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; + +export interface UseVirtualListOptions { + count: number; + itemHeight: number | ((index: number) => number); + overscan?: number; +} + +export interface VirtualItem { + index: number; + offsetTop: number; + size: number; +} + +export interface UseVirtualListResult { + parentRef: React.RefObject; + virtualItems: VirtualItem[]; + totalHeight: number; + scrollToIndex: (index: number, align?: 'start' | 'center' | 'end' | 'auto') => void; +} + +export const useVirtualList = ({ + count, + itemHeight, + overscan = 3, +}: UseVirtualListOptions): UseVirtualListResult => { + const parentRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + + const getItemHeight = useCallback( + (index: number): number => { + return typeof itemHeight === 'function' ? itemHeight(index) : itemHeight; + }, + [itemHeight] + ); + + const { offsets, totalHeight } = useMemo(() => { + const calculatedOffsets: number[] = new Array(count); + let currentOffset = 0; + + for (let i = 0; i < count; i++) { + calculatedOffsets[i] = currentOffset; + currentOffset += getItemHeight(i); + } + + return { + offsets: calculatedOffsets, + totalHeight: currentOffset, + }; + }, [count, getItemHeight]); + + useEffect(() => { + const element = parentRef.current; + if (!element) return; + + let frameId: number | null = null; + + const handleScroll = () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + frameId = requestAnimationFrame(() => { + setScrollTop(element.scrollTop); + }); + }; + + const updateHeight = () => { + setViewportHeight(element.clientHeight); + }; + + updateHeight(); + setScrollTop(element.scrollTop); + + element.addEventListener('scroll', handleScroll, { passive: true }); + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target === element) { + setViewportHeight(entry.contentRect.height); + } + } + }); + + resizeObserver.observe(element); + + return () => { + element.removeEventListener('scroll', handleScroll); + resizeObserver.disconnect(); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, []); + + const virtualItems = useMemo(() => { + if (count === 0) { + return []; + } + + if (viewportHeight === 0) { + const initialCount = Math.min(count, Math.max(10, overscan * 2)); + const initialItems: VirtualItem[] = []; + for (let i = 0; i < initialCount; i++) { + initialItems.push({ + index: i, + offsetTop: offsets[i], + size: getItemHeight(i), + }); + } + return initialItems; + } + + const scrollBottom = scrollTop + viewportHeight; + + let startIndex = 0; + let low = 0; + let high = count - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const midOffset = offsets[mid]; + const midSize = getItemHeight(mid); + + if (midOffset + midSize >= scrollTop) { + startIndex = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + let endIndex = startIndex; + low = startIndex; + high = count - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const midOffset = offsets[mid]; + + if (midOffset <= scrollBottom) { + endIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + startIndex = Math.max(0, startIndex - overscan); + endIndex = Math.min(count - 1, endIndex + overscan); + + const items: VirtualItem[] = []; + for (let i = startIndex; i <= endIndex; i++) { + items.push({ + index: i, + offsetTop: offsets[i], + size: getItemHeight(i), + }); + } + + return items; + }, [count, viewportHeight, scrollTop, offsets, overscan, getItemHeight]); + + const scrollToIndex = useCallback( + (index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') => { + const element = parentRef.current; + if (!element || index < 0 || index >= count) return; + + const itemOffset = offsets[index]; + const itemSize = getItemHeight(index); + const currentScroll = element.scrollTop; + const currentHeight = element.clientHeight; + + let targetScroll = itemOffset; + + if (align === 'center') { + targetScroll = itemOffset - currentHeight / 2 + itemSize / 2; + } else if (align === 'end') { + targetScroll = itemOffset - currentHeight + itemSize; + } else if (align === 'auto') { + if (itemOffset < currentScroll) { + targetScroll = itemOffset; + } else if (itemOffset + itemSize > currentScroll + currentHeight) { + targetScroll = itemOffset - currentHeight + itemSize; + } else { + return; + } + } + + element.scrollTop = Math.max(0, Math.min(totalHeight - currentHeight, targetScroll)); + }, + [count, offsets, getItemHeight, totalHeight] + ); + + return { + parentRef, + virtualItems, + totalHeight, + scrollToIndex, + }; +}; diff --git a/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx index 51f645e4..ff42ff0b 100644 --- a/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx +++ b/frontend/src/pages/admin/AdminLogs/AdminLogsPage.tsx @@ -8,6 +8,7 @@ import { useTranslation } from '../../../i18n/config'; import { useAuthStore } from '../../../store/useAuthStore'; import { Navigate } from 'react-router-dom'; import { ROUTES } from '../../../routes/paths'; +import { TableSkeleton } from '../../../components/common/Skeleton'; export const AdminLogsPage: React.FC = () => { const { t } = useTranslation(); @@ -288,9 +289,7 @@ export const AdminLogsPage: React.FC = () => {
{isLoading ? ( -
- -
+ ) : (
diff --git a/frontend/src/pages/owner/Chat/components/ContactsTable.tsx b/frontend/src/pages/owner/Chat/components/ContactsTable.tsx index 270bab3c..84e259a3 100644 --- a/frontend/src/pages/owner/Chat/components/ContactsTable.tsx +++ b/frontend/src/pages/owner/Chat/components/ContactsTable.tsx @@ -4,6 +4,8 @@ import { Loader2, AlertCircle, Plus } from 'lucide-react'; import type { BotUserResponse } from '../../../../types/bot'; import { ContactAvatar } from './ContactAvatar'; import { t } from '../../../../i18n/config'; +import { useVirtualList } from '../../../../hooks/useVirtualList'; +import { TableSkeleton } from '../../../../components/common/Skeleton'; interface ContactsTableProps { botId: number; @@ -26,6 +28,17 @@ export const ContactsTable: React.FC = ({ }) => { const navigate = useNavigate(); + const { parentRef, virtualItems, totalHeight } = useVirtualList({ + count: filteredContacts.length, + itemHeight: 64, + overscan: 4, + }); + + const firstItem = virtualItems[0]; + const lastItem = virtualItems[virtualItems.length - 1]; + const paddingTop = firstItem ? firstItem.offsetTop : 0; + const paddingBottom = lastItem ? Math.max(0, totalHeight - (lastItem.offsetTop + lastItem.size)) : 0; + const parseMetadata = (metaStr: string | null) => { try { return metaStr ? JSON.parse(metaStr) : {}; @@ -74,8 +87,8 @@ export const ContactsTable: React.FC = ({ if (isContactsLoading) { return ( -
- +
+
); } @@ -89,7 +102,7 @@ export const ContactsTable: React.FC = ({ } return ( -
+
@@ -112,7 +125,15 @@ export const ContactsTable: React.FC = ({ - {filteredContacts.map((c) => { + {paddingTop > 0 && ( + + + )} + {virtualItems.map(({ index }) => { + const c = filteredContacts[index]; + if (!c) return null; + const isSelected = selectedContactIds.has(c.id); const meta = parseMetadata(c.metadata); const isPaused = meta.paused; @@ -171,6 +192,11 @@ export const ContactsTable: React.FC = ({ ); })} + {paddingBottom > 0 && ( + + + )}
+
+
diff --git a/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx b/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx index 7f97a493..92b60b07 100644 --- a/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx +++ b/frontend/src/pages/owner/Chat/components/ConversationList.test.tsx @@ -30,7 +30,7 @@ describe('ConversationList', () => { chatFilter="all" /> ); - expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); }); it('renders conversation items', () => { diff --git a/frontend/src/pages/owner/Chat/components/ConversationList.tsx b/frontend/src/pages/owner/Chat/components/ConversationList.tsx index 0a7fdc1d..f84a543d 100644 --- a/frontend/src/pages/owner/Chat/components/ConversationList.tsx +++ b/frontend/src/pages/owner/Chat/components/ConversationList.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { Loader2, Heart } from 'lucide-react'; +import { Heart } from 'lucide-react'; import type { ConversationResponse } from '../../../../types/crm'; import { UserAvatar } from './UserAvatar'; import { timeAgo } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; +import { useVirtualList } from '../../../../hooks/useVirtualList'; +import { ConversationListSkeleton } from '../../../../components/common/Skeleton'; interface ConversationListProps { conversations: ConversationResponse[]; @@ -27,69 +29,90 @@ export const ConversationList: React.FC = ({ unreadConvIds, searchQuery, chatFilter, -}) => ( -
-
- {isLoading ? ( -
- ) : conversations.length === 0 ? ( -
- {searchQuery - ? t('crm.list.no_conversations_found') - : chatFilter === 'open' - ? t('crm.list.no_open_conversations') - : chatFilter === 'closed' - ? t('crm.list.no_closed_conversations') - : t('crm.list.no_conversations')} -
- ) : ( - conversations.map(c => { - const isSel = c.id === selectedConvId; - const isFav = favorites.includes(c.id); - const isUnrd = c.unread || unreadConvIds.includes(c.id); - return ( -
onSelect(c.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(c.id); - } - }} - className={`w-full text-left px-4 py-3 flex items-start gap-2.5 transition-all cursor-pointer border-b-2 border-[#0A0A0A] group ${isSel ? 'bg-white font-black' : 'bg-[#F2EBDD] hover:bg-white'}`} - > -
- - {isUnrd && } -
-
-
- {c.botUserName} - {timeAgo(c.lastMessageAt)} -
-
-

{c.lastMessage || 'No messages'}

- {c.botName && ( - - {c.botName} - - )} +}) => { + const { parentRef, virtualItems, totalHeight } = useVirtualList({ + count: conversations.length, + itemHeight: 68, + overscan: 4, + }); + + return ( +
+
+ {isLoading ? ( + + ) : conversations.length === 0 ? ( +
+ {searchQuery + ? t('crm.list.no_conversations_found') + : chatFilter === 'open' + ? t('crm.list.no_open_conversations') + : chatFilter === 'closed' + ? t('crm.list.no_closed_conversations') + : t('crm.list.no_conversations')} +
+ ) : ( +
+ {virtualItems.map(({ index, offsetTop, size }) => { + const c = conversations[index]; + if (!c) return null; + const isSel = c.id === selectedConvId; + const isFav = favorites.includes(c.id); + const isUnrd = c.unread || unreadConvIds.includes(c.id); + + return ( +
onSelect(c.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(c.id); + } + }} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${size}px`, + transform: `translateY(${offsetTop}px)`, + }} + className={`w-full text-left px-4 py-3 flex items-start gap-2.5 transition-all cursor-pointer border-b-2 border-[#0A0A0A] group ${isSel ? 'bg-white font-black' : 'bg-[#F2EBDD] hover:bg-white'}`} + > +
+ + {isUnrd && } +
+
+
+ {c.botUserName} + {timeAgo(c.lastMessageAt)} +
+
+

{c.lastMessage || 'No messages'}

+ {c.botName && ( + + {c.botName} + + )} +
+
+
-
- -
- ); - }) - )} + ); + })} +
+ )} +
-
-); + ); +}; diff --git a/frontend/src/pages/owner/Chat/components/MessageArea.tsx b/frontend/src/pages/owner/Chat/components/MessageArea.tsx index c6b758c0..cc650da7 100644 --- a/frontend/src/pages/owner/Chat/components/MessageArea.tsx +++ b/frontend/src/pages/owner/Chat/components/MessageArea.tsx @@ -1,6 +1,5 @@ -import React, { useMemo, useRef, useEffect } from 'react'; +import React, { useMemo, useRef, useEffect, useCallback } from 'react'; import { - Loader2, MessageSquare, ExternalLink, } from 'lucide-react'; @@ -12,6 +11,7 @@ import { MessageBubble } from './MessageBubble'; import { ChatToolbar } from './ChatToolbar'; import { formatDateSeparator, getDateKey } from '../../../../utils/crmChat'; import { t } from '../../../../i18n/config'; +import { MessageAreaSkeleton } from '../../../../components/common/Skeleton'; interface MessageAreaProps { conversation: ConversationResponse | null; @@ -60,24 +60,22 @@ export const MessageArea: React.FC = ({ }) => { const messagesEndRef = useRef(null); - const scrollToBottom = (behavior: 'auto' | 'smooth' = 'auto') => { + const scrollToBottom = useCallback((behavior: 'auto' | 'smooth' = 'auto') => { messagesEndRef.current?.scrollIntoView({ behavior }); - }; + }, []); useEffect(() => { if (!conversation) return; - const behavior = 'auto'; - scrollToBottom(behavior); - - const t1 = setTimeout(() => scrollToBottom(behavior), 50); - const t2 = setTimeout(() => scrollToBottom(behavior), 150); + scrollToBottom('auto'); + const t1 = setTimeout(() => scrollToBottom('auto'), 50); + const t2 = setTimeout(() => scrollToBottom('auto'), 150); return () => { clearTimeout(t1); clearTimeout(t2); }; - }, [messages, conversation?.id]); + }, [messages.length, conversation?.id, scrollToBottom]); const handleImageLoad = () => { scrollToBottom('auto'); @@ -150,8 +148,8 @@ export const MessageArea: React.FC = ({
{isMsgLoading ? ( -
- ) : messages.length === 0 ? ( + + ) : groupedMessages.length === 0 ? (
No messages in this conversation.
) : ( groupedMessages.map((group, gi) => ( @@ -176,7 +174,7 @@ export const MessageArea: React.FC = ({
)) )} - {messages.length > 0 &&
} +
); diff --git a/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx b/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx index 325f1dab..375afc21 100644 --- a/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx +++ b/frontend/src/pages/public/Landing/components/StickySolutionsSection.tsx @@ -14,6 +14,7 @@ import { Mic, } from 'lucide-react'; import { useTranslation } from '../../../../i18n/config'; +import { createSafeHtml } from '../../../../utils/sanitize'; interface StickySolutionsSectionProps { onCtaClick: () => void; @@ -454,7 +455,7 @@ export const StickySolutionsSection: React.FC = ({ : 'bg-[#182533] text-white rounded-2xl rounded-tl-xs shadow-xs border border-white/5' }`} > -
$1') }} /> +
$1'))} />
((set) => { const savedAccessToken = localStorage.getItem('accessToken'); const savedRefreshToken = localStorage.getItem('refreshToken'); const savedUserJson = localStorage.getItem('user'); - + let savedUser: User | null = null; if (savedUserJson) { try { @@ -26,6 +27,7 @@ export const useAuthStore = create((set) => { localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', refreshToken); localStorage.setItem('user', JSON.stringify(user)); + broadcastEvent('AUTH_LOGIN', { accessToken, refreshToken, user }); set({ accessToken, refreshToken, @@ -39,6 +41,7 @@ export const useAuthStore = create((set) => { localStorage.removeItem('user'); useBotStore.getState().clearBots(); queryClient.clear(); + broadcastEvent('AUTH_LOGOUT'); set({ accessToken: null, refreshToken: null, diff --git a/frontend/src/store/useBotStore.ts b/frontend/src/store/useBotStore.ts index cf27a0fc..704ceb39 100644 --- a/frontend/src/store/useBotStore.ts +++ b/frontend/src/store/useBotStore.ts @@ -1,10 +1,11 @@ import { create } from 'zustand'; import type { BotState } from '../types/bot'; +import { broadcastEvent } from '../utils/multiTabSync'; export const useBotStore = create((set) => { const savedActiveBotIdStr = localStorage.getItem('activeBotId'); let savedActiveBotId: number | null = null; - + if (savedActiveBotIdStr) { const parsed = parseInt(savedActiveBotIdStr, 10); if (!isNaN(parsed)) { @@ -23,6 +24,7 @@ export const useBotStore = create((set) => { } else { localStorage.removeItem('activeBotId'); } + broadcastEvent('BOT_CHANGED', { botId: id }); set({ activeBotId: id }); }, diff --git a/frontend/src/store/useNetworkStore.test.ts b/frontend/src/store/useNetworkStore.test.ts new file mode 100644 index 00000000..d4a2fae1 --- /dev/null +++ b/frontend/src/store/useNetworkStore.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useNetworkStore } from './useNetworkStore'; + +describe('useNetworkStore', () => { + beforeEach(() => { + useNetworkStore.setState({ + isOnline: true, + webSocketStatus: 'connected', + hasBeenOffline: false, + }); + }); + + it('initializes with online and connected status', () => { + const state = useNetworkStore.getState(); + expect(state.isOnline).toBe(true); + expect(state.webSocketStatus).toBe('connected'); + expect(state.hasBeenOffline).toBe(false); + }); + + it('updates isOnline and tracks hasBeenOffline flag', () => { + useNetworkStore.getState().setOnline(false); + let state = useNetworkStore.getState(); + expect(state.isOnline).toBe(false); + expect(state.hasBeenOffline).toBe(true); + + useNetworkStore.getState().setOnline(true); + state = useNetworkStore.getState(); + expect(state.isOnline).toBe(true); + expect(state.hasBeenOffline).toBe(true); + }); + + it('updates webSocketStatus correctly', () => { + useNetworkStore.getState().setWebSocketStatus('reconnecting'); + let state = useNetworkStore.getState(); + expect(state.webSocketStatus).toBe('reconnecting'); + expect(state.hasBeenOffline).toBe(true); + + useNetworkStore.getState().setWebSocketStatus('connected'); + state = useNetworkStore.getState(); + expect(state.webSocketStatus).toBe('connected'); + + useNetworkStore.getState().setWebSocketStatus('disconnected'); + state = useNetworkStore.getState(); + expect(state.webSocketStatus).toBe('disconnected'); + expect(state.hasBeenOffline).toBe(false); + }); + + it('allows manual reset of hasBeenOffline flag', () => { + useNetworkStore.getState().setOnline(false); + expect(useNetworkStore.getState().hasBeenOffline).toBe(true); + + useNetworkStore.getState().setHasBeenOffline(false); + expect(useNetworkStore.getState().hasBeenOffline).toBe(false); + }); +}); diff --git a/frontend/src/store/useNetworkStore.ts b/frontend/src/store/useNetworkStore.ts new file mode 100644 index 00000000..7297261b --- /dev/null +++ b/frontend/src/store/useNetworkStore.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; + +export type WebSocketStatus = 'connected' | 'connecting' | 'reconnecting' | 'disconnected'; + +export interface NetworkState { + isOnline: boolean; + webSocketStatus: WebSocketStatus; + hasBeenOffline: boolean; + setOnline: (isOnline: boolean) => void; + setWebSocketStatus: (status: WebSocketStatus) => void; + setHasBeenOffline: (hasBeenOffline: boolean) => void; +} + +export const useNetworkStore = create((set) => ({ + isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, + webSocketStatus: 'connected', + hasBeenOffline: false, + + setOnline: (isOnline) => + set((state) => ({ + isOnline, + hasBeenOffline: !isOnline ? true : state.hasBeenOffline, + })), + + setWebSocketStatus: (status) => + set((state) => ({ + webSocketStatus: status, + hasBeenOffline: + status === 'reconnecting' + ? true + : status === 'disconnected' + ? false + : state.hasBeenOffline, + })), + + setHasBeenOffline: (hasBeenOffline) => set({ hasBeenOffline }), +})); diff --git a/frontend/src/utils/idempotency.test.ts b/frontend/src/utils/idempotency.test.ts new file mode 100644 index 00000000..5d604d5f --- /dev/null +++ b/frontend/src/utils/idempotency.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + IDEMPOTENCY_HEADER_NAME, + IDEMPOTENT_MUTATION_METHODS, + generateIdempotencyKey, + shouldAttachIdempotencyKey, +} from './idempotency'; + +describe('idempotency utility', () => { + const originalCrypto = globalThis.crypto; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: originalCrypto, + writable: true, + configurable: true, + }); + }); + + it('should define the standard header name and mutation methods', () => { + expect(IDEMPOTENCY_HEADER_NAME).toBe('Idempotency-Key'); + expect(IDEMPOTENT_MUTATION_METHODS).toEqual(['POST', 'DELETE', 'PATCH']); + }); + + it('should generate valid UUID v4 string using crypto.randomUUID when available', () => { + const mockUuid = '123e4567-e89b-42d3-a456-426614174000'; + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: vi.fn(() => mockUuid) }, + writable: true, + configurable: true, + }); + + const key = generateIdempotencyKey(); + expect(key).toBe(mockUuid); + }); + + it('should generate valid UUID v4 string format when crypto is undefined', () => { + Object.defineProperty(globalThis, 'crypto', { + value: undefined, + writable: true, + configurable: true, + }); + + const key = generateIdempotencyKey(); + const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(uuidV4Regex.test(key)).toBe(true); + }); + + it('should correctly identify when idempotency key should be attached', () => { + expect(shouldAttachIdempotencyKey('post')).toBe(true); + expect(shouldAttachIdempotencyKey('POST')).toBe(true); + expect(shouldAttachIdempotencyKey('delete')).toBe(true); + expect(shouldAttachIdempotencyKey('DELETE')).toBe(true); + expect(shouldAttachIdempotencyKey('patch')).toBe(true); + expect(shouldAttachIdempotencyKey('PATCH')).toBe(true); + expect(shouldAttachIdempotencyKey('get')).toBe(false); + expect(shouldAttachIdempotencyKey('GET')).toBe(false); + expect(shouldAttachIdempotencyKey('put')).toBe(false); + expect(shouldAttachIdempotencyKey('PUT')).toBe(false); + expect(shouldAttachIdempotencyKey(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/utils/idempotency.ts b/frontend/src/utils/idempotency.ts new file mode 100644 index 00000000..c14a212c --- /dev/null +++ b/frontend/src/utils/idempotency.ts @@ -0,0 +1,23 @@ +export const IDEMPOTENCY_HEADER_NAME = 'Idempotency-Key'; + +export const IDEMPOTENT_MUTATION_METHODS = ['POST', 'DELETE', 'PATCH'] as const; + +export const generateIdempotencyKey = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => { + const randomNibble = (Math.random() * 16) | 0; + const value = character === 'x' ? randomNibble : (randomNibble & 0x3) | 0x8; + return value.toString(16); + }); +}; + +export const shouldAttachIdempotencyKey = (method?: string): boolean => { + if (!method) { + return false; + } + const upperMethod = method.toUpperCase(); + return upperMethod === 'POST' || upperMethod === 'DELETE' || upperMethod === 'PATCH'; +}; diff --git a/frontend/src/utils/multiTabSync.test.ts b/frontend/src/utils/multiTabSync.test.ts new file mode 100644 index 00000000..d5baa959 --- /dev/null +++ b/frontend/src/utils/multiTabSync.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + broadcastEvent, + subscribeToSyncEvents, + getCurrentTabId, + type SyncMessage, +} from './multiTabSync'; + +describe('multiTabSync utility', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('provides a unique tab identifier', () => { + const tabId = getCurrentTabId(); + expect(tabId).toBeDefined(); + expect(typeof tabId).toBe('string'); + expect(tabId.length).toBeGreaterThan(5); + }); + + it('broadcasts event and notifies storage listener from different tab', () => { + const mockListener = vi.fn(); + const unsubscribe = subscribeToSyncEvents(mockListener); + + const externalMessage: SyncMessage = { + type: 'AUTH_LOGOUT', + payload: { reason: 'session_expired' }, + senderTabId: 'different-tab-id-999', + timestamp: Date.now(), + }; + + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify(externalMessage), + }); + + window.dispatchEvent(storageEvent); + + expect(mockListener).toHaveBeenCalledWith(externalMessage); + unsubscribe(); + }); + + it('ignores storage events originating from the same tab', () => { + const mockListener = vi.fn(); + const unsubscribe = subscribeToSyncEvents(mockListener); + + const sameTabMessage: SyncMessage = { + type: 'BOT_CHANGED', + payload: { botId: 5 }, + senderTabId: getCurrentTabId(), + timestamp: Date.now(), + }; + + const storageEvent = new StorageEvent('storage', { + key: 'launchly_multitab_sync_event', + newValue: JSON.stringify(sameTabMessage), + }); + + window.dispatchEvent(storageEvent); + + expect(mockListener).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('broadcasts events to localStorage without throwing', () => { + expect(() => broadcastEvent('BOT_CHANGED', { botId: 10 })).not.toThrow(); + }); +}); diff --git a/frontend/src/utils/multiTabSync.ts b/frontend/src/utils/multiTabSync.ts new file mode 100644 index 00000000..ce2a548f --- /dev/null +++ b/frontend/src/utils/multiTabSync.ts @@ -0,0 +1,106 @@ +export type SyncEventType = + | 'AUTH_LOGOUT' + | 'AUTH_LOGIN' + | 'BOT_CHANGED' + | 'SYNC_QUERY_INVALIDATE'; + +export interface SyncMessage { + type: SyncEventType; + payload?: T; + senderTabId: string; + timestamp: number; +} + +const CHANNEL_NAME = 'launchly_multitab_sync'; +const FALLBACK_STORAGE_KEY = 'launchly_multitab_sync_event'; + +const CURRENT_TAB_ID = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).substring(2, 9) + Date.now().toString(36); + +let sharedChannel: BroadcastChannel | null = null; + +const getChannel = (): BroadcastChannel | null => { + if (typeof BroadcastChannel === 'undefined') { + return null; + } + if (!sharedChannel) { + try { + sharedChannel = new BroadcastChannel(CHANNEL_NAME); + } catch { + sharedChannel = null; + } + } + return sharedChannel; +}; + +export const broadcastEvent = (type: SyncEventType, payload?: T): void => { + const message: SyncMessage = { + type, + payload, + senderTabId: CURRENT_TAB_ID, + timestamp: Date.now(), + }; + + const channel = getChannel(); + if (channel) { + try { + channel.postMessage(message); + } catch { + } + } + + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(message)); + } catch { + } + } +}; + +export const subscribeToSyncEvents = ( + listener: (message: SyncMessage) => void +): (() => void) => { + const channel = getChannel(); + + const handleChannelMessage = (event: MessageEvent) => { + if (!event.data || event.data.senderTabId === CURRENT_TAB_ID) { + return; + } + listener(event.data); + }; + + const handleStorageMessage = (event: StorageEvent) => { + if (event.key !== FALLBACK_STORAGE_KEY || !event.newValue) { + return; + } + try { + const message = JSON.parse(event.newValue) as SyncMessage; + if (message.senderTabId === CURRENT_TAB_ID) { + return; + } + listener(message); + } catch { + } + }; + + if (channel) { + channel.addEventListener('message', handleChannelMessage); + } + + if (typeof window !== 'undefined') { + window.addEventListener('storage', handleStorageMessage); + } + + return () => { + if (channel) { + channel.removeEventListener('message', handleChannelMessage); + } + if (typeof window !== 'undefined') { + window.removeEventListener('storage', handleStorageMessage); + } + }; +}; + +export const getCurrentTabId = (): string => CURRENT_TAB_ID; diff --git a/frontend/src/utils/requestCancellation.test.ts b/frontend/src/utils/requestCancellation.test.ts new file mode 100644 index 00000000..d61a4ffb --- /dev/null +++ b/frontend/src/utils/requestCancellation.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getRequestSignature, + cancelPendingRequest, + registerRequest, + removePendingRequest, + isRequestCanceled, + createAbortController, + clearAllPendingRequests, +} from './requestCancellation'; +import type { InternalAxiosRequestConfig } from 'axios'; + +describe('requestCancellation utility', () => { + beforeEach(() => { + clearAllPendingRequests(); + }); + + it('generates consistent request signature', () => { + const config = { + method: 'get', + url: '/crm/conversations', + params: { page: 1, search: 'test' }, + } as unknown as InternalAxiosRequestConfig; + + const signature = getRequestSignature(config); + expect(signature).toBe('GET:/crm/conversations:{"page":1,"search":"test"}'); + }); + + it('auto-registers and cancels superseded search queries', () => { + const config1 = { + method: 'get', + url: '/crm/conversations', + params: { search: 'al' }, + } as unknown as InternalAxiosRequestConfig; + + registerRequest(config1); + expect(config1.signal).toBeDefined(); + expect(config1.signal?.aborted).toBe(false); + + const config2 = { + method: 'get', + url: '/crm/conversations', + params: { search: 'al' }, + } as unknown as InternalAxiosRequestConfig; + + registerRequest(config2); + expect(config1.signal?.aborted).toBe(true); + expect(config2.signal?.aborted).toBe(false); + }); + + it('removes pending request on completion', () => { + const config = { + method: 'get', + url: '/crm/conversations', + params: { search: 'test' }, + } as unknown as InternalAxiosRequestConfig; + + registerRequest(config); + removePendingRequest(config); + + const signature = getRequestSignature(config); + expect(() => cancelPendingRequest(signature)).not.toThrow(); + }); + + it('correctly identifies canceled errors', () => { + const canceledErr = { name: 'CanceledError' }; + const abortErr = { name: 'AbortError' }; + const codeErr = { code: 'ERR_CANCELED' }; + const regularErr = new Error('Regular error'); + + expect(isRequestCanceled(canceledErr)).toBe(true); + expect(isRequestCanceled(abortErr)).toBe(true); + expect(isRequestCanceled(codeErr)).toBe(true); + expect(isRequestCanceled(regularErr)).toBe(false); + }); + + it('creates standalone abort controller', () => { + const { signal, abort } = createAbortController(); + expect(signal.aborted).toBe(false); + abort('manual stop'); + expect(signal.aborted).toBe(true); + }); +}); diff --git a/frontend/src/utils/requestCancellation.ts b/frontend/src/utils/requestCancellation.ts new file mode 100644 index 00000000..759c0ffc --- /dev/null +++ b/frontend/src/utils/requestCancellation.ts @@ -0,0 +1,76 @@ +import axios, { type InternalAxiosRequestConfig } from 'axios'; + +const pendingRequests = new Map(); + +export const getRequestSignature = (config: InternalAxiosRequestConfig): string => { + const method = (config.method || 'get').toUpperCase(); + const url = config.url || ''; + const params = config.params ? JSON.stringify(config.params) : ''; + return `${method}:${url}:${params}`; +}; + +export const cancelPendingRequest = (signature: string): void => { + const existingController = pendingRequests.get(signature); + if (existingController) { + existingController.abort('Request superseded by newer request'); + pendingRequests.delete(signature); + } +}; + +export const registerRequest = ( + config: InternalAxiosRequestConfig, + autoCancelDuplicate: boolean = false +): InternalAxiosRequestConfig => { + if (config.signal) { + return config; + } + + const isGet = (config.method || 'get').toUpperCase() === 'GET'; + const isSearchQuery = + config.params && ('query' in config.params || 'search' in config.params || 'q' in config.params); + + if (autoCancelDuplicate || (isGet && isSearchQuery)) { + const signature = getRequestSignature(config); + cancelPendingRequest(signature); + + const controller = new AbortController(); + config.signal = controller.signal; + pendingRequests.set(signature, controller); + } + + return config; +}; + +export const removePendingRequest = (config: InternalAxiosRequestConfig): void => { + const signature = getRequestSignature(config); + pendingRequests.delete(signature); +}; + +export const isRequestCanceled = (error: unknown): boolean => { + if (axios.isCancel(error)) { + return true; + } + if (error && typeof error === 'object') { + const err = error as { name?: string; code?: string }; + return err.name === 'CanceledError' || err.name === 'AbortError' || err.code === 'ERR_CANCELED'; + } + return false; +}; + +export const createAbortController = (): { + signal: AbortSignal; + abort: (reason?: string) => void; +} => { + const controller = new AbortController(); + return { + signal: controller.signal, + abort: (reason) => controller.abort(reason), + }; +}; + +export const clearAllPendingRequests = (): void => { + pendingRequests.forEach((controller) => { + controller.abort('All requests aborted'); + }); + pendingRequests.clear(); +}; diff --git a/frontend/src/utils/sanitize.test.ts b/frontend/src/utils/sanitize.test.ts new file mode 100644 index 00000000..1d742546 --- /dev/null +++ b/frontend/src/utils/sanitize.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeHtml, sanitizeUrl, escapeHtml, createSafeHtml } from './sanitize'; + +describe('sanitize utility', () => { + it('escapes html entities correctly', () => { + const raw = '
&\'"
'; + const escaped = escapeHtml(raw); + expect(escaped).toBe('<div class="test">&'"</div>'); + }); + + it('sanitizes dangerous URLs and allows safe protocols', () => { + expect(sanitizeUrl('javascript:alert(1)')).toBe(''); + expect(sanitizeUrl('data:text/html,')).toBe(''); + expect(sanitizeUrl('vbscript:msgbox(1)')).toBe(''); + expect(sanitizeUrl('https://launchly.app/docs')).toBe('https://launchly.app/docs'); + expect(sanitizeUrl('http://example.com')).toBe('http://example.com'); + expect(sanitizeUrl('/dashboard/chat')).toBe('/dashboard/chat'); + expect(sanitizeUrl('mailto:support@launchly.app')).toBe('mailto:support@launchly.app'); + expect(sanitizeUrl('tel:+380501234567')).toBe('tel:+380501234567'); + }); + + it('strips script tags and executable attributes from HTML', () => { + const malicious = '

Hello World

'; + const sanitized = sanitizeHtml(malicious); + expect(sanitized).not.toContain(''; + const safeObject = createSafeHtml(dirty); + expect(safeObject).toEqual({ __html: 'Test' }); + }); +}); diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/utils/sanitize.ts new file mode 100644 index 00000000..f95b3c64 --- /dev/null +++ b/frontend/src/utils/sanitize.ts @@ -0,0 +1,130 @@ +export interface SanitizeOptions { + allowedTags?: string[]; + allowedAttributes?: string[]; + allowedProtocols?: string[]; +} + +const DEFAULT_ALLOWED_TAGS = new Set([ + 'div', + 'b', + 'strong', + 'i', + 'em', + 'u', + 's', + 'strike', + 'p', + 'br', + 'span', + 'code', + 'pre', + 'ul', + 'ol', + 'li', + 'blockquote', + 'a', +]); + +const DEFAULT_ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); + +export const escapeHtml = (text: string): string => { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +export const sanitizeUrl = (url: string): string => { + if (!url) return ''; + const trimmed = url.trim(); + + if (trimmed.startsWith('/') || trimmed.startsWith('#')) { + return trimmed; + } + + try { + const parsed = new URL(trimmed, 'https://launchly.app'); + if (DEFAULT_ALLOWED_PROTOCOLS.has(parsed.protocol)) { + return trimmed; + } + } catch { + return ''; + } + + return ''; +}; + +export const sanitizeHtml = (dirtyHtml: string, options?: SanitizeOptions): string => { + if (!dirtyHtml) return ''; + + const allowedTags = options?.allowedTags + ? new Set(options.allowedTags.map((t) => t.toLowerCase())) + : DEFAULT_ALLOWED_TAGS; + + if (typeof DOMParser === 'undefined') { + return escapeHtml(dirtyHtml); + } + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(dirtyHtml, 'text/html'); + + const cleanNode = (node: Node) => { + const children = Array.from(node.childNodes); + for (const child of children) { + if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + if (!allowedTags.has(tagName)) { + element.remove(); + continue; + } + + const attributes = Array.from(element.attributes); + for (const attr of attributes) { + const attrName = attr.name.toLowerCase(); + + if (attrName.startsWith('on') || attrName === 'style') { + element.removeAttribute(attr.name); + continue; + } + + if (tagName === 'a' && attrName === 'href') { + const safeHref = sanitizeUrl(attr.value); + if (!safeHref) { + element.removeAttribute('href'); + } else { + element.setAttribute('href', safeHref); + element.setAttribute('rel', 'noopener noreferrer'); + element.setAttribute('target', '_blank'); + } + } else if (attrName !== 'class' && attrName !== 'title') { + element.removeAttribute(attr.name); + } + } + + cleanNode(element); + } else if (child.nodeType !== Node.TEXT_NODE) { + child.remove(); + } + } + }; + + cleanNode(doc.body); + return doc.body.innerHTML; + } catch { + return escapeHtml(dirtyHtml); + } +}; + +export const createSafeHtml = ( + dirtyHtml: string, + options?: SanitizeOptions +): { __html: string } => { + return { + __html: sanitizeHtml(dirtyHtml, options), + }; +};