From fbb7ab2d90e14f521051ece2cf5e580dedac0583 Mon Sep 17 00:00:00 2001 From: pascal Date: Wed, 12 Aug 2026 17:03:22 +0200 Subject: [PATCH] zmiany w ustawieniach --- .../polskalokalnie/auth/AuthController.java | 30 + .../pl/polskalokalnie/auth/AuthService.java | 119 +- .../java/pl/polskalokalnie/auth/PhoneOtp.java | 11 + .../auth/PhoneVerificationService.java | 59 +- .../auth/dto/AccountRequests.java | 11 + .../auth/dto/UpdateProfileRequest.java | 2 + .../polskalokalnie/auth/dto/UserResponse.java | 2 + .../pl/polskalokalnie/config/DataSeeder.java | 5 + .../pl/polskalokalnie/config/SchemaFixer.java | 24 + .../pl/polskalokalnie/send/SmsSender.java | 21 +- .../java/pl/polskalokalnie/user/AppUser.java | 13 + .../java/pl/polskalokalnie/user/AreaUnit.java | 7 + .../java/pl/polskalokalnie/user/Currency.java | 8 + .../pl/polskalokalnie/user/NickService.java | 154 ++ .../user/ProfileVisibility.java | 8 + .../user/PublicProfileController.java | 24 +- .../user/PublicProfileResponse.java | 4 + .../polskalokalnie/user/UserRepository.java | 4 + .../pl/polskalokalnie/user/UserSettings.java | 285 ++ .../user/UserSettingsController.java | 43 + .../user/UserSettingsRepository.java | 11 + .../user/UserSettingsRequest.java | 29 + .../user/UserSettingsResponse.java | 50 + .../user/UserSettingsService.java | 135 + .../pl/polskalokalnie/send/SmsSenderTest.java | 37 + .../polskalokalnie/user/NickServiceTest.java | 65 + frontend/src/App.tsx | 2394 +++++++++-------- frontend/src/AuthPages.tsx | 3 +- frontend/src/auth.tsx | 100 +- frontend/src/routes.ts | 8 + frontend/src/styles.css | 284 +- frontend/tests/routing.spec.ts | 28 +- 32 files changed, 2784 insertions(+), 1194 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/user/AreaUnit.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/Currency.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/NickService.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/UserSettings.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/UserSettingsRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java create mode 100644 backend/src/test/java/pl/polskalokalnie/send/SmsSenderTest.java create mode 100644 backend/src/test/java/pl/polskalokalnie/user/NickServiceTest.java diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java index d56d384..bf4c7d7 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java @@ -4,12 +4,16 @@ import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.auth.dto.AccountRequests.ChangePasswordRequest; +import pl.polskalokalnie.auth.dto.AccountRequests.DeleteAccountRequest; import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest; import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest; import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest; @@ -98,4 +102,30 @@ public class AuthController { public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) { return authService.updateProfile(authentication.getName(), request); } + + /** + * Propozycja nazwy uzytkownika z imienia i nazwiska. Ustawienia konta pokazuja ja jako gotowa + * wartosc - nicka nie wpisuje sie recznie, zeby adres profilu nie stal sie polem do zabawy. + */ + @GetMapping("/nick-suggestion") + public NickSuggestionResponse nickSuggestion(Authentication authentication, @RequestParam(required = false) String fullName) { + return new NickSuggestionResponse(authService.suggestNick(authentication.getName(), fullName)); + } + + public record NickSuggestionResponse(String nick) { + } + + // --- Operacje na wlasnym koncie z /konto/ustawienia --- + + @PostMapping("/change-password") + public ResponseEntity changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) { + authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/me") + public ResponseEntity deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) { + authService.deleteOwnAccount(authentication.getName(), request.password()); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index a5485cc..29583ef 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -26,9 +26,11 @@ import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AuthProvider; import pl.polskalokalnie.user.BlockedEmailRepository; import pl.polskalokalnie.user.ContactPreference; +import pl.polskalokalnie.user.NickService; import pl.polskalokalnie.user.PreferredLanguage; import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettingsService; @Service public class AuthService { @@ -46,6 +48,8 @@ public class AuthService { private final AuthTokenRepository authTokenRepository; private final AccountEmailService accountEmailService; private final PhoneVerificationService phoneVerificationService; + private final NickService nickService; + private final UserSettingsService userSettingsService; public AuthService( UserRepository userRepository, @@ -56,7 +60,9 @@ public class AuthService { LeadSyncService leadSyncService, AuthTokenRepository authTokenRepository, AccountEmailService accountEmailService, - PhoneVerificationService phoneVerificationService + PhoneVerificationService phoneVerificationService, + NickService nickService, + UserSettingsService userSettingsService ) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; @@ -67,6 +73,8 @@ public class AuthService { this.authTokenRepository = authTokenRepository; this.accountEmailService = accountEmailService; this.phoneVerificationService = phoneVerificationService; + this.nickService = nickService; + this.userSettingsService = userSettingsService; } @Transactional @@ -84,6 +92,9 @@ public class AuthService { AppUser user = new AppUser(); user.setEmail(email); user.setFullName(request.fullName().trim()); + // Nick nadaje system z imienia i nazwiska ("Jan Kowalski" -> "jan.kowalski"), a przy kolizji + // dokleja numer. Uzytkownik nie wymysla go w formularzu rejestracji. + user.setNick(nickService.generateFromName(request.fullName(), email, null)); user.setPasswordHash(passwordEncoder.encode(request.password())); user.setRole(Role.USER); user.setProvider(AuthProvider.LOCAL); @@ -103,13 +114,15 @@ public class AuthService { String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES); accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token); - // Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru. - boolean phoneVerificationRequired = phone != null; - if (phoneVerificationRequired) { - phoneVerificationService.sendOtp(saved.getEmail(), phone); + // Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru. Konto jest juz + // zalozone, wiec awaria bramki nie moze przerwac rejestracji; zglaszamy tylko, ze kodu nie ma + // co wpisywac, a uzytkownik potwierdzi numer pozniej w ustawieniach konta. + boolean codeSent = false; + if (phone != null) { + codeSent = phoneVerificationService.sendOtp(saved.getEmail(), phone).delivered(); } - return new RegisterResponse(saved.getEmail(), phoneVerificationRequired); + return new RegisterResponse(saved.getEmail(), codeSent); } public AuthResponse login(LoginRequest request) { @@ -187,6 +200,10 @@ public class AuthService { phoneVerificationService.verifyOtp(normalizeEmail(email), code); } + /** + * Recznie zamowiony kod SMS. Tutaj - inaczej niz przy rejestracji - porazka bramki musi dojsc + * do uzytkownika, zeby nie czekal na SMS, ktory nigdy nie przyjdzie. + */ public void resendPhoneOtp(String email) { String normalized = normalizeEmail(email); AppUser user = userRepository.findByEmailIgnoreCase(normalized) @@ -194,7 +211,17 @@ public class AuthService { if (user.getPhone() == null || user.getPhone().isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu"); } - phoneVerificationService.sendOtp(normalized, user.getPhone()); + if (user.isPhoneVerified()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten numer jest już potwierdzony"); + } + + PhoneVerificationService.OtpDispatch dispatch = phoneVerificationService.sendOtp(normalized, user.getPhone()); + switch (dispatch.status()) { + case SENT -> { } + case GATEWAY_DISABLED -> throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, dispatch.detail()); + case FAILED -> throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, + "Nie udało się wysłać SMS-a: " + (dispatch.detail() == null ? "bramka odrzuciła wysyłkę" : dispatch.detail())); + } } // --- Pomocnicze tokeny --- @@ -252,6 +279,7 @@ public class AuthService { AppUser created = new AppUser(); created.setEmail(email); created.setFullName(name); + created.setNick(nickService.generateFromName(name, email, null)); created.setRole(Role.USER); created.setProvider(provider); // Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane. @@ -277,10 +305,20 @@ public class AuthService { AppUser user = userRepository.findByEmailIgnoreCase(email) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); - textModerationService.validateOrThrow(request.fullName(), request.phone(), request.birthDate(), request.address()); + textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address()); user.setFullName(request.fullName().trim()); - user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null); + if (request.nick() != null && !request.nick().isBlank()) { + user.setNick(nickService.validateForUser(request.nick(), user)); + } + + // Zmiana numeru uniewaznia wczesniejsza weryfikacje SMS - inaczej nowy, niepotwierdzony numer + // dziedziczylby znaczek "zweryfikowany" po poprzednim. + String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null; + if (!java.util.Objects.equals(phone, user.getPhone())) { + user.setPhoneVerified(false); + } + user.setPhone(phone); user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE); user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL); @@ -299,6 +337,58 @@ public class AuthService { return UserResponse.from(saved); } + /** + * Zmiana hasla z poziomu ustawien konta. Wymaga dotychczasowego hasla - sam token nie wystarczy, + * wiec przejeta sesja nie pozwala przejac konta na stale. + */ + @Transactional + public void changePassword(String email, String currentPassword, String newPassword) { + AppUser user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + + if (user.getPasswordHash() == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie"); + } + if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe"); + } + if (passwordEncoder.matches(newPassword, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nowe hasło musi różnić się od dotychczasowego"); + } + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + userRepository.save(user); + + // Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla. + authTokenRepository.findByUserEmailAndPurpose(user.getEmail(), TokenPurpose.PASSWORD_RESET).forEach(old -> { + if (old.getUsedAt() == null) { + old.setUsedAt(Instant.now()); + authTokenRepository.save(old); + } + }); + } + + /** + * Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu + * z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu. + */ + @Transactional + public void deleteOwnAccount(String email, String password) { + AppUser user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + + if (user.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konta administratora nie można usunąć z poziomu ustawień"); + } + if (user.getPasswordHash() == null || !passwordEncoder.matches(password, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe"); + } + + userSettingsService.deleteForUser(user.getId()); + userRepository.delete(user); + } + private AuthResponse buildAuthResponse(AppUser user) { return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user)); } @@ -307,6 +397,17 @@ public class AuthService { return email.trim().toLowerCase(Locale.ROOT); } + /** + * Propozycja nazwy uzytkownika dla podanego imienia i nazwiska. Uzywana przez ustawienia konta: + * nicka nie wpisuje sie recznie, tylko przyjmuje gotowa, wolna propozycje systemu. + */ + public String suggestNick(String email, String fullName) { + AppUser user = userRepository.findByEmailIgnoreCase(normalizeEmail(email)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName(); + return nickService.generateFromName(source, user.getEmail(), user); + } + private String defaultSocialEmail(AuthProvider provider) { return switch (provider) { case GOOGLE -> "demo.google@gmail.com"; diff --git a/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java b/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java index 4d302ea..3fd4f44 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java @@ -32,10 +32,21 @@ public class PhoneOtp { @Column(nullable = false) private int attempts = 0; + // Znacznik ostatniej wysylki - pilnuje odstepu miedzy kolejnymi SMS-ami (kazdy kosztuje). + private Instant lastSentAt; + public Long getId() { return id; } + public Instant getLastSentAt() { + return lastSentAt; + } + + public void setLastSentAt(Instant lastSentAt) { + this.lastSentAt = lastSentAt; + } + public String getUserEmail() { return userEmail; } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java b/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java index 86dd4a4..bc458e6 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java @@ -1,6 +1,7 @@ package pl.polskalokalnie.auth; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import org.slf4j.Logger; @@ -26,6 +27,7 @@ public class PhoneVerificationService { private static final SecureRandom RANDOM = new SecureRandom(); private static final int MAX_ATTEMPTS = 5; private static final int TTL_MINUTES = 10; + private static final int RESEND_COOLDOWN_SECONDS = 60; private final PhoneOtpRepository otpRepository; private final SmsSender smsSender; @@ -42,31 +44,74 @@ public class PhoneVerificationService { this.userRepository = userRepository; } + /** + * Wysyla kod SMS i mowi, co sie z nim naprawde stalo. Wolajacy decyduje, czy porazka ma + * przerwac operacje (recznie klikniete "wyslij kod") czy tylko ja oznaczyc (rejestracja, + * gdzie konto jest juz zalozone i nie wolno go stracic przez awarie bramki). + */ @Transactional - public void sendOtp(String email, String phone) { + public OtpDispatch sendOtp(String email, String phone) { if (phone == null || phone.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu"); } - String code = String.format("%06d", RANDOM.nextInt(1_000_000)); PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new); + + // Kazdy SMS kosztuje, a endpoint wysylki jest publiczny - trzymamy odstep miedzy wysylkami. + Instant lastSentAt = otp.getLastSentAt(); + if (lastSentAt != null) { + long secondsSince = Duration.between(lastSentAt, Instant.now()).getSeconds(); + if (secondsSince < RESEND_COOLDOWN_SECONDS) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Kod został już wysłany. Kolejny możesz zamówić za " + (RESEND_COOLDOWN_SECONDS - secondsSince) + " s"); + } + } + + String code = String.format("%06d", RANDOM.nextInt(1_000_000)); otp.setUserEmail(email); otp.setPhone(phone.trim()); otp.setCode(code); otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES)); otp.setAttempts(0); - otpRepository.save(otp); SmsGatewayConfig sms = mailConfigService.getSms(); - boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank(); - String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut."; + boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank() + && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank(); if (!configured) { + // Tryb deweloperski: bez bramki kod trafia do logu, ale nie udajemy, ze SMS poszedl. + // Nie ustawiamy lastSentAt - skoro nic nie wyszlo, nie ma za co karac odstepem. + otpRepository.save(otp); log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code); - return; + return new OtpDispatch(OtpStatus.GATEWAY_DISABLED, + "Bramka SMS nie jest włączona - skonfiguruj ją w panelu administratora (Konfiguracja SMS)."); } + + // Odstep liczymy od momentu, w ktorym naprawde odpytalismy bramke. + otp.setLastSentAt(Instant.now()); + otpRepository.save(otp); + + String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut."; var result = smsSender.send(sms, phone.trim(), message); if (!result.ok()) { - log.warn("[SMS-OTP] Nie udalo sie wyslac kodu do {} ({}). Kod: {}", email, phone, code); + // Bez logowania kodu - przy dzialajacej bramce nie ma powodu, zeby trzymac go w logach. + log.warn("[SMS-OTP] Bramka odrzucila wysylke do {}: {}", email, result.error()); + return new OtpDispatch(OtpStatus.FAILED, result.error()); + } + return new OtpDispatch(OtpStatus.SENT, result.providerMessageId()); + } + + public enum OtpStatus { + /** SMS przyjety przez bramke. */ + SENT, + /** Bramka wylaczona lub nieskonfigurowana - kod istnieje, ale nikt go nie dostal. */ + GATEWAY_DISABLED, + /** Bramka odrzucila wysylke. */ + FAILED + } + + public record OtpDispatch(OtpStatus status, String detail) { + public boolean delivered() { + return status == OtpStatus.SENT; } } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java index 8d10183..b3536a6 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java @@ -21,4 +21,15 @@ public final class AccountRequests { public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) { } + + /** Zmiana hasla z poziomu ustawien konta - wymaga podania dotychczasowego hasla. */ + public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 100) String newPassword + ) { + } + + /** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */ + public record DeleteAccountRequest(@NotBlank String password) { + } } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java index b23bf48..022f3ce 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java @@ -6,6 +6,8 @@ import pl.polskalokalnie.user.PreferredLanguage; public record UpdateProfileRequest( @NotBlank String fullName, + // Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje. + String nick, String phone, String birthDate, String address, diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java index 699ef56..f335962 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java @@ -13,6 +13,7 @@ public record UserResponse( Long id, String email, String fullName, + String nick, Role role, AuthProvider provider, AccountType accountType, @@ -33,6 +34,7 @@ public record UserResponse( user.getId(), user.getEmail(), user.getFullName(), + user.getNick(), user.getRole(), user.getProvider(), user.getAccountType(), diff --git a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java index 0ccafcb..1677538 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java +++ b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java @@ -19,6 +19,7 @@ import pl.polskalokalnie.promotion.PromotionPlan; import pl.polskalokalnie.promotion.PromotionPlanRepository; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AuthProvider; +import pl.polskalokalnie.user.NickService; import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.UserRepository; @@ -31,6 +32,7 @@ public class DataSeeder implements CommandLineRunner { private final PromotionPlanRepository promotionPlanRepository; private final PromotionPackageRepository promotionPackageRepository; private final PasswordEncoder passwordEncoder; + private final NickService nickService; private final String adminEmail; private final String adminPassword; private final String adminName; @@ -42,6 +44,7 @@ public class DataSeeder implements CommandLineRunner { PromotionPlanRepository promotionPlanRepository, PromotionPackageRepository promotionPackageRepository, PasswordEncoder passwordEncoder, + NickService nickService, @Value("${app.admin.email:admin@mieszko.pl}") String adminEmail, @Value("${app.admin.password:Admin123!}") String adminPassword, @Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName @@ -52,6 +55,7 @@ public class DataSeeder implements CommandLineRunner { this.promotionPlanRepository = promotionPlanRepository; this.promotionPackageRepository = promotionPackageRepository; this.passwordEncoder = passwordEncoder; + this.nickService = nickService; this.adminEmail = adminEmail; this.adminPassword = adminPassword; this.adminName = adminName; @@ -138,6 +142,7 @@ public class DataSeeder implements CommandLineRunner { AppUser admin = new AppUser(); admin.setEmail(adminEmail.toLowerCase()); admin.setFullName(adminName); + admin.setNick(nickService.generateUnique(adminEmail.split("@")[0])); admin.setPasswordHash(passwordEncoder.encode(adminPassword)); admin.setRole(Role.ADMIN); admin.setProvider(AuthProvider.LOCAL); diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index 9171ddb..012b31f 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -41,6 +41,30 @@ public class SchemaFixer { jdbcTemplate.execute( "ALTER TABLE app_users ALTER COLUMN preferred_language SET NOT NULL" ); + // Publiczna nazwa uzytkownika. Istniejacym kontom nadajemy ja z czesci adresu e-mail przed @, + // z numerem na koncu przy kolizji - dopiero po wypelnieniu wszystkich wierszy zakladamy UNIQUE. + jdbcTemplate.execute( + "ALTER TABLE app_users ADD COLUMN IF NOT EXISTS nick VARCHAR(40)" + ); + jdbcTemplate.execute( + "UPDATE app_users u SET nick = base.candidate || CASE WHEN base.rn = 1 THEN '' ELSE base.rn::text END " + + "FROM ( " + + " SELECT id, candidate, ROW_NUMBER() OVER (PARTITION BY candidate ORDER BY id) AS rn FROM ( " + + " SELECT id, COALESCE(NULLIF(regexp_replace(lower(split_part(email, '@', 1)), '[^a-z0-9._-]', '', 'g'), ''), 'uzytkownik') AS candidate " + + " FROM app_users WHERE nick IS NULL " + + " ) normalized " + + ") base " + + "WHERE u.id = base.id AND u.nick IS NULL" + ); + jdbcTemplate.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS app_users_nick_key ON app_users (nick)" + ); + + // Odstep miedzy kolejnymi SMS-ami z kodem - kolumna dochodzi do istniejacej tabeli. + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS phone_otps ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP" + ); + jdbcTemplate.execute( "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS file_type VARCHAR(120)" ); diff --git a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java index 808be69..b0f3272 100644 --- a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java +++ b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java @@ -38,9 +38,14 @@ public class SmsSender { int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0 ? config.getTimeoutSeconds() : 10; + String recipient = normalizePhone(phone); + if (recipient.isBlank()) { + return SendResult.failure("Pusty numer telefonu odbiorcy"); + } + ObjectNode payload = objectMapper.createObjectNode(); payload.put("api_key", config.getApiKey()); - payload.put("to", phone == null ? "" : phone.trim()); + payload.put("to", recipient); payload.put("message", message == null ? "" : message); String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator(); payload.put("creator", creator); @@ -75,6 +80,20 @@ public class SmsSender { } } + /** + * Numery trzymamy w postaci czytelnej dla czlowieka ("+48 601 234 567"), a bramka oczekuje + * samego numeru bez spacji i separatorow. Zostawiamy wiodacy plus i cyfry. + */ + static String normalizePhone(String phone) { + if (phone == null) { + return ""; + } + String trimmed = phone.trim(); + boolean international = trimmed.startsWith("+"); + String digits = trimmed.replaceAll("\\D", ""); + return digits.isEmpty() ? "" : (international ? "+" + digits : digits); + } + private JsonNode tryParse(String body) { if (body == null || body.isBlank()) { return null; diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index cfbfd41..8d7bcfb 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -27,6 +27,11 @@ public class AppUser { @Column(length = 120) private String fullName; + // Publiczna nazwa uzytkownika (polskalokalnie.pl/u/{nick}). Nadawana automatycznie przy rejestracji + // z czesci adresu e-mail przed @, pozniej zmienialna w ustawieniach konta. Unikalna w skali serwisu. + @Column(unique = true, length = 40) + private String nick; + // Nullable: konta zakladane przez logowanie spoleczne nie maja lokalnego hasla. @Column(length = 100) private String passwordHash; @@ -113,6 +118,14 @@ public class AppUser { this.fullName = fullName; } + public String getNick() { + return nick; + } + + public void setNick(String nick) { + this.nick = nick; + } + public String getPasswordHash() { return passwordHash; } diff --git a/backend/src/main/java/pl/polskalokalnie/user/AreaUnit.java b/backend/src/main/java/pl/polskalokalnie/user/AreaUnit.java new file mode 100644 index 0000000..9bdb801 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/AreaUnit.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.user; + +/** Jednostka powierzchni: metry kwadratowe albo stopy kwadratowe. */ +public enum AreaUnit { + M2, + FT2 +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/Currency.java b/backend/src/main/java/pl/polskalokalnie/user/Currency.java new file mode 100644 index 0000000..fb3420d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/Currency.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.user; + +/** Waluta, w ktorej uzytkownik chce widziec ceny ofert. */ +public enum Currency { + PLN, + EUR, + USD +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/NickService.java b/backend/src/main/java/pl/polskalokalnie/user/NickService.java new file mode 100644 index 0000000..f3a9fae --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/NickService.java @@ -0,0 +1,154 @@ +package pl.polskalokalnie.user; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +/** + * Nadawanie i walidacja publicznej nazwy uzytkownika (nick). + * + * Nick powstaje automatycznie przy zakladaniu konta - z czesci adresu e-mail przed @ - zeby uzytkownik + * nie musial go wymyslac przy rejestracji, a ustawienia konta mialy od razu wypelniona wartosc. + * Uzytkownik moze go pozniej zmienic; unikalnosc pilnuje kolumna z indeksem UNIQUE i sprawdzenie tutaj. + */ +@Service +public class NickService { + + public static final int MIN_LENGTH = 3; + public static final int MAX_LENGTH = 40; + + /** + * Nazwy zastrzezone - nick jest adresem profilu, wiec nie moze sugerowac, ze konto nalezy + * do serwisu albo jego obslugi. Bez tego dowolna osoba moze zostac "administrator". + */ + private static final Set RESERVED = Set.of( + "admin", "administrator", "administracja", "moderator", "moderacja", + "polskalokalnie", "polska-lokalnie", "polska.lokalnie", "pl", + "biuro", "kontakt", "pomoc", "support", "obsluga", "bok", + "serwis", "system", "root", "null", "undefined", + "ustawienia", "konto", "profil", "oferta", "ogloszenie", "wiadomosci"); + + private final UserRepository userRepository; + + public NickService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * Zamienia dowolny tekst na dozwolony ksztalt nicka: male litery bez polskich znakow, cyfry, + * kropka, myslnik i podkreslnik. Pusty wynik oznacza, ze z podanego tekstu nie da sie zbudowac nicka. + */ + public String normalize(String raw) { + if (raw == null) { + return ""; + } + String withoutDiacritics = Normalizer.normalize(raw.trim(), Normalizer.Form.NFD) + .replaceAll("\\p{InCombiningDiacriticalMarks}+", "") + .replace("ł", "l") + .replace("Ł", "L"); + String cleaned = withoutDiacritics.toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9._-]", "") + // Powtorzone separatory zwijamy do jednego: "jan..kowalski" i "jan.kowalski" wygladaja + // niemal identycznie, a to nazwa publiczna - takie pary uzywa sie do podszywania sie. + .replaceAll("[._-]{2,}", ".") + .replaceAll("^[._-]+", "") + .replaceAll("[._-]+$", ""); + return cleaned.length() > MAX_LENGTH ? cleaned.substring(0, MAX_LENGTH) : cleaned; + } + + /** + * Buduje wolny nick na podstawie propozycji. Przy kolizji dokleja kolejny numer, wiec dwie osoby + * o tym samym imieniu i nazwisku dostana rozne nicki. + */ + public String generateUnique(String proposal) { + return generateUnique(proposal, null); + } + + /** + * Jak wyzej, ale nick nalezacy do {@code owner} nie jest traktowany jako zajety - dzieki temu + * propozycja dla wlasnego konta nie dostaje niepotrzebnego sufiksu. + */ + public String generateUnique(String proposal, AppUser owner) { + String base = normalize(proposal); + if (base.length() < MIN_LENGTH) { + base = ("uzytkownik" + base); + base = base.length() > MAX_LENGTH ? base.substring(0, MAX_LENGTH) : base; + } + if (isFree(base, owner)) { + return base; + } + for (int suffix = 2; suffix < 10_000; suffix++) { + String candidate = withSuffix(base, suffix); + if (isFree(candidate, owner)) { + return candidate; + } + } + throw new ResponseStatusException(HttpStatus.CONFLICT, "Nie udało się nadać nazwy użytkownika"); + } + + /** + * Nick z imienia i nazwiska ("Jan Kowalski" -> "jan.kowalski"). Gdy z nazwy nie da sie zbudowac + * sensownego nicka (np. konto spoleczne bez nazwy), wraca do czesci adresu e-mail przed @. + */ + public String generateFromName(String fullName, String email, AppUser owner) { + String fromName = normalize(joinNameParts(fullName)); + if (fromName.length() >= MIN_LENGTH && !RESERVED.contains(fromName)) { + return generateUnique(fromName, owner); + } + int at = email == null ? -1 : email.indexOf('@'); + String localPart = at > 0 ? email.substring(0, at) : email; + return generateUnique(localPart, owner); + } + + private boolean isFree(String candidate, AppUser owner) { + if (RESERVED.contains(candidate)) { + return false; + } + return userRepository.findByNickIgnoreCase(candidate) + .map(existing -> owner != null && existing.getId().equals(owner.getId())) + .orElse(true); + } + + // "Jan Maria Kowalski" -> "jan.kowalski": pierwszy i ostatni czlon, kropka miedzy nimi. + private String joinNameParts(String fullName) { + if (fullName == null || fullName.isBlank()) { + return ""; + } + String[] parts = fullName.trim().split("\\s+"); + if (parts.length == 1) { + return parts[0]; + } + return parts[0] + "." + parts[parts.length - 1]; + } + + /** + * Sprawdza nick podany przez uzytkownika. Zwraca znormalizowana wartosc albo rzuca bledem + * z komunikatem do pokazania w formularzu. + */ + public String validateForUser(String raw, AppUser owner) { + String normalized = normalize(raw); + if (normalized.length() < MIN_LENGTH) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Nazwa użytkownika musi mieć co najmniej " + MIN_LENGTH + " znaki (dozwolone: litery, cyfry, kropka, myślnik, podkreślnik)"); + } + if (RESERVED.contains(normalized)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Ta nazwa użytkownika jest zastrzeżona"); + } + userRepository.findByNickIgnoreCase(normalized).ifPresent(existing -> { + if (!existing.getId().equals(owner.getId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Ta nazwa użytkownika jest już zajęta"); + } + }); + return normalized; + } + + private String withSuffix(String base, int suffix) { + String tail = String.valueOf(suffix); + int room = MAX_LENGTH - tail.length(); + String head = base.length() > room ? base.substring(0, room) : base; + return head + tail; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java b/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java new file mode 100644 index 0000000..f97d249 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/ProfileVisibility.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.user; + +/** Kto widzi profil publiczny uzytkownika. */ +public enum ProfileVisibility { + PUBLIC, + CONTACTS, + PRIVATE +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java index 9f069fd..0c98a23 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java @@ -21,24 +21,42 @@ public class PublicProfileController { private final UserRepository userRepository; private final ListingRepository listingRepository; + private final UserSettingsRepository userSettingsRepository; - public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository) { + public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository, + UserSettingsRepository userSettingsRepository) { this.userRepository = userRepository; this.listingRepository = listingRepository; + this.userSettingsRepository = userSettingsRepository; } @GetMapping("/{id}/profile") public PublicProfileResponse profile(@PathVariable Long id) { - AppUser user = userRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")); + return toResponse(userRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"))); + } + /** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */ + @GetMapping("/by-nick/{nick}/profile") + public PublicProfileResponse profileByNick(@PathVariable String nick) { + return toResponse(userRepository.findByNickIgnoreCase(nick) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"))); + } + + private PublicProfileResponse toResponse(AppUser user) { List listings = user.getEmail() == null ? List.of() : listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED); + // Opis i zdjecie profilowe uzytkownik ustawia w /konto/ustawienia; brak wiersza = nic nie ustawil. + UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null); + return new PublicProfileResponse( user.getId(), user.getFullName(), + user.getNick(), + settings == null ? null : settings.getBio(), + settings == null ? null : settings.getAvatarImage(), user.getAccountType(), user.isVerified(), user.getCreatedAt(), diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java index 1042eaf..6063a6d 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java @@ -11,6 +11,10 @@ import java.util.List; public record PublicProfileResponse( Long id, String fullName, + String nick, + // Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null. + String bio, + String avatarImage, AccountType accountType, boolean verified, Instant memberSince, diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java index 0c70c8a..b110e87 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java @@ -9,6 +9,10 @@ public interface UserRepository extends JpaRepository { boolean existsByEmailIgnoreCase(String email); + Optional findByNickIgnoreCase(String nick); + + boolean existsByNickIgnoreCase(String nick); + Optional findFirstByRole(Role role); long countByRole(Role role); diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java new file mode 100644 index 0000000..2be6c19 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java @@ -0,0 +1,285 @@ +package pl.polskalokalnie.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +/** + * Ustawienia konta zapisywane po stronie serwera - jeden wiersz na uzytkownika. + * + * Wczesniej te wartosci zyly wylacznie w useState i localStorage przegladarki, wiec ginely po + * odswiezeniu strony i roznily sie miedzy urzadzeniami. Zgody marketingowe celowo NIE trafiaja tutaj: + * ich jedynym miejscem pozostaje lead (MarketingConsentController), bo to one steruja kampaniami. + */ +@Entity +@Table(name = "user_settings") +public class UserSettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private Long userId; + + // --- Profil publiczny --- + + @Column(length = 160) + private String bio; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'PUBLIC'") + private ProfileVisibility profileVisibility = ProfileVisibility.PUBLIC; + + // Zdjecia trzymamy jako data URL - tak samo jak okladki ogloszen (PropertyListing.coverPhoto). + @Column(columnDefinition = "TEXT") + private String avatarImage; + + @Column(columnDefinition = "TEXT") + private String coverImage; + + // --- Preferencje prezentacji --- + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + @ColumnDefault("'PLN'") + private Currency currency = Currency.PLN; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + @ColumnDefault("'M2'") + private AreaUnit areaUnit = AreaUnit.M2; + + @Column(nullable = false) + @ColumnDefault("false") + private boolean directOffersOnly = false; + + @Column(nullable = false) + @ColumnDefault("true") + private boolean hideInactiveOffers = true; + + @Column(nullable = false) + @ColumnDefault("true") + private boolean saveSearchesOnHome = true; + + // --- Powiadomienia e-mail (zgoda marketingowa jest osobno, na leadzie) --- + + @Column(nullable = false) + @ColumnDefault("true") + private boolean notifySavedSearches = true; + + @Column(nullable = false) + @ColumnDefault("true") + private boolean notifyPriceAlerts = true; + + @Column(nullable = false) + @ColumnDefault("true") + private boolean notifyMessages = true; + + @Column(nullable = false) + @ColumnDefault("false") + private boolean notifyProductNews = false; + + // --- Preferencje wyszukiwania (podpowiadane przy filtrowaniu ofert) --- + + @Column(length = 255) + private String searchLocations; + + @Column(length = 40) + private String searchPropertyType; + + private Integer searchBudgetMax; + + private Integer searchAreaMin; + + private Integer searchAreaMax; + + private Integer searchRoomsMin; + + private Integer searchRoomsMax; + + public static UserSettings defaultsFor(Long userId) { + UserSettings settings = new UserSettings(); + settings.setUserId(userId); + return settings; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public ProfileVisibility getProfileVisibility() { + return profileVisibility; + } + + public void setProfileVisibility(ProfileVisibility profileVisibility) { + this.profileVisibility = profileVisibility; + } + + public String getAvatarImage() { + return avatarImage; + } + + public void setAvatarImage(String avatarImage) { + this.avatarImage = avatarImage; + } + + public String getCoverImage() { + return coverImage; + } + + public void setCoverImage(String coverImage) { + this.coverImage = coverImage; + } + + public Currency getCurrency() { + return currency; + } + + public void setCurrency(Currency currency) { + this.currency = currency; + } + + public AreaUnit getAreaUnit() { + return areaUnit; + } + + public void setAreaUnit(AreaUnit areaUnit) { + this.areaUnit = areaUnit; + } + + public boolean isDirectOffersOnly() { + return directOffersOnly; + } + + public void setDirectOffersOnly(boolean directOffersOnly) { + this.directOffersOnly = directOffersOnly; + } + + public boolean isHideInactiveOffers() { + return hideInactiveOffers; + } + + public void setHideInactiveOffers(boolean hideInactiveOffers) { + this.hideInactiveOffers = hideInactiveOffers; + } + + public boolean isSaveSearchesOnHome() { + return saveSearchesOnHome; + } + + public void setSaveSearchesOnHome(boolean saveSearchesOnHome) { + this.saveSearchesOnHome = saveSearchesOnHome; + } + + public boolean isNotifySavedSearches() { + return notifySavedSearches; + } + + public void setNotifySavedSearches(boolean notifySavedSearches) { + this.notifySavedSearches = notifySavedSearches; + } + + public boolean isNotifyPriceAlerts() { + return notifyPriceAlerts; + } + + public void setNotifyPriceAlerts(boolean notifyPriceAlerts) { + this.notifyPriceAlerts = notifyPriceAlerts; + } + + public boolean isNotifyMessages() { + return notifyMessages; + } + + public void setNotifyMessages(boolean notifyMessages) { + this.notifyMessages = notifyMessages; + } + + public boolean isNotifyProductNews() { + return notifyProductNews; + } + + public void setNotifyProductNews(boolean notifyProductNews) { + this.notifyProductNews = notifyProductNews; + } + + public String getSearchLocations() { + return searchLocations; + } + + public void setSearchLocations(String searchLocations) { + this.searchLocations = searchLocations; + } + + public String getSearchPropertyType() { + return searchPropertyType; + } + + public void setSearchPropertyType(String searchPropertyType) { + this.searchPropertyType = searchPropertyType; + } + + public Integer getSearchBudgetMax() { + return searchBudgetMax; + } + + public void setSearchBudgetMax(Integer searchBudgetMax) { + this.searchBudgetMax = searchBudgetMax; + } + + public Integer getSearchAreaMin() { + return searchAreaMin; + } + + public void setSearchAreaMin(Integer searchAreaMin) { + this.searchAreaMin = searchAreaMin; + } + + public Integer getSearchAreaMax() { + return searchAreaMax; + } + + public void setSearchAreaMax(Integer searchAreaMax) { + this.searchAreaMax = searchAreaMax; + } + + public Integer getSearchRoomsMin() { + return searchRoomsMin; + } + + public void setSearchRoomsMin(Integer searchRoomsMin) { + this.searchRoomsMin = searchRoomsMin; + } + + public Integer getSearchRoomsMax() { + return searchRoomsMax; + } + + public void setSearchRoomsMax(Integer searchRoomsMax) { + this.searchRoomsMax = searchRoomsMax; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java new file mode 100644 index 0000000..5558b76 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsController.java @@ -0,0 +1,43 @@ +package pl.polskalokalnie.user; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * Ustawienia konta zalogowanego uzytkownika (/konto/ustawienia). Endpoint wymaga uwierzytelnienia + * przez regule anyRequest().authenticated() w SecurityConfig. + */ +@RestController +@RequestMapping("/api/me/settings") +public class UserSettingsController { + + private final UserSettingsService service; + private final UserRepository userRepository; + + public UserSettingsController(UserSettingsService service, UserRepository userRepository) { + this.service = service; + this.userRepository = userRepository; + } + + @GetMapping + public UserSettingsResponse get(Authentication authentication) { + return service.get(currentUserId(authentication)); + } + + @PutMapping + public UserSettingsResponse update(Authentication authentication, @RequestBody UserSettingsRequest request) { + return service.update(currentUserId(authentication), request); + } + + private Long currentUserId(Authentication authentication) { + return userRepository.findByEmailIgnoreCase(authentication.getName()) + .map(AppUser::getId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRepository.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRepository.java new file mode 100644 index 0000000..614f641 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRepository.java @@ -0,0 +1,11 @@ +package pl.polskalokalnie.user; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserSettingsRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + void deleteByUserId(Long userId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java new file mode 100644 index 0000000..3ab2d2f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java @@ -0,0 +1,29 @@ +package pl.polskalokalnie.user; + +/** + * Zapis ustawien konta. Pola obiektowe (null) oznaczaja "zostaw jak bylo" - dzieki temu pojedyncza + * sekcja formularza moze zapisac sie sama, bez przesylania calego stanu strony. + */ +public record UserSettingsRequest( + String bio, + ProfileVisibility profileVisibility, + String avatarImage, + String coverImage, + Currency currency, + AreaUnit areaUnit, + Boolean directOffersOnly, + Boolean hideInactiveOffers, + Boolean saveSearchesOnHome, + Boolean notifySavedSearches, + Boolean notifyPriceAlerts, + Boolean notifyMessages, + Boolean notifyProductNews, + String searchLocations, + String searchPropertyType, + Integer searchBudgetMax, + Integer searchAreaMin, + Integer searchAreaMax, + Integer searchRoomsMin, + Integer searchRoomsMax +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java new file mode 100644 index 0000000..cee23cc --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java @@ -0,0 +1,50 @@ +package pl.polskalokalnie.user; + +/** Komplet ustawien konta odsylany do przegladarki. */ +public record UserSettingsResponse( + String bio, + ProfileVisibility profileVisibility, + String avatarImage, + String coverImage, + Currency currency, + AreaUnit areaUnit, + boolean directOffersOnly, + boolean hideInactiveOffers, + boolean saveSearchesOnHome, + boolean notifySavedSearches, + boolean notifyPriceAlerts, + boolean notifyMessages, + boolean notifyProductNews, + String searchLocations, + String searchPropertyType, + Integer searchBudgetMax, + Integer searchAreaMin, + Integer searchAreaMax, + Integer searchRoomsMin, + Integer searchRoomsMax +) { + public static UserSettingsResponse from(UserSettings settings) { + return new UserSettingsResponse( + settings.getBio(), + settings.getProfileVisibility(), + settings.getAvatarImage(), + settings.getCoverImage(), + settings.getCurrency(), + settings.getAreaUnit(), + settings.isDirectOffersOnly(), + settings.isHideInactiveOffers(), + settings.isSaveSearchesOnHome(), + settings.isNotifySavedSearches(), + settings.isNotifyPriceAlerts(), + settings.isNotifyMessages(), + settings.isNotifyProductNews(), + settings.getSearchLocations(), + settings.getSearchPropertyType(), + settings.getSearchBudgetMax(), + settings.getSearchAreaMin(), + settings.getSearchAreaMax(), + settings.getSearchRoomsMin(), + settings.getSearchRoomsMax() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java new file mode 100644 index 0000000..fec150b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java @@ -0,0 +1,135 @@ +package pl.polskalokalnie.user; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.moderation.TextModerationService; + +/** + * Odczyt i zapis ustawien konta. Brak wiersza w bazie oznacza ustawienia domyslne - tworzymy go + * dopiero przy pierwszym zapisie, wiec konta, ktore nigdy nic nie zmienialy, nie zasmiecaja tabeli. + */ +@Service +public class UserSettingsService { + + /** + * Gorny limit na zdjecie w postaci data URL. Front skaluje obrazy przed wyslaniem (ok. 100-200 KB), + * wiec ten limit lapie tylko proby obejscia formularza. Trzymamy go ponizej limitu ciala zadania + * w nginx, zeby uzytkownik dostal czytelny blad zamiast 413 z proxy. + */ + private static final int MAX_IMAGE_DATA_URL_LENGTH = 700_000; + + private static final int MAX_BIO_LENGTH = 160; + + private final UserSettingsRepository repository; + private final TextModerationService textModerationService; + + public UserSettingsService(UserSettingsRepository repository, TextModerationService textModerationService) { + this.repository = repository; + this.textModerationService = textModerationService; + } + + public UserSettingsResponse get(Long userId) { + return UserSettingsResponse.from( + repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId))); + } + + /** + * Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu + * kazda sekcja formularza moze zapisac wylacznie swoje pola. + */ + @Transactional + public UserSettingsResponse update(Long userId, UserSettingsRequest request) { + UserSettings settings = repository.findByUserId(userId) + .orElseGet(() -> UserSettings.defaultsFor(userId)); + + if (request.bio() != null) { + String bio = request.bio().trim(); + if (bio.length() > MAX_BIO_LENGTH) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Biografia może mieć maksymalnie " + MAX_BIO_LENGTH + " znaków"); + } + textModerationService.validateOrThrow(bio); + settings.setBio(bio.isEmpty() ? null : bio); + } + if (request.profileVisibility() != null) { + settings.setProfileVisibility(request.profileVisibility()); + } + if (request.avatarImage() != null) { + settings.setAvatarImage(cleanImage(request.avatarImage(), "Zdjęcie profilowe")); + } + if (request.coverImage() != null) { + settings.setCoverImage(cleanImage(request.coverImage(), "Zdjęcie w tle")); + } + if (request.currency() != null) { + settings.setCurrency(request.currency()); + } + if (request.areaUnit() != null) { + settings.setAreaUnit(request.areaUnit()); + } + if (request.directOffersOnly() != null) { + settings.setDirectOffersOnly(request.directOffersOnly()); + } + if (request.hideInactiveOffers() != null) { + settings.setHideInactiveOffers(request.hideInactiveOffers()); + } + if (request.saveSearchesOnHome() != null) { + settings.setSaveSearchesOnHome(request.saveSearchesOnHome()); + } + if (request.notifySavedSearches() != null) { + settings.setNotifySavedSearches(request.notifySavedSearches()); + } + if (request.notifyPriceAlerts() != null) { + settings.setNotifyPriceAlerts(request.notifyPriceAlerts()); + } + if (request.notifyMessages() != null) { + settings.setNotifyMessages(request.notifyMessages()); + } + if (request.notifyProductNews() != null) { + settings.setNotifyProductNews(request.notifyProductNews()); + } + if (request.searchLocations() != null) { + String locations = request.searchLocations().trim(); + textModerationService.validateOrThrow(locations); + settings.setSearchLocations(locations.isEmpty() ? null : locations); + } + if (request.searchPropertyType() != null) { + String type = request.searchPropertyType().trim(); + settings.setSearchPropertyType(type.isEmpty() ? null : type); + } + settings.setSearchBudgetMax(pickNumber(request.searchBudgetMax(), settings.getSearchBudgetMax())); + settings.setSearchAreaMin(pickNumber(request.searchAreaMin(), settings.getSearchAreaMin())); + settings.setSearchAreaMax(pickNumber(request.searchAreaMax(), settings.getSearchAreaMax())); + settings.setSearchRoomsMin(pickNumber(request.searchRoomsMin(), settings.getSearchRoomsMin())); + settings.setSearchRoomsMax(pickNumber(request.searchRoomsMax(), settings.getSearchRoomsMax())); + + return UserSettingsResponse.from(repository.save(settings)); + } + + @Transactional + public void deleteForUser(Long userId) { + repository.deleteByUserId(userId); + } + + // Liczby: wartosc ujemna to sygnal "wyczysc pole" (formularz wysyla -1 dla pustego inputa). + private Integer pickNumber(Integer incoming, Integer current) { + if (incoming == null) { + return current; + } + return incoming < 0 ? null : incoming; + } + + private String cleanImage(String dataUrl, String label) { + String trimmed = dataUrl.trim(); + if (trimmed.isEmpty()) { + return null; + } + if (!trimmed.startsWith("data:image/")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " musi być obrazem przesłanym z formularza"); + } + if (trimmed.length() > MAX_IMAGE_DATA_URL_LENGTH) { + throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, label + " jest za duże. Wybierz mniejszy plik."); + } + return trimmed; + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/send/SmsSenderTest.java b/backend/src/test/java/pl/polskalokalnie/send/SmsSenderTest.java new file mode 100644 index 0000000..22b4177 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/send/SmsSenderTest.java @@ -0,0 +1,37 @@ +package pl.polskalokalnie.send; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Numery telefonow trzymamy w bazie w postaci czytelnej dla czlowieka ("+48 601 234 567"), + * a bramka SMS oczekuje samego numeru. Bez normalizacji wysylka konczy sie bledem bramki. + */ +class SmsSenderTest { + + @Test + @DisplayName("usuwa spacje i separatory, zachowujac kierunkowy") + void normalizujeNumerZeSpacjami() { + assertEquals("+48601234567", SmsSender.normalizePhone("+48 601 234 567")); + assertEquals("+48601234567", SmsSender.normalizePhone("+48 601234567")); + assertEquals("+48601234567", SmsSender.normalizePhone(" +48-601-234-567 ")); + assertEquals("+48601234567", SmsSender.normalizePhone("+48 (601) 234 567")); + } + + @Test + @DisplayName("numer bez kierunkowego zostaje bez plusa") + void zachowujeNumerKrajowy() { + assertEquals("601234567", SmsSender.normalizePhone("601 234 567")); + assertEquals("601234567", SmsSender.normalizePhone("601-234-567")); + } + + @Test + @DisplayName("pusty lub bezsensowny numer daje pusty wynik") + void pustyNumer() { + assertEquals("", SmsSender.normalizePhone(null)); + assertEquals("", SmsSender.normalizePhone(" ")); + assertEquals("", SmsSender.normalizePhone("brak numeru")); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/user/NickServiceTest.java b/backend/src/test/java/pl/polskalokalnie/user/NickServiceTest.java new file mode 100644 index 0000000..1ec56ba --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/user/NickServiceTest.java @@ -0,0 +1,65 @@ +package pl.polskalokalnie.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Normalizacja nazwy uzytkownika. Nick jest czescia adresu profilu (/u/{nick}), wiec musi byc + * pozbawiony polskich znakow, spacji i wszystkiego, co psuje URL. + */ +class NickServiceTest { + + // normalize() nie korzysta z repozytorium - do testu wystarczy instancja bez zaleznosci. + private final NickService nickService = new NickService(null); + + @Test + @DisplayName("zamienia polskie znaki na odpowiedniki ASCII") + void usuwaPolskieZnaki() { + assertEquals("zazolc", nickService.normalize("Zażółć")); + assertEquals("lakowski", nickService.normalize("Łąkowski")); + assertEquals("jaskowalski", nickService.normalize("Jaś Kowalski!")); + } + + @Test + @DisplayName("zostawia kropke, mysnik i podkreslnik, wycina reszte") + void dopuszczaTylkoBezpieczneZnaki() { + assertEquals("jan.kowalski", nickService.normalize("jan.kowalski")); + assertEquals("jan-kowalski", nickService.normalize("jan-kowalski")); + assertEquals("jan_kowalski", nickService.normalize("jan_kowalski")); + assertEquals("jankowalski", nickService.normalize("jan kowalski")); + assertEquals("jankowalski.etc", nickService.normalize("jan@kowalski/../etc")); + } + + @Test + @DisplayName("zwija powtorzone separatory - inaczej jan..kowalski podszywa sie pod jan.kowalski") + void zwijaPowtorzoneSeparatory() { + assertEquals("jan.kowalski", nickService.normalize("jan..kowalski")); + assertEquals("jan.kowalski", nickService.normalize("jan---kowalski")); + assertEquals("jan.kowalski", nickService.normalize("jan_-.kowalski")); + } + + @Test + @DisplayName("obcina separatory z poczatku i konca") + void obcinaSeparatoryNaBrzegach() { + assertEquals("kowalski", nickService.normalize("...kowalski---")); + assertEquals("kowalski", nickService.normalize("_kowalski_")); + } + + @Test + @DisplayName("nie przekracza dozwolonej dlugosci") + void pilnujeDlugosci() { + String result = nickService.normalize("a".repeat(200)); + assertEquals(NickService.MAX_LENGTH, result.length()); + } + + @Test + @DisplayName("z tekstu bez liter i cyfr nie da sie zbudowac nicka") + void pustyWynikDlaSmieci() { + assertEquals("", nickService.normalize(null)); + assertEquals("", nickService.normalize(" ")); + assertTrue(nickService.normalize("!@#$%").isEmpty()); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0748036..157ac0a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,10 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; -import { Link, NavLink, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, listingPath, publicProfilePath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; +import { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { ProtectedRoute } from './ProtectedRoute'; import { apiFetch, useAuth } from './auth'; -import type { AccountType, AuthUser, ContactPreference, PreferredLanguage, RegisterPending } from './auth'; +import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, UserSettings, UserSettingsPatch } from './auth'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; @@ -24,7 +24,6 @@ const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; const plLogoLight = new URL('./assets/pl_logo_light.png', import.meta.url).href; const plLogoDark = new URL('./assets/pl_logo_dark.png', import.meta.url).href; -const SMS_VERIFICATION_CODE = '123456'; // Podswietlenie aktywnej pozycji menu wyznacza NavLink na podstawie adresu. const navLinkClass = ({ isActive }: { isActive: boolean }) => (isActive ? 'active' : ''); @@ -3151,7 +3150,12 @@ function App() { navigate(ROUTES.favorites); return; } - if (link === 'accountSecurity' || link === 'account' || link === 'accountListings') { + // Powiadomienia sprzed scalenia stron moga wskazywac na accountSecurity - to teraz sekcja ustawien. + if (link === 'accountSecurity') { + navigate(ROUTES.accountSettings); + return; + } + if (link === 'accountSettings' || link === 'account' || link === 'accountListings') { navigate(ROUTES[link]); return; } @@ -3612,11 +3616,14 @@ function App() { } /> } /> } /> - } /> - } /> + {/* Bezpieczenstwo i edycja profilu sa teraz sekcjami /konto/ustawienia - stare adresy + zostaja jako przekierowania, zeby zapisane linki i zakladki dalej dzialaly. */} + } /> + } /> } /> } /> } /> + } /> } /> } /> @@ -6052,9 +6059,8 @@ function NegotiationPage({ function AccountSidebar() { const { user } = useAuth(); const location = useLocation(); - // Ustawienia obejmuja takze podstrony profilu - podswietlamy je wspolna pozycja menu. + // Ustawienia obejmuja takze podglad profilu publicznego - podswietlamy je wspolna pozycja menu. const isSettingsSection = location.pathname === ROUTES.accountSettings - || location.pathname === ROUTES.accountProfileEdit || location.pathname === ROUTES.accountPublicProfile; const [messagesUnreadCount, setMessagesUnreadCount] = useState(0); // Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty. @@ -6185,7 +6191,6 @@ function AccountSidebar() {

Ustawienia

Dane i ustawienia Powiadomienia - Bezpieczeństwo Pomoc i kontakt @@ -10136,1096 +10141,857 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => ); } -function AccountSettingsPage() { - const navigate = useNavigate(); - const { user } = useAuth(); - const [accountSwitches, setAccountSwitches] = useState([true, true, false, false]); - // Indeks 4 ("Oferty promowane i newsletter") to zgoda marketingowa e-mail zapisywana na serwerze. - const [mailSwitches, setMailSwitches] = useState([true, true, true, false, false]); - const [smsConsent, setSmsConsent] = useState(true); - const address = user?.address ?? ''; - const birthDate = user?.birthDate ?? ''; +// --------------------------------------------------------------------------- +// /konto/ustawienia - jedyne miejsce z logika konta. +// +// Wczesniej te same rzeczy stały w trzech miejscach naraz: karta "Zarzadzanie kontem" w ustawieniach +// powtarzala cala strone /konto/bezpieczenstwo (i przeczyla jej co do statusu 2FA), jezyk komunikacji +// wystepowal dwa razy na jednym ekranie, a edycja danych zyla na osobnej podstronie i zapisywala +// polowe pol do localStorage zamiast na serwer. Teraz zrodlem prawdy jest backend: +// profil w PUT /auth/me, reszta w /api/me/settings, zgody marketingowe w /api/me/marketing. +// --------------------------------------------------------------------------- - // Wczytaj aktualną zgodę marketingową użytkownika (steruje kwalifikacją do kampanii). - useEffect(() => { - if (!user) { - return; - } - let active = true; - apiFetch<{ emailConsent: boolean; smsConsent: boolean }>('/me/marketing') - .then((consent) => { - if (!active) { - return; - } - setSmsConsent(consent.smsConsent); - setMailSwitches((current) => current.map((value, idx) => (idx === 4 ? consent.emailConsent : value))); - }) - .catch(() => { - // Brak zgody/sesji - zostawiamy wartości domyślne. - }); - return () => { - active = false; - }; - }, [user]); +// Kierunkowe telefonow z wymagana liczba cyfr - stala modulu, nie budujemy jej przy kazdym renderze. +const PHONE_COUNTRY_OPTIONS = [ + { iso2: 'pl', code: '+48', country: 'Polska', digits: 9 }, + { iso2: 'al', code: '+355', country: 'Albania', digits: 9 }, + { iso2: 'at', code: '+43', country: 'Austria', digits: 10 }, + { iso2: 'be', code: '+32', country: 'Belgia', digits: 9 }, + { iso2: 'bg', code: '+359', country: 'Bułgaria', digits: 9 }, + { iso2: 'hr', code: '+385', country: 'Chorwacja', digits: 9 }, + { iso2: 'cy', code: '+357', country: 'Cypr', digits: 8 }, + { iso2: 'cz', code: '+420', country: 'Czechy', digits: 9 }, + { iso2: 'dk', code: '+45', country: 'Dania', digits: 8 }, + { iso2: 'ee', code: '+372', country: 'Estonia', digits: 8 }, + { iso2: 'fi', code: '+358', country: 'Finlandia', digits: 10 }, + { iso2: 'fr', code: '+33', country: 'Francja', digits: 9 }, + { iso2: 'de', code: '+49', country: 'Niemcy', digits: 11 }, + { iso2: 'gr', code: '+30', country: 'Grecja', digits: 10 }, + { iso2: 'hu', code: '+36', country: 'Węgry', digits: 9 }, + { iso2: 'is', code: '+354', country: 'Islandia', digits: 7 }, + { iso2: 'ie', code: '+353', country: 'Irlandia', digits: 9 }, + { iso2: 'it', code: '+39', country: 'Włochy', digits: 10 }, + { iso2: 'lv', code: '+371', country: 'Łotwa', digits: 8 }, + { iso2: 'lt', code: '+370', country: 'Litwa', digits: 8 }, + { iso2: 'lu', code: '+352', country: 'Luksemburg', digits: 9 }, + { iso2: 'mt', code: '+356', country: 'Malta', digits: 8 }, + { iso2: 'nl', code: '+31', country: 'Holandia', digits: 9 }, + { iso2: 'no', code: '+47', country: 'Norwegia', digits: 8 }, + { iso2: 'pt', code: '+351', country: 'Portugalia', digits: 9 }, + { iso2: 'ro', code: '+40', country: 'Rumunia', digits: 9 }, + { iso2: 'rs', code: '+381', country: 'Serbia', digits: 9 }, + { iso2: 'sk', code: '+421', country: 'Słowacja', digits: 9 }, + { iso2: 'si', code: '+386', country: 'Słowenia', digits: 8 }, + { iso2: 'es', code: '+34', country: 'Hiszpania', digits: 9 }, + { iso2: 'se', code: '+46', country: 'Szwecja', digits: 9 }, + { iso2: 'ch', code: '+41', country: 'Szwajcaria', digits: 9 }, + { iso2: 'ua', code: '+380', country: 'Ukraina', digits: 9 }, + { iso2: 'gb', code: '+44', country: 'Wielka Brytania', digits: 10 }, + { iso2: 'tr', code: '+90', country: 'Turcja', digits: 10 }, + { iso2: 'us', code: '+1', country: 'USA/Kanada', digits: 10 }, + { iso2: 'mx', code: '+52', country: 'Meksyk', digits: 10 }, + { iso2: 'br', code: '+55', country: 'Brazylia', digits: 11 }, + { iso2: 'ar', code: '+54', country: 'Argentyna', digits: 10 }, + { iso2: 'cl', code: '+56', country: 'Chile', digits: 9 }, + { iso2: 'co', code: '+57', country: 'Kolumbia', digits: 10 }, + { iso2: 'au', code: '+61', country: 'Australia', digits: 9 }, + { iso2: 'nz', code: '+64', country: 'Nowa Zelandia', digits: 9 }, + { iso2: 'jp', code: '+81', country: 'Japonia', digits: 10 }, + { iso2: 'kr', code: '+82', country: 'Korea Południowa', digits: 10 }, + { iso2: 'cn', code: '+86', country: 'Chiny', digits: 11 }, + { iso2: 'in', code: '+91', country: 'Indie', digits: 10 }, + { iso2: 'pk', code: '+92', country: 'Pakistan', digits: 10 }, + { iso2: 'id', code: '+62', country: 'Indonezja', digits: 10 }, + { iso2: 'th', code: '+66', country: 'Tajlandia', digits: 9 }, + { iso2: 'vn', code: '+84', country: 'Wietnam', digits: 9 }, + { iso2: 'sg', code: '+65', country: 'Singapur', digits: 8 }, + { iso2: 'my', code: '+60', country: 'Malezja', digits: 10 }, + { iso2: 'za', code: '+27', country: 'RPA', digits: 9 }, + { iso2: 'eg', code: '+20', country: 'Egipt', digits: 10 }, + { iso2: 'ma', code: '+212', country: 'Maroko', digits: 9 }, + { iso2: 'ng', code: '+234', country: 'Nigeria', digits: 10 }, + { iso2: 'ke', code: '+254', country: 'Kenia', digits: 9 }, + { iso2: 'ae', code: '+971', country: 'ZEA', digits: 9 }, + { iso2: 'sa', code: '+966', country: 'Arabia Saudyjska', digits: 9 }, + { iso2: 'il', code: '+972', country: 'Izrael', digits: 9 }, + { iso2: 'qa', code: '+974', country: 'Katar', digits: 8 }, +] as const; - const toggleAccountSwitch = (index: number) => { - setAccountSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); - }; +type PhoneCountry = (typeof PHONE_COUNTRY_OPTIONS)[number]; - const toggleMailSwitch = (index: number) => { - setMailSwitches((current) => { - const next = current.map((value, idx) => (idx === index ? !value : value)); - // Zgodę marketingową e-mail (indeks 4) utrwalamy na serwerze. - if (index === 4) { - apiFetch('/me/marketing', { - method: 'PUT', - body: JSON.stringify({ emailConsent: next[4], smsConsent }), - }).catch(() => { - // Błąd zapisu nie blokuje UI; wartość zostanie zsynchronizowana przy kolejnym wejściu. - }); - } - return next; - }); - }; +const DEFAULT_PHONE_COUNTRY = PHONE_COUNTRY_OPTIONS[0]; - return ( -
-
- - -
-
-

Dane i ustawienia

-

Zarządzaj swoimi danymi osobowymi, preferencjami i ustawieniami konta.

-
- -
-
-
-

Moje dane

-
-
- -
- {getDisplayName(user)} - Konto Premium -
- Edytuj -
-
-
Adres e-mail{user?.email}
-
Numer telefonu{window.localStorage.getItem(accountStorageKey('Phone', user)) || user?.phone || 'Nie podano'}
-
Data urodzenia{birthDate ? birthDate.split('-').reverse().join('.') : 'Nie podano'}
-
Adres zamieszkania{address || 'Nie podano'}
-
Preferencje kontaktu{contactPreferenceLabel(user?.contactPreference)}
-
Język komunikacji{preferredLanguageLabel(user?.preferredLanguage)}
-
-
- -
-
-

Preferencje wyszukiwania

- -
-
-
LokalizacjeWarszawa (Mokotów, Śródmieście, Wola)
-
Typ nieruchomościMieszkania
-
Budżetdo 800 000 zł
-
Metrażod 30 m² do 80 m²
-
Liczba pokoi2 - 4 pokoje
-
-
- -
-
-

Ustawienia konta

-
-
-
Język{preferredLanguageLabel(user?.preferredLanguage)}
-
WalutaPLN (zł)
-
Jednostki powierzchniMetry kwadratowe (m²)
-
Pokaż tylko oferty bezpośrednie
-
Ukryj oferty nieaktualne
-
Zapisywanie wyszukiwań na stronie głównej
-
Tryb ciemny
-
-
- -
-
-

Zarządzanie kontem

-
-
-
Zmień hasłoZaktualizuj swoje hasło do konta.
-
Dwuetapowa weryfikacjaZabezpiecz konto dodatkową warstwą ochrony.
-
Urządzenia i sesjeZobacz, gdzie i na jakich urządzeniach jesteś zalogowany.
-
Usuń kontoTrwale usuń swoje konto i wszystkie dane.
-
-
- -
-
-

Powiadomienia e-mail

-

Wybierz, o czym chcesz otrzymywać powiadomienia.

-
-
-
Nowe oferty z zapisanych wyszukiwań
-
Alerty cenowe
-
Wiadomości od użytkowników
-
Nowości i aktualizacje
-
Oferty promowane i newsletter
-
-
- -
-
-

Export danych

-

Pobierz kopię swoich danych z konta w serwisie Polska Lokalnie.

-
-
- -
- Pobierz swoje dane - Otrzymasz plik z Twoimi danymi, zapisanymi wyszukiwaniami, ulubionymi ogłoszeniami i innymi informacjami. - -
-
-
-
- -
- Twoje dane są u nas bezpieczne. Stosujemy szyfrowanie SSL i zaawansowane środki ochrony danych. -
-
-
-
- ); +// Rozbicie zapisanego numeru ("+48 123456789") na kierunkowy i cyfry, zeby formularz pokazal +// dokladnie to, co jest w bazie - bez posilkowania sie pamiecia przegladarki. +function splitStoredPhone(stored: string | null | undefined): { prefix: string; digits: string } { + const raw = (stored ?? '').trim(); + if (!raw) { + return { prefix: DEFAULT_PHONE_COUNTRY.code, digits: '' }; + } + const match = [...PHONE_COUNTRY_OPTIONS] + .sort((a, b) => b.code.length - a.code.length) + .find((option) => raw.startsWith(option.code)); + if (!match) { + return { prefix: DEFAULT_PHONE_COUNTRY.code, digits: raw.replace(/\D/g, '').slice(0, DEFAULT_PHONE_COUNTRY.digits) }; + } + return { prefix: match.code, digits: raw.slice(match.code.length).replace(/\D/g, '').slice(0, match.digits) }; } -function AccountSecurityPage() { - const { user } = useAuth(); - const recentSecurityActivity = [ - { - id: 'activity-1', - icon: 'screen', - tone: 'green', - title: 'Nowe logowanie', - details: 'Warszawa, Polska • Chrome • Windows', - when: 'Dzisiaj, 09:15', - status: 'To Ty', - }, - { - id: 'activity-2', - icon: 'lock', - tone: 'blue', - title: 'Zmiana hasła', - details: 'Warszawa, Polska • Chrome • Windows', - when: 'Wczoraj, 16:42', - status: 'To Ty', - }, - { - id: 'activity-3', - icon: 'screen', - tone: 'green', - title: 'Nowe logowanie', - details: 'Kraków, Polska • Safari • iOS', - when: '12 maja 2025, 11:03', - status: 'To Ty', - }, - { - id: 'activity-4', - icon: 'warning', - tone: 'orange', - title: 'Wylogowanie ze wszystkich urządzeń', - details: 'Warszawa, Polska • Chrome • Windows', - when: '10 maja 2025, 15:20', - status: 'To Ty', - }, - ] as const; - - const securityCards = [ - { - id: 'password', - icon: 'lock', - title: 'Hasło', - description: 'Zmień swoje hasło regularnie, aby chronić konto.', - details: 'Ostatnia zmiana: 12 maja 2025', - action: 'Zmień hasło', - iconTone: 'green', - tone: 'ok', - }, - { - id: '2fa', - icon: 'shield', - title: 'Weryfikacja dwuetapowa', - description: 'Dodaj dodatkową warstwę ochrony do swojego konta.', - details: 'Status: Włączona', - action: 'Zarządzaj', - iconTone: 'blue', - tone: 'ok', - }, - { - id: 'email', - icon: 'mail', - title: 'E-mail', - description: 'Zarządzaj swoim adresem e-mail i ustawieniami.', - details: user?.email || '', - action: 'Zarządzaj', - iconTone: 'yellow', - tone: 'neutral', - }, - { - id: 'phone', - icon: 'phone', - title: 'Numer telefonu', - description: 'Dodaj numer telefonu do odzyskiwania konta.', - details: '+48 123 456 789', - action: 'Zarządzaj', - iconTone: 'green', - tone: 'neutral', - }, - { - id: 'trusted-devices', - icon: 'screen', - title: 'Zaufane urządzenia', - description: 'Zarządzaj urządzeniami, na których jesteś zalogowany.', - details: '2 aktywne urządzenia', - action: 'Zobacz', - iconTone: 'purple', - tone: 'neutral', - }, - { - id: 'sign-out-all', - icon: 'trash', - title: 'Wyloguj się ze wszystkich urządzeń', - description: 'Zakończ wszystkie aktywne sesje na innych urządzeniach.', - details: 'Dla zwiększenia bezpieczeństwa konta', - action: 'Wyloguj', - iconTone: 'pink', - tone: 'danger', - }, - ] as const; - - return ( -
-
- - -
-
-

Bezpieczeństwo

-

Zarządzaj bezpieczeństwem swojego konta i chroń swoje dane.

-
- -
-
-

Poziom bezpieczeństwa

- -
- - -
- Świetnie! Twoje konto jest bardzo dobrze zabezpieczone. -
    -
  • Silne hasło
  • -
  • Weryfikacja dwuetapowa
  • -
  • Zweryfikowany e-mail
  • -
  • Brak podejrzanych aktywności
  • -
- -
-
-
- -
-
-

Ostatnia aktywność

- -
- -
- {recentSecurityActivity.map((item) => ( -
- -
- {item.title} - {item.details} -
-
- {item.when} - {item.status} -
-
- ))} -
-
-
- -
-

Ustawienia bezpieczeństwa

- -
- {securityCards.map((item) => ( -
- -
- {item.title} -

{item.description}

- {item.details} -
- -
- ))} -
-
-
-
-
- ); +function phoneMaskGroupsFor(digitsRequired: number): number[] { + if (digitsRequired <= 7) { + return [3, digitsRequired - 3]; + } + if (digitsRequired === 8) { + return [4, 4]; + } + if (digitsRequired === 9) { + return [3, 3, 3]; + } + if (digitsRequired === 10) { + return [3, 3, 4]; + } + return [3, 4, digitsRequired - 7]; } -function AccountProfileEditPage() { - const navigate = useNavigate(); - const { user, updateProfile } = useAuth(); - const { notify } = useNotifications(); - type UploadTarget = 'avatar' | 'cover'; - type MessageTone = 'success' | 'error' | 'info'; +function formatPhoneWithMask(digits: string, groups: number[]): string { + let cursor = 0; + return groups + .map((groupLength) => { + const chunk = digits.slice(cursor, cursor + groupLength); + cursor += groupLength; + return chunk; + }) + .filter(Boolean) + .join('-'); +} - const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/webp']); - const maxImageSizeBytes = 5 * 1024 * 1024; - const minDimensions: Record = { - avatar: { width: 160, height: 160 }, - cover: { width: 960, height: 260 }, - }; +type SettingsUploadTarget = 'avatar' | 'cover'; - const loadImage = (source: string) => new Promise((resolve, reject) => { +const SETTINGS_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +const SETTINGS_IMAGE_MAX_BYTES = 5 * 1024 * 1024; +const SETTINGS_IMAGE_MIN_DIMENSIONS: Record = { + avatar: { width: 160, height: 160 }, + cover: { width: 960, height: 260 }, +}; + +function loadImageElement(source: string): Promise { + return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Nie udało się odczytać obrazu.')); image.src = source; }); +} - const calculateSkinToneRatio = (image: HTMLImageElement) => { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) { - return 0; +// Zgrubna heurystyka udzialu barw skory - odsiewa oczywiste zdjecia nagosci przed wyslaniem na serwer. +function calculateSkinToneRatio(image: HTMLImageElement): number { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return 0; + } + + const sampleWidth = 64; + const sampleHeight = Math.max(64, Math.round((image.height / image.width) * 64)); + canvas.width = sampleWidth; + canvas.height = sampleHeight; + context.drawImage(image, 0, 0, sampleWidth, sampleHeight); + + const { data } = context.getImageData(0, 0, sampleWidth, sampleHeight); + let skinPixels = 0; + const totalPixels = data.length / 4; + + for (let index = 0; index < data.length; index += 4) { + const red = data[index]; + const green = data[index + 1]; + const blue = data[index + 2]; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const isSkinTone = red > 95 && green > 40 && blue > 20 && max - min > 15 && Math.abs(red - green) > 15 && red > green && red > blue; + if (isSkinTone) { + skinPixels += 1; } + } - const sampleWidth = 64; - const sampleHeight = Math.max(64, Math.round((image.height / image.width) * 64)); - canvas.width = sampleWidth; - canvas.height = sampleHeight; - context.drawImage(image, 0, 0, sampleWidth, sampleHeight); + return totalPixels > 0 ? skinPixels / totalPixels : 0; +} - const { data } = context.getImageData(0, 0, sampleWidth, sampleHeight); - let skinPixels = 0; - const totalPixels = data.length / 4; +/** + * Sprawdza plik i zwraca gotowy do zapisu data URL. Obraz jest przy okazji skalowany + * (compressImageFile) - inaczej 5 MB zdjecie po zamianie na base64 nie przeszloby przez proxy. + */ +async function prepareProfileImage(file: File, target: SettingsUploadTarget): Promise<{ ok: true; dataUrl: string } | { ok: false; reason: string }> { + if (!SETTINGS_IMAGE_MIME_TYPES.has(file.type)) { + return { ok: false, reason: 'Dozwolone są tylko pliki JPG, PNG lub WEBP.' }; + } + if (file.size > SETTINGS_IMAGE_MAX_BYTES) { + return { ok: false, reason: 'Plik jest za duży. Maksymalny rozmiar to 5 MB.' }; + } + if (/(nude|naked|porn|xxx|sex|erotic|nago|gola|goly|nagie)/i.test(file.name)) { + return { ok: false, reason: 'Nazwa pliku wskazuje na niedozwoloną treść.' }; + } - for (let index = 0; index < data.length; index += 4) { - const red = data[index]; - const green = data[index + 1]; - const blue = data[index + 2]; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const isSkinTone = red > 95 && green > 40 && blue > 20 && max - min > 15 && Math.abs(red - green) > 15 && red > green && red > blue; - if (isSkinTone) { - skinPixels += 1; - } + try { + const dataUrl = await compressImageFile(file, target === 'avatar' ? 512 : 1280, 0.8); + if (!dataUrl.startsWith('data:image/')) { + return { ok: false, reason: 'Nie udało się przygotować zdjęcia. Spróbuj ponownie.' }; } - - return totalPixels > 0 ? skinPixels / totalPixels : 0; - }; - - const verifyUploadedImage = async (file: File, target: UploadTarget) => { - if (!allowedMimeTypes.has(file.type)) { - return { ok: false as const, reason: 'Dozwolone są tylko pliki JPG, PNG lub WEBP.' }; + const image = await loadImageElement(dataUrl); + const minimum = SETTINGS_IMAGE_MIN_DIMENSIONS[target]; + if (image.width < minimum.width || image.height < minimum.height) { + return { ok: false, reason: `Zdjęcie ma za małą rozdzielczość. Minimum: ${minimum.width}x${minimum.height}px.` }; } - - if (file.size > maxImageSizeBytes) { - return { ok: false as const, reason: 'Plik jest za duży. Maksymalny rozmiar to 5 MB.' }; + if (calculateSkinToneRatio(image) > (target === 'avatar' ? 0.82 : 0.76)) { + return { ok: false, reason: 'Zdjęcie nie przeszło automatycznej weryfikacji bezpieczeństwa.' }; } + return { ok: true, dataUrl }; + } catch { + return { ok: false, reason: 'Nie udało się zweryfikować zdjęcia. Spróbuj ponownie.' }; + } +} - if (/(nude|naked|porn|xxx|sex|erotic|nago|gola|goly|nagie)/i.test(file.name)) { - return { ok: false as const, reason: 'Nazwa pliku wskazuje na niedozwoloną treść.' }; - } +const CURRENCY_LABELS: Record = { + PLN: 'PLN (zł)', + EUR: 'EUR (€)', + USD: 'USD ($)', +}; - try { - const dataUrl = await readFileAsDataUrl(file); - const image = await loadImage(dataUrl); - if (image.width < minDimensions[target].width || image.height < minDimensions[target].height) { - return { - ok: false as const, - reason: `Zdjęcie ma za małą rozdzielczość. Minimum: ${minDimensions[target].width}x${minDimensions[target].height}px.`, - }; - } +const AREA_UNIT_LABELS: Record = { + M2: 'Metry kwadratowe (m²)', + FT2: 'Stopy kwadratowe (ft²)', +}; - const skinToneRatio = calculateSkinToneRatio(image); - const skinToneThreshold = target === 'avatar' ? 0.82 : 0.76; - if (skinToneRatio > skinToneThreshold) { - return { ok: false as const, reason: 'Zdjęcie nie przeszło automatycznej weryfikacji bezpieczeństwa.' }; - } +const PROFILE_VISIBILITY_OPTIONS: Array<{ value: ProfileVisibility; label: string; description: string }> = [ + { value: 'PUBLIC', label: 'Publiczna', description: 'Twój profil jest widoczny dla wszystkich użytkowników.' }, + { value: 'CONTACTS', label: 'Tylko kontakty', description: 'Profil widzą tylko osoby, z którymi masz aktywny kontakt.' }, + { value: 'PRIVATE', label: 'Prywatna', description: 'Twój profil jest ukryty i widzisz go tylko Ty.' }, +]; - return { ok: true as const, dataUrl }; - } catch { - return { ok: false as const, reason: 'Nie udało się zweryfikować zdjęcia. Spróbuj ponownie.' }; - } - }; +const SETTINGS_PROPERTY_TYPES = ['Mieszkania', 'Domy', 'Apartamenty', 'Działki', 'Lokale użytkowe']; - const phoneCountryOptions = [ - { iso2: 'pl', code: '+48', country: 'Polska', digits: 9 }, - { iso2: 'al', code: '+355', country: 'Albania', digits: 9 }, - { iso2: 'at', code: '+43', country: 'Austria', digits: 10 }, - { iso2: 'be', code: '+32', country: 'Belgia', digits: 9 }, - { iso2: 'bg', code: '+359', country: 'Bułgaria', digits: 9 }, - { iso2: 'hr', code: '+385', country: 'Chorwacja', digits: 9 }, - { iso2: 'cy', code: '+357', country: 'Cypr', digits: 8 }, - { iso2: 'cz', code: '+420', country: 'Czechy', digits: 9 }, - { iso2: 'dk', code: '+45', country: 'Dania', digits: 8 }, - { iso2: 'ee', code: '+372', country: 'Estonia', digits: 8 }, - { iso2: 'fi', code: '+358', country: 'Finlandia', digits: 10 }, - { iso2: 'fr', code: '+33', country: 'Francja', digits: 9 }, - { iso2: 'de', code: '+49', country: 'Niemcy', digits: 11 }, - { iso2: 'gr', code: '+30', country: 'Grecja', digits: 10 }, - { iso2: 'hu', code: '+36', country: 'Węgry', digits: 9 }, - { iso2: 'is', code: '+354', country: 'Islandia', digits: 7 }, - { iso2: 'ie', code: '+353', country: 'Irlandia', digits: 9 }, - { iso2: 'it', code: '+39', country: 'Włochy', digits: 10 }, - { iso2: 'lv', code: '+371', country: 'Łotwa', digits: 8 }, - { iso2: 'lt', code: '+370', country: 'Litwa', digits: 8 }, - { iso2: 'lu', code: '+352', country: 'Luksemburg', digits: 9 }, - { iso2: 'mt', code: '+356', country: 'Malta', digits: 8 }, - { iso2: 'nl', code: '+31', country: 'Holandia', digits: 9 }, - { iso2: 'no', code: '+47', country: 'Norwegia', digits: 8 }, - { iso2: 'pt', code: '+351', country: 'Portugalia', digits: 9 }, - { iso2: 'ro', code: '+40', country: 'Rumunia', digits: 9 }, - { iso2: 'rs', code: '+381', country: 'Serbia', digits: 9 }, - { iso2: 'sk', code: '+421', country: 'Słowacja', digits: 9 }, - { iso2: 'si', code: '+386', country: 'Słowenia', digits: 8 }, - { iso2: 'es', code: '+34', country: 'Hiszpania', digits: 9 }, - { iso2: 'se', code: '+46', country: 'Szwecja', digits: 9 }, - { iso2: 'ch', code: '+41', country: 'Szwajcaria', digits: 9 }, - { iso2: 'ua', code: '+380', country: 'Ukraina', digits: 9 }, - { iso2: 'gb', code: '+44', country: 'Wielka Brytania', digits: 10 }, - { iso2: 'tr', code: '+90', country: 'Turcja', digits: 10 }, - { iso2: 'us', code: '+1', country: 'USA/Kanada', digits: 10 }, - { iso2: 'mx', code: '+52', country: 'Meksyk', digits: 10 }, - { iso2: 'br', code: '+55', country: 'Brazylia', digits: 11 }, - { iso2: 'ar', code: '+54', country: 'Argentyna', digits: 10 }, - { iso2: 'cl', code: '+56', country: 'Chile', digits: 9 }, - { iso2: 'co', code: '+57', country: 'Kolumbia', digits: 10 }, - { iso2: 'au', code: '+61', country: 'Australia', digits: 9 }, - { iso2: 'nz', code: '+64', country: 'Nowa Zelandia', digits: 9 }, - { iso2: 'jp', code: '+81', country: 'Japonia', digits: 10 }, - { iso2: 'kr', code: '+82', country: 'Korea Południowa', digits: 10 }, - { iso2: 'cn', code: '+86', country: 'Chiny', digits: 11 }, - { iso2: 'in', code: '+91', country: 'Indie', digits: 10 }, - { iso2: 'pk', code: '+92', country: 'Pakistan', digits: 10 }, - { iso2: 'id', code: '+62', country: 'Indonezja', digits: 10 }, - { iso2: 'th', code: '+66', country: 'Tajlandia', digits: 9 }, - { iso2: 'vn', code: '+84', country: 'Wietnam', digits: 9 }, - { iso2: 'sg', code: '+65', country: 'Singapur', digits: 8 }, - { iso2: 'my', code: '+60', country: 'Malezja', digits: 10 }, - { iso2: 'za', code: '+27', country: 'RPA', digits: 9 }, - { iso2: 'eg', code: '+20', country: 'Egipt', digits: 10 }, - { iso2: 'ma', code: '+212', country: 'Maroko', digits: 9 }, - { iso2: 'ng', code: '+234', country: 'Nigeria', digits: 10 }, - { iso2: 'ke', code: '+254', country: 'Kenia', digits: 9 }, - { iso2: 'ae', code: '+971', country: 'ZEA', digits: 9 }, - { iso2: 'sa', code: '+966', country: 'Arabia Saudyjska', digits: 9 }, - { iso2: 'il', code: '+972', country: 'Izrael', digits: 9 }, - { iso2: 'qa', code: '+974', country: 'Katar', digits: 8 }, - ]; +// Musi odpowiadac RESEND_COOLDOWN_SECONDS w PhoneVerificationService - inaczej przycisk odblokuje +// sie wczesniej, niz backend przyjmie kolejna wysylke, i uzytkownik dostanie blad 429. +const OTP_RESEND_COOLDOWN_SECONDS = 60; - const [profile, setProfile] = useState({ - firstName: getFirstName(getDisplayName(user)), - lastName: getLastName(getDisplayName(user)), - nick: (user?.email || 'uzytkownik').split('@')[0], - email: user?.email || '', - phonePrefix: window.localStorage.getItem(accountStorageKey('PhonePrefix', user)) ?? '+48', - phone: (window.localStorage.getItem(accountStorageKey('PhoneDigits', user)) ?? (user?.phone || '').replace(/\D/g, '')), - birthDate: user?.birthDate ?? window.localStorage.getItem(accountStorageKey('BirthDate', user)) ?? '', - address: user?.address ?? window.localStorage.getItem(accountStorageKey('Address', user)) ?? '', - contactPreference: user?.contactPreference ?? 'EMAIL_AND_PHONE', - preferredLanguage: user?.preferredLanguage ?? 'PL', +// Pojedynczy wiersz z przelacznikiem. Zapis idzie na serwer od razu po kliknieciu. +function SettingsToggleRow({ icon, label, hint, checked, disabled, onChange }: { + icon: string; + label: string; + hint?: string; + checked: boolean; + disabled?: boolean; + onChange: (next: boolean) => void; +}) { + return ( +
+ + {label}{hint && {hint}} +
+ ); +} + +function AccountSettingsPage() { + const { user, updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, verifyPhone, resendPhoneOtp } = useAuth(); + const { notify } = useNotifications(); + const navigate = useNavigate(); + + type Tone = 'success' | 'error' | 'info'; + type Banner = { text: string; tone: Tone } | null; + + const [settings, setSettings] = useState(null); + const [consent, setConsent] = useState({ emailConsent: false, smsConsent: false }); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + + // --- Formularz danych osobowych --- + const [identity, setIdentity] = useState(() => ({ + firstName: '', + lastName: '', + nick: '', + phonePrefix: DEFAULT_PHONE_COUNTRY.code as string, + phoneDigits: '', + birthDate: '', + address: '', + contactPreference: 'EMAIL_AND_PHONE' as ContactPreference, + preferredLanguage: 'PL' as PreferredLanguage, bio: '', - }); - const [profilePhotoName, setProfilePhotoName] = useState(() => window.localStorage.getItem(accountStorageKey('AvatarName', user)) ?? ''); - const [profilePhotoImage, setProfilePhotoImage] = useState(() => window.localStorage.getItem(accountStorageKey('AvatarImage', user)) ?? ''); - const profilePhotoInputRef = useRef(null); - const phoneCountryRef = useRef(null); - const [coverPhotoName, setCoverPhotoName] = useState(() => window.localStorage.getItem(accountStorageKey('CoverName', user)) ?? ''); - const coverPhotoInputRef = useRef(null); - const [message, setMessage] = useState(''); - const [messageTone, setMessageTone] = useState('success'); - const [isPhoneCountryOpen, setIsPhoneCountryOpen] = useState(false); + })); + const [identityBanner, setIdentityBanner] = useState(null); + const [isSavingIdentity, setIsSavingIdentity] = useState(false); + const [isSuggestingNick, setIsSuggestingNick] = useState(false); const [isPhoneTouched, setIsPhoneTouched] = useState(false); - const [isPhoneVerified, setIsPhoneVerified] = useState(false); - const [profileVisibility, setProfileVisibility] = useState<'publiczna' | 'prywatna' | 'kontakty'>('publiczna'); - const [isPhoneVerificationModalOpen, setIsPhoneVerificationModalOpen] = useState(false); - const [phoneVerificationStep, setPhoneVerificationStep] = useState<1 | 2 | 3>(1); - const [verificationCodeDigits, setVerificationCodeDigits] = useState(() => Array.from({ length: 6 }, () => '')); - const [verificationResendCooldown, setVerificationResendCooldown] = useState(28); - const [verificationCodeError, setVerificationCodeError] = useState(''); - const verificationInputRefs = useRef>([]); + const [isPhoneCountryOpen, setIsPhoneCountryOpen] = useState(false); + const phoneCountryRef = useRef(null); + const avatarInputRef = useRef(null); + const coverInputRef = useRef(null); - const selectedPhoneCountry = phoneCountryOptions.find((option) => option.code === profile.phonePrefix) ?? phoneCountryOptions[0]; - const selectedPhoneDigitsRequired = selectedPhoneCountry.digits; - const phoneDigitsOnly = profile.phone.replace(/\D/g, ''); - const isPhoneNumberComplete = phoneDigitsOnly.length === selectedPhoneDigitsRequired; - const shouldShowPhoneError = isPhoneTouched && !isPhoneNumberComplete; + // --- Weryfikacja telefonu (realny OTP z backendu) --- + const [isOtpOpen, setIsOtpOpen] = useState(false); + const [otpCode, setOtpCode] = useState(''); + const [otpError, setOtpError] = useState(''); + const [otpInfo, setOtpInfo] = useState(''); + const [otpBusy, setOtpBusy] = useState(false); + const [otpCooldown, setOtpCooldown] = useState(0); - const phoneMaskGroups = (() => { - if (selectedPhoneDigitsRequired <= 7) { - return [3, selectedPhoneDigitsRequired - 3]; - } - if (selectedPhoneDigitsRequired === 8) { - return [4, 4]; - } - if (selectedPhoneDigitsRequired === 9) { - return [3, 3, 3]; - } - if (selectedPhoneDigitsRequired === 10) { - return [3, 3, 4]; - } - return [3, 4, selectedPhoneDigitsRequired - 7]; - })(); + // --- Preferencje wyszukiwania --- + const [searchPrefs, setSearchPrefs] = useState({ + locations: '', + propertyType: '', + budgetMax: '', + areaMin: '', + areaMax: '', + roomsMin: '', + roomsMax: '', + }); + const [searchBanner, setSearchBanner] = useState(null); + const [isSavingSearch, setIsSavingSearch] = useState(false); - const formatPhoneWithMask = (digits: string) => { - let cursor = 0; - return phoneMaskGroups - .map((groupLength) => { - const chunk = digits.slice(cursor, cursor + groupLength); - cursor += groupLength; - return chunk; - }) - .filter(Boolean) - .join('-'); - }; + // --- Bezpieczenstwo --- + const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' }); + const [passwordBanner, setPasswordBanner] = useState(null); + const [isSavingPassword, setIsSavingPassword] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [deletePassword, setDeletePassword] = useState(''); + const [deleteError, setDeleteError] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [prefsBanner, setPrefsBanner] = useState(null); + + const selectedCountry: PhoneCountry = PHONE_COUNTRY_OPTIONS.find((option) => option.code === identity.phonePrefix) ?? DEFAULT_PHONE_COUNTRY; + const phoneMaskGroups = phoneMaskGroupsFor(selectedCountry.digits); const phoneMaskPlaceholder = phoneMaskGroups.map((groupLength) => '-'.repeat(groupLength)).join('-'); - const phoneDisplayValue = formatPhoneWithMask(phoneDigitsOnly); - const phoneSmsDisplayValue = formatPhoneWithMask(phoneDigitsOnly).replace(/-/g, ' '); + const phoneDisplayValue = formatPhoneWithMask(identity.phoneDigits, phoneMaskGroups); + const isPhoneComplete = identity.phoneDigits.length === selectedCountry.digits; + const shouldShowPhoneError = isPhoneTouched && identity.phoneDigits.length > 0 && !isPhoneComplete; + const composedPhone = identity.phoneDigits ? `${identity.phonePrefix} ${identity.phoneDigits}` : ''; + // OTP idzie na numer zapisany w bazie, wiec potwierdzac mozna dopiero zapisany numer. + const isPhoneSaved = composedPhone !== '' && composedPhone === (user?.phone ?? ''); const todayIsoDate = new Date().toISOString().slice(0, 10); - const setField = (field: keyof typeof profile, value: string) => { - setProfile((current) => ({ ...current, [field]: value })); - }; - - const showTemporaryMessage = (text: string, tone: MessageTone = 'success') => { - setMessageTone(tone); - setMessage(text); - setTimeout(() => setMessage(''), 2400); - }; - - const saveProfile = async () => { - if (phoneDigitsOnly.length > 0 && !isPhoneNumberComplete) { - setIsPhoneTouched(true); - showTemporaryMessage(`Numer telefonu dla kraju ${selectedPhoneCountry.country} musi mieć ${selectedPhoneDigitsRequired} cyfr.`, 'error'); + // Formularz wypelniamy danymi z serwera - tymi samymi, ktore uzytkownik podal przy rejestracji. + useEffect(() => { + if (!user) { return; } - const fullPhone = profile.phone ? `${profile.phonePrefix} ${profile.phone}` : ''; - try { - await updateProfile( - `${profile.firstName} ${profile.lastName}`.trim(), - fullPhone || undefined, - profile.birthDate || undefined, - profile.address.trim() || undefined, - profile.contactPreference as ContactPreference, - profile.preferredLanguage as PreferredLanguage, - ); - } catch (error) { - showTemporaryMessage(error instanceof Error ? error.message : 'Nie udało się zapisać zmian.', 'error'); + const phone = splitStoredPhone(user.phone); + setIdentity((current) => ({ + ...current, + firstName: getFirstName(user.fullName || ''), + lastName: getLastName(user.fullName || ''), + nick: user.nick ?? '', + phonePrefix: phone.prefix, + phoneDigits: phone.digits, + birthDate: user.birthDate ?? '', + address: user.address ?? '', + contactPreference: user.contactPreference, + preferredLanguage: user.preferredLanguage, + })); + }, [user]); + + useEffect(() => { + if (!user) { return; } - window.localStorage.setItem(accountStorageKey('PhonePrefix', user), profile.phonePrefix); - window.localStorage.setItem(accountStorageKey('PhoneDigits', user), profile.phone); - window.localStorage.setItem(accountStorageKey('Phone', user), fullPhone); - showTemporaryMessage('Zmiany zapisane pomyślnie.', 'success'); - }; - - const handlePhoneInputChange = (rawValue: string) => { - const digits = rawValue.replace(/\D/g, '').slice(0, selectedPhoneDigitsRequired); - setField('phone', digits); - setIsPhoneTouched(true); - setIsPhoneVerified(false); - }; - - const handlePhoneCountryChange = (countryCode: string) => { - const nextCountry = phoneCountryOptions.find((option) => option.code === countryCode); - if (!nextCountry) { - return; - } - const normalizedPhone = phoneDigitsOnly.slice(0, nextCountry.digits); - setProfile((current) => ({ ...current, phonePrefix: nextCountry.code, phone: normalizedPhone })); - setIsPhoneTouched(true); - setIsPhoneVerified(false); - }; - - const openPhoneVerificationModal = () => { - if (!isPhoneNumberComplete) { - setIsPhoneTouched(true); - return; - } - setPhoneVerificationStep(1); - setVerificationCodeDigits(Array.from({ length: 6 }, () => '')); - setVerificationResendCooldown(28); - setVerificationCodeError(''); - setIsPhoneVerificationModalOpen(true); - }; - - const closePhoneVerificationModal = () => { - setPhoneVerificationStep(1); - setVerificationCodeDigits(Array.from({ length: 6 }, () => '')); - setVerificationResendCooldown(28); - setVerificationCodeError(''); - setIsPhoneVerificationModalOpen(false); - }; - - const sendVerificationSms = () => { - showTemporaryMessage('Wysłano SMS z kodem potwierdzającym.', 'success'); - setVerificationCodeDigits(Array.from({ length: 6 }, () => '')); - setVerificationResendCooldown(28); - setVerificationCodeError(''); - setPhoneVerificationStep(2); - }; - - const handleVerificationDigitChange = (index: number, rawValue: string) => { - const nextDigit = rawValue.replace(/\D/g, '').slice(-1); - const nextDigits = [...verificationCodeDigits]; - nextDigits[index] = nextDigit; - setVerificationCodeDigits(nextDigits); - setVerificationCodeError(''); - if (nextDigit && index < verificationInputRefs.current.length - 1) { - verificationInputRefs.current[index + 1]?.focus(); - } - }; - - const handleVerificationDigitKeyDown = (index: number, event: ReactKeyboardEvent) => { - if (event.key === 'Backspace' && !verificationCodeDigits[index] && index > 0) { - verificationInputRefs.current[index - 1]?.focus(); - } - }; - - const handleVerificationPaste = (event: ReactClipboardEvent) => { - event.preventDefault(); - const pastedDigits = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); - if (!pastedDigits) { - return; - } - const nextDigits = Array.from({ length: 6 }, (_, index) => pastedDigits[index] ?? ''); - setVerificationCodeDigits(nextDigits); - setVerificationCodeError(''); - const focusIndex = Math.min(pastedDigits.length, 5); - verificationInputRefs.current[focusIndex]?.focus(); - }; - - const resendVerificationSms = () => { - if (verificationResendCooldown > 0) { - return; - } - setVerificationResendCooldown(28); - setVerificationCodeError(''); - showTemporaryMessage('Wysłano nowy kod SMS.', 'success'); - }; - - const handleModeratedUpload = async (event: ReactChangeEvent, target: UploadTarget) => { - const file = event.target.files?.[0]; - if (!file) { - return; - } - - showTemporaryMessage('Trwa weryfikacja zdjęcia...', 'info'); - const result = await verifyUploadedImage(file, target); - if (!result.ok) { - showTemporaryMessage(result.reason, 'error'); - event.target.value = ''; - return; - } - - if (target === 'avatar') { - window.localStorage.setItem(accountStorageKey('AvatarImage', user), result.dataUrl); - window.localStorage.setItem(accountStorageKey('AvatarName', user), file.name); - setProfilePhotoImage(result.dataUrl); - setProfilePhotoName(file.name); - showTemporaryMessage('Zdjęcie profilowe przeszło weryfikację i zostało zapisane.', 'success'); - return; - } - - window.localStorage.setItem(accountStorageKey('CoverImage', user), result.dataUrl); - window.localStorage.setItem(accountStorageKey('CoverName', user), file.name); - setCoverPhotoName(file.name); - showTemporaryMessage('Zdjęcie w tle przeszło weryfikację i zostało zapisane.', 'success'); - }; - - const clearProfilePhoto = () => { - window.localStorage.removeItem(accountStorageKey('AvatarImage', user)); - window.localStorage.removeItem(accountStorageKey('AvatarName', user)); - if (profilePhotoInputRef.current) { - profilePhotoInputRef.current.value = ''; - } - setProfilePhotoImage(''); - setProfilePhotoName(''); - showTemporaryMessage('Usunięto zdjęcie profilowe.', 'success'); - }; - - const clearCoverPhoto = () => { - window.localStorage.removeItem(accountStorageKey('CoverImage', user)); - window.localStorage.removeItem(accountStorageKey('CoverName', user)); - if (coverPhotoInputRef.current) { - coverPhotoInputRef.current.value = ''; - } - setCoverPhotoName(''); - showTemporaryMessage('Usunięto zdjęcie w tle.', 'success'); - }; + let active = true; + setIsLoading(true); + Promise.all([ + loadSettings(), + apiFetch<{ emailConsent: boolean; smsConsent: boolean }>('/me/marketing').catch(() => ({ emailConsent: false, smsConsent: false })), + ]) + .then(([loaded, marketing]) => { + if (!active) { + return; + } + setSettings(loaded); + setConsent({ emailConsent: marketing.emailConsent, smsConsent: marketing.smsConsent }); + setIdentity((current) => ({ ...current, bio: loaded.bio ?? '' })); + setSearchPrefs({ + locations: loaded.searchLocations ?? '', + propertyType: loaded.searchPropertyType ?? '', + budgetMax: loaded.searchBudgetMax != null ? String(loaded.searchBudgetMax) : '', + areaMin: loaded.searchAreaMin != null ? String(loaded.searchAreaMin) : '', + areaMax: loaded.searchAreaMax != null ? String(loaded.searchAreaMax) : '', + roomsMin: loaded.searchRoomsMin != null ? String(loaded.searchRoomsMin) : '', + roomsMax: loaded.searchRoomsMax != null ? String(loaded.searchRoomsMax) : '', + }); + setLoadError(''); + }) + .catch((error: unknown) => { + if (active) { + setLoadError(error instanceof Error ? error.message : 'Nie udało się wczytać ustawień konta.'); + } + }) + .finally(() => { + if (active) { + setIsLoading(false); + } + }); + return () => { + active = false; + }; + }, [user, loadSettings]); useEffect(() => { const closeOnOutsideClick = (event: MouseEvent) => { - if (!phoneCountryRef.current) { - return; - } - if (!phoneCountryRef.current.contains(event.target as Node)) { + if (phoneCountryRef.current && !phoneCountryRef.current.contains(event.target as Node)) { setIsPhoneCountryOpen(false); } }; - window.addEventListener('mousedown', closeOnOutsideClick); return () => window.removeEventListener('mousedown', closeOnOutsideClick); }, []); useEffect(() => { - if (!isPhoneVerificationModalOpen || phoneVerificationStep !== 2 || verificationResendCooldown <= 0) { + if (otpCooldown <= 0) { return; } - const intervalId = window.setInterval(() => { - setVerificationResendCooldown((current) => (current > 0 ? current - 1 : 0)); - }, 1000); - return () => window.clearInterval(intervalId); - }, [isPhoneVerificationModalOpen, phoneVerificationStep, verificationResendCooldown]); + const timer = window.setTimeout(() => setOtpCooldown((current) => current - 1), 1000); + return () => window.clearTimeout(timer); + }, [otpCooldown]); - useEffect(() => { - if (!isPhoneVerificationModalOpen || phoneVerificationStep !== 2) { - return; - } - verificationInputRefs.current[0]?.focus(); - }, [isPhoneVerificationModalOpen, phoneVerificationStep]); + const setIdentityField = (field: K, value: (typeof identity)[K]) => { + setIdentity((current) => ({ ...current, [field]: value })); + }; - useEffect(() => { - if (!isPhoneVerificationModalOpen || phoneVerificationStep !== 2) { + /** + * Prosi serwer o wolna nazwe uzytkownika dla imienia i nazwiska z formularza. Uzytkownik nie wpisuje + * nicka recznie - przyjmuje propozycje, a wolne miejsce w puli sprawdza baza, nie przegladarka. + */ + const suggestNickFromName = async () => { + const fullName = `${identity.firstName.trim()} ${identity.lastName.trim()}`.trim(); + if (!fullName) { + setIdentityBanner({ text: 'Podaj imię i nazwisko, żeby dopasować nazwę użytkownika.', tone: 'error' }); return; } - const isComplete = verificationCodeDigits.every((digit) => digit.length === 1); - if (!isComplete) { - if (verificationCodeError) { - setVerificationCodeError(''); + setIsSuggestingNick(true); + setIdentityBanner(null); + try { + const suggestion = await apiFetch<{ nick: string }>(`/auth/nick-suggestion?fullName=${encodeURIComponent(fullName)}`); + setIdentityField('nick', suggestion.nick); + if (suggestion.nick === (user?.nick ?? '')) { + setIdentityBanner({ text: 'Twoja nazwa użytkownika już odpowiada imieniu i nazwisku.', tone: 'info' }); } + } catch (error) { + setIdentityBanner({ text: error instanceof Error ? error.message : 'Nie udało się dopasować nazwy użytkownika.', tone: 'error' }); + } finally { + setIsSuggestingNick(false); + } + }; + + /** + * Zapis pojedynczego ustawienia. Przelacznik zmienia sie od razu (UI nie czeka na siec), + * a przy bledzie wracamy do poprzedniej wartosci i mowimy o tym wprost. + */ + const patchSettings = async (patch: UserSettingsPatch, successText?: string) => { + const previous = settings; + if (previous) { + setSettings({ ...previous, ...(patch as Partial) }); + } + try { + const saved = await saveSettings(patch); + setSettings(saved); + if (successText) { + setPrefsBanner({ text: successText, tone: 'success' }); + } + return saved; + } catch (error) { + if (previous) { + setSettings(previous); + } + setPrefsBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać ustawienia.', tone: 'error' }); + return null; + } + }; + + const updateConsent = async (next: { emailConsent: boolean; smsConsent: boolean }) => { + const previous = consent; + setConsent(next); + try { + await apiFetch('/me/marketing', { method: 'PUT', body: JSON.stringify(next) }); + } catch (error) { + setConsent(previous); + setPrefsBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać zgody.', tone: 'error' }); + } + }; + + const saveIdentity = async () => { + if (identity.phoneDigits.length > 0 && !isPhoneComplete) { + setIsPhoneTouched(true); + setIdentityBanner({ text: `Numer telefonu dla kraju ${selectedCountry.country} musi mieć ${selectedCountry.digits} cyfr.`, tone: 'error' }); return; } - const enteredCode = verificationCodeDigits.join(''); - if (enteredCode === SMS_VERIFICATION_CODE) { - setVerificationCodeError(''); - setIsPhoneVerified(true); - setPhoneVerificationStep(3); - notify({ category: 'system', icon: 'check', title: 'Numer telefonu zweryfikowany', body: 'Twój numer telefonu został pomyślnie zweryfikowany.', link: 'accountSecurity', dedupeKey: 'phone-verified' }); + const fullName = `${identity.firstName.trim()} ${identity.lastName.trim()}`.trim(); + if (!fullName) { + setIdentityBanner({ text: 'Podaj imię i nazwisko.', tone: 'error' }); return; } - setVerificationCodeError('Kod jest nieprawidłowy. Spróbuj ponownie.'); - }, [isPhoneVerificationModalOpen, phoneVerificationStep, verificationCodeDigits, verificationCodeError]); + + setIsSavingIdentity(true); + setIdentityBanner(null); + try { + await updateProfile({ + fullName, + nick: identity.nick.trim() || undefined, + phone: composedPhone || undefined, + birthDate: identity.birthDate || undefined, + address: identity.address.trim() || undefined, + contactPreference: identity.contactPreference, + preferredLanguage: identity.preferredLanguage, + }); + await patchSettings({ bio: identity.bio.trim() }); + setIdentityBanner({ text: 'Dane zapisane.', tone: 'success' }); + } catch (error) { + setIdentityBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać danych.', tone: 'error' }); + } finally { + setIsSavingIdentity(false); + } + }; + + const handleImageUpload = async (event: ReactChangeEvent, target: SettingsUploadTarget) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + setPrefsBanner({ text: 'Trwa weryfikacja zdjęcia...', tone: 'info' }); + const result = await prepareProfileImage(file, target); + event.target.value = ''; + if (!result.ok) { + setPrefsBanner({ text: result.reason, tone: 'error' }); + return; + } + await patchSettings( + target === 'avatar' ? { avatarImage: result.dataUrl } : { coverImage: result.dataUrl }, + target === 'avatar' ? 'Zdjęcie profilowe zapisane.' : 'Zdjęcie w tle zapisane.', + ); + }; + + const sendOtp = async () => { + if (!user) { + return; + } + setOtpBusy(true); + setOtpError(''); + setOtpInfo(''); + try { + await resendPhoneOtp(user.email); + setOtpInfo(`Wysłaliśmy kod SMS na numer ${user.phone ?? ''}.`); + setOtpCooldown(OTP_RESEND_COOLDOWN_SECONDS); + setIsOtpOpen(true); + } catch (error) { + const message = error instanceof Error ? error.message : 'Nie udało się wysłać kodu SMS.'; + if (isOtpOpen) { + // Okno juz otwarte (ponowna wysylka) - blad pokazujemy w nim. + setOtpError(message); + } else { + // SMS nie wyszedl, wiec nie otwieramy okna na kod, ktorego nikt nie dostanie. + setPrefsBanner({ text: message, tone: 'error' }); + } + } finally { + setOtpBusy(false); + } + }; + + const submitOtp = async (event: ReactFormEvent) => { + event.preventDefault(); + if (!user) { + return; + } + setOtpBusy(true); + setOtpError(''); + try { + await verifyPhone(user.email, otpCode.trim()); + await refreshUser(); + setIsOtpOpen(false); + setOtpCode(''); + notify({ + category: 'system', + icon: 'check', + title: 'Numer telefonu zweryfikowany', + body: 'Twój numer telefonu został pomyślnie potwierdzony.', + link: 'accountSettings', + dedupeKey: 'phone-verified', + }); + } catch (error) { + setOtpError(error instanceof Error ? error.message : 'Kod jest nieprawidłowy.'); + } finally { + setOtpBusy(false); + } + }; + + const saveSearchPrefs = async () => { + setIsSavingSearch(true); + setSearchBanner(null); + // Puste pole liczbowe wysylamy jako -1: backend traktuje to jako wyczyszczenie wartosci. + const asNumber = (value: string) => (value.trim() === '' ? -1 : Number(value)); + const saved = await patchSettings({ + searchLocations: searchPrefs.locations.trim(), + searchPropertyType: searchPrefs.propertyType.trim(), + searchBudgetMax: asNumber(searchPrefs.budgetMax), + searchAreaMin: asNumber(searchPrefs.areaMin), + searchAreaMax: asNumber(searchPrefs.areaMax), + searchRoomsMin: asNumber(searchPrefs.roomsMin), + searchRoomsMax: asNumber(searchPrefs.roomsMax), + }); + setSearchBanner(saved + ? { text: 'Preferencje wyszukiwania zapisane.', tone: 'success' } + : { text: 'Nie udało się zapisać preferencji.', tone: 'error' }); + setIsSavingSearch(false); + }; + + const submitPasswordChange = async (event: ReactFormEvent) => { + event.preventDefault(); + setPasswordBanner(null); + if (passwordForm.next.length < 8) { + setPasswordBanner({ text: 'Nowe hasło musi mieć co najmniej 8 znaków.', tone: 'error' }); + return; + } + if (passwordForm.next !== passwordForm.confirm) { + setPasswordBanner({ text: 'Nowe hasła nie są identyczne.', tone: 'error' }); + return; + } + setIsSavingPassword(true); + try { + await changePassword(passwordForm.current, passwordForm.next); + setPasswordForm({ current: '', next: '', confirm: '' }); + setPasswordBanner({ text: 'Hasło zostało zmienione.', tone: 'success' }); + } catch (error) { + setPasswordBanner({ text: error instanceof Error ? error.message : 'Nie udało się zmienić hasła.', tone: 'error' }); + } finally { + setIsSavingPassword(false); + } + }; + + const submitAccountDeletion = async (event: ReactFormEvent) => { + event.preventDefault(); + setIsDeleting(true); + setDeleteError(''); + try { + await deleteAccount(deletePassword); + navigate(ROUTES.home, { replace: true }); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : 'Nie udało się usunąć konta.'); + } finally { + setIsDeleting(false); + } + }; + + const avatarImage = settings?.avatarImage ?? ''; + const coverImage = settings?.coverImage ?? ''; + const visibility = settings?.profileVisibility ?? 'PUBLIC'; + const accountTypeLabel = user?.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto prywatne'; return ( <> -
-
- +
+
+ -
-
Dane i ustawienia Edytuj profil
-
-

Edytuj profil

-

Zarządzaj swoimi danymi osobowymi i wizerunkiem w serwisie Polska Lokalnie.

-
+
+
+

Dane i ustawienia

+

Wszystkie ustawienia konta w jednym miejscu - dane osobowe, profil, powiadomienia i bezpieczeństwo.

+
-
-
-
-

Zdjęcie profilowe

-
+ {loadError &&

{loadError}

} + {prefsBanner &&

{prefsBanner.text}

} -
-
{profilePhotoImage ? Zdjęcie profilowe : getInitials(getDisplayName(user))}
-
- {profilePhotoName ? `Wybrano: ${profilePhotoName}` : 'JPG, PNG lub WEBP. Maks. 5 MB.'} -
- - {profilePhotoName && } -
+
+ {/* --- Dane osobowe: to, co uzytkownik podal przy rejestracji, gotowe do edycji --- */} +
+
+

Moje dane

+

Nazwa użytkownika, e-mail i telefon są zapisane z rejestracji. Możesz je tutaj zmienić.

-
-
- -
-

Dane podstawowe

-
- -
- - -
- -
-