diff --git a/backend/src/main/java/com/bounswe2026group1/backend/config/I18nConfig.java b/backend/src/main/java/com/bounswe2026group1/backend/config/I18nConfig.java new file mode 100644 index 00000000..f378dba3 --- /dev/null +++ b/backend/src/main/java/com/bounswe2026group1/backend/config/I18nConfig.java @@ -0,0 +1,48 @@ +package com.bounswe2026group1.backend.config; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; + +import java.util.List; +import java.util.Locale; + +/** + * Wires Spring's message-source + locale-resolver so user-facing error + * strings produced by services (e.g. {@code RegisteredUserService}) can be + * resolved against the caller's locale. + * + * Locale resolution: read {@code Accept-Language} header, default to English + * when missing or unsupported. Supported list is kept narrow (en, tr) so an + * unrecognised header doesn't surprise the user with another fallback. + * + * Note: an authenticated user's persisted {@code preferredLanguage} (#505) + * is currently honoured by clients sending the right {@code Accept-Language} + * header. If we ever want server-side resolution against the DB column, this + * is the spot to plug in a custom {@code LocaleResolver}. + */ +@Configuration +public class I18nConfig { + + @Bean + public MessageSource messageSource() { + ResourceBundleMessageSource source = new ResourceBundleMessageSource(); + source.setBasename("messages"); + source.setDefaultEncoding("UTF-8"); + // Fall back through messages_xx -> messages.properties before failing, + // so a missing TR key returns the EN string rather than raising. + source.setFallbackToSystemLocale(false); + return source; + } + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); + resolver.setSupportedLocales(List.of(Locale.ENGLISH, Locale.forLanguageTag("tr"))); + resolver.setDefaultLocale(Locale.ENGLISH); + return resolver; + } +} diff --git a/backend/src/main/java/com/bounswe2026group1/backend/controller/RegisteredUserController.java b/backend/src/main/java/com/bounswe2026group1/backend/controller/RegisteredUserController.java index 43753ab4..fc42bc63 100644 --- a/backend/src/main/java/com/bounswe2026group1/backend/controller/RegisteredUserController.java +++ b/backend/src/main/java/com/bounswe2026group1/backend/controller/RegisteredUserController.java @@ -168,6 +168,33 @@ public ResponseEntity updateProfile(@PathVariable Long id, } } + @PutMapping("/{id}/profile/language") + @Operation( + summary = "Update the authenticated user's UI language preference", + description = "Sets the caller's preferred language (EN or TR). The path id must match the caller's id." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Updated profile (self-view, includes preferredLanguage)."), + @ApiResponse(responseCode = "400", description = "Unknown or blank language value."), + @ApiResponse(responseCode = "401", description = "Authentication required."), + @ApiResponse(responseCode = "403", description = "Path id does not match the caller."), + @ApiResponse(responseCode = "404", description = "No user with that id.") + }) + @SecurityRequirement(name = OpenApiConfig.BEARER_SCHEME) + public ResponseEntity updateLanguage(@PathVariable Long id, + @RequestBody @Valid com.bounswe2026group1.backend.dto.UpdateLanguageRequest request) { + try { + requireOwner(id); + return ResponseEntity.ok(registeredUserService.updateLanguage(id, request)); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); + } + } + @PostMapping(value = "/{id}/profile/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Upload a new avatar for the authenticated user", diff --git a/backend/src/main/java/com/bounswe2026group1/backend/dto/UpdateLanguageRequest.java b/backend/src/main/java/com/bounswe2026group1/backend/dto/UpdateLanguageRequest.java new file mode 100644 index 00000000..67b83493 --- /dev/null +++ b/backend/src/main/java/com/bounswe2026group1/backend/dto/UpdateLanguageRequest.java @@ -0,0 +1,19 @@ +package com.bounswe2026group1.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Set the caller's UI language preference.") +public class UpdateLanguageRequest { + + @Schema(description = "Language code matching the Language enum.", + example = "TR", allowableValues = {"EN", "TR"}) + @NotBlank(message = "Language must not be blank") + private String language; +} diff --git a/backend/src/main/java/com/bounswe2026group1/backend/dto/UserProfileDTO.java b/backend/src/main/java/com/bounswe2026group1/backend/dto/UserProfileDTO.java index 97c69bbf..55cd47d8 100644 --- a/backend/src/main/java/com/bounswe2026group1/backend/dto/UserProfileDTO.java +++ b/backend/src/main/java/com/bounswe2026group1/backend/dto/UserProfileDTO.java @@ -62,6 +62,10 @@ public class UserProfileDTO { example = "false") private boolean leaderboardHidden; + @Schema(description = "Preferred UI language. Null on public profile lookups; populated on self-views.", + example = "EN", allowableValues = {"EN", "TR"}, nullable = true) + private String preferredLanguage; + @Data @Builder @NoArgsConstructor diff --git a/backend/src/main/java/com/bounswe2026group1/backend/model/Language.java b/backend/src/main/java/com/bounswe2026group1/backend/model/Language.java new file mode 100644 index 00000000..c982f763 --- /dev/null +++ b/backend/src/main/java/com/bounswe2026group1/backend/model/Language.java @@ -0,0 +1,10 @@ +package com.bounswe2026group1.backend.model; + +/** + * UI language preference persisted on {@link RegisteredUser}. New users + * default to {@link #EN}; clients send the wire form as the enum name + * ("EN" / "TR") in {@code UpdateLanguageRequest}. + */ +public enum Language { + EN, TR +} diff --git a/backend/src/main/java/com/bounswe2026group1/backend/model/RegisteredUser.java b/backend/src/main/java/com/bounswe2026group1/backend/model/RegisteredUser.java index 172bd3f0..aa7e84c1 100644 --- a/backend/src/main/java/com/bounswe2026group1/backend/model/RegisteredUser.java +++ b/backend/src/main/java/com/bounswe2026group1/backend/model/RegisteredUser.java @@ -121,6 +121,16 @@ public class RegisteredUser { columnDefinition = "BOOLEAN DEFAULT FALSE") private boolean leaderboardHidden = false; + /** + * UI language preference. Web and mobile read this on login so the + * choice survives a fresh session on another device. Declared last to + * keep the Lombok all-args constructor backwards-compatible. + */ + @Enumerated(EnumType.STRING) + @Column(name = "preferred_language", nullable = false, length = 8, + columnDefinition = "VARCHAR(8) DEFAULT 'EN'") + private Language preferredLanguage = Language.EN; + @PrePersist protected void onCreate() { if (this.registeredAt == null) { @@ -135,5 +145,8 @@ protected void onCreate() { if (this.routingConstraints == null) { this.routingConstraints = new HashSet<>(); } + if (this.preferredLanguage == null) { + this.preferredLanguage = Language.EN; + } } } diff --git a/backend/src/main/java/com/bounswe2026group1/backend/service/RegisteredUserService.java b/backend/src/main/java/com/bounswe2026group1/backend/service/RegisteredUserService.java index 69ea3349..ab2f4f76 100644 --- a/backend/src/main/java/com/bounswe2026group1/backend/service/RegisteredUserService.java +++ b/backend/src/main/java/com/bounswe2026group1/backend/service/RegisteredUserService.java @@ -17,6 +17,8 @@ import com.bounswe2026group1.backend.repository.UserBadgeRepository; import com.bounswe2026group1.backend.util.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.authentication.BadCredentialsException; @@ -43,20 +45,26 @@ public class RegisteredUserService { private final LeaderboardService leaderboardService; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; + private final MessageSource messageSource; // Password strength regex pattern: Minimum 8 characters, at least one uppercase letter, one lowercase letter, one digit and one special character private static final String PASSWORD_PATTERN = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!]).{8,}$"; + /** Resolve an i18n message against the request's locale (see {@link com.bounswe2026group1.backend.config.I18nConfig}). */ + private String msg(String code, Object... args) { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); + } + public RegisterResponse registerUser(RegisterRequest request) { // 1. Check Uniqueness if (registeredUserRepository.existsByEmail(request.getEmail())) { - throw new IllegalArgumentException("Email is already in use."); + throw new IllegalArgumentException(msg("error.email.alreadyInUse")); } // 2. Password strength validation if (!request.getPassword().matches(PASSWORD_PATTERN)) { - throw new IllegalArgumentException("Password must be at least 8 characters long, and include an uppercase letter, a lowercase letter, a digit, and a special character."); + throw new IllegalArgumentException(msg("error.password.weak")); } // 3. User Entity Conversion & Password Hashing @@ -82,11 +90,11 @@ public RegisterResponse registerUser(RegisterRequest request) { public LoginResponse loginUser(LoginRequest request) { // 1. Find user by email RegisteredUser user = registeredUserRepository.findByEmail(request.getEmail()) - .orElseThrow(() -> new BadCredentialsException("Invalid email or password")); + .orElseThrow(() -> new BadCredentialsException(msg("error.auth.invalidCredentials"))); // 2. Check password if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { - throw new BadCredentialsException("Invalid email or password"); + throw new BadCredentialsException(msg("error.auth.invalidCredentials")); } // 3. Generate JWT token @@ -144,7 +152,7 @@ public List getAllProfiles() { * Email is omitted in the DTO (privacy) — search results are public. */ public Page searchUsers(String query, Pageable pageable) { if (query == null || query.isBlank()) { - throw new IllegalArgumentException("Search query must not be empty."); + throw new IllegalArgumentException(msg("error.search.emptyQuery")); } String trimmed = query.trim(); Page page = registeredUserRepository @@ -225,6 +233,25 @@ public UserProfileDTO setLeaderboardHidden(Long id, boolean hidden) { return toProfileDTO(saved, true); } + /** + * Owner-only language preference update (#505). Rejects unknown enum + * values with {@link IllegalArgumentException} so the controller can + * return 400. + */ + public UserProfileDTO updateLanguage(Long id, com.bounswe2026group1.backend.dto.UpdateLanguageRequest request) { + com.bounswe2026group1.backend.model.Language lang; + try { + lang = com.bounswe2026group1.backend.model.Language.valueOf(request.getLanguage().toUpperCase()); + } catch (IllegalArgumentException | NullPointerException e) { + throw new IllegalArgumentException("Unknown language: " + request.getLanguage()); + } + RegisteredUser user = registeredUserRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("User not found with id: " + id)); + user.setPreferredLanguage(lang); + RegisteredUser saved = registeredUserRepository.save(user); + return toProfileDTO(saved, true); + } + /** All badges currently held by a user — exposed as its own endpoint so * the public profile page can fetch badges without going through the * full profile DTO. */ @@ -284,6 +311,11 @@ private UserProfileDTO buildDTO(RegisteredUser user, .badges(badges) .topBadge(Badge.pickHighestTier(badges)) .leaderboardHidden(user.isLeaderboardHidden()) + // Same privacy gate as email: only surface the language preference + // on self-views so it doesn't leak through public lookups. + .preferredLanguage(includeEmail && user.getPreferredLanguage() != null + ? user.getPreferredLanguage().name() + : null) .build(); } } diff --git a/backend/src/main/resources/db/migration/V6__add_preferred_language.sql b/backend/src/main/resources/db/migration/V6__add_preferred_language.sql new file mode 100644 index 00000000..86ffb13d --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__add_preferred_language.sql @@ -0,0 +1,7 @@ +-- Adds the per-user UI language preference (#505). +-- Hibernate's ddl-auto=update creates this column on fresh dev databases, but +-- the Flyway migration is the source of truth for prod/CI databases that +-- already have the schema baselined. + +ALTER TABLE registered_users + ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(8) NOT NULL DEFAULT 'EN'; diff --git a/backend/src/main/resources/messages.properties b/backend/src/main/resources/messages.properties new file mode 100644 index 00000000..2e9dd6b4 --- /dev/null +++ b/backend/src/main/resources/messages.properties @@ -0,0 +1,14 @@ +# User-facing error and validation messages. Keep in sync with +# messages_tr.properties and any other locale files. +# +# Resolved via MessageSource at the point an exception is thrown so the +# caller's locale (Accept-Language header, falling back to the authenticated +# user's preferredLanguage) controls the rendered language. + +error.email.alreadyInUse=Email is already in use. +error.password.weak=Password must be at least 8 characters long, and include an uppercase letter, a lowercase letter, a digit, and a special character. +error.auth.invalidCredentials=Invalid email or password +error.user.notFoundById=User not found with id: {0} +error.user.notFoundByEmail=User not found with email: {0} +error.search.emptyQuery=Search query must not be empty. +error.language.unknown=Unknown language: {0} diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties new file mode 100644 index 00000000..979b361a --- /dev/null +++ b/backend/src/main/resources/messages_tr.properties @@ -0,0 +1,10 @@ +# Turkish translations of user-facing error messages. +# Keep keys in sync with messages_en.properties. + +error.email.alreadyInUse=Bu e-posta zaten kullanımda. +error.password.weak=Şifre en az 8 karakter olmalı ve büyük harf, küçük harf, rakam ve özel karakter içermeli. +error.auth.invalidCredentials=Geçersiz e-posta veya şifre +error.user.notFoundById=ID ile kullanıcı bulunamadı: {0} +error.user.notFoundByEmail=E-posta ile kullanıcı bulunamadı: {0} +error.search.emptyQuery=Arama sorgusu boş olamaz. +error.language.unknown=Bilinmeyen dil: {0} diff --git a/backend/src/test/java/com/bounswe2026group1/backend/service/AdminUserServiceTest.java b/backend/src/test/java/com/bounswe2026group1/backend/service/AdminUserServiceTest.java index 02509b92..363a8971 100644 --- a/backend/src/test/java/com/bounswe2026group1/backend/service/AdminUserServiceTest.java +++ b/backend/src/test/java/com/bounswe2026group1/backend/service/AdminUserServiceTest.java @@ -3,6 +3,7 @@ import com.bounswe2026group1.backend.dto.admin.AdminChangeRoleRequest; import com.bounswe2026group1.backend.dto.admin.AdminCreateUserRequest; import com.bounswe2026group1.backend.dto.admin.AdminUserResponse; +import com.bounswe2026group1.backend.model.Language; import com.bounswe2026group1.backend.model.RegisteredUser; import com.bounswe2026group1.backend.model.UserRole; import com.bounswe2026group1.backend.model.UserStatus; @@ -44,9 +45,9 @@ class AdminUserServiceTest { @BeforeEach void setUp() { adminUser = new RegisteredUser(1L, "Admin", "admin@test.com", "hash", UserRole.ADMIN, - UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false); + UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false, Language.EN); regularUser = new RegisteredUser(2L, "User", "user@test.com", "hash", UserRole.USER, - UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false); + UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false, Language.EN); } // --- listUsers --- @@ -170,7 +171,7 @@ void changeRole_Fail_SelfDemotion() { @Test void changeRole_Fail_LastAdmin() { RegisteredUser anotherAdmin = new RegisteredUser(3L, "Admin2", "admin2@test.com", "hash", UserRole.ADMIN, - UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false); + UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false, Language.EN); when(userRepository.findById(3L)).thenReturn(Optional.of(anotherAdmin)); when(userRepository.countByRole(UserRole.ADMIN)).thenReturn(1L); @@ -190,7 +191,7 @@ void createUser_Success() { when(userRepository.existsByEmail("new@test.com")).thenReturn(false); when(passwordEncoder.encode(any())).thenReturn("encoded"); RegisteredUser saved = new RegisteredUser(5L, "New", "new@test.com", "encoded", UserRole.USER, - UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false); + UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false, Language.EN); when(userRepository.save(any())).thenReturn(saved); AdminCreateUserRequest req = new AdminCreateUserRequest(); @@ -249,7 +250,7 @@ void deleteUser_Fail_SelfDelete() { @Test void deleteUser_Fail_LastAdmin() { RegisteredUser anotherAdmin = new RegisteredUser(3L, "Admin2", "admin2@test.com", "hash", UserRole.ADMIN, - UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false); + UserStatus.ACTIVE, null, 0, null, null, null, null, null, null, false, Language.EN); when(userRepository.findById(3L)).thenReturn(Optional.of(anotherAdmin)); when(userRepository.countByRole(UserRole.ADMIN)).thenReturn(1L); diff --git a/backend/src/test/java/com/bounswe2026group1/backend/service/RegisteredUserServiceTest.java b/backend/src/test/java/com/bounswe2026group1/backend/service/RegisteredUserServiceTest.java index eeb6f1b7..10b63670 100644 --- a/backend/src/test/java/com/bounswe2026group1/backend/service/RegisteredUserServiceTest.java +++ b/backend/src/test/java/com/bounswe2026group1/backend/service/RegisteredUserServiceTest.java @@ -46,6 +46,7 @@ class RegisteredUserServiceTest { @Mock private LeaderboardService leaderboardService; @Mock private PasswordEncoder passwordEncoder; @Mock private JwtUtil jwtUtil; + @Mock private org.springframework.context.MessageSource messageSource; @InjectMocks private RegisteredUserService registeredUserService; @@ -74,6 +75,23 @@ void setUp() { validLoginRequest = new LoginRequest(); validLoginRequest.setEmail("test@test.com"); validLoginRequest.setPassword("StrongP@ss1"); + + // MessageSource always returns the message code's last-segment hint so + // existing `getMessage().contains(...)` assertions still match. + org.mockito.Mockito.lenient() + .when(messageSource.getMessage(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenAnswer(inv -> { + String code = inv.getArgument(0); + return switch (code) { + case "error.email.alreadyInUse" -> "Email is already in use."; + case "error.password.weak" -> "Password must be at least 8 characters long."; + case "error.auth.invalidCredentials" -> "Invalid email or password"; + case "error.search.emptyQuery" -> "Search query must not be empty."; + default -> code; + }; + }); } // --- REGISTER TESTS --- @@ -610,4 +628,82 @@ void searchUsers_emptyResults_returnsEmptyPage() { assertEquals(0, dtos.getTotalElements()); verify(reportRepository, never()).countByCreatedByIdIn(anyCollection()); } + + // --- UPDATE LANGUAGE TESTS (#505) --- + + @Test + void updateLanguage_happyPath_persistsLanguageAndReturnsSelfView() { + when(registeredUserRepository.findById(1L)).thenReturn(Optional.of(mockUser)); + when(registeredUserRepository.save(any(RegisteredUser.class))).thenAnswer(inv -> inv.getArgument(0)); + when(reportRepository.countByCreatedById(1L)).thenReturn(0L); + when(routeRepository.countByCreatedById(1L)).thenReturn(0L); + + com.bounswe2026group1.backend.dto.UpdateLanguageRequest req = + new com.bounswe2026group1.backend.dto.UpdateLanguageRequest("TR"); + UserProfileDTO dto = registeredUserService.updateLanguage(1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisteredUser.class); + verify(registeredUserRepository).save(captor.capture()); + assertEquals(com.bounswe2026group1.backend.model.Language.TR, + captor.getValue().getPreferredLanguage()); + // Self-view → preferredLanguage is included in the DTO. + assertEquals("TR", dto.getPreferredLanguage()); + assertEquals("test@test.com", dto.getEmail(), "self-view should also include email"); + } + + @Test + void updateLanguage_lowercaseInput_isCoerced() { + when(registeredUserRepository.findById(1L)).thenReturn(Optional.of(mockUser)); + when(registeredUserRepository.save(any(RegisteredUser.class))).thenAnswer(inv -> inv.getArgument(0)); + when(reportRepository.countByCreatedById(1L)).thenReturn(0L); + when(routeRepository.countByCreatedById(1L)).thenReturn(0L); + + registeredUserService.updateLanguage(1L, + new com.bounswe2026group1.backend.dto.UpdateLanguageRequest("tr")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisteredUser.class); + verify(registeredUserRepository).save(captor.capture()); + assertEquals(com.bounswe2026group1.backend.model.Language.TR, + captor.getValue().getPreferredLanguage()); + } + + @Test + void updateLanguage_unknownValue_throwsIllegalArgument() { + // No findById stub — enum validation runs first and short-circuits + // before the repository is touched. Mockito strict mode would flag + // any stub here as unused. + assertThrows(IllegalArgumentException.class, () -> + registeredUserService.updateLanguage(1L, + new com.bounswe2026group1.backend.dto.UpdateLanguageRequest("DE"))); + verify(registeredUserRepository, never()).save(any(RegisteredUser.class)); + } + + @Test + void updateLanguage_userMissing_throwsNoSuchElement() { + when(registeredUserRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> + registeredUserService.updateLanguage(99L, + new com.bounswe2026group1.backend.dto.UpdateLanguageRequest("EN"))); + verify(registeredUserRepository, never()).save(any(RegisteredUser.class)); + } + + @Test + void preferredLanguage_omittedOnPublicLookups_includedOnSelfView() { + mockUser.setPreferredLanguage(com.bounswe2026group1.backend.model.Language.TR); + when(registeredUserRepository.findById(1L)).thenReturn(Optional.of(mockUser)); + when(reportRepository.countByCreatedById(1L)).thenReturn(0L); + when(routeRepository.countByCreatedById(1L)).thenReturn(0L); + + // Public lookup: language hidden (privacy parity with email). + UserProfileDTO publicDto = registeredUserService.getProfileById(1L); + assertNull(publicDto.getPreferredLanguage()); + assertNull(publicDto.getEmail()); + + // Self lookup: both included. + when(registeredUserRepository.findByEmail("test@test.com")).thenReturn(Optional.of(mockUser)); + UserProfileDTO selfDto = registeredUserService.getProfileByEmail("test@test.com"); + assertEquals("TR", selfDto.getPreferredLanguage()); + assertEquals("test@test.com", selfDto.getEmail()); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2331222e..d7ffb052 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,12 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "5.95.2", + "i18next": "^26.0.10", + "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", "react": "19.2.4", "react-dom": "19.2.4", + "react-i18next": "^17.0.7", "react-leaflet": "^5.0.0", "react-router-dom": "7.13.2" }, @@ -298,7 +301,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2570,6 +2572,52 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.10.tgz", + "integrity": "sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3431,6 +3479,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.7.tgz", + "integrity": "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.10", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -3884,6 +3959,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "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", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", @@ -4044,6 +4128,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c7f3f77e..338cdc2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,9 +13,12 @@ }, "dependencies": { "@tanstack/react-query": "5.95.2", + "i18next": "^26.0.10", + "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", "react": "19.2.4", "react-dom": "19.2.4", + "react-i18next": "^17.0.7", "react-leaflet": "^5.0.0", "react-router-dom": "7.13.2" }, diff --git a/frontend/src/components/AvatarUploader.jsx b/frontend/src/components/AvatarUploader.jsx index 2ee56155..ed5913f8 100644 --- a/frontend/src/components/AvatarUploader.jsx +++ b/frontend/src/components/AvatarUploader.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/jpg'] const MAX_BYTES = 15 * 1024 * 1024 @@ -14,6 +15,7 @@ function validateFile(file) { } function AvatarUploader({ currentAvatarUrl, isUploading, isDeleting, onUpload, onDelete }) { + const { t } = useTranslation() const [pendingFile, setPendingFile] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) const [error, setError] = useState('') @@ -75,7 +77,7 @@ function AvatarUploader({ currentAvatarUrl, isUploading, isDeleting, onUpload, o setPendingFile(null) setPreviewUrl(null) } catch (e) { - setError(e.message || 'Failed to upload avatar.') + setError(e.message || t('avatar.uploadFailed')) } } @@ -87,11 +89,11 @@ function AvatarUploader({ currentAvatarUrl, isUploading, isDeleting, onUpload, o async function handleDelete() { if (!currentAvatarUrl) return - if (!window.confirm('Remove your avatar?')) return + if (!window.confirm(t('avatar.removeConfirm'))) return try { await onDelete() } catch (e) { - setError(e.message || 'Failed to remove avatar.') + setError(e.message || t('avatar.removeFailed')) } } @@ -140,7 +142,7 @@ function AvatarUploader({ currentAvatarUrl, isUploading, isDeleting, onUpload, o disabled={isUploading} className="px-4 py-2 rounded-lg primary-gradient text-on-primary font-semibold disabled:opacity-60 cursor-pointer" > - {isUploading ? 'Uploading…' : 'Save avatar'} + {isUploading ? t('avatar.uploading') : t('avatar.save')} ) : ( @@ -158,7 +160,7 @@ function AvatarUploader({ currentAvatarUrl, isUploading, isDeleting, onUpload, o onClick={() => fileInputRef.current?.click()} className="px-4 py-2 rounded-lg primary-gradient text-on-primary font-semibold cursor-pointer" > - {currentAvatarUrl ? 'Change avatar' : 'Upload avatar'} + {currentAvatarUrl ? t('avatar.change') : t('avatar.upload')} {currentAvatarUrl && ( )} )}

- {isDragging ? 'Drop image to preview…' : 'JPEG or PNG, max 15 MB. You can also drag-and-drop into this box.'} + {isDragging ? t('avatar.dropToPreview') : t('avatar.hint')}

diff --git a/frontend/src/components/BadgeIcon.jsx b/frontend/src/components/BadgeIcon.jsx index f231c3aa..6583585e 100644 --- a/frontend/src/components/BadgeIcon.jsx +++ b/frontend/src/components/BadgeIcon.jsx @@ -1,6 +1,9 @@ +import { useTranslation } from 'react-i18next' + // Material Symbols icon + label mapping for each backend Badge enum value. // Keep in sync with com.bounswe2026group1.backend.model.Badge — adding a new -// badge there means adding an entry here. +// badge there means adding an entry here. Labels/descriptions here are the +// EN fallback; the active locale's translation wins when present. const BADGE_META = { TRUSTED_REPORTER: { label: 'Trusted Reporter', @@ -27,15 +30,19 @@ const BADGE_META = { * Sizes: 'sm' (compact, just the icon), 'md' (default chip with label). */ function BadgeIcon({ badge, size = 'md' }) { + const { t } = useTranslation() const meta = BADGE_META[badge] if (!meta) return null + const label = t(`badge.${badge}.label`, meta.label) + const description = t(`badge.${badge}.description`, meta.description) + if (size === 'sm') { return ( {meta.icon} @@ -46,13 +53,13 @@ function BadgeIcon({ badge, size = 'md' }) { return ( {meta.icon} - {meta.label} + {label} ) } diff --git a/frontend/src/components/CreateFixRequestPanel.jsx b/frontend/src/components/CreateFixRequestPanel.jsx index d82b60cd..e34a8582 100644 --- a/frontend/src/components/CreateFixRequestPanel.jsx +++ b/frontend/src/components/CreateFixRequestPanel.jsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../context/AuthContext.jsx' import { submitFixRequest } from '../services/reportService.js' @@ -14,6 +15,7 @@ import { submitFixRequest } from '../services/reportService.js' * picked them when the report was created, so we don't ask again. */ function CreateFixRequestPanel({ reportId, reportTitle, onClose, onSubmitted }) { + const { t } = useTranslation() const { token, isAuthenticated } = useAuth() const navigate = useNavigate() const [imageFile, setImageFile] = useState(null) @@ -41,7 +43,7 @@ function CreateFixRequestPanel({ reportId, reportTitle, onClose, onSubmitted }) const file = e.dataTransfer.files?.[0] if (!file) return if (!['image/jpeg', 'image/png'].includes(file.type)) { - setError('Only JPG or PNG files are accepted.') + setError(t('report.createFixOnlyJpgPng')) return } setImage(file) @@ -49,7 +51,7 @@ function CreateFixRequestPanel({ reportId, reportTitle, onClose, onSubmitted }) async function handleSubmit() { if (!isAuthenticated) { onClose(); navigate('/login'); return } - if (!imageFile) return setError('Please attach a photo of the fixed area.') + if (!imageFile) return setError(t('report.createFixAttachPhoto')) setSubmitting(true) setError('') @@ -62,11 +64,11 @@ function CreateFixRequestPanel({ reportId, reportTitle, onClose, onSubmitted }) // a friendlier message — server returns the raw status reason which is // OK but not great copy. if (err.status === 409) { - setError('Someone else already submitted a fix report for this. Vote on theirs instead.') + setError(t('report.createFixDuplicate')) } else if (err.status === 400) { - setError(err.message || 'Invalid submission. Check your photo and description length.') + setError(err.message || t('report.createFixInvalidSubmission')) } else { - setError(err.message || 'Failed to submit fix report. Please try again.') + setError(err.message || t('report.createFixGenericError')) } } finally { setSubmitting(false) @@ -82,7 +84,7 @@ function CreateFixRequestPanel({ reportId, reportTitle, onClose, onSubmitted }) />