diff --git a/backend/src/docs/api/auth-api.md b/backend/src/docs/api/auth-api.md index c10e85a0..b06cb7c7 100644 --- a/backend/src/docs/api/auth-api.md +++ b/backend/src/docs/api/auth-api.md @@ -1,5 +1,7 @@ # Auth API Endpoints +> **Note:** All login and token refresh endpoints now return `accessToken` and `refreshToken` both in the JSON payload and as `HttpOnly` cookies. Web clients should rely on cookies (by setting `credentials: 'include'`) while mobile clients should store tokens from the JSON response securely. + Base path: `/api/v1/auth` | Method | Endpoint | Description | diff --git a/backend/src/main/java/com/swipelab/auth/api/AuthController.java b/backend/src/main/java/com/swipelab/auth/api/AuthController.java index 2053ed19..e05ac28c 100644 --- a/backend/src/main/java/com/swipelab/auth/api/AuthController.java +++ b/backend/src/main/java/com/swipelab/auth/api/AuthController.java @@ -27,6 +27,9 @@ import java.security.Principal; import java.util.HashMap; import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Cookie; @RestController @RequestMapping("/api/v1/auth") @@ -40,12 +43,46 @@ public class AuthController { private final com.swipelab.auth.application.JwtService jwtService; private final SpringTemplateEngine templateEngine; + private void setAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) { + Cookie accessCookie = new Cookie("accessToken", accessToken); + accessCookie.setHttpOnly(true); + accessCookie.setPath("/"); + accessCookie.setMaxAge(3600); // 1 hour + + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(604800); // 7 days + + response.addCookie(accessCookie); + response.addCookie(refreshCookie); + } + + private void clearAuthCookies(HttpServletResponse response) { + Cookie accessCookie = new Cookie("accessToken", ""); + accessCookie.setHttpOnly(true); + accessCookie.setPath("/"); + accessCookie.setMaxAge(0); + + Cookie refreshCookie = new Cookie("refreshToken", ""); + refreshCookie.setHttpOnly(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); + + response.addCookie(accessCookie); + response.addCookie(refreshCookie); + } + /** * Register a new user */ @PostMapping("/register") - public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { - return ResponseEntity.status(HttpStatus.CREATED).body(authenticationService.register(request)); + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request, HttpServletResponse response) { + java.util.Map result = authenticationService.register(request); + if (result.containsKey("accessToken") && result.containsKey("refreshToken")) { + setAuthCookies(response, (String) result.get("accessToken"), (String) result.get("refreshToken")); + } + return ResponseEntity.status(HttpStatus.CREATED).body(result); } /** @@ -140,33 +177,58 @@ public ResponseEntity testEndpoint() { @PostMapping("/login") public ResponseEntity login( - @Valid @RequestBody LoginRequest request) { - return ResponseEntity.ok(authenticationService.login(request)); + @Valid @RequestBody LoginRequest request, HttpServletResponse response) { + AuthResponse authResponse = authenticationService.login(request); + setAuthCookies(response, authResponse.getAccessToken(), authResponse.getRefreshToken()); + return ResponseEntity.ok(authResponse); } @PostMapping("/refresh") public ResponseEntity refreshToken( - @RequestHeader("Authorization") String authorizationHeader) { + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + HttpServletRequest request, HttpServletResponse response) { + + String refreshToken = null; + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + refreshToken = authorizationHeader.substring(7); + } else if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + refreshToken = cookie.getValue(); + } + } + } - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + if (refreshToken == null) { throw new UnauthorizedException("Missing refresh token"); } - String refreshToken = authorizationHeader.substring(7); - return ResponseEntity.ok(authenticationService.refresh(refreshToken)); + AuthResponse authResponse = authenticationService.refresh(refreshToken); + setAuthCookies(response, authResponse.getAccessToken(), authResponse.getRefreshToken()); + return ResponseEntity.ok(authResponse); } @PostMapping("/logout") public ResponseEntity logout( - @RequestHeader("Authorization") String authorizationHeader) { - - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - throw new UnauthorizedException("Missing refresh token"); + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + HttpServletRequest request, HttpServletResponse response) { + + String refreshToken = null; + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + refreshToken = authorizationHeader.substring(7); + } else if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + refreshToken = cookie.getValue(); + } + } } - String refreshToken = authorizationHeader.substring(7); - authenticationService.logout(refreshToken); + if (refreshToken != null) { + authenticationService.logout(refreshToken); + } + clearAuthCookies(response); return ResponseEntity.noContent().build(); } @@ -182,7 +244,7 @@ public ResponseEntity me(Principal principal) { } @PostMapping("/login/google") - public ResponseEntity loginGoogle(@RequestBody Map payload) { + public ResponseEntity loginGoogle(@RequestBody Map payload, HttpServletResponse response) { String credential = payload.get("credential"); if (credential == null) { credential = payload.get("idToken"); @@ -227,6 +289,7 @@ public ResponseEntity loginGoogle(@RequestBody Map String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); + setAuthCookies(response, accessToken, refreshToken); return ResponseEntity.ok(authMapper.toAuthResponse(accessToken, refreshToken, user)); } @@ -272,6 +335,24 @@ public ResponseEntity> resetPassword( return ResponseEntity.ok(response); } + /** + * Change password for the currently authenticated user + * + * Endpoint: POST /api/v1/auth/password/change + */ + @PostMapping("/password/change") + public ResponseEntity> changePassword( + @Valid @RequestBody ChangePasswordRequest request) { + + authenticationService.changePassword(request); + + Map response = new HashMap<>(); + response.put("message", "Password changed successfully."); + response.put("status", "success"); + + return ResponseEntity.ok(response); + } + /** * Send an invitation email to a new admin or researcher. * Restricted to the Super Admin — uses the same SpEL bean check diff --git a/backend/src/main/java/com/swipelab/auth/application/AuthenticationService.java b/backend/src/main/java/com/swipelab/auth/application/AuthenticationService.java index 107c965d..4f8aea0f 100644 --- a/backend/src/main/java/com/swipelab/auth/application/AuthenticationService.java +++ b/backend/src/main/java/com/swipelab/auth/application/AuthenticationService.java @@ -230,8 +230,8 @@ public void forgotPassword(String email) { // Look up user by email User user = userRepository.findByEmail(email).orElse(null); - // If user exists, generate token and send email - if (user != null) { + // If user exists and is a LOCAL user, generate token and send email + if (user != null && user.getProvider() == com.swipelab.auth.infrastructure.AuthProvider.LOCAL) { // Generate password reset token String resetToken = UUID.randomUUID().toString(); @@ -316,6 +316,32 @@ public void inviteAdmin(com.swipelab.auth.dto.InviteAdminRequest request) { emailService.sendInvitationEmail(user.getEmail(), request.getRole().name(), invitationToken); } + /** + * Changes password for a currently authenticated local user. + */ + @Transactional + public void changePassword(com.swipelab.auth.dto.ChangePasswordRequest request) { + org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new UnauthorizedException("User not authenticated"); + } + + String username = authentication.getName(); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UnauthorizedException("Authenticated user not found")); + + if (user.getProvider() != com.swipelab.auth.infrastructure.AuthProvider.LOCAL) { + throw new IllegalArgumentException("External users cannot change their password"); + } + + String hashedPassword = passwordEncoder.encode(request.getNewPassword()); + user.setPasswordHash(hashedPassword); + + // Invalidate all refresh tokens for security + user.setRefreshTokenHash(null); + + userRepository.save(user); + } } diff --git a/backend/src/main/java/com/swipelab/auth/domain/AuthMapper.java b/backend/src/main/java/com/swipelab/auth/domain/AuthMapper.java index 58cc360e..a20265d6 100644 --- a/backend/src/main/java/com/swipelab/auth/domain/AuthMapper.java +++ b/backend/src/main/java/com/swipelab/auth/domain/AuthMapper.java @@ -61,6 +61,7 @@ public UserProfileResponse toUserProfileResponse(User user) { .rank(user.getRank() != null ? user.getRank() : "UNRANKED") .isSuperAdmin(securityAuthorizationService.isSuperAdmin(user.getUsername())) .active(user.getActive() != null ? user.getActive() : true) + .provider(user.getProvider() != null ? user.getProvider().name() : null) // Credibility composite score — 0 (bad) to 100 (perfect), default 50 for new users .credibilityScore(user.getCredibilityScore()) .build(); diff --git a/backend/src/main/java/com/swipelab/auth/dto/ChangePasswordRequest.java b/backend/src/main/java/com/swipelab/auth/dto/ChangePasswordRequest.java new file mode 100644 index 00000000..f1fa2047 --- /dev/null +++ b/backend/src/main/java/com/swipelab/auth/dto/ChangePasswordRequest.java @@ -0,0 +1,19 @@ +package com.swipelab.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChangePasswordRequest { + + @NotBlank(message = "New password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + private String newPassword; +} diff --git a/backend/src/main/java/com/swipelab/auth/external/ExternalAuthController.java b/backend/src/main/java/com/swipelab/auth/external/ExternalAuthController.java index a134f36f..0739ec23 100644 --- a/backend/src/main/java/com/swipelab/auth/external/ExternalAuthController.java +++ b/backend/src/main/java/com/swipelab/auth/external/ExternalAuthController.java @@ -2,20 +2,16 @@ import com.swipelab.auth.dto.AuthResponse; import com.swipelab.auth.domain.AuthMapper; -import com.swipelab.auth.dto.AuthResponse; import com.swipelab.auth.dto.ExternalLoginRequest; -import com.swipelab.auth.dto.AuthResponse; import com.swipelab.users.dto.UserProfileResponse; -import com.swipelab.auth.dto.AuthResponse; import com.swipelab.users.domain.User; -import com.swipelab.auth.dto.AuthResponse; import jakarta.validation.Valid; -import com.swipelab.auth.dto.AuthResponse; import lombok.RequiredArgsConstructor; -import com.swipelab.auth.dto.AuthResponse; import org.springframework.http.ResponseEntity; -import com.swipelab.auth.dto.AuthResponse; import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.Cookie; @RestController @RequestMapping("/api/v1/auth/external") @@ -26,6 +22,21 @@ public class ExternalAuthController { private final AuthMapper authMapper; private final com.swipelab.auth.application.JwtService jwtService; + private void setAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) { + Cookie accessCookie = new Cookie("accessToken", accessToken); + accessCookie.setHttpOnly(true); + accessCookie.setPath("/"); + accessCookie.setMaxAge(3600); // 1 hour + + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(604800); // 7 days + + response.addCookie(accessCookie); + response.addCookie(refreshCookie); + } + /** * Called by the frontend immediately after a successful Stardbi login. * Validates the Stardbi access token, auto-provisions a local SwipeLab @@ -36,7 +47,7 @@ public class ExternalAuthController { */ @PostMapping("/stardbi/loginExternal") public ResponseEntity loginExternal( - @Valid @RequestBody ExternalLoginRequest request) { + @Valid @RequestBody ExternalLoginRequest request, HttpServletResponse httpResponse) { User user = stardbiAuthService.loginExternal(request); if (user != null) { @@ -53,6 +64,7 @@ public ResponseEntity loginExternal( .user(profile) .build(); + setAuthCookies(httpResponse, accessToken, refreshToken); return ResponseEntity.ok(response); } return ResponseEntity.status(401).build(); diff --git a/backend/src/main/java/com/swipelab/auth/infrastructure/JwtAuthenticationFilter.java b/backend/src/main/java/com/swipelab/auth/infrastructure/JwtAuthenticationFilter.java index c72cc671..f137fce0 100644 --- a/backend/src/main/java/com/swipelab/auth/infrastructure/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/swipelab/auth/infrastructure/JwtAuthenticationFilter.java @@ -63,6 +63,15 @@ private String getJwtFromRequest(HttpServletRequest request) { if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } + + // Support for HttpOnly cookies + if (request.getCookies() != null) { + for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } return null; } diff --git a/backend/src/main/java/com/swipelab/auth/infrastructure/SecurityConfig.java b/backend/src/main/java/com/swipelab/auth/infrastructure/SecurityConfig.java index 8593087d..763d9f58 100644 --- a/backend/src/main/java/com/swipelab/auth/infrastructure/SecurityConfig.java +++ b/backend/src/main/java/com/swipelab/auth/infrastructure/SecurityConfig.java @@ -77,6 +77,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/favicon.ico", // Auth endpoints (strictly matched) "/api/v1/auth/login", + "/api/v1/auth/logout", "/api/v1/auth/register", "/api/v1/auth/refresh", "/api/v1/auth/password/forgot", @@ -135,9 +136,9 @@ public CorsConfigurationSource corsConfigurationSource() { } else { configuration.setAllowedOrigins(List.of("*")); // Fallback } - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(false); + configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/backend/src/main/java/com/swipelab/tasks/domain/TaskMapper.java b/backend/src/main/java/com/swipelab/tasks/domain/TaskMapper.java index 8d3c2e25..e1d7c9e5 100644 --- a/backend/src/main/java/com/swipelab/tasks/domain/TaskMapper.java +++ b/backend/src/main/java/com/swipelab/tasks/domain/TaskMapper.java @@ -90,6 +90,10 @@ public void updateEntity(Task task, UpdateTaskRequest request) { if (request.getSharedWithResearchers() != null) { task.setSharedWithResearchers(request.getSharedWithResearchers()); } + + if (request.getConsensusThreshold() != null) { + task.setConsensusThreshold(request.getConsensusThreshold()); + } // targetSpecies handled in service } diff --git a/backend/src/main/java/com/swipelab/tasks/dto/UpdateTaskRequest.java b/backend/src/main/java/com/swipelab/tasks/dto/UpdateTaskRequest.java index 669e8634..cf9e10f4 100644 --- a/backend/src/main/java/com/swipelab/tasks/dto/UpdateTaskRequest.java +++ b/backend/src/main/java/com/swipelab/tasks/dto/UpdateTaskRequest.java @@ -14,6 +14,7 @@ public class UpdateTaskRequest { private List assignedUsernames; private List sharedWithResearchers; private Boolean isPublic; + private Double consensusThreshold; /** * Map of Species Name -> List of SpeciesReferenceImage IDs diff --git a/backend/src/main/java/com/swipelab/users/dto/UserProfileResponse.java b/backend/src/main/java/com/swipelab/users/dto/UserProfileResponse.java index 08c915b9..aac43818 100644 --- a/backend/src/main/java/com/swipelab/users/dto/UserProfileResponse.java +++ b/backend/src/main/java/com/swipelab/users/dto/UserProfileResponse.java @@ -19,6 +19,7 @@ public class UserProfileResponse { private String displayName; private String profileImageUrl; private UserRole role; + private String provider; // Gamification data private Long score; diff --git a/backend/src/main/java/com/swipelab/users/infrastructure/AdminNotificationRepository.java b/backend/src/main/java/com/swipelab/users/infrastructure/AdminNotificationRepository.java index d3d234c5..1f2e0b47 100644 --- a/backend/src/main/java/com/swipelab/users/infrastructure/AdminNotificationRepository.java +++ b/backend/src/main/java/com/swipelab/users/infrastructure/AdminNotificationRepository.java @@ -27,7 +27,7 @@ public interface AdminNotificationRepository extends JpaRepository state.initialize); + const isMaintenanceMode = useAppStateStore((state) => state.isMaintenanceMode); React.useEffect(() => { - useAuthStore.getState().initialize(); - }, []); + initialize(); + }, [initialize]); useSessionHeartbeat(); + useHealthCheck(); return ( - - - - - - - - - + + + + + + {isMaintenanceMode ? ( + + ) : ( + + )} + + + + + + ); } diff --git a/frontend/app/api/__tests__/apiFetch.test.ts b/frontend/app/api/__tests__/apiFetch.test.ts index 1d23a0fb..38274ea4 100644 --- a/frontend/app/api/__tests__/apiFetch.test.ts +++ b/frontend/app/api/__tests__/apiFetch.test.ts @@ -1,5 +1,5 @@ -import { apiFetch } from '../apiFetch'; -import { useAuthStore } from '../../stores/authStore'; +import { apiFetch } from '@/api/apiFetch'; +import { useAuthStore } from '@/stores/authStore'; // Mock dependencies jest.mock('../../stores/authStore', () => ({ diff --git a/frontend/app/api/__tests__/useAssignTask.test.ts b/frontend/app/api/__tests__/useAssignTask.test.ts deleted file mode 100644 index 4dc24c6b..00000000 --- a/frontend/app/api/__tests__/useAssignTask.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Tests for useAssignTask cache-refresh behaviour (Issue: myTasksScreen stale cache). - * - * Why: invalidateQueries only marks a query stale; it will NOT trigger a network - * request unless the component re-renders or window is re-focused. - * refetchQueries(type:'active') forces an immediate fetch for every mounted subscriber, - * which is what we want when the user presses the assign (+) button. - */ -import { renderHook, act } from '@testing-library/react-hooks'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import React from 'react'; -import { useAssignTask, QUERY_KEYS } from '../queries'; - -// ── Mocks ──────────────────────────────────────────────────────────────────── - -jest.mock('expo-secure-store', () => ({ - getItemAsync: jest.fn(() => Promise.resolve('mock_token')), -})); - -jest.mock('../../stores/authStore', () => ({ - useAuthStore: { - getState: jest.fn(() => ({ - setIsBanned: jest.fn(), - setSessionExpiredMessage: jest.fn(), - logout: jest.fn(), - })), - }, -})); - -const mockFetch = jest.fn(); -global.fetch = mockFetch; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const buildWrapper = (queryClient: QueryClient) => { - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); -}; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('useAssignTask', () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - jest.clearAllMocks(); - }); - - afterEach(() => { - queryClient.clear(); - }); - - // Happy flow: successful assignment triggers immediate refetch of all three queries - it('refetches myTasks, availableTasks, and statistics on successful assignment', async () => { - // Simulate a successful POST /tasks/:id/assign response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ taskId: 42, name: 'Example' }), - clone: () => ({ json: async () => ({}) }), - }); - - const refetchQueriesSpy = jest.spyOn(queryClient, 'refetchQueries'); - - const wrapper = buildWrapper(queryClient); - const { result } = renderHook(() => useAssignTask(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync(42); - }); - - // All three cache buckets must be scheduled for an active refetch - const calledKeys = refetchQueriesSpy.mock.calls.map( - (args) => JSON.stringify((args[0] as any)?.queryKey), - ); - - expect(calledKeys).toContain(JSON.stringify(QUERY_KEYS.myTasks)); - expect(calledKeys).toContain(JSON.stringify(QUERY_KEYS.availableTasks)); - expect(calledKeys).toContain(JSON.stringify(QUERY_KEYS.statistics)); - }); - - // Edge case: 409 Conflict (already assigned) must NOT trigger any cache refetch - it('does NOT refetch queries on 409 (already assigned) error', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 409, - json: async () => ({ message: 'Task already assigned' }), - clone: () => ({ json: async () => ({}) }), - }); - - const refetchQueriesSpy = jest.spyOn(queryClient, 'refetchQueries'); - - const wrapper = buildWrapper(queryClient); - const { result } = renderHook(() => useAssignTask(), { wrapper }); - - await act(async () => { - try { - await result.current.mutateAsync(42); - } catch { - // expected rejection - } - }); - - expect(refetchQueriesSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/app/api/apiFetch.ts b/frontend/app/api/apiFetch.ts index 360d5861..099de118 100644 --- a/frontend/app/api/apiFetch.ts +++ b/frontend/app/api/apiFetch.ts @@ -1,6 +1,7 @@ -import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; -import { API_ENDPOINTS } from './apiEndpoints'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { getAccessToken, getRefreshToken, getItem } from '@/utils/tokenUtils'; +import Toast from 'react-native-toast-message'; const USE_MOCKS = __DEV__ @@ -27,17 +28,10 @@ export async function forceTokenRefresh(): Promise { isRefreshing = true; - let refreshToken; - let authProvider; - if (Platform.OS === 'web') { - refreshToken = localStorage.getItem("refreshToken"); - authProvider = localStorage.getItem("authProvider"); - } else { - refreshToken = await SecureStore.getItemAsync("refreshToken"); - authProvider = await SecureStore.getItemAsync("authProvider"); - } + let refreshToken = await getRefreshToken(); + let authProvider = await getItem("authProvider"); - if (!refreshToken) { + if (!refreshToken && Platform.OS !== 'web') { isRefreshing = false; onRefreshed(null); return false; @@ -47,9 +41,8 @@ export async function forceTokenRefresh(): Promise { // SwipeLab backend refresh (works for both local users and Stardbi researchers via BFF) const refreshResponse = await fetch(backendUrl + API_ENDPOINTS.AUTH.REFRESH, { method: "POST", - headers: { - "Authorization": `Bearer ${refreshToken}`, - }, + credentials: "include", // Important for sending the refresh cookie on web + headers: refreshToken ? { "Authorization": `Bearer ${refreshToken}` } : {}, }); if (refreshResponse.ok) { @@ -58,7 +51,7 @@ export async function forceTokenRefresh(): Promise { const newRefresh = data.refreshToken || refreshToken; if (newAccess) { - const { useAuthStore } = require("../stores/authStore"); + const { useAuthStore } = require("@/stores/authStore"); await useAuthStore.getState().updateTokens(newAccess, newRefresh); isRefreshing = false; @@ -91,19 +84,17 @@ export async function apiFetch( // } - // Get token from storage - let token; - if (Platform.OS === 'web') { - token = localStorage.getItem("token"); - } else { - token = await SecureStore.getItemAsync("token"); - } + // Get token from storage (null on web if cookies are used) + const token = await getAccessToken(); - const fullUrl = backendUrl + input; - console.log("[apiFetch] Full exact URL being fetch'ed:", fullUrl); + const fullUrl = url.startsWith('http') ? url : backendUrl + url; + if (__DEV__) { + console.log("[apiFetch] Full exact URL being fetch'ed:", fullUrl); + } const response = await fetch(fullUrl, { ...init, + credentials: "include", // Required for HttpOnly cookies on web headers: { ...(init?.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -116,7 +107,7 @@ export async function apiFetch( const cloned = response.clone(); const body = await cloned.json(); if (body?.errorCode === 'STARDBI_SESSION_EXPIRED') { - const { useAuthStore } = require("../stores/authStore"); + const { useAuthStore } = require("@/stores/authStore"); useAuthStore.getState().setSessionExpiredMessage(true); setTimeout(() => { useAuthStore.getState().logout(); @@ -159,42 +150,24 @@ export async function apiFetch( const refreshSuccess = await forceTokenRefresh(); if (refreshSuccess) { - let newToken; - if (Platform.OS === 'web') { - newToken = localStorage.getItem("token"); - } else { - newToken = await SecureStore.getItemAsync("token"); - } + const newToken = await getAccessToken(); - if (newToken) { - return fetch(fullUrl, { - ...init, - headers: { - ...(init?.headers ?? {}), - Authorization: `Bearer ${newToken}`, - }, - }); - } + return fetch(fullUrl, { + ...init, + credentials: "include", + headers: { + ...(init?.headers ?? {}), + ...(newToken ? { Authorization: `Bearer ${newToken}` } : {}), + }, + }); } // If no refresh token or refresh failed, we must logout - const { useAuthStore } = require("../stores/authStore"); - - // Only show "Session Expired" if they actually had a refresh token - let hadRefreshToken = false; - if (Platform.OS === 'web') { - hadRefreshToken = !!localStorage.getItem("refreshToken"); - } else { - hadRefreshToken = !!SecureStore.getItem("refreshToken"); // Sync read is ok here, or we can just rely on the fact that if they had a token, they are logged in. Wait, SecureStore.getItemAsync is async. Let's do it safely. - } - // Actually, forceTokenRefresh already knows if there's a refresh token, but it's encapsulated. + const { useAuthStore } = require("@/stores/authStore"); - if (Platform.OS === 'web') { - hadRefreshToken = !!localStorage.getItem("refreshToken"); - } else { - // For mobile, we'll just check if they are currently marked as authenticated in the store - hadRefreshToken = useAuthStore.getState().isAuthenticated; - } + const isAuthenticatedFlag = await getItem("isAuthenticated"); + const localToken = await getAccessToken(); + const hadRefreshToken = Platform.OS === 'web' ? isAuthenticatedFlag === 'true' : !!localToken; if (hadRefreshToken) { useAuthStore.getState().setSessionExpiredMessage(true); @@ -214,7 +187,7 @@ export async function apiFetch( const cloned = response.clone(); const body = await cloned.json(); if (body?.errorCode === 'ACCOUNT_BANNED') { - const { useAuthStore } = require("../stores/authStore"); + const { useAuthStore } = require("@/stores/authStore"); useAuthStore.getState().setIsBanned(true); } } catch { @@ -222,5 +195,23 @@ export async function apiFetch( } } + // Handle generic errors (non-401, non-403) with a Toast + if (!response.ok && response.status !== 401 && response.status !== 403 && response.status !== 500) { + const urlString = input.toString(); + if (!urlString.includes('/login') && !urlString.includes('/refresh')) { + Toast.show({ + type: 'error', + text1: 'API Error', + text2: `Request failed with status ${response.status}`, + }); + } + } + + // 500 Maintenance Mode handling + if (response.status >= 500) { + const { useAppStateStore } = require('@/stores/appStateStore'); + useAppStateStore.getState().setMaintenanceMode(true); + } + return response; } diff --git a/frontend/app/api/queries.ts b/frontend/app/api/queries.ts index 242b3ef5..a91317f3 100644 --- a/frontend/app/api/queries.ts +++ b/frontend/app/api/queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiFetch } from './apiFetch'; -import { API_ENDPOINTS } from './apiEndpoints'; +import { apiFetch } from '@/api/apiFetch'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; export const QUERY_KEYS = { // Tasks @@ -390,7 +390,7 @@ export const useDeleteSpeciesRefImage = () => { }); }; -import { queryClient } from '../queryClient'; +import { queryClient } from '@/queryClient'; export const preloadAfterLogin = async (role: string) => { diff --git a/frontend/app/components/GlobalErrorBoundary.tsx b/frontend/app/components/GlobalErrorBoundary.tsx new file mode 100644 index 00000000..07562d6a --- /dev/null +++ b/frontend/app/components/GlobalErrorBoundary.tsx @@ -0,0 +1,81 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; +import { theme } from "@/theme/theme"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class GlobalErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( + + Oops! Something went wrong. + + {this.state.error?.message || "An unexpected error occurred."} + + this.setState({ hasError: false, error: null })} + > + Try Again + + + ); + } + + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: theme.colors.background, + padding: theme.spacing.lg, + }, + title: { + fontSize: theme.typography.sizes.xl, + fontWeight: "bold", + color: theme.colors.error, + marginBottom: theme.spacing.md, + }, + message: { + fontSize: theme.typography.sizes.md, + color: theme.colors.textSecondary, + textAlign: "center", + marginBottom: theme.spacing.xl, + }, + button: { + backgroundColor: theme.colors.primary, + paddingHorizontal: theme.spacing.xl, + paddingVertical: theme.spacing.md, + borderRadius: theme.borderRadius.md, + }, + buttonText: { + color: "#fff", + fontSize: theme.typography.sizes.md, + fontWeight: "600", + }, +}); diff --git a/frontend/app/components/RegisterForm.tsx b/frontend/app/components/RegisterForm.tsx index ed3d53cf..f5129a28 100644 --- a/frontend/app/components/RegisterForm.tsx +++ b/frontend/app/components/RegisterForm.tsx @@ -9,10 +9,10 @@ import { View, } from "react-native"; import { Ionicons } from '@expo/vector-icons'; -import { apiFetch } from "../api/apiFetch"; -import { useAuthStore } from "../stores/authStore"; -import { useModeStore } from "../stores/modeStore"; -import { API_ENDPOINTS } from '../api/apiEndpoints'; +import { apiFetch } from "@/api/apiFetch"; +import { useAuthStore } from "@/stores/authStore"; +import { useModeStore } from "@/stores/modeStore"; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; interface Props { diff --git a/frontend/components/external-link.tsx b/frontend/app/components/external-link.tsx similarity index 100% rename from frontend/components/external-link.tsx rename to frontend/app/components/external-link.tsx diff --git a/frontend/components/haptic-tab.tsx b/frontend/app/components/haptic-tab.tsx similarity index 100% rename from frontend/components/haptic-tab.tsx rename to frontend/app/components/haptic-tab.tsx diff --git a/frontend/components/hello-wave.tsx b/frontend/app/components/hello-wave.tsx similarity index 100% rename from frontend/components/hello-wave.tsx rename to frontend/app/components/hello-wave.tsx diff --git a/frontend/app/components/layout/ScreenHeaderLayout/ScreenHeaderLayout.tsx b/frontend/app/components/layout/ScreenHeaderLayout/ScreenHeaderLayout.tsx index bb812de3..a4453a12 100644 --- a/frontend/app/components/layout/ScreenHeaderLayout/ScreenHeaderLayout.tsx +++ b/frontend/app/components/layout/ScreenHeaderLayout/ScreenHeaderLayout.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; -import { ScreenHeaderLayoutProps } from "./ScreenHeaderLayout.types"; -import { useThemeStore } from '../../../stores/themeStore'; +import { ScreenHeaderLayoutProps } from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout.types"; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../../constants/theme'; diff --git a/frontend/app/components/layout/ScreenHeaderLayout/index.ts b/frontend/app/components/layout/ScreenHeaderLayout/index.ts index b71265d9..4f205994 100644 --- a/frontend/app/components/layout/ScreenHeaderLayout/index.ts +++ b/frontend/app/components/layout/ScreenHeaderLayout/index.ts @@ -1,3 +1,3 @@ -export { default } from "./ScreenHeaderLayout"; -export * from "./ScreenHeaderLayout.types"; +export { default } from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; +export * from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout.types"; diff --git a/frontend/app/components/layout/WebAppShell.tsx b/frontend/app/components/layout/WebAppShell.tsx index b39c2940..268aa057 100644 --- a/frontend/app/components/layout/WebAppShell.tsx +++ b/frontend/app/components/layout/WebAppShell.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { Platform, StyleSheet, View, ViewStyle } from 'react-native'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; interface Props { diff --git a/frontend/components/parallax-scroll-view.tsx b/frontend/app/components/parallax-scroll-view.tsx similarity index 87% rename from frontend/components/parallax-scroll-view.tsx rename to frontend/app/components/parallax-scroll-view.tsx index e29ebfbd..e2de6b1d 100644 --- a/frontend/components/parallax-scroll-view.tsx +++ b/frontend/app/components/parallax-scroll-view.tsx @@ -7,8 +7,8 @@ import Animated, { useScrollOffset, } from 'react-native-reanimated'; -import { useColorScheme } from '@/app/hooks/use-color-scheme'; -import { useThemeColor } from '@/app/hooks/use-theme-color'; +import { useColorScheme } from '@/hooks/use-color-scheme'; +import { useThemeColor } from '@/hooks/use-theme-color'; import { ThemedView } from '@/components/themed-view'; const HEADER_HEIGHT = 250; @@ -52,7 +52,7 @@ export default function ParallaxScrollView({ {headerImage} diff --git a/frontend/app/components/researcher/ConfidenceTrendChart.tsx b/frontend/app/components/researcher/ConfidenceTrendChart.tsx index d15bbaea..8dcee110 100644 --- a/frontend/app/components/researcher/ConfidenceTrendChart.tsx +++ b/frontend/app/components/researcher/ConfidenceTrendChart.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; -import type { ConfidenceTrendPoint } from '../../types/analyticsTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import type { ConfidenceTrendPoint } from '@/types/analyticsTypes'; type Props = { data: ConfidenceTrendPoint[]; diff --git a/frontend/app/components/researcher/ExportModal.tsx b/frontend/app/components/researcher/ExportModal.tsx index 04bac354..58b05900 100644 --- a/frontend/app/components/researcher/ExportModal.tsx +++ b/frontend/app/components/researcher/ExportModal.tsx @@ -14,10 +14,10 @@ import { } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; -import MultiSelect, { MultiSelectOption } from '../ui/MultiSelect'; -import { useAdminTasks, useExportClassificationsCsv } from '../../api/queries'; -import { downloadCsvBlob } from '../../services/csvDownload'; +import { useThemeStore } from '@/stores/themeStore'; +import MultiSelect, { MultiSelectOption } from '@/components/ui/MultiSelect'; +import { useAdminTasks, useExportClassificationsCsv } from '@/api/queries'; +import { downloadCsvBlob } from '@/services/csvDownload'; interface ExportModalProps { visible: boolean; diff --git a/frontend/app/components/researcher/GoldImageCard.tsx b/frontend/app/components/researcher/GoldImageCard.tsx index c52e67fc..c63e3e6d 100644 --- a/frontend/app/components/researcher/GoldImageCard.tsx +++ b/frontend/app/components/researcher/GoldImageCard.tsx @@ -8,7 +8,7 @@ import { View } from "react-native"; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; type GoldImageData = { id: number; diff --git a/frontend/app/components/researcher/LabelDistributionBar.tsx b/frontend/app/components/researcher/LabelDistributionBar.tsx index d1346cd2..567f2d06 100644 --- a/frontend/app/components/researcher/LabelDistributionBar.tsx +++ b/frontend/app/components/researcher/LabelDistributionBar.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; -import type { LabelDistributionPoint } from '../../types/analyticsTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import type { LabelDistributionPoint } from '@/types/analyticsTypes'; type Props = { data: LabelDistributionPoint[]; diff --git a/frontend/app/components/researcher/ResearcherTopBar.tsx b/frontend/app/components/researcher/ResearcherTopBar.tsx index 75f59899..7d420aec 100644 --- a/frontend/app/components/researcher/ResearcherTopBar.tsx +++ b/frontend/app/components/researcher/ResearcherTopBar.tsx @@ -1,15 +1,15 @@ import React from "react"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; -import { useAuthStore } from "../../stores/authStore"; -import { useModeStore } from "../../stores/modeStore"; -import { useThemeStore } from "../../stores/themeStore"; +import { useAuthStore } from "@/stores/authStore"; +import { useModeStore } from "@/stores/modeStore"; +import { useThemeStore } from "@/stores/themeStore"; import { Ionicons as VectorIcons } from '@expo/vector-icons'; // Cast to any to accept strict React 19 types const Ionicons = VectorIcons as any; import { useNavigation, CommonActions } from '@react-navigation/native'; -import useResponsive from "../../hooks/useResponsive"; -import NotificationBell from "../ui/NotificationBell"; +import useResponsive from "@/hooks/useResponsive"; +import NotificationBell from "@/components/ui/NotificationBell"; export default function AdminTopBar() { const navigation = useNavigation(); diff --git a/frontend/app/components/researcher/TaskCard.tsx b/frontend/app/components/researcher/TaskCard.tsx index 94385651..82e717c8 100644 --- a/frontend/app/components/researcher/TaskCard.tsx +++ b/frontend/app/components/researcher/TaskCard.tsx @@ -6,7 +6,7 @@ import { TouchableOpacity, View, } from "react-native"; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; type AdminTask = { diff --git a/frontend/app/components/researcher/TimeWindowCards.tsx b/frontend/app/components/researcher/TimeWindowCards.tsx index a3fd89bb..6c53c6da 100644 --- a/frontend/app/components/researcher/TimeWindowCards.tsx +++ b/frontend/app/components/researcher/TimeWindowCards.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; -import type { ActivitySummary } from '../../types/analyticsTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import type { ActivitySummary } from '@/types/analyticsTypes'; type Window = { label: string; diff --git a/frontend/app/components/researcher/addTask/SpeciesImagePicker.tsx b/frontend/app/components/researcher/addTask/SpeciesImagePicker.tsx index f6eb5a52..4d027e5f 100644 --- a/frontend/app/components/researcher/addTask/SpeciesImagePicker.tsx +++ b/frontend/app/components/researcher/addTask/SpeciesImagePicker.tsx @@ -15,11 +15,11 @@ import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { useQueryClient } from '@tanstack/react-query'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import { API_ENDPOINTS } from '../../../api/apiEndpoints'; -import { apiFetch } from '../../../api/apiFetch'; -import { SpeciesRefImage } from './addTaskTypes'; -import AuthenticatedImage from '../../ui/AuthenticatedImage'; +import { useThemeStore } from '@/stores/themeStore'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { apiFetch } from '@/api/apiFetch'; +import { SpeciesRefImage } from '@/components/researcher/addTask/addTaskTypes'; +import AuthenticatedImage from '@/components/ui/AuthenticatedImage'; const MAX_IMAGES = 3; const MIN_IMAGES = 1; diff --git a/frontend/app/components/researcher/addTask/StepConfirm.tsx b/frontend/app/components/researcher/addTask/StepConfirm.tsx index 3600dc38..1c2efeee 100644 --- a/frontend/app/components/researcher/addTask/StepConfirm.tsx +++ b/frontend/app/components/researcher/addTask/StepConfirm.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import { StepConfirmProps } from './addTaskTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import { StepConfirmProps } from '@/components/researcher/addTask/addTaskTypes'; export default function StepConfirm({ formData, onBack, onSubmit, loading, availableOptions }: StepConfirmProps) { const { theme } = useThemeStore(); diff --git a/frontend/app/components/researcher/addTask/StepDescription.tsx b/frontend/app/components/researcher/addTask/StepDescription.tsx index 7f635950..d2e99021 100644 --- a/frontend/app/components/researcher/addTask/StepDescription.tsx +++ b/frontend/app/components/researcher/addTask/StepDescription.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StyleSheet, Text, TextInput, TouchableOpacity, View, Platform, ScrollView } from 'react-native'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import { StepProps } from './addTaskTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import { StepProps } from '@/components/researcher/addTask/addTaskTypes'; export default function StepDescription({ formData, onUpdate, onNext, onBack }: StepProps) { const { theme } = useThemeStore(); diff --git a/frontend/app/components/researcher/addTask/StepExperiments.tsx b/frontend/app/components/researcher/addTask/StepExperiments.tsx index 287ea51f..1c9f8c29 100644 --- a/frontend/app/components/researcher/addTask/StepExperiments.tsx +++ b/frontend/app/components/researcher/addTask/StepExperiments.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { StyleSheet, Text, TouchableOpacity, View, ScrollView, Platform } from 'react-native'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import MultiSelect from '../../ui/MultiSelect'; -import { StepExperimentsProps } from './addTaskTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import MultiSelect from '@/components/ui/MultiSelect'; +import { StepExperimentsProps } from '@/components/researcher/addTask/addTaskTypes'; export default function StepExperiments({ formData, diff --git a/frontend/app/components/researcher/addTask/StepName.tsx b/frontend/app/components/researcher/addTask/StepName.tsx index fc2cf71e..f70e1bbf 100644 --- a/frontend/app/components/researcher/addTask/StepName.tsx +++ b/frontend/app/components/researcher/addTask/StepName.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StyleSheet, Text, TextInput, TouchableOpacity, View, Platform, ScrollView } from 'react-native'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import { StepProps } from './addTaskTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import { StepProps } from '@/components/researcher/addTask/addTaskTypes'; export default function StepName({ formData, onUpdate, onNext }: StepProps) { const { theme } = useThemeStore(); diff --git a/frontend/app/components/researcher/addTask/StepRecipients.tsx b/frontend/app/components/researcher/addTask/StepRecipients.tsx index 89049936..2b141f57 100644 --- a/frontend/app/components/researcher/addTask/StepRecipients.tsx +++ b/frontend/app/components/researcher/addTask/StepRecipients.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { StyleSheet, Text, TouchableOpacity, View, ScrollView, Platform } from 'react-native'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import MultiSelect from '../../ui/MultiSelect'; -import { StepRecipientsProps } from './addTaskTypes'; +import { useThemeStore } from '@/stores/themeStore'; +import MultiSelect from '@/components/ui/MultiSelect'; +import { StepRecipientsProps } from '@/components/researcher/addTask/addTaskTypes'; export default function StepRecipients({ formData, onUpdate, onNext, onBack, diff --git a/frontend/app/components/researcher/addTask/StepSpecies.tsx b/frontend/app/components/researcher/addTask/StepSpecies.tsx index 049476cc..315a4f28 100644 --- a/frontend/app/components/researcher/addTask/StepSpecies.tsx +++ b/frontend/app/components/researcher/addTask/StepSpecies.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, TouchableOpacity, View, ScrollView, Platform, ActivityIndicator } from 'react-native'; import { Colors } from '../../../../constants/theme'; -import { useThemeStore } from '../../../stores/themeStore'; -import { SpeciesRefImage, StepSpeciesProps } from './addTaskTypes'; -import MultiSelect from '../../../components/ui/MultiSelect'; -import SpeciesImagePicker from './SpeciesImagePicker'; +import { useThemeStore } from '@/stores/themeStore'; +import { SpeciesRefImage, StepSpeciesProps } from '@/components/researcher/addTask/addTaskTypes'; +import MultiSelect from '@/components/ui/MultiSelect'; +import SpeciesImagePicker from '@/components/researcher/addTask/SpeciesImagePicker'; export default function StepSpecies({ formData, diff --git a/frontend/app/components/researcher/addTask/addTaskTypes.ts b/frontend/app/components/researcher/addTask/addTaskTypes.ts index cf1db49e..cc8ce433 100644 --- a/frontend/app/components/researcher/addTask/addTaskTypes.ts +++ b/frontend/app/components/researcher/addTask/addTaskTypes.ts @@ -1,4 +1,4 @@ -import { MultiSelectOption } from '../../ui/MultiSelect'; +import { MultiSelectOption } from '@/components/ui/MultiSelect'; /** One reference image entry — held in local state until task submit. */ export interface SpeciesRefImage { diff --git a/frontend/components/themed-text.tsx b/frontend/app/components/themed-text.tsx similarity index 95% rename from frontend/components/themed-text.tsx rename to frontend/app/components/themed-text.tsx index 731f036f..d79d0a1c 100644 --- a/frontend/components/themed-text.tsx +++ b/frontend/app/components/themed-text.tsx @@ -1,6 +1,6 @@ import { StyleSheet, Text, type TextProps } from 'react-native'; -import { useThemeColor } from '@/app/hooks/use-theme-color'; +import { useThemeColor } from '@/hooks/use-theme-color'; export type ThemedTextProps = TextProps & { lightColor?: string; diff --git a/frontend/components/themed-view.tsx b/frontend/app/components/themed-view.tsx similarity index 87% rename from frontend/components/themed-view.tsx rename to frontend/app/components/themed-view.tsx index 48afddc5..6f181d82 100644 --- a/frontend/components/themed-view.tsx +++ b/frontend/app/components/themed-view.tsx @@ -1,6 +1,6 @@ import { View, type ViewProps } from 'react-native'; -import { useThemeColor } from '@/app/hooks/use-theme-color'; +import { useThemeColor } from '@/hooks/use-theme-color'; export type ThemedViewProps = ViewProps & { lightColor?: string; diff --git a/frontend/app/components/ui/AuthenticatedImage.tsx b/frontend/app/components/ui/AuthenticatedImage.tsx index 5441f6e8..9765048b 100644 --- a/frontend/app/components/ui/AuthenticatedImage.tsx +++ b/frontend/app/components/ui/AuthenticatedImage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Image, ImageProps, Platform, StyleSheet, View, ActivityIndicator } from 'react-native'; -import { useAuthStore } from '../../stores/authStore'; -import { backendUrl } from '../../api/apiFetch'; +import { useAuthStore } from '@/stores/authStore'; +import { apiFetch, backendUrl } from '@/api/apiFetch'; interface AuthenticatedImageProps extends Omit { uri: string | null | undefined; @@ -46,11 +46,7 @@ export default function AuthenticatedImage({ setLoading(true); setError(false); try { - const headers: Record = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - const response = await fetch(resolvedUri, { headers }); + const response = await apiFetch(resolvedUri); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.statusText}`); } diff --git a/frontend/app/components/ui/MultiSelect.tsx b/frontend/app/components/ui/MultiSelect.tsx index afa1a5c5..46bf339b 100644 --- a/frontend/app/components/ui/MultiSelect.tsx +++ b/frontend/app/components/ui/MultiSelect.tsx @@ -9,7 +9,7 @@ import { View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; export interface MultiSelectOption { id: string | number; diff --git a/frontend/app/components/ui/NotificationBell.tsx b/frontend/app/components/ui/NotificationBell.tsx index 57173370..7243b1f0 100644 --- a/frontend/app/components/ui/NotificationBell.tsx +++ b/frontend/app/components/ui/NotificationBell.tsx @@ -15,8 +15,9 @@ import { useMarkAllNotificationsRead, useMarkNotificationRead, useNotificationUnreadCount, -} from '../../hooks/useAdminNotifications'; -import { AdminNotification } from '../../types/fraudTypes'; +} from '@/hooks/useAdminNotifications'; +import { useThemeColor } from '@/hooks/use-theme-color'; +import { AdminNotification } from '@/types/fraudTypes'; const SEVERITY_COLORS: Record = { INFO: '#3b82f6', @@ -33,6 +34,7 @@ const SEVERITY_COLORS: Record = { export default function NotificationBell() { const [open, setOpen] = useState(false); const scaleAnim = useRef(new Animated.Value(1)).current; + const iconColor = useThemeColor({ light: '#000', dark: '#fff' }, 'text'); const { data: countData } = useNotificationUnreadCount(); const unreadCount = countData?.unreadCount ?? 0; @@ -46,8 +48,8 @@ export default function NotificationBell() { const onBellPress = () => { // Brief scale pulse for tactile feedback Animated.sequence([ - Animated.timing(scaleAnim, { toValue: 1.2, duration: 100, useNativeDriver: true }), - Animated.timing(scaleAnim, { toValue: 1, duration: 100, useNativeDriver: true }), + Animated.timing(scaleAnim, { toValue: 1.2, duration: 100, useNativeDriver: false }), + Animated.timing(scaleAnim, { toValue: 1, duration: 100, useNativeDriver: false }), ]).start(); setOpen(true); }; @@ -77,7 +79,9 @@ export default function NotificationBell() { > - {item.title} + + {item.title.replace(/[⚠️🚫✅🚨🔔]/g, '').trim()} + {item.message} {item.targetUsername && ( @{item.targetUsername} @@ -99,7 +103,7 @@ export default function NotificationBell() { activeOpacity={0.75} > - + {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} @@ -125,7 +129,7 @@ export default function NotificationBell() { {/* Panel header */} - 🔔 Notifications + Notifications void; diff --git a/frontend/app/components/user/SwipeCard.tsx b/frontend/app/components/user/SwipeCard.tsx index 83ee42eb..576ea0b5 100644 --- a/frontend/app/components/user/SwipeCard.tsx +++ b/frontend/app/components/user/SwipeCard.tsx @@ -8,10 +8,10 @@ import { Text, View, } from 'react-native'; -import { SwipeDirection } from '../../types'; -import { useThemeStore } from '../../stores/themeStore'; +import { SwipeDirection } from '@/types'; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; -import AuthenticatedImage from '../ui/AuthenticatedImage'; +import AuthenticatedImage from '@/components/ui/AuthenticatedImage'; const SCREEN_WIDTH = Dimensions.get('window').width; const SWIPE_THRESHOLD = 120; diff --git a/frontend/app/components/user/TaskCard.tsx b/frontend/app/components/user/TaskCard.tsx index fd2bff05..90e388d3 100644 --- a/frontend/app/components/user/TaskCard.tsx +++ b/frontend/app/components/user/TaskCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; interface TaskCardProps { diff --git a/frontend/app/components/user/UserTopBar.tsx b/frontend/app/components/user/UserTopBar.tsx index 21646c72..9bf5912f 100644 --- a/frontend/app/components/user/UserTopBar.tsx +++ b/frontend/app/components/user/UserTopBar.tsx @@ -2,11 +2,11 @@ import { Ionicons as VectorIcons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; import React from "react"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; -import { useProfile } from '../../api/queries'; -import { useAuthStore } from "../../stores/authStore"; -import { useModeStore } from "../../stores/modeStore"; -import { useThemeStore } from "../../stores/themeStore"; -import useResponsive from "../../hooks/useResponsive"; +import { useProfile } from '@/api/queries'; +import { useAuthStore } from "@/stores/authStore"; +import { useModeStore } from "@/stores/modeStore"; +import { useThemeStore } from "@/stores/themeStore"; +import useResponsive from "@/hooks/useResponsive"; // Cast to any to accept strict React 19 types const Ionicons = VectorIcons as any; diff --git a/frontend/app/hooks/use-color-scheme.ts b/frontend/app/hooks/use-color-scheme.ts index 580e2513..86fac4b1 100644 --- a/frontend/app/hooks/use-color-scheme.ts +++ b/frontend/app/hooks/use-color-scheme.ts @@ -1,4 +1,4 @@ -import { useThemeStore } from '../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; export function useColorScheme() { const { theme } = useThemeStore(); diff --git a/frontend/app/hooks/use-color-scheme.web.ts b/frontend/app/hooks/use-color-scheme.web.ts index c52682b9..127a2b38 100644 --- a/frontend/app/hooks/use-color-scheme.web.ts +++ b/frontend/app/hooks/use-color-scheme.web.ts @@ -1,4 +1,4 @@ -import { useThemeStore } from '../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; export function useColorScheme() { const { theme } = useThemeStore(); diff --git a/frontend/app/hooks/use-theme-color.ts b/frontend/app/hooks/use-theme-color.ts index 98a79f6e..c06feacd 100644 --- a/frontend/app/hooks/use-theme-color.ts +++ b/frontend/app/hooks/use-theme-color.ts @@ -3,15 +3,15 @@ * https://docs.expo.dev/guides/color-schemes/ */ -import { useColorScheme } from '@/app/hooks/use-color-scheme'; -import { Colors } from '@/constants/theme'; +import { useColorScheme } from '@/hooks/use-color-scheme'; +import { Colors } from '../../constants/theme'; export function useThemeColor( props: { light?: string; dark?: string }, colorName: keyof typeof Colors.light & keyof typeof Colors.dark ) { const theme = useColorScheme() ?? 'light'; - const colorFromProps = props[theme]; + const colorFromProps = props[theme as keyof typeof props]; if (colorFromProps) { return colorFromProps; diff --git a/frontend/app/hooks/useAdminNotifications.ts b/frontend/app/hooks/useAdminNotifications.ts index 0836fb32..df3723c9 100644 --- a/frontend/app/hooks/useAdminNotifications.ts +++ b/frontend/app/hooks/useAdminNotifications.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiFetch } from '../api/apiFetch'; -import { API_ENDPOINTS } from '../api/apiEndpoints'; -import { AdminNotification, AdminNotificationPage } from '../types/fraudTypes'; +import { apiFetch } from '@/api/apiFetch'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { AdminNotification, AdminNotificationPage } from '@/types/fraudTypes'; const ADMIN_NOTIF_KEYS = { all: ['admin', 'notifications'] as const, diff --git a/frontend/app/hooks/useHealthCheck.ts b/frontend/app/hooks/useHealthCheck.ts new file mode 100644 index 00000000..61bc809d --- /dev/null +++ b/frontend/app/hooks/useHealthCheck.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { useAppStateStore } from '@/stores/appStateStore'; +import { backendUrl } from '@/api/apiFetch'; + +export function useHealthCheck() { + const isMaintenanceMode = useAppStateStore((state) => state.isMaintenanceMode); + const setMaintenanceMode = useAppStateStore((state) => state.setMaintenanceMode); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (!isMaintenanceMode) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return; + } + + const checkHealth = async () => { + try { + const response = await fetch(`${backendUrl}/health`); + if (response.ok) { + setMaintenanceMode(false); + } + } catch (e) { + // Still down + } + }; + + // Poll every 10 seconds + timerRef.current = setInterval(checkHealth, 10000); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [isMaintenanceMode, setMaintenanceMode]); +} diff --git a/frontend/app/hooks/useSessionHeartbeat.ts b/frontend/app/hooks/useSessionHeartbeat.ts index b425f1ae..3cb2f476 100644 --- a/frontend/app/hooks/useSessionHeartbeat.ts +++ b/frontend/app/hooks/useSessionHeartbeat.ts @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import { AppState, Platform } from 'react-native'; -import { useAuthStore } from '../stores/authStore'; +import { useAuthStore } from '@/stores/authStore'; import { jwtDecode } from 'jwt-decode'; -import { forceTokenRefresh } from '../api/apiFetch'; +import { forceTokenRefresh } from '@/api/apiFetch'; const THRESHOLD_SECONDS = 180; // 3 minutes const INTERVAL_MS = 60 * 1000; // Check every minute @@ -11,7 +11,7 @@ export function useSessionHeartbeat() { const checkTokenAndRefresh = async () => { try { const state = useAuthStore.getState(); - if (!state.token) { + if (!state.token || state.token === 'web-cookie-placeholder') { return; } diff --git a/frontend/app/hooks/useSwipeGesture.ts b/frontend/app/hooks/useSwipeGesture.ts index 1c214dc6..ed0e3049 100644 --- a/frontend/app/hooks/useSwipeGesture.ts +++ b/frontend/app/hooks/useSwipeGesture.ts @@ -1,6 +1,6 @@ import { useRef } from 'react'; import { Animated, Dimensions, PanResponder } from 'react-native'; -import { SwipeDirection } from '../types'; +import { SwipeDirection } from '@/types'; const SCREEN_WIDTH = Dimensions.get('window').width; const SWIPE_THRESHOLD = 120; diff --git a/frontend/app/mocks/data/analytics.mock.ts b/frontend/app/mocks/data/analytics.mock.ts index eb7df070..19f97a0e 100644 --- a/frontend/app/mocks/data/analytics.mock.ts +++ b/frontend/app/mocks/data/analytics.mock.ts @@ -2,7 +2,7 @@ import type { PlatformOverview, GlobalStats, UserPerformance, -} from '../../types/analyticsTypes'; +} from '@/types/analyticsTypes'; // ─── Platform Overview mock ─────────────────────────────────────────────────── diff --git a/frontend/app/mocks/data/collection.mock.ts b/frontend/app/mocks/data/collection.mock.ts index b8c81688..7486383f 100644 --- a/frontend/app/mocks/data/collection.mock.ts +++ b/frontend/app/mocks/data/collection.mock.ts @@ -1,4 +1,4 @@ -import { CollectionItem, CollectionStats, SwipeDirection } from '../../types'; +import { CollectionItem, CollectionStats, SwipeDirection } from '@/types'; // Sample images for collection mock const sampleImages = [ diff --git a/frontend/app/mocks/data/dashboard.researcher.mock.ts b/frontend/app/mocks/data/dashboard.researcher.mock.ts index 8524a979..b194cba0 100644 --- a/frontend/app/mocks/data/dashboard.researcher.mock.ts +++ b/frontend/app/mocks/data/dashboard.researcher.mock.ts @@ -1,4 +1,4 @@ -import { taxonomyMock } from './taxonomy.mock' +import { taxonomyMock } from '@/mocks/data/taxonomy.mock' export const dashboardAdminMock = { tasks: { diff --git a/frontend/app/mocks/data/recipients.mock.ts b/frontend/app/mocks/data/recipients.mock.ts index 9835f7c6..dc1d4002 100644 --- a/frontend/app/mocks/data/recipients.mock.ts +++ b/frontend/app/mocks/data/recipients.mock.ts @@ -1,4 +1,4 @@ -import { User, usersMock } from './users.mock'; +import { User, usersMock } from '@/mocks/data/users.mock'; export interface RecipientGroup { id: number; diff --git a/frontend/app/mocks/mockRouter.ts b/frontend/app/mocks/mockRouter.ts index 88e5868f..797ed8db 100644 --- a/frontend/app/mocks/mockRouter.ts +++ b/frontend/app/mocks/mockRouter.ts @@ -1,23 +1,23 @@ -import { authMock } from './data/auth.mock' -import { classificationMock } from './data/classification.mock' -import { addToCollection, getCollection, getCollectionStats } from './data/collection.mock' -import { dashboardAdminMock } from './data/dashboard.researcher.mock' -import { dashboardUserMock } from './data/dashboard.user.mock' - -import { getTaskAnalytics, getUserPerformance, MOCK_PLATFORM_OVERVIEW, MOCK_GLOBAL_STATS } from './data/analytics.mock' -import { refinedChallengesMock } from './data/challenges.mock' -import { setUserAccuracy, statisticsMock } from './data/statistics.mock' - -import { MOCK_GOLD_IMAGES } from './data/goldImages.mock' -import { getLeaderboardData, setUserScore } from './data/leaderboard.mock' +import { authMock } from '@/mocks/data/auth.mock' +import { classificationMock } from '@/mocks/data/classification.mock' +import { addToCollection, getCollection, getCollectionStats } from '@/mocks/data/collection.mock' +import { dashboardAdminMock } from '@/mocks/data/dashboard.researcher.mock' +import { dashboardUserMock } from '@/mocks/data/dashboard.user.mock' + +import { getTaskAnalytics, getUserPerformance, MOCK_PLATFORM_OVERVIEW, MOCK_GLOBAL_STATS } from '@/mocks/data/analytics.mock' +import { refinedChallengesMock } from '@/mocks/data/challenges.mock' +import { setUserAccuracy, statisticsMock } from '@/mocks/data/statistics.mock' + +import { MOCK_GOLD_IMAGES } from '@/mocks/data/goldImages.mock' +import { getLeaderboardData, setUserScore } from '@/mocks/data/leaderboard.mock' import { addRecipientGroup, addUserToGroup, getRecipientGroups, removeUserFromGroup -} from './data/recipients.mock' -import { usersMock } from './data/users.mock' -import { API_ENDPOINTS } from '../api/apiEndpoints'; +} from '@/mocks/data/recipients.mock' +import { usersMock } from '@/mocks/data/users.mock' +import { API_ENDPOINTS } from '@/api/apiEndpoints'; type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' @@ -398,18 +398,6 @@ export async function mockRouter( return jsonResponse(getCollectionStats()); } - if (method === 'POST' && url.endsWith(API_ENDPOINTS.COLLECTION.ADD)) { - const { imageUrl, label, taskId, taskName, question } = body as { - imageUrl: string; - label: string; - taskId: number; - taskName: string; - question: string; - }; - const newItem = addToCollection(imageUrl, label as any, taskId, taskName, question); - return jsonResponse(newItem, 201); - } - // ---------- GOLD IMAGES ---------- if (method === 'GET' && url.split('?')[0].endsWith(API_ENDPOINTS.researcher.GOLD_IMAGES_GET_ALL)) { return jsonResponse(MOCK_GOLD_IMAGES) diff --git a/frontend/app/navigation/ResearcherNavigator.tsx b/frontend/app/navigation/ResearcherNavigator.tsx index b7938977..66e4af09 100644 --- a/frontend/app/navigation/ResearcherNavigator.tsx +++ b/frontend/app/navigation/ResearcherNavigator.tsx @@ -2,30 +2,30 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import React from "react"; import { StyleSheet, View } from "react-native"; -import BottomBar from "./components/BottomBar"; -import TopBar from "./components/TopBar"; - -import { useAuthStore } from "../stores/authStore"; - -import AddTaskScreen from "../screens/researcher/AddTaskScreen"; -import ResearcherDashboard from "../screens/researcher/ResearcherDashboard"; -import EditTaskScreen from "../screens/researcher/EditTaskScreen"; -import TaskDetailsScreen from "../screens/researcher/TaskDetailsScreen"; -import TasksManagementScreen from "../screens/researcher/TasksManagementScreen"; -import GoldImagesManagementScreen from "../screens/researcher/GoldImagesManagementScreen"; -import AddGoldImageScreen from "../screens/researcher/AddGoldImageScreen"; -import AddUserScreen from "../screens/researcher/AddUserScreen"; -import AnalyticsScreen from "../screens/researcher/AnalyticsScreen"; -import RecipientsListScreen from "../screens/researcher/RecipientsListScreen"; -import RecipientGroupDetailsScreen from "../screens/researcher/RecipientGroupDetailsScreen"; -import UsersManagementScreen from "../screens/researcher/UsersManagementScreen"; -import TaxonomyScreen from "../screens/researcher/TaxonomyScreen"; -import SettingsScreen from "../screens/shared/SettingsScreen"; -import ProfileScreen from "../screens/shared/ProfileScreen"; -import SpeciesReferenceImagesScreen from "../screens/researcher/SpeciesReferenceImagesScreen"; -import MaliciousLabelingConfigScreen from "../screens/researcher/MaliciousLabelingConfigScreen"; - -import { researcherStackParamList } from "./researcherStack.types"; +import BottomBar from "@/navigation/components/BottomBar"; +import TopBar from "@/navigation/components/TopBar"; + +import { useAuthStore } from "@/stores/authStore"; + +import AddTaskScreen from "@/screens/researcher/AddTaskScreen"; +import ResearcherDashboard from "@/screens/researcher/ResearcherDashboard"; +import EditTaskScreen from "@/screens/researcher/EditTaskScreen"; +import TaskDetailsScreen from "@/screens/researcher/TaskDetailsScreen"; +import TasksManagementScreen from "@/screens/researcher/TasksManagementScreen"; +import GoldImagesManagementScreen from "@/screens/researcher/GoldImagesManagementScreen"; +import AddGoldImageScreen from "@/screens/researcher/AddGoldImageScreen"; +import AddUserScreen from "@/screens/researcher/AddUserScreen"; +import AnalyticsScreen from "@/screens/researcher/AnalyticsScreen"; +import RecipientsListScreen from "@/screens/researcher/RecipientsListScreen"; +import RecipientGroupDetailsScreen from "@/screens/researcher/RecipientGroupDetailsScreen"; +import UsersManagementScreen from "@/screens/researcher/UsersManagementScreen"; +import TaxonomyScreen from "@/screens/researcher/TaxonomyScreen"; +import SettingsScreen from "@/screens/shared/SettingsScreen"; +import ProfileScreen from "@/screens/shared/ProfileScreen"; +import SpeciesReferenceImagesScreen from "@/screens/researcher/SpeciesReferenceImagesScreen"; +import MaliciousLabelingConfigScreen from "@/screens/researcher/MaliciousLabelingConfigScreen"; + +import { researcherStackParamList } from "@/navigation/researcherStack.types"; const Stack = createNativeStackNavigator(); diff --git a/frontend/app/navigation/RootNavigator.tsx b/frontend/app/navigation/RootNavigator.tsx index c35b5a74..c5382550 100644 --- a/frontend/app/navigation/RootNavigator.tsx +++ b/frontend/app/navigation/RootNavigator.tsx @@ -3,21 +3,21 @@ import { NavigationContainer } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import React from "react"; -import { ActivityIndicator, View, Text } from "react-native"; +import { ActivityIndicator, View, Text, Platform } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; // stores -import { useAuthStore } from "../stores/authStore"; -import { useModeStore } from "../stores/modeStore"; +import { useAuthStore } from "@/stores/authStore"; +import { useModeStore } from "@/stores/modeStore"; // navigators -import ResearcherNavigator from "./ResearcherNavigator"; -import UserNavigator from "./UserNavigator"; -import { useProfile } from "../api/queries"; +import ResearcherNavigator from "@/navigation/ResearcherNavigator"; +import UserNavigator from "@/navigation/UserNavigator"; +import { useProfile } from "@/api/queries"; // screens -import LoginScreen from "../screens/shared/LoginScreen"; -import BannedScreen from "../screens/shared/BannedScreen"; +import LoginScreen from "@/screens/shared/LoginScreen"; +import BannedScreen from "@/screens/shared/BannedScreen"; export default function RootNavigator() { const { token, role, isLoading, sessionExpiredMessage, isSuperAdmin, isBanned: storeIsBanned } = useAuthStore(); @@ -27,6 +27,15 @@ export default function RootNavigator() { const { data: profile } = useProfile({ enabled: !!token }); const isBanned = storeIsBanned || profile?.status === 'BANNED'; + // Fix for Web: Blur active element on navigation to prevent aria-hidden focus warnings + const handleStateChange = () => { + if (Platform.OS === 'web' && typeof document !== 'undefined') { + if (document.activeElement && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } + }; + if (sessionExpiredMessage) { return ( @@ -48,7 +57,7 @@ export default function RootNavigator() { if (!token) { return ( - + - + {isAdmin ? mode === "researcher" ? : diff --git a/frontend/app/navigation/UserNavigator.tsx b/frontend/app/navigation/UserNavigator.tsx index 257c84ce..ee503e0a 100644 --- a/frontend/app/navigation/UserNavigator.tsx +++ b/frontend/app/navigation/UserNavigator.tsx @@ -2,19 +2,19 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import React from "react"; import { StyleSheet, View } from "react-native"; -import SettingsScreen from "../screens/shared/SettingsScreen"; -import LeaderboardScreen from "../screens/user/LeaderboardScreen"; -import UserMyTasksScreen from "../screens/user/UserMyTasksScreen"; -import SwipeScreen from "../screens/user/SwipeScreen"; -import ChallengesScreen from "../screens/user/ChallengesScreen"; -import StatsScreen from "../screens/user/StatsScreen"; -import MyCollectionScreen from "../screens/user/MyCollectionScreen"; -import ProfileScreen from "../screens/shared/ProfileScreen"; -import BottomBar from "./components/BottomBar"; -import TaskDetailsScreen from "../screens/user/TaskDetailsScreen"; -import TopBar from "./components/TopBar"; +import SettingsScreen from "@/screens/shared/SettingsScreen"; +import LeaderboardScreen from "@/screens/user/LeaderboardScreen"; +import UserMyTasksScreen from "@/screens/user/UserMyTasksScreen"; +import SwipeScreen from "@/screens/user/SwipeScreen"; +import ChallengesScreen from "@/screens/user/ChallengesScreen"; +import StatsScreen from "@/screens/user/StatsScreen"; +import MyCollectionScreen from "@/screens/user/MyCollectionScreen"; +import ProfileScreen from "@/screens/shared/ProfileScreen"; +import BottomBar from "@/navigation/components/BottomBar"; +import TaskDetailsScreen from "@/screens/user/TaskDetailsScreen"; +import TopBar from "@/navigation/components/TopBar"; -import CollectionDetailsScreen from "../screens/user/CollectionDetailsScreen"; +import CollectionDetailsScreen from "@/screens/user/CollectionDetailsScreen"; const Stack = createNativeStackNavigator(); diff --git a/frontend/app/navigation/components/BottomBar.tsx b/frontend/app/navigation/components/BottomBar.tsx index 2753a5b0..179f59d4 100644 --- a/frontend/app/navigation/components/BottomBar.tsx +++ b/frontend/app/navigation/components/BottomBar.tsx @@ -1,8 +1,8 @@ import { useNavigation } from "@react-navigation/native"; import React from "react"; import { Image, ImageSourcePropType, StyleSheet, Text, TouchableOpacity, View } from "react-native"; -import { useThemeStore } from "../../stores/themeStore"; -import useResponsive from "../../hooks/useResponsive"; +import { useThemeStore } from "@/stores/themeStore"; +import useResponsive from "@/hooks/useResponsive"; import { Colors } from "../../../constants/theme"; interface NavItem { diff --git a/frontend/app/navigation/components/TopBar.tsx b/frontend/app/navigation/components/TopBar.tsx index 3133fb23..56be7d39 100644 --- a/frontend/app/navigation/components/TopBar.tsx +++ b/frontend/app/navigation/components/TopBar.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { useAuthStore } from "../../stores/authStore"; -import { useModeStore } from "../../stores/modeStore"; +import { useAuthStore } from "@/stores/authStore"; +import { useModeStore } from "@/stores/modeStore"; -import ResearcherTopBar from "../../components/researcher/ResearcherTopBar"; -import UserTopBar from "../../components/user/UserTopBar"; +import ResearcherTopBar from "@/components/researcher/ResearcherTopBar"; +import UserTopBar from "@/components/user/UserTopBar"; export default function TopBar() { const { mode } = useModeStore(); diff --git a/frontend/app/screens/researcher/AddGoldImageScreen.tsx b/frontend/app/screens/researcher/AddGoldImageScreen.tsx index 23e068ff..cb22b1cb 100644 --- a/frontend/app/screens/researcher/AddGoldImageScreen.tsx +++ b/frontend/app/screens/researcher/AddGoldImageScreen.tsx @@ -10,15 +10,16 @@ import { View, Image as RNImage, Platform, + Keyboard, } from "react-native"; import * as ImagePicker from 'expo-image-picker'; -import { apiFetch } from "../../api/apiFetch"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout"; -import { useThemeStore } from '../../stores/themeStore'; +import { apiFetch } from "@/api/apiFetch"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout"; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; import { useQueryClient } from '@tanstack/react-query'; -import MultiSelect from '../../components/ui/MultiSelect'; +import MultiSelect from '@/components/ui/MultiSelect'; type UrlValidationState = 'idle' | 'checking' | 'valid' | 'invalid'; @@ -135,6 +136,12 @@ export default function AddGoldImageScreen({ navigation }: any) { }; const handleSubmit = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + if (loading) return; setStatusMessage(null); if (uploadType === "url" && !imageUrl) { diff --git a/frontend/app/screens/researcher/AddTaskScreen.tsx b/frontend/app/screens/researcher/AddTaskScreen.tsx index e4c5c592..96d2964e 100644 --- a/frontend/app/screens/researcher/AddTaskScreen.tsx +++ b/frontend/app/screens/researcher/AddTaskScreen.tsx @@ -1,24 +1,24 @@ import React, { useEffect, useState } from "react"; -import { Alert, KeyboardAvoidingView, Platform, StyleSheet, View, Text, TouchableOpacity } from "react-native"; +import { Alert, KeyboardAvoidingView, Platform, StyleSheet, View, Text, TouchableOpacity, Keyboard } from "react-native"; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../../constants/theme'; -import { API_ENDPOINTS } from "../../api/apiEndpoints"; -import { apiFetch } from "../../api/apiFetch"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout"; +import { API_ENDPOINTS } from "@/api/apiEndpoints"; +import { apiFetch } from "@/api/apiFetch"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout"; import { useQueryClient } from "@tanstack/react-query"; -import StepIndicator from "../../components/ui/StepIndicator"; -import { useThemeStore } from '../../stores/themeStore'; -import useResponsive from '../../hooks/useResponsive'; +import StepIndicator from "@/components/ui/StepIndicator"; +import { useThemeStore } from '@/stores/themeStore'; +import useResponsive from '@/hooks/useResponsive'; -import { AddTaskFormData } from "../../components/researcher/addTask/addTaskTypes"; -import StepConfirm from "../../components/researcher/addTask/StepConfirm"; -import StepDescription from "../../components/researcher/addTask/StepDescription"; +import { AddTaskFormData } from "@/components/researcher/addTask/addTaskTypes"; +import StepConfirm from "@/components/researcher/addTask/StepConfirm"; +import StepDescription from "@/components/researcher/addTask/StepDescription"; -import StepName from "../../components/researcher/addTask/StepName"; -import StepRecipients from "../../components/researcher/addTask/StepRecipients"; -import StepSpecies from "../../components/researcher/addTask/StepSpecies"; -import StepExperiments from "../../components/researcher/addTask/StepExperiments"; -import { useSpeciesPoolImages } from "../../api/queries"; +import StepName from "@/components/researcher/addTask/StepName"; +import StepRecipients from "@/components/researcher/addTask/StepRecipients"; +import StepSpecies from "@/components/researcher/addTask/StepSpecies"; +import StepExperiments from "@/components/researcher/addTask/StepExperiments"; +import { useSpeciesPoolImages } from "@/api/queries"; const STEPS = ["Name", "Description", "Experiments", "Species", "Recipients", "Confirm"]; @@ -116,6 +116,11 @@ export default function AddTaskScreen({ route, navigation }: any) { }; const handleSubmit = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + if (!formData.name || !formData.description) { Alert.alert("Validation Error", "Task name and description are required"); return; diff --git a/frontend/app/screens/researcher/AddUserScreen.tsx b/frontend/app/screens/researcher/AddUserScreen.tsx index 5d9ae1b3..7bc79003 100644 --- a/frontend/app/screens/researcher/AddUserScreen.tsx +++ b/frontend/app/screens/researcher/AddUserScreen.tsx @@ -8,15 +8,17 @@ import { TextInput, TouchableOpacity, View, + Platform, + Keyboard, } from 'react-native'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; import { useNavigation } from '@react-navigation/native'; -import { apiFetch } from '../../api/apiFetch'; -import { researcherStackParamList } from '../../navigation/researcherStack.types'; +import { apiFetch } from '@/api/apiFetch'; +import { researcherStackParamList } from '@/navigation/researcherStack.types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; type NavigationProp = NativeStackNavigationProp; @@ -32,6 +34,11 @@ export default function AddUserScreen() { const [loading, setLoading] = useState(false); const handleSubmit = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + if (!username.trim() || !email.trim()) { Alert.alert("Error", "Please fill in all fields"); return; diff --git a/frontend/app/screens/researcher/AnalyticsScreen.tsx b/frontend/app/screens/researcher/AnalyticsScreen.tsx index a4398f49..15792887 100644 --- a/frontend/app/screens/researcher/AnalyticsScreen.tsx +++ b/frontend/app/screens/researcher/AnalyticsScreen.tsx @@ -13,22 +13,22 @@ import { } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; import { useQueryClient } from '@tanstack/react-query'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout'; -import MetricCard from '../../components/researcher/MetricCard'; -import TimeWindowCards from '../../components/researcher/TimeWindowCards'; -import ConfidenceTrendChart from '../../components/researcher/ConfidenceTrendChart'; -import LabelDistributionBar from '../../components/researcher/LabelDistributionBar'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout'; +import MetricCard from '@/components/researcher/MetricCard'; +import TimeWindowCards from '@/components/researcher/TimeWindowCards'; +import ConfidenceTrendChart from '@/components/researcher/ConfidenceTrendChart'; +import LabelDistributionBar from '@/components/researcher/LabelDistributionBar'; import { useAnalyticsOverview, useAnalyticsTop, useAnalyticsTask, useAdminTasks, QUERY_KEYS, -} from '../../api/queries'; -import type { UserPerformance, TaskAnalytics } from '../../types/analyticsTypes'; -import ExportModal from '../../components/researcher/ExportModal'; +} from '@/api/queries'; +import type { UserPerformance, TaskAnalytics } from '@/types/analyticsTypes'; +import ExportModal from '@/components/researcher/ExportModal'; type Tab = 'overview' | 'tasks'; diff --git a/frontend/app/screens/researcher/EditTaskScreen.tsx b/frontend/app/screens/researcher/EditTaskScreen.tsx index 9c18d477..676d0d95 100644 --- a/frontend/app/screens/researcher/EditTaskScreen.tsx +++ b/frontend/app/screens/researcher/EditTaskScreen.tsx @@ -3,6 +3,8 @@ import React, { useEffect, useState } from "react"; import { ActivityIndicator, Alert, + Keyboard, + Platform, ScrollView, StyleSheet, Text, @@ -12,16 +14,16 @@ import { } from "react-native"; import { Colors } from '../../../constants/theme'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { apiFetch } from "../../api/apiFetch"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout"; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { apiFetch } from "@/api/apiFetch"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout"; import { useQueryClient } from "@tanstack/react-query"; -import MultiSelect from "../../components/ui/MultiSelect"; -import SpeciesImagePicker from "../../components/researcher/addTask/SpeciesImagePicker"; -import { SpeciesRefImage } from "../../components/researcher/addTask/addTaskTypes"; -import { useSpeciesPoolImages, QUERY_KEYS } from "../../api/queries"; -import { researcherStackParamList } from "../../navigation/researcherStack.types"; -import { useThemeStore } from '../../stores/themeStore'; +import MultiSelect from "@/components/ui/MultiSelect"; +import SpeciesImagePicker from "@/components/researcher/addTask/SpeciesImagePicker"; +import { SpeciesRefImage } from "@/components/researcher/addTask/addTaskTypes"; +import { useSpeciesPoolImages, QUERY_KEYS } from "@/api/queries"; +import { researcherStackParamList } from "@/navigation/researcherStack.types"; +import { useThemeStore } from '@/stores/themeStore'; type Props = NativeStackScreenProps< @@ -48,6 +50,7 @@ export default function EditTaskScreen({ route, navigation }: Props) { const [optionsLoading, setOptionsLoading] = useState(false); const [loading, setLoading] = useState(false); const [isPublic, setIsPublic] = useState(false); + const [consensusThreshold, setConsensusThreshold] = useState(3); const { theme } = useThemeStore(); const themeColors = Colors[theme as keyof typeof Colors]; @@ -143,6 +146,7 @@ export default function EditTaskScreen({ route, navigation }: Props) { setSelectedExperiments(data.experiments?.map((id: number) => String(id)) || []); setIsPublic(data.isPublic || false); + setConsensusThreshold(data.consensusThreshold || 3); const loadedGroups = data.recipientGroups?.map((id: number) => `G-${id}`) || []; const loadedUsers = data.assignedUsernames?.map((un: string) => `U-${un}`) || []; @@ -155,6 +159,11 @@ export default function EditTaskScreen({ route, navigation }: Props) { }, [taskId]); const handleSubmit = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + if (!name || !description) { Alert.alert("Validation Error", "Task name and description are required"); return; @@ -208,6 +217,7 @@ export default function EditTaskScreen({ route, navigation }: Props) { recipientGroups: selectedRecipients.filter(id => id.startsWith("G-")).map(id => Number(id.replace("G-", ""))), assignedUsernames: selectedRecipients.filter(id => id.startsWith("U-")).map(id => id.replace("U-", "")), sharedWithResearchers, + consensusThreshold, }; const res = await apiFetch( @@ -231,9 +241,9 @@ export default function EditTaskScreen({ route, navigation }: Props) { // page reflects the new reference images right away instead of waiting out // the query's staleTime. The details/list queries all live under ['tasks']. // Await the details refetch so the data is fresh before we navigate back. - await queryClient.refetchQueries({ queryKey: QUERY_KEYS.taskDetails(taskId) }); - queryClient.refetchQueries({ queryKey: ["tasks"] }); - queryClient.refetchQueries({ queryKey: ["species", "pool"] }); + await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.taskDetails(taskId) }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries({ queryKey: ["species", "pool"] }); Alert.alert("Success", "Task updated successfully"); navigation.navigate("TasksManagement"); @@ -321,6 +331,44 @@ export default function EditTaskScreen({ route, navigation }: Props) { )} + + + CONSENSUS SETTINGS + + + + Threshold + + Cumulative score required for an image to reach consensus (3 - 20). + E.g., 3 requires 3 expert classifications. + + + + { + const val = Math.max(3, consensusThreshold - 1); + setConsensusThreshold(val); + }} + disabled={consensusThreshold <= 3} + > + - + + {consensusThreshold} + { + const val = Math.min(20, consensusThreshold + 1); + setConsensusThreshold(val); + }} + disabled={consensusThreshold >= 20} + > + = 20 ? themeColors.textSecondary : themeColors.text }]}>+ + + + + + Experiments ; diff --git a/frontend/app/screens/researcher/ResearcherDashboard.tsx b/frontend/app/screens/researcher/ResearcherDashboard.tsx index ca491ecf..2f87ece0 100644 --- a/frontend/app/screens/researcher/ResearcherDashboard.tsx +++ b/frontend/app/screens/researcher/ResearcherDashboard.tsx @@ -8,10 +8,10 @@ import { TouchableOpacity, View, } from "react-native"; -import useResponsive from "../../hooks/useResponsive"; -import { useThemeStore } from '../../stores/themeStore'; +import useResponsive from "@/hooks/useResponsive"; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; -import { useAuthStore } from "../../stores/authStore"; +import { useAuthStore } from "@/stores/authStore"; // Images import addGoldImg from "../../../assets/images/add_gold_image.png"; diff --git a/frontend/app/screens/researcher/SpeciesReferenceImagesScreen.tsx b/frontend/app/screens/researcher/SpeciesReferenceImagesScreen.tsx index 086f1aea..1a201f78 100644 --- a/frontend/app/screens/researcher/SpeciesReferenceImagesScreen.tsx +++ b/frontend/app/screens/researcher/SpeciesReferenceImagesScreen.tsx @@ -15,19 +15,19 @@ import { useRoute, useNavigation, RouteProp } from "@react-navigation/native"; import { NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as ImagePicker from 'expo-image-picker'; import { Ionicons } from "@expo/vector-icons"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; -import { useThemeStore } from "../../stores/themeStore"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; +import { useThemeStore } from "@/stores/themeStore"; import { Colors } from "../../../constants/theme"; -import AuthenticatedImage from "../../components/ui/AuthenticatedImage"; -import { useSpeciesPoolImages, useDeleteSpeciesRefImage, useProfile } from "../../api/queries"; -import { useAuthStore } from "../../stores/authStore"; -import { apiFetch } from "../../api/apiFetch"; -import { API_ENDPOINTS } from "../../api/apiEndpoints"; -import { researcherStackParamList } from "../../navigation/researcherStack.types"; +import AuthenticatedImage from "@/components/ui/AuthenticatedImage"; +import { useSpeciesPoolImages, useDeleteSpeciesRefImage, useProfile } from "@/api/queries"; +import { useAuthStore } from "@/stores/authStore"; +import { apiFetch } from "@/api/apiFetch"; +import { API_ENDPOINTS } from "@/api/apiEndpoints"; +import { researcherStackParamList } from "@/navigation/researcherStack.types"; import taxonomyImg from "../../../assets/images/taxonomy.png"; -import { queryClient } from "../../queryClient"; +import { queryClient } from "@/queryClient"; type SpeciesRefImagesRouteProp = RouteProp; type NavigationProp = NativeStackNavigationProp; diff --git a/frontend/app/screens/researcher/TaskDetailsScreen.tsx b/frontend/app/screens/researcher/TaskDetailsScreen.tsx index 65034de6..28b148bd 100644 --- a/frontend/app/screens/researcher/TaskDetailsScreen.tsx +++ b/frontend/app/screens/researcher/TaskDetailsScreen.tsx @@ -12,10 +12,10 @@ import { } from "react-native"; import { Colors } from '../../../constants/theme'; -import { researcherStackParamList } from "../../navigation/researcherStack.types"; -import { useThemeStore } from '../../stores/themeStore'; -import AuthenticatedImage from '../../components/ui/AuthenticatedImage'; -import { useTaskDetails, useExperiments, useUpdateTaskStatus } from "../../api/queries"; +import { researcherStackParamList } from "@/navigation/researcherStack.types"; +import { useThemeStore } from '@/stores/themeStore'; +import AuthenticatedImage from '@/components/ui/AuthenticatedImage'; +import { useTaskDetails, useExperiments, useUpdateTaskStatus } from "@/api/queries"; type Props = NativeStackScreenProps; @@ -149,7 +149,7 @@ export default function TaskDetailsScreen({ route, navigation }: Props) { diff --git a/frontend/app/screens/researcher/TasksManagementScreen.tsx b/frontend/app/screens/researcher/TasksManagementScreen.tsx index 98ade376..70ec0508 100644 --- a/frontend/app/screens/researcher/TasksManagementScreen.tsx +++ b/frontend/app/screens/researcher/TasksManagementScreen.tsx @@ -4,10 +4,10 @@ import React, { useState, useCallback } from 'react'; import { useFocusEffect } from '@react-navigation/native'; import { ActivityIndicator, FlatList, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { useAdminTasks, useUpdateTaskStatus } from '../../api/queries'; -import TaskCard from '../../components/researcher/TaskCard'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout'; -import { useThemeStore } from '../../stores/themeStore'; +import { useAdminTasks, useUpdateTaskStatus } from '@/api/queries'; +import TaskCard from '@/components/researcher/TaskCard'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout'; +import { useThemeStore } from '@/stores/themeStore'; type StatusFilter = 'ALL' | 'ACTIVE' | 'PAUSED' | 'ARCHIVED' | 'PROCESSING'; diff --git a/frontend/app/screens/researcher/TaxonomyScreen.tsx b/frontend/app/screens/researcher/TaxonomyScreen.tsx index 1c850d19..05ccaaf5 100644 --- a/frontend/app/screens/researcher/TaxonomyScreen.tsx +++ b/frontend/app/screens/researcher/TaxonomyScreen.tsx @@ -11,12 +11,12 @@ import { } from "react-native"; import { useNavigation } from "@react-navigation/native"; import { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { researcherStackParamList } from "../../navigation/researcherStack.types"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; -import { useThemeStore } from '../../stores/themeStore'; +import { researcherStackParamList } from "@/navigation/researcherStack.types"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; import { Ionicons } from '@expo/vector-icons'; -import { useSpeciesMetadata } from "../../api/queries"; +import { useSpeciesMetadata } from "@/api/queries"; // Images import taxonomyImg from "../../../assets/images/taxonomy.png"; diff --git a/frontend/app/screens/researcher/UsersManagementScreen.tsx b/frontend/app/screens/researcher/UsersManagementScreen.tsx index a357cf32..7bdd56a6 100644 --- a/frontend/app/screens/researcher/UsersManagementScreen.tsx +++ b/frontend/app/screens/researcher/UsersManagementScreen.tsx @@ -12,14 +12,14 @@ import { View, Alert, } from 'react-native'; -import useResponsive from '../../hooks/useResponsive'; +import useResponsive from '@/hooks/useResponsive'; import { Colors } from '../../../constants/theme'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { apiFetch } from '../../api/apiFetch'; -import { QUERY_KEYS, useAdminUsers, useProfile } from "../../api/queries"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; -import { researcherStackParamList } from "../../navigation/researcherStack.types"; -import { useThemeStore } from '../../stores/themeStore'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { apiFetch } from '@/api/apiFetch'; +import { QUERY_KEYS, useAdminUsers, useProfile } from "@/api/queries"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; +import { researcherStackParamList } from "@/navigation/researcherStack.types"; +import { useThemeStore } from '@/stores/themeStore'; import { useMutation, useQueryClient } from '@tanstack/react-query'; // Images diff --git a/frontend/app/screens/shared/BannedScreen.tsx b/frontend/app/screens/shared/BannedScreen.tsx index 75e19c95..855b90e5 100644 --- a/frontend/app/screens/shared/BannedScreen.tsx +++ b/frontend/app/screens/shared/BannedScreen.tsx @@ -6,7 +6,7 @@ import { View, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useAuthStore } from '../../stores/authStore'; +import { useAuthStore } from '@/stores/authStore'; /** * Shown when any API call returns a 403 with a BANNED account code, diff --git a/frontend/app/screens/shared/LoginScreen.tsx b/frontend/app/screens/shared/LoginScreen.tsx index d2544efc..932ab710 100644 --- a/frontend/app/screens/shared/LoginScreen.tsx +++ b/frontend/app/screens/shared/LoginScreen.tsx @@ -1,13 +1,14 @@ import * as Google from "expo-auth-session/providers/google"; import * as WebBrowser from "expo-web-browser"; import React, { useEffect, useState } from "react"; -import { ActivityIndicator, Image, Platform, StyleSheet, Text, TextInput, TouchableOpacity, View, ScrollView } from "react-native"; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { apiFetch, backendUrl } from "../../api/apiFetch"; -import { preloadAfterLogin } from "../../api/queries"; -import RegisterForm from "../../components/RegisterForm"; -import { useAuthStore } from "../../stores/authStore"; -import useResponsive from "../../hooks/useResponsive"; +import { ActivityIndicator, Image, Platform, StyleSheet, Text, TextInput, TouchableOpacity, View, ScrollView, Keyboard } from "react-native"; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { apiFetch, backendUrl } from "@/api/apiFetch"; +import { preloadAfterLogin } from "@/api/queries"; +import RegisterForm from "@/components/RegisterForm"; +import { useAuthStore } from "@/stores/authStore"; +import useResponsive from "@/hooks/useResponsive"; +import { theme } from "@/theme/theme"; @@ -88,6 +89,10 @@ export default function LoginScreen() { }, [response]); const handleLogin = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } setLoading(true); setError(""); @@ -131,6 +136,10 @@ export default function LoginScreen() { }; const handleExternalLogin = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } setLoading(true); setError(""); @@ -212,22 +221,25 @@ export default function LoginScreen() { Welcome to SwipeLab Swipe • Label • Improve Research - - + {/* @ts-ignore RN Web form role */} + + + + {error ? {error} : null} @@ -260,20 +272,20 @@ export default function LoginScreen() { } const styles = StyleSheet.create({ - screenContainer: { flex: 1, backgroundColor: '#fff' }, + screenContainer: { flex: 1, backgroundColor: theme.colors.background }, scrollContent: { flexGrow: 1 }, // Web: center the card vertically on the page webScreenContainer: { alignItems: 'center', justifyContent: 'center', - backgroundColor: '#fff', + backgroundColor: theme.colors.background, flexGrow: 1, }, container: { flex: 1, alignItems: "center", justifyContent: "center", - backgroundColor: "#fff", + backgroundColor: theme.colors.background, paddingHorizontal: 20, }, webCard: { @@ -285,15 +297,16 @@ const styles = StyleSheet.create({ }, logo: { width: 140, height: 140, resizeMode: "contain", marginBottom: 30 }, title: { fontSize: 28, fontWeight: "bold", marginBottom: 6 }, - subtitle: { fontSize: 16, color: "#777", marginBottom: 20 }, - input: { width: "85%", borderWidth: 1, borderColor: "#ccc", padding: 10, borderRadius: 8, color: "#000", marginBottom: 12, fontSize: 16 }, - loginButton: { width: "85%", backgroundColor: "#4B7BE5", padding: 12, borderRadius: 8, alignItems: "center", marginBottom: 12 }, - researcherButton: { backgroundColor: "#2E8B57" }, + subtitle: { fontSize: 16, color: theme.colors.textSecondary, marginBottom: 20 }, + formContainer: { width: "100%", alignItems: "center" }, + input: { width: "85%", borderWidth: 1, borderColor: theme.colors.border, padding: 10, borderRadius: 8, color: theme.colors.text, marginBottom: 12, fontSize: 16 }, + loginButton: { width: "85%", backgroundColor: theme.colors.primary, padding: 12, borderRadius: 8, alignItems: "center", marginBottom: 12 }, + researcherButton: { backgroundColor: theme.colors.secondary }, loginButtonText: { color: "#fff", fontSize: 16, fontWeight: "600" }, - orText: { marginVertical: 10, fontSize: 14, color: "#555" }, - googleButton: { width: "85%", backgroundColor: "white", padding: 12, flexDirection: "row", alignItems: "center", borderRadius: 8, borderWidth: 1, borderColor: "#ccc", justifyContent: "center" }, + orText: { marginVertical: 10, fontSize: 14, color: theme.colors.textSecondary }, + googleButton: { width: "85%", backgroundColor: theme.colors.background, padding: 12, flexDirection: "row", alignItems: "center", borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border, justifyContent: "center" }, googleIcon: { width: 22, height: 22, marginRight: 10 }, googleText: { fontSize: 16, fontWeight: "600" }, - error: { color: "red", marginBottom: 8 }, - registerText: { color: "#4B7BE5", textAlign: "center", fontSize: 14 }, + error: { color: theme.colors.error, marginBottom: 8 }, + registerText: { color: theme.colors.primary, textAlign: "center", fontSize: 14 }, }); diff --git a/frontend/app/screens/shared/MaintenanceScreen.tsx b/frontend/app/screens/shared/MaintenanceScreen.tsx new file mode 100644 index 00000000..28a446f5 --- /dev/null +++ b/frontend/app/screens/shared/MaintenanceScreen.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, Linking, SafeAreaView } from 'react-native'; +import { Image } from 'expo-image'; +import { theme } from '@/theme/theme'; + +export function MaintenanceScreen() { + const handleContactSupport = () => { + Linking.openURL('mailto:swipelab.developers@gmail.com?subject=SwipeLab%20-%20Server%20is%20down'); + }; + + return ( + + + + + We'll be right back! + + SwipeLab is currently undergoing maintenance. + We're working hard to make things better for you. + + + + + + Contact Support + + + + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: theme.colors.primary, + zIndex: 9999, // Ensure it overlays everything + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: theme.spacing.xl, + }, + logo: { + width: 100, + height: 100, + marginBottom: theme.spacing.xl, + }, + mainImage: { + width: 200, + height: 200, + marginBottom: theme.spacing.xl, + }, + title: { + fontSize: theme.typography.sizes.xl, + fontWeight: 'bold', + color: '#ffffff', + marginBottom: theme.spacing.md, + textAlign: 'center', + }, + message: { + fontSize: theme.typography.sizes.md, + color: 'rgba(255, 255, 255, 0.8)', + textAlign: 'center', + lineHeight: 24, + marginBottom: theme.spacing.xl, + }, + loader: { + marginBottom: theme.spacing.xxl, + }, + contactButton: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingVertical: theme.spacing.md, + paddingHorizontal: theme.spacing.xl, + borderRadius: theme.borderRadius.round, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.5)', + }, + contactButtonText: { + color: '#ffffff', + fontSize: theme.typography.sizes.md, + fontWeight: 'bold', + } +}); diff --git a/frontend/app/screens/shared/ProfileScreen.tsx b/frontend/app/screens/shared/ProfileScreen.tsx index efde5644..15a3d6fb 100644 --- a/frontend/app/screens/shared/ProfileScreen.tsx +++ b/frontend/app/screens/shared/ProfileScreen.tsx @@ -1,9 +1,9 @@ import { useNavigation } from "@react-navigation/native"; import React, { useState } from "react"; -import { ActivityIndicator, Alert, Image, Modal, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native"; -import { apiFetch } from "../../api/apiFetch"; -import ScreenHeaderLayout from "../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; -import useResponsive from "../../hooks/useResponsive"; +import { ActivityIndicator, Alert, Image, Modal, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, Keyboard, Platform } from "react-native"; +import { apiFetch } from "@/api/apiFetch"; +import ScreenHeaderLayout from "@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout"; +import useResponsive from "@/hooks/useResponsive"; interface UserProfile { username: string; @@ -11,13 +11,14 @@ interface UserProfile { rank: string; score: number; badges: string[]; + provider?: string; } import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { useProfile, useMyBadges } from "../../api/queries"; -import { getBadgeIcon } from "../../constants/badgeIcons"; +import { useThemeStore } from '@/stores/themeStore'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { useProfile, useMyBadges } from "@/api/queries"; +import { getBadgeIcon } from "@/constants/badgeIcons"; export default function ProfileScreen() { const navigation = useNavigation(); @@ -42,6 +43,10 @@ export default function ProfileScreen() { }; const handleCancelChangePassword = () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } setIsChangingPassword(false); setNewPassword(""); setConfirmPassword(""); @@ -49,6 +54,10 @@ export default function ProfileScreen() { }; const handleSavePassword = async () => { + Keyboard.dismiss(); + if (Platform.OS === 'web' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } setPasswordError(""); if (!newPassword || !confirmPassword) { @@ -164,11 +173,13 @@ export default function ProfileScreen() { {/* Actions Section */} - - - Change Password - - + {user.provider === 'LOCAL' && ( + + + Change Password + + + )} {/* Change Password Modal */} (); diff --git a/frontend/app/screens/user/LeaderboardScreen.tsx b/frontend/app/screens/user/LeaderboardScreen.tsx index 97ddd3c4..79fecb96 100644 --- a/frontend/app/screens/user/LeaderboardScreen.tsx +++ b/frontend/app/screens/user/LeaderboardScreen.tsx @@ -2,11 +2,11 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { apiFetch } from '../../api/apiFetch'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; -import { useThemeStore } from '../../stores/themeStore'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { useLeaderboard, useRank, useProfile } from '../../api/queries'; +import { apiFetch } from '@/api/apiFetch'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; +import { useThemeStore } from '@/stores/themeStore'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { useLeaderboard, useRank, useProfile } from '@/api/queries'; interface LeaderboardEntry { diff --git a/frontend/app/screens/user/MyCollectionScreen.tsx b/frontend/app/screens/user/MyCollectionScreen.tsx index 55153c9a..a52ebbcb 100644 --- a/frontend/app/screens/user/MyCollectionScreen.tsx +++ b/frontend/app/screens/user/MyCollectionScreen.tsx @@ -7,14 +7,14 @@ import { Text, View, } from 'react-native'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; -import { useCollectionStore, CollectionEntry } from '../../stores/collectionStore'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; +import { useCollectionStore, CollectionEntry } from '@/stores/collectionStore'; import { useNavigation } from '@react-navigation/native'; -import { useThemeStore } from '../../stores/themeStore'; +import { useThemeStore } from '@/stores/themeStore'; import { Colors } from '../../../constants/theme'; -import AuthenticatedImage from '../../components/ui/AuthenticatedImage'; +import AuthenticatedImage from '@/components/ui/AuthenticatedImage'; -import { parseImageUrl } from '../../utils/imageUtils'; +import { parseImageUrl } from '@/utils/imageUtils'; function formatDate(iso: string): string { const d = new Date(iso); diff --git a/frontend/app/screens/user/StatsScreen.tsx b/frontend/app/screens/user/StatsScreen.tsx index e7b64020..0880c5ad 100644 --- a/frontend/app/screens/user/StatsScreen.tsx +++ b/frontend/app/screens/user/StatsScreen.tsx @@ -2,11 +2,11 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { apiFetch } from '../../api/apiFetch'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; -import { useThemeStore } from '../../stores/themeStore'; -import { useAllStatistics } from '../../api/queries'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { apiFetch } from '@/api/apiFetch'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; +import { useThemeStore } from '@/stores/themeStore'; +import { useAllStatistics } from '@/api/queries'; const DEBUG_MODE = false; // Set to true for testing controls diff --git a/frontend/app/screens/user/SwipeScreen.tsx b/frontend/app/screens/user/SwipeScreen.tsx index 999672ff..57bc4a32 100644 --- a/frontend/app/screens/user/SwipeScreen.tsx +++ b/frontend/app/screens/user/SwipeScreen.tsx @@ -13,18 +13,18 @@ import { } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { API_ENDPOINTS } from '../../api/apiEndpoints'; -import { apiFetch } from '../../api/apiFetch'; -import { QUERY_KEYS, useMyTasks, useSwipeBatch } from '../../api/queries'; -import ReferenceGallery from '../../components/user/ReferenceGallery'; -import SwipeButtons from '../../components/user/SwipeButtons'; -import SwipeCard, { SwipeCardHandle } from '../../components/user/SwipeCard'; -import WarningToast from '../../components/ui/WarningToast'; -import useResponsive from '../../hooks/useResponsive'; -import { useSwipeStore } from '../../stores/swipeStore'; -import { useThemeStore } from '../../stores/themeStore'; -import { SwipeDirection } from '../../types'; -import { ClassificationWarning } from '../../types/fraudTypes'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { apiFetch } from '@/api/apiFetch'; +import { QUERY_KEYS, useMyTasks, useSwipeBatch } from '@/api/queries'; +import ReferenceGallery from '@/components/user/ReferenceGallery'; +import SwipeButtons from '@/components/user/SwipeButtons'; +import SwipeCard, { SwipeCardHandle } from '@/components/user/SwipeCard'; +import WarningToast from '@/components/ui/WarningToast'; +import useResponsive from '@/hooks/useResponsive'; +import { useSwipeStore } from '@/stores/swipeStore'; +import { useThemeStore } from '@/stores/themeStore'; +import { SwipeDirection } from '@/types'; +import { ClassificationWarning } from '@/types/fraudTypes'; // ─── Accent used across Quick Start UI ────────────────────────────────────── @@ -133,6 +133,10 @@ export default function SwipeScreen() { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.challenges }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.myBadges }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.userProfile }); + // Ensure task progress and global stats update in the background + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.myTasks }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.taskDetails(currentImage.taskId) }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.statistics }); } }) .catch((e) => { diff --git a/frontend/app/screens/user/TaskDetailsScreen.tsx b/frontend/app/screens/user/TaskDetailsScreen.tsx index d18f2ac6..96d6b90f 100644 --- a/frontend/app/screens/user/TaskDetailsScreen.tsx +++ b/frontend/app/screens/user/TaskDetailsScreen.tsx @@ -4,18 +4,29 @@ import React from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Colors } from '../../../constants/theme'; -import { useThemeStore } from '../../stores/themeStore'; -import { useSwipeStore } from '../../stores/swipeStore'; +import { useThemeStore } from '@/stores/themeStore'; +import { useSwipeStore } from '@/stores/swipeStore'; +import { useMyTasks, useAvailableTasks } from '@/api/queries'; +import AuthenticatedImage from '@/components/ui/AuthenticatedImage'; export default function TaskDetailsScreen() { const navigation = useNavigation(); const route = useRoute(); - const { task } = route.params; + const { task: initialTask } = route.params; const { theme } = useThemeStore(); const themeColors = Colors[theme as keyof typeof Colors]; const isDark = theme === 'dark'; const { setActiveTaskId } = useSwipeStore(); + // Fetch live data so progress updates after classification + const { data: myTasks } = useMyTasks(); + const { data: availableTasks } = useAvailableTasks(); + + const taskId = initialTask.id ?? initialTask.taskId; + const task = myTasks?.find((t: any) => (t.id ?? t.taskId) === taskId) + || availableTasks?.find((t: any) => (t.id ?? t.taskId) === taskId) + || initialTask; + const totalImages = task.progress?.totalImages ?? task.totalImages ?? 0; const imagesClassified = task.progress?.imagesClassified ?? task.imagesClassified ?? 0; const pending = totalImages - imagesClassified; @@ -122,20 +133,46 @@ export default function TaskDetailsScreen() { index === (task.targetSpecies || []).length - 1 && { borderBottomWidth: 0 }, ]} > - - - - - - {commonName || name} - - {commonName && name && commonName !== name && ( - - {name} + + + + + + + {commonName || name} - )} + {commonName && name && commonName !== name && ( + + {name} + + )} + - + {(s.referenceImages || []).length > 0 && ( + + {s.referenceImages.map((img: any, idx: number) => { + let imageUri = ''; + if (img.imageUrl) { + imageUri = img.imageUrl; + } else if (img.data) { + imageUri = `data:${img.contentType || 'image/jpeg'};base64,${img.data}`; + } + return ( + + + + ); + })} + + )} ); })} @@ -146,10 +183,20 @@ export default function TaskDetailsScreen() { {/* ── Footer CTA ───────────────────────────────────────────────────── */} - - - Start Classifying - + 0 ? handlePlay : undefined} + activeOpacity={pending > 0 ? 0.85 : 1} + disabled={pending === 0} + > + {pending > 0 && } + + {pending === 0 ? 'Completed' : (imagesClassified > 0 ? 'Continue Classifying' : 'Start Classifying')} + + {pending > 0 && } @@ -345,11 +392,15 @@ const styles = StyleSheet.create({ elevation: 2, }, speciesRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: 'column', + alignItems: 'stretch', paddingVertical: 12, paddingHorizontal: 14, borderBottomWidth: 1, + }, + speciesRowHeader: { + flexDirection: 'row', + alignItems: 'center', gap: 12, }, speciesIconWrap: { @@ -368,6 +419,23 @@ const styles = StyleSheet.create({ fontStyle: 'italic', marginTop: 1, }, + imageCard: { + alignItems: 'center', + width: 100, + }, + image: { + width: 100, + height: 100, + borderRadius: 10, + backgroundColor: '#DBEAFE', + borderWidth: 1, + }, + imageCaption: { + fontSize: 11, + marginTop: 5, + textAlign: 'center', + maxWidth: 100, + }, // Footer footer: { diff --git a/frontend/app/screens/user/TasksScreen.tsx b/frontend/app/screens/user/TasksScreen.tsx index bc1da5ad..b9179bef 100644 --- a/frontend/app/screens/user/TasksScreen.tsx +++ b/frontend/app/screens/user/TasksScreen.tsx @@ -1,4 +1,4 @@ // Re-exports UserMyTasksScreen as the canonical tasks screen for the user role. // The navigator currently routes "Tasks" directly to UserMyTasksScreen; // this file exists as a named alias for future routing flexibility. -export { default } from './UserMyTasksScreen'; \ No newline at end of file +export { default } from '@/screens/user/UserMyTasksScreen'; \ No newline at end of file diff --git a/frontend/app/screens/user/UserMyTasksScreen.tsx b/frontend/app/screens/user/UserMyTasksScreen.tsx index e84e2ff0..9c05cd39 100644 --- a/frontend/app/screens/user/UserMyTasksScreen.tsx +++ b/frontend/app/screens/user/UserMyTasksScreen.tsx @@ -1,15 +1,15 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useState } from 'react'; -import { useMyTasks, useAvailableTasks, useStatistics, useAssignTask } from "../../api/queries"; +import { useMyTasks, useAvailableTasks, useStatistics, useAssignTask } from "@/api/queries"; import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../../constants/theme'; -import ScreenHeaderLayout from '../../components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; -import TaskCard from '../../components/user/TaskCard'; -import ErrorToast from '../../components/ui/ErrorToast'; -import { useThemeStore } from '../../stores/themeStore'; -import { useSwipeStore } from '../../stores/swipeStore'; +import ScreenHeaderLayout from '@/components/layout/ScreenHeaderLayout/ScreenHeaderLayout'; +import TaskCard from '@/components/user/TaskCard'; +import ErrorToast from '@/components/ui/ErrorToast'; +import { useThemeStore } from '@/stores/themeStore'; +import { useSwipeStore } from '@/stores/swipeStore'; export default function UserMyTasksScreen() { diff --git a/frontend/app/services/__tests__/csvDownload.test.ts b/frontend/app/services/__tests__/csvDownload.test.ts index a29255fd..5ca5fc34 100644 --- a/frontend/app/services/__tests__/csvDownload.test.ts +++ b/frontend/app/services/__tests__/csvDownload.test.ts @@ -1,4 +1,4 @@ -import { downloadCsvBlob } from '../csvDownload'; +import { downloadCsvBlob } from '@/services/csvDownload'; import { Platform } from 'react-native'; // Mock expo modules for mobile path diff --git a/frontend/app/services/imageService.ts b/frontend/app/services/imageService.ts index f5ca27ce..3c218569 100644 --- a/frontend/app/services/imageService.ts +++ b/frontend/app/services/imageService.ts @@ -1,4 +1,4 @@ -import { Question, SwipeResult } from '../types'; +import { Question, SwipeResult } from '@/types'; export class ImageService { // Mock service - replace with actual API calls diff --git a/frontend/app/stores/appStateStore.ts b/frontend/app/stores/appStateStore.ts new file mode 100644 index 00000000..af41c8d8 --- /dev/null +++ b/frontend/app/stores/appStateStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface AppState { + isMaintenanceMode: boolean; + setMaintenanceMode: (isMaintenance: boolean) => void; +} + +export const useAppStateStore = create((set) => ({ + isMaintenanceMode: false, + setMaintenanceMode: (isMaintenanceMode) => set({ isMaintenanceMode }), +})); diff --git a/frontend/app/stores/authStore.ts b/frontend/app/stores/authStore.ts index ad19f256..3f13dbb1 100644 --- a/frontend/app/stores/authStore.ts +++ b/frontend/app/stores/authStore.ts @@ -2,10 +2,10 @@ import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; import { create } from "zustand"; -import { apiFetch } from "../api/apiFetch"; -import { useModeStore } from "./modeStore"; -import { API_ENDPOINTS } from '../api/apiEndpoints'; import { jwtDecode } from "jwt-decode"; +import { setTokens, clearTokens, setItem, getItem, removeItem } from "@/utils/tokenUtils"; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; +import { useModeStore } from "@/stores/modeStore"; type Role = "USER" | "RESEARCHER" | null; @@ -49,21 +49,13 @@ export const useAuthStore = create((set) => ({ setAuth: async (token, role, refreshToken) => { set({ token, role, authProvider: "LOCAL" }); - if (Platform.OS === 'web') { - localStorage.setItem("token", token); - localStorage.setItem("authProvider", "LOCAL"); - if (role) localStorage.setItem("role", role); - if (refreshToken) localStorage.setItem("refreshToken", refreshToken); - } else { - await SecureStore.setItemAsync("token", token); - await SecureStore.setItemAsync("authProvider", "LOCAL"); - if (role) await SecureStore.setItemAsync("role", role); - if (refreshToken) await SecureStore.setItemAsync("refreshToken", refreshToken); - } + await setTokens(token, refreshToken); + await setItem("authProvider", "LOCAL"); + if (role) await setItem("role", role); // Automatically set researcher mode if role is RESEARCHER if (role === "RESEARCHER") { - useModeStore.getState().setMode("researcher"); // keeping mode string same for now if modeStore uses researcher, but we'll update modeStore next + useModeStore.getState().setMode("researcher"); } else { useModeStore.getState().setMode("USER"); } @@ -71,31 +63,16 @@ export const useAuthStore = create((set) => ({ setExternalAuth: async (token, refreshToken, username) => { set({ token, role: "RESEARCHER", authProvider: "STARDBI" }); - if (Platform.OS === 'web') { - localStorage.setItem("token", token); - localStorage.setItem("role", "RESEARCHER"); - localStorage.setItem("authProvider", "STARDBI"); - localStorage.setItem("refreshToken", refreshToken); - localStorage.setItem("username", username); - } else { - await SecureStore.setItemAsync("token", token); - await SecureStore.setItemAsync("role", "RESEARCHER"); - await SecureStore.setItemAsync("authProvider", "STARDBI"); - await SecureStore.setItemAsync("refreshToken", refreshToken); - await SecureStore.setItemAsync("username", username); - } + await setTokens(token, refreshToken); + await setItem("role", "RESEARCHER"); + await setItem("authProvider", "STARDBI"); + await setItem("username", username); useModeStore.getState().setMode("researcher"); }, updateTokens: async (token, refreshToken) => { set({ token }); - if (Platform.OS === 'web') { - localStorage.setItem("token", token); - localStorage.setItem("refreshToken", refreshToken); - } else { - await SecureStore.setItemAsync("token", token); - await SecureStore.setItemAsync("refreshToken", refreshToken); - } + await setTokens(token, refreshToken); }, logout: async () => { @@ -106,98 +83,68 @@ export const useAuthStore = create((set) => ({ return; } - // 1. Get the refresh token before clearing storage - let refreshToken = null; - if (Platform.OS === 'web') { - refreshToken = localStorage.getItem("refreshToken"); - } else { - refreshToken = await SecureStore.getItemAsync("refreshToken"); - } + const { getRefreshToken } = require("@/utils/tokenUtils"); + const refreshToken = await getRefreshToken(); - // 2. Clear frontend state immediately to prevent re-entry + // 1. Clear frontend state immediately to prevent re-entry set({ token: null, role: null, authProvider: null, isSuperAdmin: false, isBanned: false }); - if (Platform.OS === 'web') { - localStorage.removeItem("token"); - localStorage.removeItem("role"); - localStorage.removeItem("refreshToken"); - localStorage.removeItem("authProvider"); - localStorage.removeItem("isSuperAdmin"); - } else { - await SecureStore.deleteItemAsync("token"); - await SecureStore.deleteItemAsync("role"); - await SecureStore.deleteItemAsync("refreshToken"); - await SecureStore.deleteItemAsync("authProvider"); - await SecureStore.deleteItemAsync("isSuperAdmin"); - } - - // 3. Call the backend to invalidate the refresh token (fire-and-forget) - if (refreshToken) { - const { backendUrl } = require("../api/apiFetch"); - - fetch(backendUrl + API_ENDPOINTS.AUTH.LOGOUT, { - method: "POST", - headers: { - "Authorization": `Bearer ${refreshToken}` - } - }) - .then(res => { - if (res.status === 401) { - console.warn("[logout] Server returned 401 on logout, but local cleanup is already complete."); - } - }) - .catch(e => console.error("Logout request failed", e)); - } + await clearTokens(); + await removeItem("role"); + await removeItem("authProvider"); + await removeItem("isSuperAdmin"); + await removeItem("username"); + + // 2. Call the backend to invalidate the refresh token / clear cookies (fire-and-forget) + const { backendUrl } = require("@/api/apiFetch"); + fetch(backendUrl + API_ENDPOINTS.AUTH.LOGOUT, { + method: "POST", + credentials: "include", // Essential for web HttpOnly cookies + headers: { + ...(refreshToken && Platform.OS !== "web" ? { Authorization: `Bearer ${refreshToken}` } : {}) + } + }).catch(e => console.error("Logout request failed", e)); // 4. Clear mode and query cache useModeStore.getState().resetMode?.(); - const { queryClient } = require("../queryClient"); + const { queryClient } = require("@/queryClient"); queryClient.clear(); }, initialize: async () => { try { - let token, role, authProvider, isSuperAdmin = false; - if (Platform.OS === 'web') { - token = localStorage.getItem("token"); - role = localStorage.getItem("role") as Role; - authProvider = localStorage.getItem("authProvider") as "LOCAL" | "STARDBI" | null; - isSuperAdmin = localStorage.getItem("isSuperAdmin") === "true"; - } else { - token = await SecureStore.getItemAsync("token"); - role = (await SecureStore.getItemAsync("role")) as Role; - authProvider = (await SecureStore.getItemAsync("authProvider")) as "LOCAL" | "STARDBI" | null; - isSuperAdmin = (await SecureStore.getItemAsync("isSuperAdmin")) === "true"; - } + const isAuthFlag = await getItem("isAuthenticated"); + const localToken = await getItem("token"); // Only present on mobile + + const role = (await getItem("role")) as Role; + const authProvider = (await getItem("authProvider")) as "LOCAL" | "STARDBI" | null; + const isSuperAdmin = (await getItem("isSuperAdmin")) === "true"; - if (token) { + const token = Platform.OS === "web" ? "web-cookie-placeholder" : localToken; + const isAuthenticated = Platform.OS === "web" ? isAuthFlag === "true" : !!token; + + if (isAuthenticated) { let isExpired = false; - try { - const decoded = jwtDecode<{ exp?: number }>(token); - if (decoded.exp && Date.now() >= decoded.exp * 1000) { + + if (Platform.OS !== "web" && token) { + try { + const decoded = jwtDecode<{ exp?: number }>(token); + if (decoded.exp && Date.now() >= decoded.exp * 1000) { + isExpired = true; + } + } catch (e) { + console.error("Invalid token on boot:", e); isExpired = true; } - } catch (e) { - console.error("Invalid token on boot:", e); - isExpired = true; } if (isExpired) { console.log("[authStore] Token expired on boot. Clearing state."); set({ sessionExpiredMessage: true }); setTimeout(async () => { - if (Platform.OS === 'web') { - localStorage.removeItem("token"); - localStorage.removeItem("role"); - localStorage.removeItem("refreshToken"); - localStorage.removeItem("authProvider"); - localStorage.removeItem("isSuperAdmin"); - } else { - await SecureStore.deleteItemAsync("token"); - await SecureStore.deleteItemAsync("role"); - await SecureStore.deleteItemAsync("refreshToken"); - await SecureStore.deleteItemAsync("authProvider"); - await SecureStore.deleteItemAsync("isSuperAdmin"); - } + await clearTokens(); + await removeItem("role"); + await removeItem("authProvider"); + await removeItem("isSuperAdmin"); set({ token: null, role: null, authProvider: null, isSuperAdmin: false, isBanned: false, sessionExpiredMessage: false }); }, 2000); } else { diff --git a/frontend/app/stores/collectionStore.ts b/frontend/app/stores/collectionStore.ts index c6e94f60..661fce22 100644 --- a/frontend/app/stores/collectionStore.ts +++ b/frontend/app/stores/collectionStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { apiFetch } from '../api/apiFetch'; -import { API_ENDPOINTS } from '../api/apiEndpoints'; +import { apiFetch } from '@/api/apiFetch'; +import { API_ENDPOINTS } from '@/api/apiEndpoints'; export interface CollectionEntry { id: number; diff --git a/frontend/app/theme/theme.ts b/frontend/app/theme/theme.ts new file mode 100644 index 00000000..eefc52ec --- /dev/null +++ b/frontend/app/theme/theme.ts @@ -0,0 +1,44 @@ +export const theme = { + colors: { + primary: '#4B7BE5', + secondary: '#2E8B57', + background: '#ffffff', + text: '#333333', + textSecondary: '#666666', + error: '#e74c3c', + warning: '#f39c12', + success: '#2ecc71', + border: '#dddddd', + cardBackground: '#f9f9f9', + }, + spacing: { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, + }, + borderRadius: { + sm: 4, + md: 8, + lg: 12, + xl: 16, + round: 9999, + }, + typography: { + sizes: { + xs: 12, + sm: 14, + md: 16, + lg: 20, + xl: 24, + xxl: 32, + }, + weights: { + regular: '400', + medium: '500', + bold: '700', + } + } +}; diff --git a/frontend/app/utils/tokenUtils.ts b/frontend/app/utils/tokenUtils.ts new file mode 100644 index 00000000..1ded1934 --- /dev/null +++ b/frontend/app/utils/tokenUtils.ts @@ -0,0 +1,68 @@ +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; + +/** + * For Web: We rely on HttpOnly cookies, so we don't store tokens in localStorage. + * For Mobile: We use SecureStore to store tokens securely. + */ + +export async function getAccessToken(): Promise { + if (Platform.OS === 'web') { + return null; // Handled by HttpOnly cookies + } + return await SecureStore.getItemAsync('token'); +} + +export async function getRefreshToken(): Promise { + if (Platform.OS === 'web') { + return null; // Handled by HttpOnly cookies + } + return await SecureStore.getItemAsync('refreshToken'); +} + +export async function setTokens(accessToken: string, refreshToken?: string): Promise { + if (Platform.OS === 'web') { + // Backend sets HttpOnly cookies, nothing to store securely in JS. + // We can store a flag that user is logged in. + localStorage.setItem('isAuthenticated', 'true'); + } else { + await SecureStore.setItemAsync('token', accessToken); + if (refreshToken) { + await SecureStore.setItemAsync('refreshToken', refreshToken); + } + } +} + +export async function clearTokens(): Promise { + if (Platform.OS === 'web') { + localStorage.removeItem('isAuthenticated'); + } else { + await SecureStore.deleteItemAsync('token'); + await SecureStore.deleteItemAsync('refreshToken'); + } +} + +// Other non-sensitive data +export async function setItem(key: string, value: string): Promise { + if (Platform.OS === 'web') { + localStorage.setItem(key, value); + } else { + await SecureStore.setItemAsync(key, value); + } +} + +export async function getItem(key: string): Promise { + if (Platform.OS === 'web') { + return localStorage.getItem(key); + } else { + return await SecureStore.getItemAsync(key); + } +} + +export async function removeItem(key: string): Promise { + if (Platform.OS === 'web') { + localStorage.removeItem(key); + } else { + await SecureStore.deleteItemAsync(key); + } +} diff --git a/frontend/assets/images/android-icon-background.png b/frontend/assets/images/android-icon-background.png deleted file mode 100644 index 5ffefc5b..00000000 Binary files a/frontend/assets/images/android-icon-background.png and /dev/null differ diff --git a/frontend/assets/images/android-icon-foreground.png b/frontend/assets/images/android-icon-foreground.png deleted file mode 100644 index 3a9e5016..00000000 Binary files a/frontend/assets/images/android-icon-foreground.png and /dev/null differ diff --git a/frontend/assets/images/android-icon-monochrome.png b/frontend/assets/images/android-icon-monochrome.png deleted file mode 100644 index 77484ebd..00000000 Binary files a/frontend/assets/images/android-icon-monochrome.png and /dev/null differ diff --git a/frontend/assets/images/config.png b/frontend/assets/images/config.png new file mode 100644 index 00000000..825ff879 Binary files /dev/null and b/frontend/assets/images/config.png differ diff --git a/frontend/assets/images/favicon.png b/frontend/assets/images/favicon.png deleted file mode 100644 index 408bd746..00000000 Binary files a/frontend/assets/images/favicon.png and /dev/null differ diff --git a/frontend/assets/images/maintenance.gif b/frontend/assets/images/maintenance.gif new file mode 100644 index 00000000..0ec98645 Binary files /dev/null and b/frontend/assets/images/maintenance.gif differ diff --git a/frontend/assets/images/partial-react-logo.png b/frontend/assets/images/partial-react-logo.png deleted file mode 100644 index 66fd9570..00000000 Binary files a/frontend/assets/images/partial-react-logo.png and /dev/null differ diff --git a/frontend/assets/images/react-logo.png b/frontend/assets/images/react-logo.png deleted file mode 100644 index 9d72a9ff..00000000 Binary files a/frontend/assets/images/react-logo.png and /dev/null differ diff --git a/frontend/assets/images/react-logo@2x.png b/frontend/assets/images/react-logo@2x.png deleted file mode 100644 index 2229b130..00000000 Binary files a/frontend/assets/images/react-logo@2x.png and /dev/null differ diff --git a/frontend/assets/images/react-logo@3x.png b/frontend/assets/images/react-logo@3x.png deleted file mode 100644 index a99b2032..00000000 Binary files a/frontend/assets/images/react-logo@3x.png and /dev/null differ diff --git a/frontend/assets/images/swipelab.gif b/frontend/assets/images/swipelab.gif new file mode 100644 index 00000000..22fda290 Binary files /dev/null and b/frontend/assets/images/swipelab.gif differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index be901109..c41e38c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,6 @@ "expo-image": "~3.0.10", "expo-image-picker": "~16.0.3", "expo-linking": "~8.0.9", - "expo-router": "^6.0.21", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.11", @@ -39,6 +38,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-toast-message": "^2.4.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "zustand": "^5.0.9" @@ -102,7 +102,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2123,6 +2122,8 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", @@ -3710,13 +3711,17 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3732,6 +3737,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3747,6 +3754,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3762,6 +3771,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3777,6 +3788,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -3795,6 +3808,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -3813,6 +3828,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3828,6 +3845,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -3847,6 +3866,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -3865,6 +3886,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, @@ -3883,6 +3906,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4198,7 +4223,6 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", "license": "MIT", - "peer": true, "dependencies": { "@react-navigation/core": "^7.14.0", "escape-string-regexp": "^4.0.0", @@ -4607,7 +4631,6 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4692,7 +4715,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -5249,7 +5271,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5417,6 +5438,8 @@ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -5986,7 +6009,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6265,7 +6287,9 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/cliui": { "version": "8.0.1", @@ -6512,6 +6536,7 @@ "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -6534,6 +6559,7 @@ "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -6552,6 +6578,7 @@ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", @@ -6568,6 +6595,7 @@ "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" @@ -6582,6 +6610,7 @@ "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "jest-get-type": "^29.6.3" }, @@ -6595,6 +6624,7 @@ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", @@ -6613,6 +6643,7 @@ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -6626,6 +6657,7 @@ "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", @@ -6641,6 +6673,7 @@ "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/types": "^29.6.3", @@ -6657,6 +6690,7 @@ "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", @@ -6673,6 +6707,7 @@ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", @@ -6700,6 +6735,7 @@ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -6717,7 +6753,8 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/create-jest/node_modules/@sinonjs/fake-timers": { "version": "10.3.0", @@ -6725,6 +6762,7 @@ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -6735,6 +6773,7 @@ "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -6757,6 +6796,7 @@ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -6774,6 +6814,7 @@ "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -6790,6 +6831,7 @@ "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" @@ -6807,6 +6849,7 @@ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6819,7 +6862,8 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/create-jest/node_modules/expect": { "version": "29.7.0", @@ -6827,6 +6871,7 @@ "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", @@ -6845,6 +6890,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6866,6 +6912,7 @@ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -6883,6 +6930,7 @@ "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -6915,6 +6963,7 @@ "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.7.0", @@ -6961,6 +7010,7 @@ "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "detect-newline": "^3.0.0" }, @@ -6974,6 +7024,7 @@ "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -6991,6 +7042,7 @@ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -7009,6 +7061,7 @@ "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", @@ -7035,6 +7088,7 @@ "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" @@ -7049,6 +7103,7 @@ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", @@ -7070,6 +7125,7 @@ "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -7085,6 +7141,7 @@ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -7095,6 +7152,7 @@ "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", @@ -7116,6 +7174,7 @@ "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/environment": "^29.7.0", @@ -7149,6 +7208,7 @@ "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -7183,6 +7243,7 @@ "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -7215,6 +7276,7 @@ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -7228,6 +7290,7 @@ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -7246,6 +7309,7 @@ "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -7264,6 +7328,7 @@ "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/test-result": "^29.7.0", "@jest/types": "^29.6.3", @@ -7284,6 +7349,7 @@ "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -7300,6 +7366,7 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.6" }, @@ -7322,7 +7389,8 @@ "url": "https://opencollective.com/fast-check" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/create-jest/node_modules/semver": { "version": "6.3.1", @@ -7330,6 +7398,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -7340,6 +7409,7 @@ "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -7351,6 +7421,7 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7367,6 +7438,7 @@ "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -7719,7 +7791,9 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/diff-sequences": { "version": "29.6.3", @@ -8120,7 +8194,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8317,7 +8390,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8600,6 +8672,7 @@ "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true, + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -8704,7 +8777,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", @@ -8811,7 +8883,6 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -8836,7 +8907,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -8908,7 +8978,6 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", - "peer": true, "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" @@ -8952,6 +9021,8 @@ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", @@ -9023,6 +9094,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", @@ -9049,6 +9122,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -9067,6 +9142,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -9091,6 +9168,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -9114,6 +9193,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -9132,6 +9213,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", @@ -9163,6 +9246,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", @@ -9193,6 +9278,8 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "license": "ISC", + "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -9803,6 +9890,8 @@ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -10043,7 +10132,6 @@ "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -11069,7 +11157,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11748,6 +11835,7 @@ "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/reporters": "^29.7.0", @@ -11796,6 +11884,7 @@ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", @@ -11812,6 +11901,7 @@ "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" @@ -11839,6 +11929,7 @@ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", @@ -11857,6 +11948,7 @@ "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.7.0", @@ -11901,6 +11993,7 @@ "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", @@ -11931,6 +12024,7 @@ "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", @@ -11962,6 +12056,7 @@ "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", @@ -12030,6 +12125,7 @@ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -12112,6 +12208,7 @@ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -12134,7 +12231,8 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-expo/node_modules/expect": { "version": "29.7.0", @@ -12160,6 +12258,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12208,6 +12307,7 @@ "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -12251,6 +12351,7 @@ "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "execa": "^5.0.0", "jest-util": "^29.7.0", @@ -12266,6 +12367,7 @@ "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -12298,6 +12400,7 @@ "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/test-result": "^29.7.0", @@ -12332,6 +12435,7 @@ "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.7.0", @@ -12378,6 +12482,7 @@ "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "detect-newline": "^3.0.0" }, @@ -12391,6 +12496,7 @@ "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -12408,6 +12514,7 @@ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -12452,6 +12559,7 @@ "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" @@ -12487,6 +12595,7 @@ "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -12512,6 +12621,7 @@ "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", @@ -12533,6 +12643,7 @@ "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "jest-regex-util": "^29.6.3", "jest-snapshot": "^29.7.0" @@ -12547,6 +12658,7 @@ "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/environment": "^29.7.0", @@ -12580,6 +12692,7 @@ "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -12664,6 +12777,7 @@ "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -12834,7 +12948,8 @@ "url": "https://opencollective.com/fast-check" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-expo/node_modules/source-map-support": { "version": "0.5.13", @@ -12842,6 +12957,7 @@ "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -12853,6 +12969,7 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -16333,7 +16450,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16374,7 +16490,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -16386,7 +16501,9 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/react-freeze": { "version": "1.0.4", @@ -16468,7 +16585,6 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -16522,7 +16638,6 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -16533,7 +16648,6 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -16544,12 +16658,21 @@ "react-native": "*" } }, + "node_modules/react-native-toast-message": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-native-toast-message/-/react-native-toast-message-2.4.0.tgz", + "integrity": "sha512-Ip4sPpf9707Qmn06Jp1aGJrYLx2mwTYBRjXzP7ebPvmjABA6OCT9bEh62E0L6s5zc/KPZ3rix+OnBKW/pOJZ4g==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -17009,7 +17132,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17019,6 +17141,8 @@ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", @@ -17044,6 +17168,8 @@ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" @@ -17066,6 +17192,8 @@ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" @@ -17089,7 +17217,6 @@ "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "react-is": "^19.1.0", "scheduler": "^0.26.0" @@ -17645,6 +17772,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "devOptional": true, "license": "MIT" }, "node_modules/set-function-length": { @@ -17721,7 +17849,9 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/shebang-command": { "version": "2.0.0", @@ -18708,7 +18838,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -18828,7 +18959,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19069,6 +19199,8 @@ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -19099,6 +19231,8 @@ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" @@ -19121,7 +19255,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -19182,6 +19315,8 @@ "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, @@ -19195,6 +19330,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -19231,6 +19368,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -19258,6 +19397,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", @@ -19283,6 +19424,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -19307,6 +19450,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -19331,6 +19476,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -19354,6 +19501,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, diff --git a/frontend/package.json b/frontend/package.json index 774d16c4..fee5aa71 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,6 @@ "expo-image": "~3.0.10", "expo-image-picker": "~16.0.3", "expo-linking": "~8.0.9", - "expo-router": "^6.0.21", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.11", @@ -47,6 +46,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-toast-message": "^2.4.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "zustand": "^5.0.9" diff --git a/frontend/tests/admin-dashboard.spec.ts b/frontend/tests/admin-dashboard.spec.ts deleted file mode 100644 index bf0bcd1c..00000000 --- a/frontend/tests/admin-dashboard.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const ADMIN_USER = 'admin_mock'; -const PASSWORD = 'password'; - -// ── Login Helper ─────────────────────────────────────────────────────────────── -async function loginAsAdmin(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - // Wait for admin dashboard — "Tasks" tile is the reliable indicator - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); -} - -// ── Tests ────────────────────────────────────────────────────────────────────── -test.describe('Admin Dashboard', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('dashboard loads after admin login', async ({ page }) => { - await expect(page.locator('text=Tasks').first()).toBeVisible(); - }); - - test('all dashboard tiles are visible', async ({ page }) => { - // The six tiles defined in AdminDashboard.tsx - await expect(page.locator('text=Tasks').first()).toBeVisible(); - await expect(page.locator('text=Add Task')).toBeVisible(); - await expect(page.locator('text=Taxonomy')).toBeVisible(); - await expect(page.locator('text=Recipients List')).toBeVisible(); - await expect(page.locator('text=Gold Images')).toBeVisible(); - await expect(page.locator('text=Add Gold Image')).toBeVisible(); - }); - - test('admin bottom navigation has all five tabs', async ({ page }) => { - // AdminNavigator bottom bar: Home, Users, Tasks, Analytics, Settings - await expect(page.locator('text=Home').first()).toBeVisible(); - await expect(page.locator('text=Users').first()).toBeVisible(); - await expect(page.locator('text=Analytics').first()).toBeVisible(); - await expect(page.locator('text=Settings').first()).toBeVisible(); - }); - - test('navigate to Tasks Management via dashboard tile', async ({ page }) => { - // Click the "Tasks" dashboard tile (first occurrence — the tile) - await page.locator('text=Tasks').first().click(); - await page.waitForTimeout(2000); - - // Should still contain Tasks text somewhere on page - await expect(page.locator('body')).toContainText('Tasks'); - }); - - test('navigate to Add Task via dashboard tile', async ({ page }) => { - await page.locator('text=Add Task').click(); - await page.waitForTimeout(2000); - - // Add Task screen should render at least one input field - const inputCount = await page.locator('input').count(); - expect(inputCount).toBeGreaterThan(0); - }); - - test('navigate to Recipients List via dashboard tile', async ({ page }) => { - await page.locator('text=Recipients List').click(); - await page.waitForTimeout(2000); - - // After navigation, the dashboard-only "Add Gold Image" tile should not be visible - await expect(page.locator('text=Add Gold Image')).not.toBeVisible({ timeout: 5000 }); - }); - - test('navigate to Analytics via bottom nav', async ({ page }) => { - await page.locator('text=Analytics').first().click(); - await page.waitForTimeout(2000); - await expect(page.locator('body')).toBeVisible(); - }); - - test('navigate to Users via bottom nav', async ({ page }) => { - await page.locator('text=Users').first().click(); - await page.waitForTimeout(2000); - await expect(page.locator('body')).toContainText('User'); - }); - - test('navigate to Settings via bottom nav', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - // Settings screen header shows "Settings" — use .first() to avoid strict mode violation - await expect(page.locator('text=Settings').first()).toBeVisible(); - }); -}); diff --git a/frontend/tests/admin-recipients.spec.ts b/frontend/tests/admin-recipients.spec.ts deleted file mode 100644 index 228af140..00000000 --- a/frontend/tests/admin-recipients.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const ADMIN_USER = 'admin_mock'; -const PASSWORD = 'password'; - -async function loginAsAdmin(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); -} - -test.describe('Admin Recipients Management', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('recipients list tile exists on dashboard', async ({ page }) => { - await expect(page.locator('text=Recipients List')).toBeVisible(); - }); - - test('clicking Recipients List navigates away from dashboard', async ({ page }) => { - await page.locator('text=Recipients List').click(); - await page.waitForTimeout(2000); - - // Dashboard-specific tiles should no longer be visible - await expect(page.locator('text=Add Gold Image')).not.toBeVisible({ timeout: 5000 }); - }); - - test('recipients page body is visible', async ({ page }) => { - await page.locator('text=Recipients List').click(); - await page.waitForTimeout(2000); - - await expect(page.locator('body')).toBeVisible(); - // Recipients page should show some content - await expect(page.locator('body')).not.toContainText('Fatal error'); - }); - - test('can navigate back to dashboard from recipients via Home tab', async ({ page }) => { - await page.locator('text=Recipients List').click(); - await page.waitForTimeout(2000); - - await page.locator('text=Home').first().click(); - await page.waitForTimeout(1500); - - // Dashboard is back — all tiles visible - await expect(page.locator('text=Add Task').first()).toBeVisible({ timeout: 5000 }); - await expect(page.locator('text=Add Gold Image').first()).toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/frontend/tests/admin-tasks.spec.ts b/frontend/tests/admin-tasks.spec.ts deleted file mode 100644 index 52d845bc..00000000 --- a/frontend/tests/admin-tasks.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const ADMIN_USER = 'admin_mock'; -const PASSWORD = 'password'; - -async function loginAsAdmin(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); -} - -// ── Tasks Management ─────────────────────────────────────────────────────────── -test.describe('Admin Tasks Management', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('tasks list page loads via dashboard tile', async ({ page }) => { - await page.locator('text=Tasks').first().click(); - await page.waitForTimeout(2000); - await expect(page.locator('body')).toContainText('Tasks'); - }); - - test('add task form renders at least one input field', async ({ page }) => { - await page.locator('text=Add Task').click(); - await page.waitForTimeout(2000); - - const inputCount = await page.locator('input').count(); - expect(inputCount).toBeGreaterThan(0); - }); - - test('can type into the first input on Add Task form', async ({ page }) => { - await page.locator('text=Add Task').click(); - await page.waitForTimeout(2000); - - const firstInput = page.locator('input').first(); - if (await firstInput.isVisible()) { - await firstInput.fill('Test Task Name'); - const value = await firstInput.inputValue(); - expect(value).toBe('Test Task Name'); - } else { - // Input may not be visible (e.g. loading) — acceptable - expect(true).toBe(true); - } - }); - - test('Add Task tile navigates away from dashboard', async ({ page }) => { - await page.locator('text=Add Task').click(); - await page.waitForTimeout(2000); - - // After navigating to Add Task, the Grid of dashboard tiles is gone - await expect(page.locator('text=Add Gold Image')).not.toBeVisible({ timeout: 5000 }); - }); -}); - -// ── Task Details ─────────────────────────────────────────────────────────────── -test.describe('Admin Task Details', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('can navigate to tasks management page', async ({ page }) => { - await page.locator('text=Tasks').first().click(); - await page.waitForTimeout(2000); - - // Body should now contain task-related content - await expect(page.locator('body')).toContainText('Tasks'); - }); - - test('can navigate back to dashboard from Tasks via Home bottom tab', async ({ page }) => { - await page.locator('text=Tasks').first().click(); - await page.waitForTimeout(1500); - - await page.locator('text=Home').first().click(); - await page.waitForTimeout(1500); - - // Dashboard tiles are back - await expect(page.locator('text=Add Task')).toBeVisible({ timeout: 5000 }); - }); -}); - -// ── Taxonomy ─────────────────────────────────────────────────────────────────── -test.describe('Admin Taxonomy', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('taxonomy tile navigates away from dashboard', async ({ page }) => { - await page.locator('text=Taxonomy').click(); - await page.waitForTimeout(2000); - await expect(page.locator('body')).toBeVisible(); - // Dashboard grid is gone - await expect(page.locator('text=Add Gold Image')).not.toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/frontend/tests/admin-users.spec.ts b/frontend/tests/admin-users.spec.ts deleted file mode 100644 index c3d082ef..00000000 --- a/frontend/tests/admin-users.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const ADMIN_USER = 'admin_mock'; -const PASSWORD = 'password'; - -// ── Login Helper ─────────────────────────────────────────────────────────────── -async function loginAsAdmin(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - // Wait for admin dashboard - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); -} - -test.describe('Admin Users Management', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - - // Navigate to Users screen via bottom nav - await page.locator('text=Users').first().click(); - await page.waitForTimeout(2000); - await expect(page.locator('input[placeholder="Search users..."]')).toBeVisible(); - }); - - test('search filters users list (happy flow)', async ({ page }) => { - // Wait for users to load - await page.waitForTimeout(2000); - - // Ensure the input exists and we can type in it - await page.locator('input[placeholder="Search users..."]').fill('admin_mock'); - await page.waitForTimeout(1000); - - // Ensure admin_mock is visible (filtered list) - await expect(page.locator('text=admin_mock').first()).toBeVisible(); - }); - - test('search shows no users found for invalid query (edge case)', async ({ page }) => { - // Wait for users to load - await page.waitForTimeout(2000); - - // Search for non-existent user - await page.locator('input[placeholder="Search users..."]').fill('nonexistent_user_12345'); - await page.waitForTimeout(1000); - - // Should show 'No users found.' text - await expect(page.locator('text=No users found.')).toBeVisible(); - }); -}); diff --git a/frontend/tests/auth.spec.ts b/frontend/tests/auth.spec.ts deleted file mode 100644 index d56f1715..00000000 --- a/frontend/tests/auth.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { test, expect, Page } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -// ── Credentials ──────────────────────────────────────────────────────────────── -const ADMIN_USER = 'admin_mock'; -const USER_USER = 'user_mock'; -const PASSWORD = 'password'; - -test.describe('Authentication', () => { - test.beforeEach(async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1500); - }); - - test('login page loads correctly', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 20000 }); - // Subtitle contains "Label • Improve Research" (partial match) - await expect(page.locator('text=Improve Research')).toBeVisible({ timeout: 5000 }); - }); - - test('shows error with invalid credentials', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill('wrong_user'); - await page.locator('input[placeholder="Password"]').fill('wrongpass'); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Invalid username or password')).toBeVisible({ timeout: 10000 }); - }); - - test('login with valid admin credentials', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - // Admin dashboard shows "Tasks" tile after login - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); - }); - - test('login with valid user credentials', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - // Should navigate away from login page - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - }); - - test('login also works by pressing Enter', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - const passwordInput = page.locator('input[placeholder="Password"]'); - await passwordInput.fill(PASSWORD); - await passwordInput.press('Enter'); - - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - }); - - test('register form appears when clicking register link', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - // Click the Register link at the bottom of the login screen - await page.locator('text=Register').last().click(); - - // Register overlay shows a "Confirm Password" field unique to that form - await expect(page.locator('input[placeholder="Confirm Password"]')).toBeVisible({ timeout: 10000 }); - }); - - test('Google OAuth button is present', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - await expect(page.locator('text=Continue with Google')).toBeVisible(); - }); - - test('Login as Researcher button is present', async ({ page }) => { - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - await expect(page.locator('text=Login as Researcher')).toBeVisible(); - }); -}); - -// ── Shared Login Helpers ─────────────────────────────────────────────────────── - -export async function loginAsAdmin(page: Page) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - // Wait for admin dashboard (Tasks tile appears) - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); -} - -export async function loginAsUser(page: Page) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - // Wait for navigation away from login - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - await page.waitForTimeout(1000); -} diff --git a/frontend/tests/e2e/admin/notifications.spec.ts b/frontend/tests/e2e/admin/notifications.spec.ts index 3a36d6b3..9660bdee 100644 --- a/frontend/tests/e2e/admin/notifications.spec.ts +++ b/frontend/tests/e2e/admin/notifications.spec.ts @@ -60,7 +60,7 @@ test.describe('[E2E] A3 Notifications', () => { await bell.click(); // Notifications load successfully: the panel opens. - await expect(page.getByText('🔔 Notifications').locator('visible=true').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Notifications', { exact: true }).locator('visible=true').first()).toBeVisible({ timeout: 10000 }); // Notification details can be viewed: at least one notification row is shown // (referencing the warned user), and it's not the empty state. @@ -87,38 +87,33 @@ test.describe('[E2E] A3 Notifications', () => { }); }); -/** Reads the session token from the page's localStorage. */ -async function sessionToken(page: Page): Promise { - return page.evaluate(() => window.localStorage.getItem('token')); -} - /** * Marks all notifications read via the app's own endpoint (the panel's "Mark all read" - * button → PATCH /api/admin/notifications/read-all). Uses Playwright's request API - * (Node-side HTTP, so no browser CORS/preflight) with the session bearer token. + * button → PATCH /api/admin/notifications/read-all). Uses browser fetch with credentials. */ async function markAllRead(page: Page): Promise { - const token = await sessionToken(page); - if (!token) return false; - const res = await page.request.patch('http://localhost:8080/api/admin/notifications/read-all', { - headers: { Authorization: `Bearer ${token}` }, + return page.evaluate(async () => { + const res = await fetch('http://localhost:8080/api/admin/notifications/read-all', { + method: 'PATCH', + credentials: 'include' + }); + return res.ok; }); - return res.ok(); } /** - * Reads the current unread count from /unread-count via Playwright's request API - * (Node-side, no browser caching) using the session bearer token. + * Reads the current unread count from /unread-count via browser fetch with credentials. */ async function unreadCount(page: Page): Promise { - const token = await sessionToken(page); - if (!token) return -1; - const res = await page.request.get('http://localhost:8080/api/admin/notifications/unread-count', { - headers: { Authorization: `Bearer ${token}` }, + return page.evaluate(async () => { + const res = await fetch('http://localhost:8080/api/admin/notifications/unread-count?_=' + Date.now(), { + credentials: 'include', + cache: 'no-store' + }); + if (!res.ok) return -1; + const data = await res.json(); + return typeof data?.unreadCount === 'number' ? data.unreadCount : -1; }); - if (!res.ok()) return -1; - const data = await res.json(); - return typeof data?.unreadCount === 'number' ? data.unreadCount : -1; } /** diff --git a/frontend/tests/e2e/admin/user-management.spec.ts b/frontend/tests/e2e/admin/user-management.spec.ts index 7c9b1f22..821f801d 100644 --- a/frontend/tests/e2e/admin/user-management.spec.ts +++ b/frontend/tests/e2e/admin/user-management.spec.ts @@ -109,10 +109,10 @@ async function expectStatus(page: Page, username: string, expected: 'Active' | ' /** Reads `active` for `username` from /users/get-all using the session's stored token. */ async function apiUserActive(page: Page, username: string): Promise { return page.evaluate(async (username) => { - const token = window.localStorage.getItem('token'); - if (!token) return null; + // The E2E tests run on the web where authentication uses HttpOnly cookies. + // The browser attaches these automatically when `credentials: 'include'` is set. const res = await fetch(`http://localhost:8080/api/v1/users/get-all?_=${Date.now()}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', cache: 'no-store', }); if (!res.ok) return null; diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index 821dfa64..4bcbea79 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -55,9 +55,9 @@ export async function assertOnUserHome(page: Page): Promise { await expect(page.locator('text=Leaderboard').first()).toBeVisible({ timeout: 10000 }); } -/** Read the JWT the app stores in web localStorage after auth (authStore.setAuth). */ +/** Check if the app stored the auth session (on web, this is the isAuthenticated flag). */ export async function getStoredToken(page: Page): Promise { - return page.evaluate(() => window.localStorage.getItem('token')); + return page.evaluate(() => window.localStorage.getItem('isAuthenticated') || window.localStorage.getItem('token')); } /** Log in as the seeded regular user and wait for the home screen. */ diff --git a/frontend/tests/e2e/user/collection.spec.ts b/frontend/tests/e2e/user/collection.spec.ts index b4af2920..296948a4 100644 --- a/frontend/tests/e2e/user/collection.spec.ts +++ b/frontend/tests/e2e/user/collection.spec.ts @@ -44,8 +44,8 @@ test.describe('[E2E] U7 View Collection', () => { // YES-classified images appear: at least one collection card with an image. await expect.poll(() => collectionImageCount(page), { timeout: 20000 }).toBeGreaterThan(0); - // Image metadata is displayed: the seeded entry's species ("BEE") is shown. - await expect(page.getByText('BEE').locator('visible=true').first()).toBeVisible({ timeout: 10000 }); + // Image metadata is displayed: the seeded entry's species ("Cat") is shown. + await expect(page.getByText('Cat', { exact: true }).locator('visible=true').first()).toBeVisible({ timeout: 10000 }); // Collection count matches the number of rendered cards. await expect.poll(() => collectionImageCount(page), { timeout: 10000 }).toBe(count); diff --git a/frontend/tests/e2e/user/settings.spec.ts b/frontend/tests/e2e/user/settings.spec.ts new file mode 100644 index 00000000..f9b02ffd --- /dev/null +++ b/frontend/tests/e2e/user/settings.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { loginAsUser, assertOnUserHome, gotoLogin } from '../helpers'; + +test.describe('[E2E] U10 Settings', () => { + test('navigates to settings and interacts with options', async ({ page }) => { + // 1. Login as user and reach home + await loginAsUser(page); + + // 2. Navigate to Settings via bottom tab + await page.getByText('Settings').first().click(); + + // 3. Verify Settings page elements + // The Settings screen header should be visible + await expect(page.getByText('Settings', { exact: true }).locator('visible=true').first()).toBeVisible({ timeout: 10000 }); + + await expect(page.getByText('Profile').locator('visible=true').first()).toBeVisible(); + await expect(page.getByText('Notifications').locator('visible=true').first()).toBeVisible(); + await expect(page.getByText('Dark Mode').locator('visible=true').first()).toBeVisible(); + + // 4. Verify Log Out button exists and works + const logOutBtn = page.getByText('Log Out').locator('visible=true').first(); + await expect(logOutBtn).toBeVisible(); + + await logOutBtn.click(); + + // 5. Assert we are returned to the login screen + await expect(page.getByText('Welcome to SwipeLab').locator('visible=true').first()).toBeVisible({ timeout: 10000 }); + }); + + test('top bar logout works', async ({ page }) => { + await loginAsUser(page); + + // Find top bar logout icon (usually an icon, but we can look for "Logout" text if present) + const topBarLogout = page.getByText('Logout', { exact: true }).locator('visible=true').first(); + await expect(topBarLogout).toBeVisible({ timeout: 10000 }); + await topBarLogout.click(); + + await expect(page.getByText('Welcome to SwipeLab').locator('visible=true').first()).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/tests/my-collection.spec.ts b/frontend/tests/my-collection.spec.ts deleted file mode 100644 index 947674bb..00000000 --- a/frontend/tests/my-collection.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const USER_USER = 'user_mock'; -const PASSWORD = 'password'; - -async function loginAsUser(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - await page.waitForTimeout(1000); -} - -// ── Helper: navigate to My Collection ───────────────────────────────────────── -// NOTE: "Collection" is NOT in the user bottom nav bar (Home, My Tasks, Leaderboard, -// Stats, Settings). It is navigated to from the CollectionDetailsScreen "Collection" -// back button, or via direct stack navigation. For testing purposes we navigate via -// the screen header of MyCollectionScreen which shows "My Collection" as its leftTitle. -// -// The most reliable navigation path available in tests: -// 1. Go to My Tasks (bottom nav) -// 2. The ScreenHeaderLayout of MyCollectionScreen shows "My Collection" — but we -// first need to reach it. Since there's no direct bottom nav entry, we test -// what IS navigable via bottom nav and mark collection tests as requiring -// a direct navigation helper via URL or internal routing. -// -// For now: we navigate via Stats page → its "Collection" link (if any) or check -// whether the screen is reachable at all. - -async function navigateToCollection(page: any) { - // The MyCollectionScreen is accessible via UserNavigator stack as "Collection" - // There is no bottom nav tab for it in the current implementation. - // We check if "My Collection" is reachable by navigating to Stats screen first, - // since MyCollectionScreen's rightTitle navigates to Stats and Stats may have - // a back-link. Since Playwright runs on web, we can check if clicking the - // "Collection" link inside CollectionDetailsScreen exists. - // - // Best available approach: check if Stats has a link back to Collection. - // If not, the collection screen tests are skipped gracefully. - await page.locator('text=Stats').first().click(); - await page.waitForTimeout(1500); - // MyCollectionScreen header rightTitle points to Stats, so no direct link from Stats to Collection. - // Return to Home and note the limitation. - await page.locator('text=Home').first().click(); - await page.waitForTimeout(1000); -} - -test.describe('My Collection Screen', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('Collection screen is accessible via internal stack navigation', async ({ page }) => { - // The "Collection" screen (MyCollectionScreen) exists in the UserNavigator stack - // as route name "Collection". It is NOT accessible from the bottom nav. - // This test verifies the app does not crash and the home screen loads correctly. - await expect(page.locator('body')).toBeVisible(); - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible(); - }); - - test('user bottom nav does not have a Collection tab', async ({ page }) => { - // Confirm the bottom nav has exactly: Home, My Tasks, Leaderboard, Stats, Settings - await expect(page.locator('text=Home').first()).toBeVisible(); - await expect(page.locator('text=My Tasks').first()).toBeVisible(); - await expect(page.locator('text=Leaderboard').first()).toBeVisible(); - await expect(page.locator('text=Stats').first()).toBeVisible(); - await expect(page.locator('text=Settings').first()).toBeVisible(); - - // "Collection" should NOT appear as a bottom nav tab - const collectionTabCount = await page.locator('text=Collection').count(); - // After login there's no Collection tab — it may appear in screen headers - // but not in the bottom nav area. We just verify no crash. - expect(true).toBe(true); - }); - - test('My Tasks screen shows Assigned Tasks section heading', async ({ page }) => { - await page.locator('text=My Tasks').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('text=Assigned Tasks')).toBeVisible({ timeout: 5000 }); - }); - - test('My Tasks screen shows Explore Tasks section heading', async ({ page }) => { - await page.locator('text=My Tasks').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('text=Explore Tasks')).toBeVisible({ timeout: 5000 }); - }); - - test('Stats screen shows user statistics content', async ({ page }) => { - await page.locator('text=Stats').first().click(); - await page.waitForTimeout(2000); - - // Stats screen should load without crashing - await expect(page.locator('body')).toBeVisible(); - await expect(page.locator('body')).not.toContainText('Fatal error'); - }); - - test('Collection Details screen navigates back to Collection', async ({ page }) => { - // CollectionDetailsScreen has a right button "Collection" that navigates back. - // This test is a placeholder for when collection items exist in the backend. - // Currently, without mock data, we verify the screen structure is correct. - await page.locator('text=My Tasks').first().click(); - await page.waitForTimeout(2000); - - // Page should be stable - await expect(page.locator('body')).toBeVisible(); - await expect(page.locator('body')).not.toContainText('Fatal error'); - }); -}); diff --git a/frontend/tests/settings.spec.ts b/frontend/tests/settings.spec.ts deleted file mode 100644 index e7ca2da6..00000000 --- a/frontend/tests/settings.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const USER_USER = 'user_mock'; -const ADMIN_USER = 'admin_mock'; -const PASSWORD = 'password'; - -async function loginAsUser(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - await page.waitForTimeout(1000); -} - -async function loginAsAdmin(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(ADMIN_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Tasks').first()).toBeVisible({ timeout: 15000 }); -} - -// ── User Settings ────────────────────────────────────────────────────────────── -test.describe('Settings Screen - User', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('can navigate to Settings via bottom tab', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - // Settings screen header shows "Settings" - await expect(page.locator('text=Settings').first()).toBeVisible(); - }); - - test('settings page shows Profile, Notifications, Dark Mode options', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('text=Profile')).toBeVisible(); - await expect(page.locator('text=Notifications')).toBeVisible(); - await expect(page.locator('text=Dark Mode')).toBeVisible(); - }); - - test('Log Out button exists on settings page', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - // The logout button text in SettingsScreen is "Log Out" (two words) - await expect(page.locator('text=Log Out')).toBeVisible({ timeout: 5000 }); - }); - - test('clicking Log Out returns to login screen', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - await page.locator('text=Log Out').click(); - await page.waitForTimeout(2000); - - // Should be back on the login screen - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 10000 }); - }); - - test('user top bar Logout also works', async ({ page }) => { - // UserTopBar has an inline "Logout" button (different from settings "Log Out") - await expect(page.locator('text=Logout').first()).toBeVisible({ timeout: 5000 }); - await page.locator('text=Logout').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 10000 }); - }); -}); - -// ── Admin Settings ───────────────────────────────────────────────────────────── -test.describe('Settings Screen - Admin', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('admin can navigate to Settings via bottom tab', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('text=Settings').first()).toBeVisible(); - }); - - test('admin settings page shows Log Out button', async ({ page }) => { - await page.locator('text=Settings').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('text=Log Out')).toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/frontend/tests/user-challenges.spec.ts b/frontend/tests/user-challenges.spec.ts deleted file mode 100644 index 6b922d55..00000000 --- a/frontend/tests/user-challenges.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const USER_USER = 'user_mock'; -const PASSWORD = 'password'; - -async function loginAsUser(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - await page.waitForTimeout(1000); -} - -test.describe('User Challenges Screen', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('challenges tab does not appear in bottom nav (navigated from top bar)', async ({ page }) => { - // Challenges is not in the bottom bar — it's reachable via the top bar stats block - // Verify bottom nav only has: Home, My Tasks, Leaderboard, Stats, Settings - const bottomNavText = await page.locator('body').innerText(); - // We just verify the page loaded without error - await expect(page.locator('body')).toBeVisible(); - }); - - test('can navigate to Challenges by clicking stats block in top bar', async ({ page }) => { - await page.waitForTimeout(1500); - - // UserTopBar has a touchable stats block that navigates to "Challenges" - // It shows "Score:", "Rank:", "days streak" text - const statsBlock = page.locator('text=Score:').first(); - const statsVisible = await statsBlock.isVisible({ timeout: 3000 }).catch(() => false); - - if (statsVisible) { - await statsBlock.click(); - await page.waitForTimeout(2000); - await expect(page.locator('body')).toBeVisible(); - } else { - // Stats block may take a moment to load user data from backend - expect(true).toBe(true); - } - }); - - test('challenges page does not crash when navigated to', async ({ page }) => { - // Navigate directly via navigation.navigate('Challenges') is not testable in Playwright - // But we can navigate via top bar stats block if visible - await page.waitForTimeout(2000); - - // Just ensure the page is stable after login - await expect(page.locator('body')).not.toContainText('Fatal error'); - }); -}); diff --git a/frontend/tests/user-stats.spec.ts b/frontend/tests/user-stats.spec.ts deleted file mode 100644 index b7ce5293..00000000 --- a/frontend/tests/user-stats.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const USER_USER = 'user_mock'; -const PASSWORD = 'password'; - -async function loginAsUser(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - await page.waitForTimeout(1000); -} - -// ── Stats ────────────────────────────────────────────────────────────────────── -test.describe('User Stats Screen', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('can navigate to Stats via bottom tab', async ({ page }) => { - const statsTab = page.locator('text=Stats').first(); - await expect(statsTab).toBeVisible({ timeout: 5000 }); - await statsTab.click(); - await page.waitForTimeout(2000); - - await expect(page.locator('body')).toBeVisible(); - }); - - test('stats page does not crash', async ({ page }) => { - await page.locator('text=Stats').first().click(); - await page.waitForTimeout(2000); - - // Page should be interactive — no fatal errors - await expect(page.locator('body')).not.toContainText('Fatal error'); - }); - - test('can navigate back to Home from Stats', async ({ page }) => { - await page.locator('text=Stats').first().click(); - await page.waitForTimeout(1500); - - await page.locator('text=Home').first().click(); - await page.waitForTimeout(1500); - - // Back on the home/swipe screen — login page still absent - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible(); - }); -}); - -// ── Leaderboard ──────────────────────────────────────────────────────────────── -test.describe('User Leaderboard', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('can navigate to Leaderboard via bottom tab', async ({ page }) => { - const tab = page.locator('text=Leaderboard').first(); - await expect(tab).toBeVisible({ timeout: 5000 }); - await tab.click(); - await page.waitForTimeout(2000); - - await expect(page.locator('body')).toBeVisible(); - }); - - test('leaderboard page does not crash', async ({ page }) => { - await page.locator('text=Leaderboard').first().click(); - await page.waitForTimeout(2000); - - await expect(page.locator('body')).not.toContainText('Fatal error'); - }); -}); - -// ── My Tasks ─────────────────────────────────────────────────────────────────── -test.describe('User My Tasks Screen', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('can navigate to My Tasks via bottom tab', async ({ page }) => { - await page.locator('text=My Tasks').first().click(); - await page.waitForTimeout(2000); - - // My Tasks screen header renders "My Tasks" - await expect(page.locator('text=My Tasks')).toBeVisible({ timeout: 5000 }); - }); - - test('my tasks page shows assigned or available tasks sections', async ({ page }) => { - await page.locator('text=My Tasks').first().click(); - await page.waitForTimeout(2000); - - // UserMyTasksScreen always shows "Assigned Tasks" section header - await expect(page.locator('text=Assigned Tasks')).toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/frontend/tests/user-swipe.spec.ts b/frontend/tests/user-swipe.spec.ts deleted file mode 100644 index acc5eabc..00000000 --- a/frontend/tests/user-swipe.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const BASE_URL = 'http://localhost:8081'; - -const USER_USER = 'user_mock'; -const PASSWORD = 'password'; - -async function loginAsUser(page: any) { - await page.goto(BASE_URL); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - await expect(page.locator('text=Welcome to SwipeLab')).toBeVisible({ timeout: 15000 }); - - await page.locator('input[placeholder="Username"]').fill(USER_USER); - await page.locator('input[placeholder="Password"]').fill(PASSWORD); - await page.locator('text=Login').first().click(); - - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible({ timeout: 15000 }); - await page.waitForTimeout(1000); -} - -test.describe('User Swipe Screen', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('user lands on swipe/home screen after login', async ({ page }) => { - // The swipe screen is the default home screen for users - await expect(page.locator('body')).toBeVisible(); - // Login screen must be gone - await expect(page.locator('text=Welcome to SwipeLab')).not.toBeVisible(); - }); - - test('user bottom navigation has correct tabs', async ({ page }) => { - // UserNavigator bottom bar: Home, My Tasks, Leaderboard, Stats, Settings - await expect(page.locator('text=Home').first()).toBeVisible(); - await expect(page.locator('text=My Tasks').first()).toBeVisible(); - await expect(page.locator('text=Leaderboard').first()).toBeVisible(); - await expect(page.locator('text=Stats').first()).toBeVisible(); - await expect(page.locator('text=Settings').first()).toBeVisible(); - }); - - test('swipe screen body is visible and interactive', async ({ page }) => { - await page.waitForTimeout(2000); - await expect(page.locator('body')).toBeVisible(); - }); - - test('keyboard arrow keys do not crash the swipe screen', async ({ page }) => { - await page.waitForTimeout(2000); - - // Keyboard navigation is a web-only feature — just check it doesn't throw - await page.keyboard.press('ArrowRight'); - await page.waitForTimeout(300); - await page.keyboard.press('ArrowLeft'); - await page.waitForTimeout(300); - await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(300); - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(300); - - // Page should still be functional - await expect(page.locator('body')).toBeVisible(); - }); - - test('user top bar shows Logout option', async ({ page }) => { - // UserTopBar has a "Logout" text button in the top right - await expect(page.locator('text=Logout').first()).toBeVisible({ timeout: 5000 }); - }); -}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 909e9010..887dc218 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -4,7 +4,7 @@ "strict": true, "paths": { "@/*": [ - "./*" + "./app/*" ] } },