aktuazalizacja profilow,prefernecji,bezpiecznestwa

This commit is contained in:
2026-08-13 16:18:46 +02:00
parent fbb7ab2d90
commit 571ec05d76
22 changed files with 1339 additions and 151 deletions
@@ -17,6 +17,7 @@ import pl.polskalokalnie.auth.dto.AccountRequests.DeleteAccountRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest; import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest; import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest; import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPasswordRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest; import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest;
import pl.polskalokalnie.auth.dto.AuthResponse; import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest; import pl.polskalokalnie.auth.dto.LoginRequest;
@@ -123,6 +124,25 @@ public class AuthController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* Sprawdza samo dotychczasowe haslo. Formularz ustawien odblokowuje pola nowego hasla dopiero
* po poprawnej odpowiedzi - blad 400 oznacza zle haslo.
*/
@PostMapping("/verify-password")
public ResponseEntity<Void> verifyPassword(Authentication authentication, @Valid @RequestBody VerifyPasswordRequest request) {
authService.verifyCurrentPassword(authentication.getName(), request.password());
return ResponseEntity.noContent().build();
}
/** Ile sekund zostalo do momentu, w ktorym mozna ponownie zmienic haslo (0 = mozna teraz). */
@GetMapping("/password-cooldown")
public PasswordCooldownResponse passwordCooldown(Authentication authentication) {
return new PasswordCooldownResponse(authService.passwordCooldownSeconds(authentication.getName()));
}
public record PasswordCooldownResponse(long secondsLeft) {
}
@DeleteMapping("/me") @DeleteMapping("/me")
public ResponseEntity<Void> deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) { public ResponseEntity<Void> deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) {
authService.deleteOwnAccount(authentication.getName(), request.password()); authService.deleteOwnAccount(authentication.getName(), request.password());
@@ -1,6 +1,7 @@
package pl.polskalokalnie.auth; package pl.polskalokalnie.auth;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
@@ -38,6 +39,8 @@ public class AuthService {
private static final SecureRandom TOKEN_RANDOM = new SecureRandom(); private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
private static final int ACTIVATION_TTL_MINUTES = 48 * 60; private static final int ACTIVATION_TTL_MINUTES = 48 * 60;
private static final int RESET_TTL_MINUTES = 60; private static final int RESET_TTL_MINUTES = 60;
// Odstep miedzy kolejnymi zmianami hasla z poziomu ustawien konta.
private static final int PASSWORD_CHANGE_COOLDOWN_MINUTES = 5;
private final UserRepository userRepository; private final UserRepository userRepository;
private final BlockedEmailRepository blockedEmailRepository; private final BlockedEmailRepository blockedEmailRepository;
@@ -297,10 +300,19 @@ public class AuthService {
public UserResponse currentUser(String email) { public UserResponse currentUser(String email) {
return userRepository.findByEmailIgnoreCase(email) return userRepository.findByEmailIgnoreCase(email)
.map(UserResponse::from) .map(this::withAvatar)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
} }
/**
* Profil zalogowanego uzytkownika razem ze zdjeciem z ustawien konta. Dzieki temu naglowek,
* panel konta i kazde inne miejsce pokazujace zalogowanego uzytkownika maja zdjecie od razu,
* bez osobnego zapytania o pelne ustawienia.
*/
private UserResponse withAvatar(AppUser user) {
return UserResponse.from(user, userSettingsService.avatarFor(user.getId()));
}
public UserResponse updateProfile(String email, UpdateProfileRequest request) { public UserResponse updateProfile(String email, UpdateProfileRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email) AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -334,7 +346,7 @@ public class AuthService {
AppUser saved = userRepository.save(user); AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved); leadSyncService.syncUser(saved);
return UserResponse.from(saved); return withAvatar(saved);
} }
/** /**
@@ -350,6 +362,16 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.CONFLICT, throw new ResponseStatusException(HttpStatus.CONFLICT,
"To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie"); "To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie");
} }
// Odstep miedzy zmianami pilnuje serwer, a nie licznik w przegladarce - blokada w interfejsie
// jest tylko podpowiedzia i mozna ja obejsc wolajac API wprost.
long waitSeconds = passwordCooldownSeconds(user);
if (waitSeconds > 0) {
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Hasło można zmieniać co " + PASSWORD_CHANGE_COOLDOWN_MINUTES
+ " minut. Kolejna zmiana będzie możliwa za " + formatWait(waitSeconds) + ".");
}
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) { if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe");
} }
@@ -358,6 +380,7 @@ public class AuthService {
} }
user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now());
userRepository.save(user); userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla. // Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -369,6 +392,51 @@ public class AuthService {
}); });
} }
/**
* Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Formularz ustawien odblokowuje
* pola nowego hasla dopiero, gdy obecne jest poprawne - dzieki temu uzytkownik nie wypelnia
* calego formularza po to, zeby dowiedziec sie, ze pomylil pierwsze pole.
*/
public void verifyCurrentPassword(String email, String password) {
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 (password == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe");
}
}
/** Ile sekund zostalo do momentu, w ktorym mozna ponownie zmienic haslo. Zero = mozna od razu. */
public long passwordCooldownSeconds(String email) {
return userRepository.findByEmailIgnoreCase(email)
.map(this::passwordCooldownSeconds)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
}
private long passwordCooldownSeconds(AppUser user) {
Instant last = user.getPasswordChangedAt();
if (last == null) {
return 0;
}
long elapsed = Duration.between(last, Instant.now()).getSeconds();
long total = PASSWORD_CHANGE_COOLDOWN_MINUTES * 60L;
return elapsed >= total ? 0 : total - elapsed;
}
// "4 min 12 s" zamiast "252 s" - komunikat czyta czlowiek, nie licznik.
private static String formatWait(long seconds) {
long minutes = seconds / 60;
long rest = seconds % 60;
if (minutes == 0) {
return rest + " s";
}
return minutes + " min " + rest + " s";
}
/** /**
* Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu * Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu
* z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu. * z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu.
@@ -390,7 +458,7 @@ public class AuthService {
} }
private AuthResponse buildAuthResponse(AppUser user) { private AuthResponse buildAuthResponse(AppUser user) {
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user)); return new AuthResponse(jwtService.generateToken(user), withAvatar(user));
} }
private String normalizeEmail(String email) { private String normalizeEmail(String email) {
@@ -29,6 +29,10 @@ public final class AccountRequests {
) { ) {
} }
/** Sprawdzenie dotychczasowego hasla przed odblokowaniem pol nowego hasla. */
public record VerifyPasswordRequest(@NotBlank String password) {
}
/** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */ /** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */
public record DeleteAccountRequest(@NotBlank String password) { public record DeleteAccountRequest(@NotBlank String password) {
} }
@@ -27,9 +27,16 @@ public record UserResponse(
boolean phoneVerified, boolean phoneVerified,
boolean blocked, boolean blocked,
int promotionCredits, int promotionCredits,
Instant createdAt Instant createdAt,
// Zdjecie profilowe z ustawien konta (data URL) albo null - wtedy interfejs rysuje inicjaly.
String avatarImage
) { ) {
/** Wariant bez zdjecia - dla miejsc, ktore go nie pokazuja (np. lista kont w panelu admina). */
public static UserResponse from(AppUser user) { public static UserResponse from(AppUser user) {
return from(user, null);
}
public static UserResponse from(AppUser user, String avatarImage) {
return new UserResponse( return new UserResponse(
user.getId(), user.getId(),
user.getEmail(), user.getEmail(),
@@ -48,7 +55,8 @@ public record UserResponse(
user.isPhoneVerified(), user.isPhoneVerified(),
user.isBlocked(), user.isBlocked(),
user.getPromotionCredits(), user.getPromotionCredits(),
user.getCreatedAt() user.getCreatedAt(),
avatarImage
); );
} }
} }
@@ -84,6 +84,26 @@ public class SchemaFixer {
"ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE" "ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE"
); );
// Widocznosc profilu ma juz tylko dwie wartosci - wariant "tylko kontakty" zostal usuniety.
// Konta, ktore go wybraly, chcialy ograniczyc widocznosc, wiec ladujemy je na PRIVATE
// (bardziej restrykcyjnej), a nie na PUBLIC. Kolejnosc jest istotna: najpierw dane,
// potem constraint, ktory tych danych juz zabrania.
jdbcTemplate.execute(
"DO $$ "
+ "BEGIN "
+ " IF to_regclass('public.user_settings') IS NOT NULL THEN "
+ " UPDATE user_settings SET profile_visibility = 'PRIVATE' WHERE profile_visibility = 'CONTACTS'; "
+ " END IF; "
+ "END $$"
);
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_settings DROP CONSTRAINT IF EXISTS user_settings_profile_visibility_check"
);
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_settings ADD CONSTRAINT user_settings_profile_visibility_check "
+ "CHECK (profile_visibility IN ('PUBLIC','PRIVATE'))"
);
// Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci. // Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci.
// Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika). // Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika).
jdbcTemplate.execute( jdbcTemplate.execute(
@@ -49,12 +49,14 @@ public record ListingDetailResponse(
// Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym // Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym
// identyfikatorze frontend otwiera jego profil publiczny. // identyfikatorze frontend otwiera jego profil publiczny.
Long ownerId, Long ownerId,
// Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly.
String ownerAvatar,
ListingStatus status, ListingStatus status,
Instant createdAt, Instant createdAt,
Long viewsCount, Long viewsCount,
Instant promotedUntil Instant promotedUntil
) { ) {
public static ListingDetailResponse from(PropertyListing listing, Long ownerId) { public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
return new ListingDetailResponse( return new ListingDetailResponse(
listing.getId(), listing.getId(),
listing.getTitle(), listing.getTitle(),
@@ -94,6 +96,7 @@ public record ListingDetailResponse(
listing.getVirtualTourUrl(), listing.getVirtualTourUrl(),
listing.getOwnerEmail(), listing.getOwnerEmail(),
ownerId, ownerId,
ownerAvatar,
listing.getStatus(), listing.getStatus(),
listing.getCreatedAt(), listing.getCreatedAt(),
listing.getViewsCount(), listing.getViewsCount(),
@@ -1,8 +1,10 @@
package pl.polskalokalnie.listing; package pl.polskalokalnie.listing;
import java.text.Normalizer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -13,6 +15,7 @@ import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository; import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@Service @Service
public class ListingService { public class ListingService {
@@ -23,21 +26,50 @@ public class ListingService {
private final TextModerationService textModerationService; private final TextModerationService textModerationService;
private final NotificationService notificationService; private final NotificationService notificationService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserSettingsService userSettingsService;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService, UserRepository userRepository) { NotificationService notificationService, UserRepository userRepository,
UserSettingsService userSettingsService) {
this.listingRepository = listingRepository; this.listingRepository = listingRepository;
this.textModerationService = textModerationService; this.textModerationService = textModerationService;
this.notificationService = notificationService; this.notificationService = notificationService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userSettingsService = userSettingsService;
} }
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow. // Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
private Long ownerIdOf(PropertyListing listing) { private AppUser ownerOf(PropertyListing listing) {
if (listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) { if (listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
return null; return null;
} }
return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).map(AppUser::getId).orElse(null); return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
}
/**
* Szczegoly ogloszenia razem z danymi wlasciciela: identyfikatorem profilu i jego zdjeciem.
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/
private ListingDetailResponse detailOf(PropertyListing listing) {
AppUser owner = ownerOf(listing);
if (owner == null) {
return ListingDetailResponse.from(listing, null, null);
}
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()));
}
/**
* Nazwa miasta bez polskich znakow i wielkosci liter - "lodz" ma znalezc "Łódź". Litera "ł"
* wymaga osobnej podmiany, bo Normalizer jej nie rozklada (to samodzielny znak, a nie "l"
* ze znakiem diakrytycznym).
*/
private static String normalizeCity(String value) {
if (value == null) {
return "";
}
String lower = value.trim().toLowerCase(Locale.ROOT).replace("ł", "l");
return Normalizer.normalize(lower, Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
} }
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania. // Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
@@ -49,7 +81,7 @@ public class ListingService {
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) { public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
return listingRepository.findAll().stream() return listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED) .filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim())) .filter(listing -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city)))
.filter(listing -> offerType == null || listing.getOfferType() == offerType) .filter(listing -> offerType == null || listing.getOfferType() == offerType)
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.sorted(promotedFirst()) .sorted(promotedFirst())
@@ -78,7 +110,7 @@ public class ListingService {
listing = listingRepository.save(listing); listing = listingRepository.save(listing);
} }
return ListingDetailResponse.from(listing, ownerIdOf(listing)); return detailOf(listing);
} }
public List<ListingResponse> findMine(String ownerEmail) { public List<ListingResponse> findMine(String ownerEmail) {
@@ -134,7 +166,7 @@ public class ListingService {
listing.setStatus(ListingStatus.PENDING); listing.setStatus(ListingStatus.PENDING);
PropertyListing saved = listingRepository.save(listing); PropertyListing saved = listingRepository.save(listing);
return ListingDetailResponse.from(saved, ownerIdOf(saved)); return detailOf(saved);
} }
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu - // Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
@@ -153,7 +185,7 @@ public class ListingService {
// ownerEmail i status pozostaja bez zmian. // ownerEmail i status pozostaja bez zmian.
PropertyListing saved = listingRepository.save(listing); PropertyListing saved = listingRepository.save(listing);
return ListingDetailResponse.from(saved, ownerIdOf(saved)); return detailOf(saved);
} }
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn). // Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
@@ -2,6 +2,7 @@ package pl.polskalokalnie.report;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import pl.polskalokalnie.user.AppUser;
public record ListingReportResponse( public record ListingReportResponse(
Long id, Long id,
@@ -23,7 +24,12 @@ public record ListingReportResponse(
String resolvedByEmail, String resolvedByEmail,
String resolutionNote String resolutionNote
) { ) {
public static ListingReportResponse from(ListingReport report) { /**
* Nazwe zglaszajacego bierzemy z jego konta, jesli nadal istnieje - po zmianie imienia
* i nazwiska panel administratora ma pokazywac aktualna wartosc, a nie te sprzed zmiany.
* Nazwa zapisana w zgloszeniu zostaje zapasem dla kont, ktorych juz nie ma.
*/
public static ListingReportResponse from(ListingReport report, AppUser reporterNow) {
return new ListingReportResponse( return new ListingReportResponse(
report.getId(), report.getId(),
report.getListingId(), report.getListingId(),
@@ -36,7 +42,9 @@ public record ListingReportResponse(
report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(), report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(),
report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(), report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(),
report.getReporterEmail(), report.getReporterEmail(),
report.getReporterName(), reporterNow != null && reporterNow.getFullName() != null && !reporterNow.getFullName().isBlank()
? reporterNow.getFullName()
: report.getReporterName(),
report.getStatus(), report.getStatus(),
report.isListingDeleted(), report.isListingDeleted(),
report.getCreatedAt(), report.getCreatedAt(),
@@ -46,12 +46,20 @@ public class ListingReportService {
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
report.setStatus(ListingReportStatus.OPEN); report.setStatus(ListingReportStatus.OPEN);
return ListingReportResponse.from(listingReportRepository.save(report)); return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report));
}
// Konto zglaszajacego, jesli nadal istnieje - stad bierze sie aktualna nazwa w panelu admina.
private AppUser reporterOf(ListingReport report) {
if (report.getReporterEmail() == null || report.getReporterEmail().isBlank()) {
return null;
}
return userRepository.findByEmailIgnoreCase(report.getReporterEmail()).orElse(null);
} }
public List<ListingReportResponse> findAllForAdmin() { public List<ListingReportResponse> findAllForAdmin() {
return listingReportRepository.findAllByOrderByCreatedAtDesc().stream() return listingReportRepository.findAllByOrderByCreatedAtDesc().stream()
.map(ListingReportResponse::from) .map(report -> ListingReportResponse.from(report, reporterOf(report)))
.toList(); .toList();
} }
@@ -62,7 +70,7 @@ public class ListingReportService {
report.setResolvedAt(Instant.now()); report.setResolvedAt(Instant.now());
report.setResolvedByEmail(adminEmail); report.setResolvedByEmail(adminEmail);
report.setResolutionNote(trimToNull(note)); report.setResolutionNote(trimToNull(note));
return ListingReportResponse.from(listingReportRepository.save(report)); return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report));
} }
@Transactional @Transactional
@@ -81,7 +89,7 @@ public class ListingReportService {
if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) { if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) {
report.setResolutionNote("Ogłoszenie usunięte przez administratora po zgłoszeniu."); report.setResolutionNote("Ogłoszenie usunięte przez administratora po zgłoszeniu.");
} }
return ListingReportResponse.from(listingReportRepository.save(report)); return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report));
} }
private ListingReport requireReport(Long id) { private ListingReport requireReport(Long id) {
@@ -2,6 +2,7 @@ package pl.polskalokalnie.report;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import pl.polskalokalnie.user.AppUser;
public record UserReportResponse( public record UserReportResponse(
Long id, Long id,
@@ -27,10 +28,15 @@ public record UserReportResponse(
String resolvedByEmail, String resolvedByEmail,
String resolutionNote String resolutionNote
) { ) {
public static UserReportResponse from(UserReport report, boolean reportedUserExists) { /**
* Nazwy bierzemy z kont, jesli te nadal istnieja - uzytkownik moze zmienic imie i nazwisko
* po zlozeniu zgloszenia, a panel administratora ma pokazywac stan zgodny z kontem. Nazwa
* zapisana w zgloszeniu zostaje jako zapas dla kont, ktorych juz nie ma.
*/
public static UserReportResponse from(UserReport report, AppUser reportedNow, AppUser reporterNow) {
return new UserReportResponse( return new UserReportResponse(
report.getId(), report.getId(),
report.getReportedName(), currentName(reportedNow, report.getReportedName()),
report.getReportedEmail(), report.getReportedEmail(),
report.getConversationId(), report.getConversationId(),
report.getListingId(), report.getListingId(),
@@ -48,14 +54,21 @@ public record UserReportResponse(
message.isHasPhoto())) message.isHasPhoto()))
.toList(), .toList(),
report.getReporterEmail(), report.getReporterEmail(),
report.getReporterName(), currentName(reporterNow, report.getReporterName()),
report.getStatus(), report.getStatus(),
report.isReportedUserDeleted(), report.isReportedUserDeleted(),
reportedUserExists, reportedNow != null,
report.getCreatedAt(), report.getCreatedAt(),
report.getResolvedAt(), report.getResolvedAt(),
report.getResolvedByEmail(), report.getResolvedByEmail(),
report.getResolutionNote() report.getResolutionNote()
); );
} }
private static String currentName(AppUser account, String storedName) {
if (account == null || account.getFullName() == null || account.getFullName().isBlank()) {
return storedName;
}
return account.getFullName();
}
} }
@@ -51,12 +51,12 @@ public class UserReportService {
} }
UserReport saved = userReportRepository.save(report); UserReport saved = userReportRepository.save(report);
return UserReportResponse.from(saved, reported != null); return UserReportResponse.from(saved, reported, findReported(saved.getReporterEmail()));
} }
public List<UserReportResponse> findAllForAdmin() { public List<UserReportResponse> findAllForAdmin() {
return userReportRepository.findAllByOrderByCreatedAtDesc().stream() return userReportRepository.findAllByOrderByCreatedAtDesc().stream()
.map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()) != null)) .map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()), findReported(report.getReporterEmail())))
.toList(); .toList();
} }
@@ -67,7 +67,7 @@ public class UserReportService {
report.setResolvedAt(Instant.now()); report.setResolvedAt(Instant.now());
report.setResolvedByEmail(adminEmail); report.setResolvedByEmail(adminEmail);
report.setResolutionNote(ReportAttachments.trimToNull(note)); report.setResolutionNote(ReportAttachments.trimToNull(note));
return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()) != null); return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()), findReported(report.getReporterEmail()));
} }
/** Usuniecie konta zgloszonego uzytkownika wraz z zamknieciem zgloszenia. */ /** Usuniecie konta zgloszonego uzytkownika wraz z zamknieciem zgloszenia. */
@@ -92,7 +92,7 @@ public class UserReportService {
if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) { if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) {
report.setResolutionNote("Konto usunięte przez administratora po zgłoszeniu."); report.setResolutionNote("Konto usunięte przez administratora po zgłoszeniu.");
} }
return UserReportResponse.from(userReportRepository.save(report), false); return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
} }
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia. // Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
@@ -91,6 +91,10 @@ public class AppUser {
@Column(nullable = false, updatable = false) @Column(nullable = false, updatable = false)
private Instant createdAt; private Instant createdAt;
// Kiedy uzytkownik ostatnio sam zmienil haslo w ustawieniach konta. Na tej podstawie liczymy
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
private Instant passwordChangedAt;
@PrePersist @PrePersist
void onCreate() { void onCreate() {
if (createdAt == null) { if (createdAt == null) {
@@ -241,4 +245,12 @@ public class AppUser {
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }
public Instant getPasswordChangedAt() {
return passwordChangedAt;
}
public void setPasswordChangedAt(Instant passwordChangedAt) {
this.passwordChangedAt = passwordChangedAt;
}
} }
@@ -1,8 +1,11 @@
package pl.polskalokalnie.user; package pl.polskalokalnie.user;
/** Kto widzi profil publiczny uzytkownika. */ /**
* Kto widzi profil publiczny uzytkownika. Domyslnie PUBLIC - profil zalozony dzis jest widoczny,
* dopoki wlasciciel sam tego nie zmieni. PRIVATE ukrywa przed innymi wszystko poza informacja,
* ze profil jest prywatny; wlasciciel widzi swoj profil zawsze.
*/
public enum ProfileVisibility { public enum ProfileVisibility {
PUBLIC, PUBLIC,
CONTACTS,
PRIVATE PRIVATE
} }
@@ -2,6 +2,7 @@ package pl.polskalokalnie.user;
import java.util.List; import java.util.List;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -31,32 +32,51 @@ public class PublicProfileController {
} }
@GetMapping("/{id}/profile") @GetMapping("/{id}/profile")
public PublicProfileResponse profile(@PathVariable Long id) { public PublicProfileResponse profile(@PathVariable Long id, Authentication authentication) {
return toResponse(userRepository.findById(id) return toResponse(userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"))); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")),
authentication);
} }
/** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */ /** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */
@GetMapping("/by-nick/{nick}/profile") @GetMapping("/by-nick/{nick}/profile")
public PublicProfileResponse profileByNick(@PathVariable String nick) { public PublicProfileResponse profileByNick(@PathVariable String nick, Authentication authentication) {
return toResponse(userRepository.findByNickIgnoreCase(nick) return toResponse(userRepository.findByNickIgnoreCase(nick)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"))); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")),
authentication);
} }
private PublicProfileResponse toResponse(AppUser user) { /**
* Profil prywatny widzi wylacznie jego wlasciciel. Dla kazdego innego konta zwracamy sama
* informacje, ze profil jest prywatny - decyzje o ukryciu podejmuje serwer, bo ukrywanie
* danych dopiero w interfejsie nie chroni przed odczytem wprost z API.
*/
private boolean isHiddenFrom(AppUser owner, UserSettings settings, Authentication authentication) {
if (settings == null || settings.getProfileVisibility() != ProfileVisibility.PRIVATE) {
return false;
}
String viewerEmail = authentication == null ? null : authentication.getName();
return viewerEmail == null || owner.getEmail() == null || !viewerEmail.equalsIgnoreCase(owner.getEmail());
}
private PublicProfileResponse toResponse(AppUser user, Authentication authentication) {
// Opis, zdjecie i widocznosc uzytkownik ustawia w /konto/ustawienia; brak wiersza = ustawienia domyslne.
UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null);
if (isHiddenFrom(user, settings, authentication)) {
return PublicProfileResponse.privateFor(user);
}
List<PropertyListing> listings = user.getEmail() == null List<PropertyListing> listings = user.getEmail() == null
? List.of() ? List.of()
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED); : 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( return new PublicProfileResponse(
user.getId(), user.getId(),
user.getFullName(), user.getFullName(),
user.getNick(), user.getNick(),
settings == null ? null : settings.getBio(), settings == null ? null : settings.getBio(),
settings == null ? null : settings.getAvatarImage(), settings == null ? null : settings.getAvatarImage(),
settings == null ? null : settings.getCoverImage(),
user.getAccountType(), user.getAccountType(),
user.isVerified(), user.isVerified(),
user.getCreatedAt(), user.getCreatedAt(),
@@ -75,7 +95,8 @@ public class PublicProfileController {
listing.getBuildingFloors(), listing.getBuildingFloors(),
listing.getCoverPhoto(), listing.getCoverPhoto(),
listing.getCreatedAt())) listing.getCreatedAt()))
.toList() .toList(),
false
); );
} }
} }
@@ -15,12 +15,38 @@ public record PublicProfileResponse(
// Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null. // Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null.
String bio, String bio,
String avatarImage, String avatarImage,
// Zdjecie w tle profilu z ustawien konta - widzi je kazdy, kto oglada profil publiczny.
String coverImage,
AccountType accountType, AccountType accountType,
boolean verified, boolean verified,
Instant memberSince, Instant memberSince,
int listingsCount, int listingsCount,
List<PublicProfileListing> listings List<PublicProfileListing> listings,
// Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola
// sa wtedy puste - interfejs pokazuje sama informacje, ze profil jest prywatny.
boolean privateProfile
) { ) {
/**
* Odpowiedz dla profilu prywatnego. Zostaje tylko tozsamosc konta, zeby dalo sie napisac
* "profil uzytkownika X jest prywatny" - bez ogloszen, opisu, zdjecia i daty dolaczenia.
*/
public static PublicProfileResponse privateFor(AppUser user) {
return new PublicProfileResponse(
user.getId(),
user.getFullName(),
user.getNick(),
null,
null,
null,
null,
false,
null,
0,
List.of(),
true
);
}
public record PublicProfileListing( public record PublicProfileListing(
Long id, Long id,
String title, String title,
@@ -0,0 +1,28 @@
package pl.polskalokalnie.user;
/**
* Same preferencje wyszukiwania z ustawien konta - bez opisu i zdjec, ktore niesie pelna
* odpowiedz /api/me/settings. Wyszukiwarka pobiera to przy kazdym wejsciu do aplikacji,
* wiec odpowiedz musi byc lekka; zdjecie w tle potrafi wazyc kilkaset kilobajtow.
*/
public record SearchPreferencesResponse(
String locations,
String propertyType,
Integer budgetMax,
Integer areaMin,
Integer areaMax,
Integer roomsMin,
Integer roomsMax
) {
public static SearchPreferencesResponse from(UserSettings settings) {
return new SearchPreferencesResponse(
settings.getSearchLocations(),
settings.getSearchPropertyType(),
settings.getSearchBudgetMax(),
settings.getSearchAreaMin(),
settings.getSearchAreaMax(),
settings.getSearchRoomsMin(),
settings.getSearchRoomsMax()
);
}
}
@@ -35,6 +35,16 @@ public class UserSettingsController {
return service.update(currentUserId(authentication), request); return service.update(currentUserId(authentication), request);
} }
/**
* Preferencje wyszukiwania w osobnej, lekkiej odpowiedzi. Wyszukiwarka pobiera je przy wejsciu
* do aplikacji, a pelne ustawienia niosa zdjecie profilowe i zdjecie w tle - kilkaset kilobajtow,
* ktorych do wypelnienia filtrow nie potrzeba.
*/
@GetMapping("/search")
public SearchPreferencesResponse searchPreferences(Authentication authentication) {
return service.searchPreferences(currentUserId(authentication));
}
private Long currentUserId(Authentication authentication) { private Long currentUserId(Authentication authentication) {
return userRepository.findByEmailIgnoreCase(authentication.getName()) return userRepository.findByEmailIgnoreCase(authentication.getName())
.map(AppUser::getId) .map(AppUser::getId)
@@ -35,6 +35,23 @@ public class UserSettingsService {
repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId))); repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId)));
} }
/** Same preferencje wyszukiwania - wyszukiwarka nie potrzebuje reszty ustawien. */
public SearchPreferencesResponse searchPreferences(Long userId) {
return SearchPreferencesResponse.from(
repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId)));
}
/**
* Samo zdjecie profilowe - do pokazania obok nazwy uzytkownika w naglowku, na profilu i przy
* ogloszeniu. Zwraca null, gdy uzytkownik zadnego nie ustawil; wtedy interfejs rysuje inicjaly.
* Osobna metoda, bo pelne ustawienia niosa jeszcze zdjecie w tle, ktorego te miejsca nie potrzebuja.
*/
public String avatarFor(Long userId) {
return repository.findByUserId(userId)
.map(UserSettings::getAvatarImage)
.orElse(null);
}
/** /**
* Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu * Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu
* kazda sekcja formularza moze zapisac wylacznie swoje pola. * kazda sekcja formularza moze zapisac wylacznie swoje pola.
+561 -80
View File
@@ -3,8 +3,9 @@ import './styles.css';
import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; 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 { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes';
import { ProtectedRoute } from './ProtectedRoute'; import { ProtectedRoute } from './ProtectedRoute';
import { Avatar, initialsFrom } from './Avatar';
import { apiFetch, useAuth } from './auth'; import { apiFetch, useAuth } from './auth';
import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, UserSettings, UserSettingsPatch } from './auth'; import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, SearchPreferences, UserSettings, UserSettingsPatch } from './auth';
import { useNotifications, toDisplayNotifications } from './notifications'; import { useNotifications, toDisplayNotifications } from './notifications';
import type { ServerNotification, DisplayNotification } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications';
import { getManualTranslation } from './i18nOverrides'; import { getManualTranslation } from './i18nOverrides';
@@ -121,6 +122,8 @@ export type ApiListingDetail = {
virtualTourUrl: string | null; virtualTourUrl: string | null;
ownerEmail: string | null; ownerEmail: string | null;
ownerId: number | null; ownerId: number | null;
// Zdjecie profilowe wlasciciela - null, gdy go nie ustawil albo ogloszenie nie ma konta w serwisie.
ownerAvatar: string | null;
status: ApiListingStatus; status: ApiListingStatus;
createdAt: string; createdAt: string;
viewsCount?: number; viewsCount?: number;
@@ -2395,15 +2398,88 @@ function clampCenter(center: LatLng): LatLng {
} }
function normalizeSearchText(value: string): string { function normalizeSearchText(value: string): string {
return value.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); return value
.trim()
.toLowerCase()
// "ł" to osobna litera, a nie "l" ze znakiem diakrytycznym - NFD jej nie rozklada, wiec bez
// tej podmiany "Łódź" zostaje jako "łodz" i wpisane "lodz" nigdy w nie nie trafia.
.replace(/ł/g, 'l')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '');
} }
/**
* Odleglosc edycyjna: ile pojedynczych poprawek dzieli dwa napisy. Liczymy dodanie, usuniecie,
* zamiane litery oraz przestawienie dwoch sasiednich liter ("wrocwal" zamiast "wroclaw") - to
* ostatnie jest jedna z najczestszych literowek przy szybkim pisaniu, a zwykly Levenshtein
* liczylby je jako dwie osobne poprawki i taka nazwa nie zmiescilaby sie w tolerancji.
*/
function editDistance(a: string, b: string): number {
if (a === b) {
return 0;
}
const rows: number[][] = [Array.from({ length: b.length + 1 }, (_, index) => index)];
for (let i = 1; i <= a.length; i += 1) {
const current = [i];
for (let j = 1; j <= b.length; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
let best = Math.min(current[j - 1] + 1, rows[i - 1][j] + 1, rows[i - 1][j - 1] + cost);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
best = Math.min(best, rows[i - 2][j - 2] + 1);
}
current[j] = best;
}
rows.push(current);
}
return rows[a.length][b.length];
}
/**
* Ile literowek wybaczamy przy zapytaniu danej dlugosci. Krotkich nie ruszamy: "gda" pasuje
* do zbyt wielu nazw, zeby jeszcze zgadywac, co uzytkownik mial na mysli.
*
* Progi dobrane pomiarem na liscie 55 polskich miast. Dopuszczenie dwoch poprawek juz przy
* siedmiu znakach zaczyna mylic prawdziwe nazwy: "krakow" trafia w Katowice, a "kielce"
* w Siedlce - a to gorszy blad niz nieznalezienie miasta wpisanego z dwiema literowkami.
* Przy obecnych progach jedyne kolizje to pary nierozroznialne takze dla czlowieka
* (Lublin i Lubin, Wrocław i Włocławek).
*/
function cityTypoTolerance(length: number): number {
if (length < 4) {
return 0;
}
return length < 8 ? 1 : 2;
}
/**
* Czy nazwa miasta pasuje do tego, co wpisano. Najpierw zwykle dopasowanie poczatku nazwy,
* a gdy ono zawiedzie - z marginesem na literowki ("warszwa" trafia w "Warszawa").
*/
function cityStartsWith(cityName: string, query: string): boolean { function cityStartsWith(cityName: string, query: string): boolean {
const normalizedQuery = normalizeSearchText(query); const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) { if (!normalizedQuery) {
return true; return true;
} }
return normalizeSearchText(cityName).startsWith(normalizedQuery); const normalizedName = normalizeSearchText(cityName);
if (normalizedName.startsWith(normalizedQuery)) {
return true;
}
const tolerance = cityTypoTolerance(normalizedQuery.length);
if (tolerance === 0) {
return false;
}
// Porownujemy z poczatkiem nazwy, nie z cala - inaczej "gdansk" przegralby z dluzszymi nazwami.
// Dlugosc okna zmieniamy w zakresie tolerancji, zeby zlapac tez brakujaca albo nadmiarowa litere.
const from = Math.max(1, normalizedQuery.length - tolerance);
const to = Math.min(normalizedName.length, normalizedQuery.length + tolerance);
for (let length = from; length <= to; length += 1) {
if (editDistance(normalizedQuery, normalizedName.slice(0, length)) <= tolerance) {
return true;
}
}
return false;
} }
function findKnownCityKey(name: string): string | null { function findKnownCityKey(name: string): string | null {
@@ -2647,16 +2723,8 @@ function getLastName(fullName: string): string {
return parts.slice(1).join(' '); return parts.slice(1).join(' ');
} }
function getInitials(fullName: string): string { // Inicjaly liczy komponent awatara - to ta sama regula w kazdym miejscu aplikacji.
const parts = fullName.trim().split(/\s+/).filter(Boolean); const getInitials = initialsFrom;
if (parts.length === 0) {
return '??';
}
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
function contactPreferenceLabel(preference: ContactPreference | null | undefined): string { function contactPreferenceLabel(preference: ContactPreference | null | undefined): string {
if (preference === 'EMAIL') { if (preference === 'EMAIL') {
@@ -2682,7 +2750,7 @@ function preferredLanguageLabel(language: PreferredLanguage | null | undefined):
} }
function App() { function App() {
const { user, logout } = useAuth(); const { user, logout, loadSearchPreferences } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const navigationType = useNavigationType(); const navigationType = useNavigationType();
@@ -2715,6 +2783,23 @@ function App() {
const notificationsCenter = useNotifications(); const notificationsCenter = useNotifications();
const [savedSearchesState, setSavedSearchesState] = useState<SavedSearchRecord[]>([]); const [savedSearchesState, setSavedSearchesState] = useState<SavedSearchRecord[]>([]);
/**
* Preferencje wyszukiwania z ustawien konta wypelniaja filtry raz - po zalogowaniu. Pozniej
* nic ich nie nadpisuje, wiec zmiana filtrow przez uzytkownika jest ostateczna az do wylogowania.
* Gosc nie ma preferencji i widzi puste filtry, tak jak dotad.
*/
const { applyPreferences } = filters;
useEffect(() => {
if (!user) {
return;
}
let cancelled = false;
loadSearchPreferences()
.then((prefs) => { if (!cancelled) { applyPreferences(prefs); } })
.catch(() => { /* brak preferencji nie moze psuc wyszukiwarki - zostaja puste filtry */ });
return () => { cancelled = true; };
}, [user, loadSearchPreferences, applyPreferences]);
// Po udanym logowaniu wracamy tam, gdzie uzytkownik zmierzal (ProtectedRoute zapisuje cel // Po udanym logowaniu wracamy tam, gdzie uzytkownik zmierzal (ProtectedRoute zapisuje cel
// w location.state.from). Gdy wszedl na logowanie wprost - admin do panelu, uzytkownik do konta. // w location.state.from). Gdy wszedl na logowanie wprost - admin do panelu, uzytkownik do konta.
const handleAuthenticated = (loggedUser: AuthUser) => { const handleAuthenticated = (loggedUser: AuthUser) => {
@@ -3615,7 +3700,7 @@ function App() {
<Route path={ROUTES.comparison} element={<AccountComparisonPage onOpenListing={openListing} />} /> <Route path={ROUTES.comparison} element={<AccountComparisonPage onOpenListing={openListing} />} />
<Route path={ROUTES.accountMeetings} element={<AccountMeetingsPage />} /> <Route path={ROUTES.accountMeetings} element={<AccountMeetingsPage />} />
<Route path={ROUTES.accountListings} element={<AccountListingsPage onOpenListing={openListing} />} /> <Route path={ROUTES.accountListings} element={<AccountListingsPage onOpenListing={openListing} />} />
<Route path={ROUTES.accountSettings} element={<AccountSettingsPage />} /> <Route path={ROUTES.accountSettings} element={<AccountSettingsPage onSearchPreferencesSaved={applyPreferences} />} />
{/* Bezpieczenstwo i edycja profilu sa teraz sekcjami /konto/ustawienia - stare adresy {/* Bezpieczenstwo i edycja profilu sa teraz sekcjami /konto/ustawienia - stare adresy
zostaja jako przekierowania, zeby zapisane linki i zakladki dalej dzialaly. */} zostaja jako przekierowania, zeby zapisane linki i zakladki dalej dzialaly. */}
<Route path={ROUTES.accountSecurity} element={<Navigate to={ROUTES.accountSettings} replace />} /> <Route path={ROUTES.accountSecurity} element={<Navigate to={ROUTES.accountSettings} replace />} />
@@ -4195,8 +4280,11 @@ function AdminPage() {
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState<string | null>(null); const [statsError, setStatsError] = useState<string | null>(null);
const loadData = useCallback(async () => { // silent = odswiezenie w tle: bez chowania tabel za komunikat "Ladowanie danych".
setLoading(true); const loadData = useCallback(async (options?: { silent?: boolean }) => {
if (!options?.silent) {
setLoading(true);
}
setError(null); setError(null);
try { try {
const [loadedUsers, loadedListings, loadedReports, loadedUserReports, loadedForbiddenWords] = await Promise.all([ const [loadedUsers, loadedListings, loadedReports, loadedUserReports, loadedForbiddenWords] = await Promise.all([
@@ -4222,6 +4310,26 @@ function AdminPage() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
/**
* Panel pobiera dane przy wejsciu, ale administrator zwykle zostawia go otwartego i przelacza
* sie na inne okna. Gdy wraca do karty, dociagamy dane od nowa - inaczej ogladalby stan sprzed
* zmian, ktore uzytkownicy zrobili w miedzyczasie (np. poprawione imie albo numer telefonu).
* Odswiezamy po powrocie, a nie w kolku - odpowiedz z ogloszeniami wazy kilkaset kilobajtow.
*/
useEffect(() => {
const refreshWhenBack = () => {
if (document.visibilityState === 'visible') {
void loadData({ silent: true });
}
};
window.addEventListener('focus', refreshWhenBack);
document.addEventListener('visibilitychange', refreshWhenBack);
return () => {
window.removeEventListener('focus', refreshWhenBack);
document.removeEventListener('visibilitychange', refreshWhenBack);
};
}, [loadData]);
const runAction = async (actionKey: string, action: () => Promise<unknown>) => { const runAction = async (actionKey: string, action: () => Promise<unknown>) => {
setBusyId(actionKey); setBusyId(actionKey);
setError(null); setError(null);
@@ -4571,7 +4679,7 @@ function AdminPage() {
<div className="admin-sidebar-status"> <div className="admin-sidebar-status">
<strong><Icon name="shield" /> System działa poprawnie</strong> <strong><Icon name="shield" /> System działa poprawnie</strong>
<p>Wszystkie usługi działają bez zarzutu.</p> <p>Wszystkie usługi działają bez zarzutu.</p>
<button type="button" onClick={loadData}>Odśwież status <Icon name="arrow" /></button> <button type="button" onClick={() => loadData()}>Odśwież status <Icon name="arrow" /></button>
</div> </div>
<Link className="admin-back" to={ROUTES.home}> <Link className="admin-back" to={ROUTES.home}>
@@ -4585,7 +4693,7 @@ function AdminPage() {
<h1>{activeMeta.title}</h1> <h1>{activeMeta.title}</h1>
<p>Zalogowano jako <strong>{user?.fullName || user?.email}</strong>. {activeMeta.subtitle}</p> <p>Zalogowano jako <strong>{user?.fullName || user?.email}</strong>. {activeMeta.subtitle}</p>
</div> </div>
<button type="button" className="admin-refresh" onClick={loadData} disabled={loading}> <button type="button" className="admin-refresh" onClick={() => loadData()} disabled={loading}>
<Icon name="shuffle" /> Odśwież <Icon name="shuffle" /> Odśwież
</button> </button>
</header> </header>
@@ -6153,7 +6261,7 @@ function AccountSidebar() {
return ( return (
<aside className="account-sidebar"> <aside className="account-sidebar">
<div className="account-profile-card"> <div className="account-profile-card">
<div className="account-avatar">{getInitials(getDisplayName(user))}</div> <Avatar className="account-avatar" name={getDisplayName(user)} image={user?.avatarImage} />
<div> <div>
<strong>{getDisplayName(user)}</strong> <strong>{getDisplayName(user)}</strong>
<small>{user?.email}</small> <small>{user?.email}</small>
@@ -10269,6 +10377,8 @@ type SettingsUploadTarget = 'avatar' | 'cover';
const SETTINGS_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const SETTINGS_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const SETTINGS_IMAGE_MAX_BYTES = 5 * 1024 * 1024; const SETTINGS_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
// Jak dlugo wisi potwierdzenie zapisu w ustawieniach konta, zanim zniknie samo.
const SETTINGS_BANNER_VISIBLE_MS = 5000;
const SETTINGS_IMAGE_MIN_DIMENSIONS: Record<SettingsUploadTarget, { width: number; height: number }> = { const SETTINGS_IMAGE_MIN_DIMENSIONS: Record<SettingsUploadTarget, { width: number; height: number }> = {
avatar: { width: 160, height: 160 }, avatar: { width: 160, height: 160 },
cover: { width: 960, height: 260 }, cover: { width: 960, height: 260 },
@@ -10363,12 +10473,24 @@ const AREA_UNIT_LABELS: Record<AreaUnit, string> = {
const PROFILE_VISIBILITY_OPTIONS: Array<{ value: ProfileVisibility; label: string; description: string }> = [ 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: '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: 'Inni zobaczą tylko informację, że Twój profil jest prywatny - bez ogłoszeń, opisu i zdjęcia.' },
{ value: 'PRIVATE', label: 'Prywatna', description: 'Twój profil jest ukryty i widzisz go tylko Ty.' },
]; ];
const SETTINGS_PROPERTY_TYPES = ['Mieszkania', 'Domy', 'Apartamenty', 'Działki', 'Lokale użytkowe']; const SETTINGS_PROPERTY_TYPES = ['Mieszkania', 'Domy', 'Apartamenty', 'Działki', 'Lokale użytkowe'];
/** Wycina z zapisanych ustawien same preferencje wyszukiwania - w postaci, ktora rozumieja filtry. */
function toSearchPreferences(settings: UserSettings): SearchPreferences {
return {
locations: settings.searchLocations,
propertyType: settings.searchPropertyType,
budgetMax: settings.searchBudgetMax,
areaMin: settings.searchAreaMin,
areaMax: settings.searchAreaMax,
roomsMin: settings.searchRoomsMin,
roomsMax: settings.searchRoomsMax,
};
}
// Musi odpowiadac RESEND_COOLDOWN_SECONDS w PhoneVerificationService - inaczej przycisk odblokuje // Musi odpowiadac RESEND_COOLDOWN_SECONDS w PhoneVerificationService - inaczej przycisk odblokuje
// sie wczesniej, niz backend przyjmie kolejna wysylke, i uzytkownik dostanie blad 429. // sie wczesniej, niz backend przyjmie kolejna wysylke, i uzytkownik dostanie blad 429.
const OTP_RESEND_COOLDOWN_SECONDS = 60; const OTP_RESEND_COOLDOWN_SECONDS = 60;
@@ -10399,8 +10521,16 @@ function SettingsToggleRow({ icon, label, hint, checked, disabled, onChange }: {
); );
} }
function AccountSettingsPage() { /**
const { user, updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, verifyPhone, resendPhoneOtp } = useAuth(); * onSearchPreferencesSaved: zapis preferencji ma od razu przelozyc sie na filtry wyszukiwarki.
* Przejscia wewnatrz aplikacji nie tworza korzenia od nowa, wiec bez tego wywolania zmiana
* zadzialalaby dopiero po przeladowaniu strony.
*/
function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferencesSaved: (prefs: SearchPreferences) => void }) {
const {
user, updateProfile, changePassword, verifyPassword, passwordCooldown,
deleteAccount, loadSettings, saveSettings, refreshUser, verifyPhone, resendPhoneOtp,
} = useAuth();
const { notify } = useNotifications(); const { notify } = useNotifications();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -10453,18 +10583,59 @@ function AccountSettingsPage() {
roomsMax: '', roomsMax: '',
}); });
const [searchBanner, setSearchBanner] = useState<Banner>(null); const [searchBanner, setSearchBanner] = useState<Banner>(null);
// Podpowiedzi lokalizacji pod polem "Lokalizacje" - jak w wyszukiwarce ofert.
const [placeSuggestions, setPlaceSuggestions] = useState<PlaceSuggestion[]>([]);
const [placesLoading, setPlacesLoading] = useState(false);
// Lista jest otwarta tylko podczas pisania - po wybraniu pozycji nie ma juz czego podpowiadac.
const [placesOpen, setPlacesOpen] = useState(false);
const skipNextPlaceFetch = useRef(false);
const [isSavingSearch, setIsSavingSearch] = useState(false); const [isSavingSearch, setIsSavingSearch] = useState(false);
// --- Bezpieczenstwo --- // --- Bezpieczenstwo ---
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' }); const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' });
const [passwordBanner, setPasswordBanner] = useState<Banner>(null); const [passwordBanner, setPasswordBanner] = useState<Banner>(null);
const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingPassword, setIsSavingPassword] = useState(false);
// Stan sprawdzenia dotychczasowego hasla. Pola nowego hasla otwieraja sie dopiero przy 'ok'.
const [currentCheck, setCurrentCheck] = useState<'empty' | 'checking' | 'ok' | 'bad'>('empty');
// Ile sekund zostalo do kolejnej dozwolonej zmiany hasla; 0 = mozna zmieniac.
const [passwordWait, setPasswordWait] = useState(0);
const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deletePassword, setDeletePassword] = useState(''); const [deletePassword, setDeletePassword] = useState('');
const [deleteError, setDeleteError] = useState(''); const [deleteError, setDeleteError] = useState('');
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [prefsBanner, setPrefsBanner] = useState<Banner>(null); const [prefsBanner, setPrefsBanner] = useState<Banner>(null);
const bannerTimers = useRef<Map<string, number>>(new Map());
/**
* Potwierdzenie zapisu, ktore gasnie samo po chwili. Kazda sekcja ustawien ma wlasny komunikat,
* wiec licznik trzymamy pod kluczem sekcji - inaczej zapis w jednej karcie gasilby napis w drugiej.
* Bledy przez to nie przechodza: te zostaja na ekranie, bo wymagaja reakcji uzytkownika.
*/
const flashBanner = (key: string, setBanner: Dispatch<SetStateAction<Banner>>, banner: Banner) => {
const running = bannerTimers.current.get(key);
if (running !== undefined) {
window.clearTimeout(running);
}
setBanner(banner);
const timer = window.setTimeout(() => {
// Gasimy tylko wlasny komunikat - jesli w miedzyczasie pojawil sie nowszy (np. blad
// kolejnej operacji), zostaje na ekranie.
setBanner((current) => (current === banner ? null : current));
bannerTimers.current.delete(key);
}, SETTINGS_BANNER_VISIBLE_MS);
bannerTimers.current.set(key, timer);
};
const flashPrefsBanner = (banner: Banner) => flashBanner('prefs', setPrefsBanner, banner);
useEffect(() => {
const timers = bannerTimers.current;
return () => {
timers.forEach((timer) => window.clearTimeout(timer));
timers.clear();
};
}, []);
const selectedCountry: PhoneCountry = PHONE_COUNTRY_OPTIONS.find((option) => option.code === identity.phonePrefix) ?? DEFAULT_PHONE_COUNTRY; const selectedCountry: PhoneCountry = PHONE_COUNTRY_OPTIONS.find((option) => option.code === identity.phonePrefix) ?? DEFAULT_PHONE_COUNTRY;
const phoneMaskGroups = phoneMaskGroupsFor(selectedCountry.digits); const phoneMaskGroups = phoneMaskGroupsFor(selectedCountry.digits);
@@ -10599,8 +10770,15 @@ function AccountSettingsPage() {
try { try {
const saved = await saveSettings(patch); const saved = await saveSettings(patch);
setSettings(saved); setSettings(saved);
// Zdjecie profilowe wisi przy zalogowanym uzytkowniku, wiec po jego zmianie odswiezamy profil -
// awatar w naglowku i panelu konta zmienia sie od razu, bez przeladowania strony.
if ('avatarImage' in patch) {
void refreshUser();
}
if (successText) { if (successText) {
setPrefsBanner({ text: successText, tone: 'success' }); // Kazde potwierdzenie zapisu gasnie samo - to informacja o wykonanej akcji, a nie stan
// formularza. Bledy zostaja na ekranie, bo wymagaja reakcji uzytkownika.
flashPrefsBanner({ text: successText, tone: 'success' });
} }
return saved; return saved;
} catch (error) { } catch (error) {
@@ -10648,7 +10826,7 @@ function AccountSettingsPage() {
preferredLanguage: identity.preferredLanguage, preferredLanguage: identity.preferredLanguage,
}); });
await patchSettings({ bio: identity.bio.trim() }); await patchSettings({ bio: identity.bio.trim() });
setIdentityBanner({ text: 'Dane zapisane.', tone: 'success' }); flashBanner('identity', setIdentityBanner, { text: 'Dane zapisane.', tone: 'success' });
} catch (error) { } catch (error) {
setIdentityBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać danych.', tone: 'error' }); setIdentityBanner({ text: error instanceof Error ? error.message : 'Nie udało się zapisać danych.', tone: 'error' });
} finally { } finally {
@@ -10727,44 +10905,168 @@ function AccountSettingsPage() {
} }
}; };
// Puste pole liczbowe wysylamy jako -1: backend traktuje to jako wyczyszczenie wartosci.
const searchPrefsPatch = (prefs: typeof searchPrefs) => {
const asNumber = (value: string) => (value.trim() === '' ? -1 : Number(value));
return {
searchLocations: prefs.locations.trim(),
searchPropertyType: prefs.propertyType.trim(),
searchBudgetMax: asNumber(prefs.budgetMax),
searchAreaMin: asNumber(prefs.areaMin),
searchAreaMax: asNumber(prefs.areaMax),
searchRoomsMin: asNumber(prefs.roomsMin),
searchRoomsMax: asNumber(prefs.roomsMax),
};
};
// Czy jest co czyscic - od tego zalezy, czy pokazujemy przycisk czyszczenia.
const hasSearchPrefs = Object.values(searchPrefs).some((value) => value.trim() !== '');
/**
* Podpowiedzi lokalizacji - ten sam mechanizm co w wyszukiwarce ofert: od dwoch znakow pytamy
* o prawdziwe miejscowosci (nie tylko miasta wojewodzkie), z opoznieniem, zeby nie wysylac
* zapytania po kazdej literze. Puste pole pokazuje najpopularniejsze miasta jako skrot.
*/
const locationQuery = searchPrefs.locations.trim();
useEffect(() => {
if (skipNextPlaceFetch.current) {
skipNextPlaceFetch.current = false;
return;
}
if (locationQuery.length < 2) {
setPlaceSuggestions([]);
setPlacesLoading(false);
return;
}
const controller = new AbortController();
setPlacesLoading(true);
const timer = window.setTimeout(() => {
fetchPlaceSuggestions(locationQuery, controller.signal)
.then((places) => setPlaceSuggestions(places))
.catch(() => { /* przerwane zapytanie albo brak sieci - zostaje poprzednia lista */ })
.finally(() => setPlacesLoading(false));
}, 300);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [locationQuery]);
// Wybor z listy wpisuje nazwe do pola, ale nie ma od razu odpytywac o podpowiedzi do tej nazwy.
const pickLocation = (label: string) => {
skipNextPlaceFetch.current = true;
setPlaceSuggestions([]);
setPlacesOpen(false);
setSearchPrefs((current) => ({ ...current, locations: label }));
};
const saveSearchPrefs = async () => { const saveSearchPrefs = async () => {
setIsSavingSearch(true); setIsSavingSearch(true);
setSearchBanner(null); setSearchBanner(null);
// Puste pole liczbowe wysylamy jako -1: backend traktuje to jako wyczyszczenie wartosci. const saved = await patchSettings(searchPrefsPatch(searchPrefs));
const asNumber = (value: string) => (value.trim() === '' ? -1 : Number(value)); if (saved) {
const saved = await patchSettings({ onSearchPreferencesSaved(toSearchPreferences(saved));
searchLocations: searchPrefs.locations.trim(), flashBanner('search', setSearchBanner, { text: 'Preferencje wyszukiwania zapisane.', tone: 'success' });
searchPropertyType: searchPrefs.propertyType.trim(), } else {
searchBudgetMax: asNumber(searchPrefs.budgetMax), setSearchBanner({ text: 'Nie udało się zapisać preferencji.', tone: 'error' });
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); setIsSavingSearch(false);
}; };
/**
* Czysci wszystkie preferencje wyszukiwania - i w formularzu, i na koncie. Zapisujemy od razu,
* bo samo oproznienie pol bez zapisu zostawiloby wyszukiwarke z filtrami, ktorych uzytkownik
* juz nie widzi w ustawieniach.
*/
const clearSearchPrefs = async () => {
const empty = {
locations: '', propertyType: '', budgetMax: '',
areaMin: '', areaMax: '', roomsMin: '', roomsMax: '',
};
setIsSavingSearch(true);
setSearchBanner(null);
setSearchPrefs(empty);
const saved = await patchSettings(searchPrefsPatch(empty));
if (saved) {
onSearchPreferencesSaved(toSearchPreferences(saved));
flashBanner('search', setSearchBanner, { text: 'Preferencje wyczyszczone.', tone: 'success' });
} else {
setSearchBanner({ text: 'Nie udało się wyczyścić preferencji.', tone: 'error' });
}
setIsSavingSearch(false);
};
// Po wejsciu na strone pytamy serwer, czy odstep po ostatniej zmianie hasla juz minal -
// licznik ma przetrwac odswiezenie strony i zmiane urzadzenia.
useEffect(() => {
if (!user) {
return;
}
let cancelled = false;
passwordCooldown()
.then((seconds) => { if (!cancelled) { setPasswordWait(seconds); } })
.catch(() => { /* brak odpowiedzi nie moze blokowac formularza - decyduje i tak serwer */ });
return () => { cancelled = true; };
}, [user, passwordCooldown]);
// Odliczanie w dol do kolejnej dozwolonej zmiany.
useEffect(() => {
if (passwordWait <= 0) {
return;
}
const timer = window.setTimeout(() => setPasswordWait(passwordWait - 1), 1000);
return () => window.clearTimeout(timer);
}, [passwordWait]);
// Sprawdzenie dotychczasowego hasla w tle, chwile po tym jak uzytkownik przestanie pisac.
// Dopiero poprawne haslo otwiera pola nowego - inaczej wypelnia sie caly formularz na darmo.
useEffect(() => {
const typed = passwordForm.current;
if (!typed) {
setCurrentCheck('empty');
return;
}
setCurrentCheck('checking');
let cancelled = false;
const timer = window.setTimeout(() => {
verifyPassword(typed)
.then(() => { if (!cancelled) { setCurrentCheck('ok'); } })
.catch(() => { if (!cancelled) { setCurrentCheck('bad'); } });
}, 600);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [passwordForm.current, verifyPassword]);
const canEditNewPassword = currentCheck === 'ok' && passwordWait === 0;
const passwordsMatch = passwordForm.next === passwordForm.confirm;
const canSubmitPassword = canEditNewPassword
&& passwordForm.next.length >= 8
&& passwordsMatch
&& !isSavingPassword;
// 300 s -> "5:00"
const waitLabel = `${Math.floor(passwordWait / 60)}:${String(passwordWait % 60).padStart(2, '0')}`;
const submitPasswordChange = async (event: ReactFormEvent<HTMLFormElement>) => { const submitPasswordChange = async (event: ReactFormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setPasswordBanner(null); setPasswordBanner(null);
if (passwordForm.next.length < 8) { if (!canSubmitPassword) {
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; return;
} }
setIsSavingPassword(true); setIsSavingPassword(true);
try { try {
await changePassword(passwordForm.current, passwordForm.next); await changePassword(passwordForm.current, passwordForm.next);
setPasswordForm({ current: '', next: '', confirm: '' }); setPasswordForm({ current: '', next: '', confirm: '' });
setPasswordBanner({ text: 'Hasło zostało zmienione.', tone: 'success' }); setCurrentCheck('empty');
flashBanner('password', setPasswordBanner, { text: 'Hasło zostało zmienione.', tone: 'success' });
// Odstep liczy serwer - pytamy go o wartosc zamiast zakladac pelne 5 minut.
setPasswordWait(await passwordCooldown());
} catch (error) { } catch (error) {
setPasswordBanner({ text: error instanceof Error ? error.message : 'Nie udało się zmienić hasła.', tone: 'error' }); setPasswordBanner({ text: error instanceof Error ? error.message : 'Nie udało się zmienić hasła.', tone: 'error' });
// Blad moze oznaczac wlasnie trwajacy odstep - odswiezamy licznik zgodnie z serwerem.
passwordCooldown().then(setPasswordWait).catch(() => { /* zostaje dotychczasowa wartosc */ });
} finally { } finally {
setIsSavingPassword(false); setIsSavingPassword(false);
} }
@@ -10787,7 +11089,12 @@ function AccountSettingsPage() {
const avatarImage = settings?.avatarImage ?? ''; const avatarImage = settings?.avatarImage ?? '';
const coverImage = settings?.coverImage ?? ''; const coverImage = settings?.coverImage ?? '';
const visibility = settings?.profileVisibility ?? 'PUBLIC'; const visibility = settings?.profileVisibility ?? 'PUBLIC';
const accountTypeLabel = user?.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto prywatne'; // Jedna nazwa stanu profilu na calej stronie: naglowek karty i podpis pod imieniem mowia to samo,
// inaczej po ustawieniu prywatnosci sekcja dalej nazywalaby sie "Profil publiczny".
const visibilityLabel = visibility === 'PRIVATE' ? 'Profil prywatny' : 'Profil publiczny';
// "Konto prywatne" jako nazwa typu konta mylilo sie z prywatnoscia profilu - typ konta mowi
// o dzialalnosci (osobista albo firmowa), a nie o widocznosci.
const accountTypeLabel = user?.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto osobiste';
return ( return (
<> <>
@@ -10809,16 +11116,15 @@ function AccountSettingsPage() {
<section className="settings-card settings-card-wide" aria-label="Moje dane"> <section className="settings-card settings-card-wide" aria-label="Moje dane">
<div className="settings-card-head"> <div className="settings-card-head">
<h2>Moje dane</h2> <h2>Moje dane</h2>
<p>Nazwa użytkownika, e-mail i telefon zapisane z rejestracji. Możesz je tutaj zmienić.</p> {/* Bez e-maila - jest przypisany do konta na stale i pole jest tylko do odczytu. */}
<p>Nazwa użytkownika i telefon zapisane z rejestracji. Możesz je tutaj zmienić.</p>
</div> </div>
<div className="profile-photo-row"> <div className="profile-photo-row">
<div className="profile-photo-avatar"> <Avatar className="profile-photo-avatar" name={getDisplayName(user)} image={avatarImage} />
{avatarImage ? <img src={avatarImage} alt="Zdjęcie profilowe" /> : getInitials(getDisplayName(user))}
</div>
<div> <div>
<strong>{getDisplayName(user)}</strong> <strong>{getDisplayName(user)}</strong>
<small>{accountTypeLabel}{user?.nick ? ` · @${user.nick}` : ''}</small> <small>{accountTypeLabel} · {visibilityLabel}{user?.nick ? ` · @${user.nick}` : ''}</small>
<div className="profile-photo-actions"> <div className="profile-photo-actions">
<label> <label>
<input <input
@@ -11022,10 +11328,10 @@ function AccountSettingsPage() {
</div> </div>
</section> </section>
{/* --- Profil publiczny --- */} {/* --- Profil publiczny albo prywatny - zaleznie od wybranej widocznosci --- */}
<section className="settings-card" aria-label="Profil publiczny"> <section className="settings-card" aria-label={visibilityLabel}>
<div className="settings-card-head"> <div className="settings-card-head">
<h2>Profil publiczny</h2> <h2>{visibilityLabel}</h2>
<p>Zdjęcie w tle i to, kto może oglądać Twój profil.</p> <p>Zdjęcie w tle i to, kto może oglądać Twój profil.</p>
</div> </div>
@@ -11090,9 +11396,32 @@ function AccountSettingsPage() {
<span>Lokalizacje</span> <span>Lokalizacje</span>
<input <input
value={searchPrefs.locations} value={searchPrefs.locations}
placeholder="Np. Warszawa (Mokotów, Wola)" placeholder="Wpisz miasto, dzielnicę lub ulicę"
onChange={(event) => setSearchPrefs((current) => ({ ...current, locations: event.target.value }))} onChange={(event) => {
setPlacesOpen(true);
setSearchPrefs((current) => ({ ...current, locations: event.target.value }));
}}
/> />
{/* Od dwoch znakow podpowiadamy prawdziwe miejscowosci - tak samo jak wyszukiwarka
ofert. Puste pole nie pokazuje nic: to preferencja, a nie wyszukiwanie,
wiec nie podsuwamy tu miast, ktorych uzytkownik nie szukal. */}
{placesOpen && locationQuery.length >= 2 && (
<div className="settings-place-suggest">
{placesLoading && <p className="settings-place-status">Szukam miejscowości...</p>}
{!placesLoading && placeSuggestions.length === 0 && (
<p className="settings-place-status">Brak wyników dla {locationQuery}". Możesz zostawić własny wpis.</p>
)}
{placeSuggestions.map((place) => (
<button type="button" key={place.id} onClick={() => pickLocation(place.label)}>
<Icon name="pin" />
<span>
<strong>{place.label}</strong>
{place.sublabel && <small>{place.sublabel}</small>}
</span>
</button>
))}
</div>
)}
</label> </label>
<label> <label>
<span>Typ nieruchomości</span> <span>Typ nieruchomości</span>
@@ -11156,6 +11485,17 @@ function AccountSettingsPage() {
</div> </div>
<div className="profile-form-actions"> <div className="profile-form-actions">
{/* Czyszczenie pokazujemy tylko wtedy, gdy jest co czyscic. */}
{hasSearchPrefs && (
<button
type="button"
className="settings-clear-button"
onClick={() => { void clearSearchPrefs(); }}
disabled={isSavingSearch || isLoading}
>
Wyczyść preferencje
</button>
)}
<button type="button" onClick={() => { void saveSearchPrefs(); }} disabled={isSavingSearch || isLoading}> <button type="button" onClick={() => { void saveSearchPrefs(); }} disabled={isSavingSearch || isLoading}>
{isSavingSearch ? 'Zapisywanie...' : 'Zapisz preferencje'} {isSavingSearch ? 'Zapisywanie...' : 'Zapisz preferencje'}
</button> </button>
@@ -11305,24 +11645,41 @@ function AccountSettingsPage() {
<form className="profile-form-grid settings-password-form" onSubmit={submitPasswordChange}> <form className="profile-form-grid settings-password-form" onSubmit={submitPasswordChange}>
<h3>Zmiana hasła</h3> <h3>Zmiana hasła</h3>
{/* Trwajacy odstep po ostatniej zmianie - formularz jest wtedy w calosci zamkniety.
Tekst siedzi w jednym <span>, bo rodzic jest flexem: kazdy osobny wezel dostalby
odstep i przed kropka po liczniku zrobilaby sie luka. */}
{passwordWait > 0 && (
<p className="password-cooldown" role="status">
<Icon name="clock" />
<span>Hasło zmieniono niedawno. Kolejna zmiana będzie możliwa za <strong>{waitLabel}</strong>.</span>
</p>
)}
<label> <label>
<span>Dotychczasowe hasło</span> <span>Dotychczasowe hasło</span>
<input <input
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
value={passwordForm.current} value={passwordForm.current}
disabled={passwordWait > 0}
onChange={(event) => setPasswordForm((current) => ({ ...current, current: event.target.value }))} onChange={(event) => setPasswordForm((current) => ({ ...current, current: event.target.value }))}
required required
/> />
{currentCheck === 'checking' && <small className="field-hint">Sprawdzam hasło...</small>}
{currentCheck === 'bad' && <small className="field-hint error">Nieprawidłowe hasło - pola nowego hasła pozostają zablokowane.</small>}
{currentCheck === 'ok' && passwordWait === 0 && <small className="field-hint ok">Hasło potwierdzone. Możesz ustawić nowe.</small>}
</label> </label>
<div className="settings-range-row"> <div className="settings-range-row">
<label> <label>
<span>Nowe hasło</span> <span>Nowe hasło</span>
<input <input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder="Min. 8 znaków" placeholder={canEditNewPassword ? 'Min. 8 znaków' : 'Najpierw podaj dotychczasowe hasło'}
value={passwordForm.next} value={passwordForm.next}
disabled={!canEditNewPassword}
onChange={(event) => setPasswordForm((current) => ({ ...current, next: event.target.value }))} onChange={(event) => setPasswordForm((current) => ({ ...current, next: event.target.value }))}
required required
/> />
@@ -11333,13 +11690,25 @@ function AccountSettingsPage() {
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={passwordForm.confirm} value={passwordForm.confirm}
disabled={!canEditNewPassword}
onChange={(event) => setPasswordForm((current) => ({ ...current, confirm: event.target.value }))} onChange={(event) => setPasswordForm((current) => ({ ...current, confirm: event.target.value }))}
required required
/> />
</label> </label>
</div> </div>
{/* Podpowiedzi pokazujemy dopiero, gdy jest co porownywac - nie karcimy za puste pola. */}
{canEditNewPassword && passwordForm.next.length > 0 && passwordForm.next.length < 8 && (
<small className="field-hint error">Nowe hasło musi mieć co najmniej 8 znaków.</small>
)}
{canEditNewPassword && passwordForm.confirm.length > 0 && !passwordsMatch && (
<small className="field-hint error">Hasła nie identyczne - popraw, żeby móc zapisać.</small>
)}
<div className="profile-form-actions"> <div className="profile-form-actions">
<button type="submit" disabled={isSavingPassword}>{isSavingPassword ? 'Zapisywanie...' : 'Zmień hasło'}</button> <button type="submit" disabled={!canSubmitPassword}>
{isSavingPassword ? 'Zapisywanie...' : (passwordWait > 0 ? `Zmiana możliwa za ${waitLabel}` : 'Zmień hasło')}
</button>
{passwordBanner && <span className={`profile-message ${passwordBanner.tone}`}>{passwordBanner.text}</span>} {passwordBanner && <span className={`profile-message ${passwordBanner.tone}`}>{passwordBanner.text}</span>}
</div> </div>
</form> </form>
@@ -11543,11 +11912,7 @@ function AccountPublicProfilePage() {
<section className="public-profile-cover-card"> <section className="public-profile-cover-card">
<div className="public-profile-cover" style={{ backgroundImage: `url(${publicProfileCover})`, backgroundPosition: 'center center' }} /> <div className="public-profile-cover" style={{ backgroundImage: `url(${publicProfileCover})`, backgroundPosition: 'center center' }} />
<div className="public-profile-summary"> <div className="public-profile-summary">
<div className="public-profile-avatar"> <Avatar className="public-profile-avatar" name={getDisplayName(user)} image={profileSettings?.avatarImage} />
{profileSettings?.avatarImage
? <img src={profileSettings.avatarImage} alt="Zdjęcie profilowe" />
: initialsFromName(getDisplayName(user))}
</div>
<div className="public-profile-user"> <div className="public-profile-user">
<h2>{getDisplayName(user)}</h2> <h2>{getDisplayName(user)}</h2>
<div className="public-profile-meta"> <div className="public-profile-meta">
@@ -12819,6 +13184,16 @@ function RentPage({
))} ))}
</div> </div>
{/* Ten sam pasek co przy sprzedazy - filtry sa wspolne dla obu list. */}
{filters.fromPreferences && (
<p className="preferences-note" role="status">
<Icon name="gear" />
<span>Filtry ustawione według Twoich preferencji wyszukiwania.</span>
<button type="button" onClick={resetAll}>Wyczyść</button>
<Link to={ROUTES.accountSettings}>Zmień preferencje</Link>
</p>
)}
<RealListingsBand <RealListingsBand
offerType="RENT" offerType="RENT"
onOpenListing={onOpenListing} onOpenListing={onOpenListing}
@@ -17549,6 +17924,29 @@ function thousands(value: number): string {
const propertyTypeOptions: PropertySearchType[] = ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Pokój']; const propertyTypeOptions: PropertySearchType[] = ['Mieszkanie', 'Dom', 'Działka', 'Lokal użytkowy', 'Pokój'];
/**
* Ustawienia konta uzywaja liczby mnogiej ("Mieszkania"), a filtry pojedynczej ("Mieszkanie").
* "Apartamenty" nie maja odpowiednika wsrod filtrow - dla nich zostaje wartosc domyslna.
*/
const PREFERENCE_PROPERTY_TYPES: Record<string, PropertySearchType> = {
Mieszkania: 'Mieszkanie',
Domy: 'Dom',
Działki: 'Działka',
'Lokale użytkowe': 'Lokal użytkowy',
};
/**
* Preferencje trzymaja lokalizacje jako swobodny tekst ("Warszawa (Mokotów, Wola)"), a filtr miasta
* porownuje sie z nazwa miasta z ogloszenia. Bierzemy pierwszy czlon - to, co przed nawiasem
* albo przecinkiem - bo tylko on ma szanse dopasowac sie do miasta.
*/
function primaryLocation(raw: string | null): string {
if (!raw) {
return '';
}
return raw.split(/[(,]/)[0].trim();
}
function useFilterState() { function useFilterState() {
const [cityInput, setCityInput] = useState(''); const [cityInput, setCityInput] = useState('');
const [city, setCity] = useState(''); const [city, setCity] = useState('');
@@ -17562,6 +17960,15 @@ function useFilterState() {
const [pierwotny, setPierwotny] = useState(true); const [pierwotny, setPierwotny] = useState(true);
const [noFeeOnly, setNoFeeOnly] = useState(false); const [noFeeOnly, setNoFeeOnly] = useState(false);
const [more, setMore] = useState<Record<string, string>>({}); const [more, setMore] = useState<Record<string, string>>({});
// Czy obecne filtry pochodza z preferencji konta - od tego zalezy pasek nad wynikami.
// Kopia w ref, bo applyPreferences jest stabilne (useCallback bez zaleznosci) i inaczej
// widzialoby wartosc z chwili utworzenia funkcji, a nie biezaca.
const [fromPreferences, setFromPreferences] = useState(false);
const fromPreferencesRef = useRef(false);
const markFromPreferences = (value: boolean) => {
fromPreferencesRef.current = value;
setFromPreferences(value);
};
const pMin = parseNumber(priceMin); const pMin = parseNumber(priceMin);
const pMax = parseNumber(priceMax); const pMax = parseNumber(priceMax);
@@ -17603,8 +18010,58 @@ function useFilterState() {
setPierwotny(true); setPierwotny(true);
setNoFeeOnly(false); setNoFeeOnly(false);
setMore({}); setMore({});
markFromPreferences(false);
}; };
/**
* Wypelnia filtry preferencjami z ustawien konta. Wolane po wczytaniu preferencji oraz po ich
* zapisaniu w ustawieniach - bez tego drugiego zmiana preferencji dzialalaby dopiero po
* przeladowaniu strony, bo przejscia wewnatrz aplikacji nie tworza komponentu od nowa.
* Gdy cokolwiek zostalo ustawione, podnosimy flage, zeby lista wynikow mogla powiedziec, skad
* wziely sie te filtry: zawezenie bez wyjasnienia wyglada jak brak ofert w serwisie.
*/
const applyPreferences = useCallback((prefs: SearchPreferences) => {
let applied = false;
const location = primaryLocation(prefs.locations);
if (location) {
setCity(location);
setCityInput(location);
applied = true;
}
const mappedType = prefs.propertyType ? PREFERENCE_PROPERTY_TYPES[prefs.propertyType.trim()] : undefined;
if (mappedType) {
setPropertyType(mappedType);
applied = true;
}
if (prefs.budgetMax != null && prefs.budgetMax > 0) {
setPriceMax(String(prefs.budgetMax));
applied = true;
}
if (prefs.areaMin != null && prefs.areaMin > 0) {
setAreaMin(String(prefs.areaMin));
applied = true;
}
if (prefs.areaMax != null && prefs.areaMax > 0) {
setAreaMax(String(prefs.areaMax));
applied = true;
}
// Filtr pokoi przyjmuje jedna wartosc (5 = "5 i wiecej"), preferencje maja zakres -
// bierzemy dolna granice, bo to ona odsiewa za male mieszkania.
if (prefs.roomsMin != null && prefs.roomsMin > 0) {
setRooms(Math.min(prefs.roomsMin, 5));
applied = true;
}
// Puste preferencje po wyczyszczeniu w ustawieniach zdejmuja filtry, ktore same nalozyly.
// Filtrow ustawionych recznie przez uzytkownika nie ruszamy.
if (!applied && fromPreferencesRef.current) {
resetAll();
return;
}
markFromPreferences(applied);
}, []);
const clearCity = () => { const clearCity = () => {
setCity(''); setCity('');
setCityInput(''); setCityInput('');
@@ -17640,6 +18097,7 @@ function useFilterState() {
pMin, pMax, aMin, aMax, pMin, pMax, aMin, aMax,
priceLabel, areaLabel, roomsLabelValue, roomsShort, marketLabel, priceLabel, areaLabel, roomsLabelValue, roomsShort, marketLabel,
resetAll, clearCity, chips, resetAll, clearCity, chips,
fromPreferences, applyPreferences,
}; };
} }
@@ -19381,7 +19839,7 @@ function BuyPage({
savedSearchActive: boolean; savedSearchActive: boolean;
onToggleSavedSearch: () => void; onToggleSavedSearch: () => void;
}) { }) {
const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll } = filters; const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll, fromPreferences } = filters;
const [, setSearchParams] = useSearchParams(); const [, setSearchParams] = useSearchParams();
const [isMapOpen, setIsMapOpen] = useState(false); const [isMapOpen, setIsMapOpen] = useState(false);
const applySort = (value: SortKey) => { const applySort = (value: SortKey) => {
@@ -19476,6 +19934,17 @@ function BuyPage({
))} ))}
</div> </div>
{/* Bez tego paska zawezona lista wyglada jak brak ofert w serwisie - uzytkownik musi
wiedziec, ze to jego wlasne preferencje z ustawien konta, i moc je zdjac. */}
{fromPreferences && (
<p className="preferences-note" role="status">
<Icon name="gear" />
<span>Filtry ustawione według Twoich preferencji wyszukiwania.</span>
<button type="button" onClick={resetAll}>Wyczyść</button>
<Link to={ROUTES.accountSettings}>Zmień preferencje</Link>
</p>
)}
<RealListingsBand <RealListingsBand
offerType="SALE" offerType="SALE"
onOpenListing={onOpenListing} onOpenListing={onOpenListing}
@@ -20832,7 +21301,7 @@ function Header({
onClick={() => setIsAccountMenuOpen((current) => !current)} onClick={() => setIsAccountMenuOpen((current) => !current)}
aria-label={getDisplayName(user)} aria-label={getDisplayName(user)}
> >
<span className="account-trigger-avatar">{getInitials(getDisplayName(user))}</span> <Avatar className="account-trigger-avatar" name={getDisplayName(user)} image={user?.avatarImage} />
<Icon name="chevron" /> <Icon name="chevron" />
</button> </button>
@@ -21000,9 +21469,12 @@ type PublicProfile = {
nick: string | null; nick: string | null;
bio: string | null; bio: string | null;
avatarImage: string | null; avatarImage: string | null;
coverImage: string | null;
accountType: AccountType; accountType: AccountType;
verified: boolean; verified: boolean;
memberSince: string; memberSince: string;
// Profil prywatny ogladany przez kogos innego niz wlasciciel - reszta pol jest wtedy pusta.
privateProfile: boolean;
listingsCount: number; listingsCount: number;
listings: { listings: {
id: number; id: number;
@@ -21057,13 +21529,6 @@ function PublicProfilePage() {
}; };
}, [id, nick]); }, [id, nick]);
const initials = (profile?.fullName ?? '')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((chunk) => chunk[0]?.toUpperCase() ?? '')
.join('') || '?';
const memberSinceLabel = profile const memberSinceLabel = profile
? new Date(profile.memberSince).toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' }) ? new Date(profile.memberSince).toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' })
: ''; : '';
@@ -21081,14 +21546,30 @@ function PublicProfilePage() {
{loading && <p className="admin-empty">Wczytywanie profilu...</p>} {loading && <p className="admin-empty">Wczytywanie profilu...</p>}
{!loading && error && <p className="admin-empty">{error}</p>} {!loading && error && <p className="admin-empty">{error}</p>}
{!loading && !error && profile && ( {/* Profil prywatny: serwer nie przysyla ogloszen ani danych profilu, wiec nie ma czego ukrywac
w interfejsie - pokazujemy sama informacje, do kogo profil nalezy i ze jest prywatny. */}
{!loading && !error && profile?.privateProfile && (
<section className="settings-card private-profile-card" aria-label="Profil prywatny">
<Avatar className="private-profile-avatar" name={profile.fullName} />
<h1>{profile.fullName}</h1>
<strong><Icon name="lock" /> Ten profil jest prywatny</strong>
<p>
Ogłoszenia, opis i pozostałe dane tego konta widzi wyłącznie jego właściciel.
Jeśli chcesz się skontaktować, napisz wiadomość w serwisie.
</p>
</section>
)}
{!loading && !error && profile && !profile.privateProfile && (
<> <>
<section className="public-profile-cover-card"> <section className="public-profile-cover-card">
<div className="public-profile-cover" style={{ backgroundImage: `url(${cityImage})`, backgroundPosition: 'center center' }} /> {/* Zdjecie w tle ustawione przez wlasciciela; bez niego zostaje grafika serwisu. */}
<div
className="public-profile-cover"
style={{ backgroundImage: `url(${profile.coverImage || cityImage})`, backgroundPosition: 'center center' }}
/>
<div className="public-profile-summary"> <div className="public-profile-summary">
<div className="public-profile-avatar"> <Avatar className="public-profile-avatar" name={profile.fullName} image={profile.avatarImage} />
{profile.avatarImage ? <img src={profile.avatarImage} alt={`Zdjęcie profilowe ${profile.fullName}`} /> : initials}
</div>
<div className="public-profile-user"> <div className="public-profile-user">
<h1>{profile.fullName}</h1> <h1>{profile.fullName}</h1>
<div className="public-profile-meta"> <div className="public-profile-meta">
@@ -24027,7 +24508,7 @@ function ListingDetailPage({
</button> </button>
<div className="listing-detail-seller-card"> <div className="listing-detail-seller-card">
<div className="listing-detail-seller-avatar">{sellerName.slice(0, 1).toUpperCase()}</div> <Avatar className="listing-detail-seller-avatar" name={sellerName} image={listing.ownerAvatar} singleLetter />
<div> <div>
<strong>{sellerName}</strong> <strong>{sellerName}</strong>
<small>{sellerRole}</small> <small>{sellerRole}</small>
+45
View File
@@ -0,0 +1,45 @@
/**
* Zdjecie profilowe uzytkownika - jedno miejsce dla calej aplikacji.
*
* Uzytkownik ustawia zdjecie raz w /konto/ustawienia, a backend dokleja je do profilu
* (/api/auth/me, profil publiczny, karta sprzedajacego przy ogloszeniu). Gdy zdjecia nie ma,
* rysujemy inicjaly na zielonym tle - ten sam wyglad co awatar w prawym gornym rogu naglowka.
*
* Rozmiar i pozycje nadaje klasa z miejsca uzycia (np. .account-trigger-avatar), a wspolna
* klasa .avatar odpowiada za ksztalt, kolor tla i wpasowanie zdjecia w kolo.
*/
type AvatarProps = {
/** Nazwa uzytkownika - zrodlo inicjalow i tekstu alternatywnego zdjecia. */
name: string;
/** Zdjecie jako data URL. Puste albo null oznacza, ze uzytkownik zadnego nie ustawil. */
image?: string | null;
/** Klasa miejsca uzycia, ktora nadaje rozmiar. */
className?: string;
/** Sama pierwsza litera imienia zamiast dwoch inicjalow - tak wyglada karta sprzedajacego. */
singleLetter?: boolean;
};
/** Inicjaly z nazwy: "Jan Kowalski" -> "JK", "joanna" -> "JO", pusta nazwa -> "??". */
export function initialsFrom(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return '??';
}
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
export function Avatar({ name, image, className, singleLetter }: AvatarProps) {
const trimmed = image?.trim();
const letters = singleLetter ? (name.trim()[0] ?? '?').toUpperCase() : initialsFrom(name);
return (
<span className={className ? `avatar ${className}` : 'avatar'}>
{trimmed
? <img src={trimmed} alt={`Zdjęcie profilowe: ${name}`} />
: letters}
</span>
);
}
+39 -3
View File
@@ -9,7 +9,8 @@ export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
export type Currency = 'PLN' | 'EUR' | 'USD'; export type Currency = 'PLN' | 'EUR' | 'USD';
export type AreaUnit = 'M2' | 'FT2'; export type AreaUnit = 'M2' | 'FT2';
export type ProfileVisibility = 'PUBLIC' | 'CONTACTS' | 'PRIVATE'; // Profil jest publiczny, dopoki wlasciciel sam nie ustawi go jako prywatny.
export type ProfileVisibility = 'PUBLIC' | 'PRIVATE';
/** /**
* Ustawienia konta trzymane na serwerze (/api/me/settings). Zgody marketingowe celowo tu nie leza - * Ustawienia konta trzymane na serwerze (/api/me/settings). Zgody marketingowe celowo tu nie leza -
@@ -41,6 +42,20 @@ export type UserSettings = {
// Zapis czesciowy: pominiete pole zostaje bez zmian, pusty tekst je czysci, liczba ujemna zeruje. // Zapis czesciowy: pominiete pole zostaje bez zmian, pusty tekst je czysci, liczba ujemna zeruje.
export type UserSettingsPatch = Partial<Record<keyof UserSettings, unknown>>; export type UserSettingsPatch = Partial<Record<keyof UserSettings, unknown>>;
/**
* Preferencje wyszukiwania w lekkiej postaci (/api/me/settings/search) - wyszukiwarka pobiera je
* przy wejsciu do aplikacji, wiec nie ciagniemy przy okazji zdjec z pelnych ustawien.
*/
export type SearchPreferences = {
locations: string | null;
propertyType: string | null;
budgetMax: number | null;
areaMin: number | null;
areaMax: number | null;
roomsMin: number | null;
roomsMax: number | null;
};
export type AuthUser = { export type AuthUser = {
id: number; id: number;
email: string; email: string;
@@ -60,6 +75,8 @@ export type AuthUser = {
blocked: boolean; blocked: boolean;
promotionCredits: number; promotionCredits: number;
createdAt: string; createdAt: string;
// Zdjecie profilowe z ustawien konta - null, gdy uzytkownik zadnego nie ustawil.
avatarImage: string | null;
}; };
type AuthResponse = { token: string; user: AuthUser }; type AuthResponse = { token: string; user: AuthUser };
@@ -89,8 +106,11 @@ type AuthContextValue = {
resendPhoneOtp: (email: string) => Promise<void>; resendPhoneOtp: (email: string) => Promise<void>;
updateProfile: (profile: ProfileUpdate) => Promise<AuthUser>; updateProfile: (profile: ProfileUpdate) => Promise<AuthUser>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>; changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
verifyPassword: (password: string) => Promise<void>;
passwordCooldown: () => Promise<number>;
deleteAccount: (password: string) => Promise<void>; deleteAccount: (password: string) => Promise<void>;
loadSettings: () => Promise<UserSettings>; loadSettings: () => Promise<UserSettings>;
loadSearchPreferences: () => Promise<SearchPreferences>;
saveSettings: (patch: UserSettingsPatch) => Promise<UserSettings>; saveSettings: (patch: UserSettingsPatch) => Promise<UserSettings>;
refreshUser: () => Promise<AuthUser | null>; refreshUser: () => Promise<AuthUser | null>;
logout: () => void; logout: () => void;
@@ -286,6 +306,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
const verifyPassword = useCallback(async (password: string) => {
await apiFetch<void>('/auth/verify-password', {
method: 'POST',
body: JSON.stringify({ password }),
});
}, []);
// Ile sekund zostalo do momentu, w ktorym serwer przyjmie kolejna zmiane hasla.
const passwordCooldown = useCallback(async () => {
const data = await apiFetch<{ secondsLeft: number }>('/auth/password-cooldown');
return data.secondsLeft;
}, []);
// Po usunieciu konta token jest bezuzyteczny - czyscimy sesje od razu, bez czekania na 401. // Po usunieciu konta token jest bezuzyteczny - czyscimy sesje od razu, bez czekania na 401.
const deleteAccount = useCallback(async (password: string) => { const deleteAccount = useCallback(async (password: string) => {
await apiFetch<void>('/auth/me', { await apiFetch<void>('/auth/me', {
@@ -299,6 +333,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const loadSettings = useCallback(async () => apiFetch<UserSettings>('/me/settings'), []); const loadSettings = useCallback(async () => apiFetch<UserSettings>('/me/settings'), []);
const loadSearchPreferences = useCallback(async () => apiFetch<SearchPreferences>('/me/settings/search'), []);
const saveSettings = useCallback( const saveSettings = useCallback(
async (patch: UserSettingsPatch) => async (patch: UserSettingsPatch) =>
apiFetch<UserSettings>('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }), apiFetch<UserSettings>('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }),
@@ -329,11 +365,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({ () => ({
user, token, loading, login, register, socialLogin, user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp, activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout, updateProfile, changePassword, verifyPassword, passwordCooldown, deleteAccount, loadSettings, loadSearchPreferences, saveSettings, refreshUser, logout,
}), }),
[user, token, loading, login, register, socialLogin, [user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp, activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout], updateProfile, changePassword, verifyPassword, passwordCooldown, deleteAccount, loadSettings, loadSearchPreferences, saveSettings, refreshUser, logout],
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+354 -29
View File
@@ -14275,9 +14275,7 @@ svg {
.account-avatar { .account-avatar {
align-items: center; align-items: center;
background: #e8f4ff;
border-radius: 999px; border-radius: 999px;
color: #20344f;
display: grid; display: grid;
font-size: 15px; font-size: 15px;
font-weight: 800; font-weight: 800;
@@ -19557,10 +19555,11 @@ svg {
color: #2b4e74; color: #2b4e74;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
font-size: 10px; font-size: 11px;
font-weight: 900; font-weight: 900;
min-height: 24px; min-height: 28px;
padding: 0 10px; padding: 0 12px;
white-space: nowrap;
} }
.settings-list .cover-upload-tag input { .settings-list .cover-upload-tag input {
@@ -19568,7 +19567,38 @@ svg {
} }
.settings-list .cover-photo-row strong { .settings-list .cover-photo-row strong {
font-size: 10px; font-size: 11px;
}
/* Podpowiedz przy zdjeciu w tle to pelne zdanie, a nie krotka etykieta. W waskiej trzeciej
kolumnie konczyla sie wielokropkiem ("Dodaj zdjecie w tle swo..."), wiec schodzi do drugiej
linii pod nazwe i zawija sie normalnie. Nazwa i przycisk zostaja w jednym wierszu. */
.settings-list article.cover-photo-row {
grid-template-columns: 22px minmax(0, 1fr) auto;
row-gap: 3px;
}
.settings-list article.cover-photo-row > span {
grid-column: 1;
grid-row: 1 / span 2;
}
.settings-list article.cover-photo-row > em {
grid-column: 2;
grid-row: 1;
}
.settings-list article.cover-photo-row > .cover-upload-tag {
grid-column: 3;
grid-row: 1;
}
.settings-list article.cover-photo-row > strong {
grid-column: 2 / span 2;
grid-row: 2;
overflow: visible;
text-align: left;
white-space: normal;
} }
.cover-photo-actions-row { .cover-photo-actions-row {
@@ -19712,6 +19742,8 @@ svg {
align-items: start; align-items: start;
gap: 8px; gap: 8px;
grid-template-columns: 22px 1fr; grid-template-columns: 22px 1fr;
/* Wyrazny odstep od wiersza ze zdjeciem w tle - to osobna decyzja, nie kolejna pozycja listy. */
padding-top: 16px;
} }
.settings-list article.profile-visibility-card > span { .settings-list article.profile-visibility-card > span {
@@ -19725,10 +19757,19 @@ svg {
grid-column: 2; grid-column: 2;
} }
/* Opis wybranej widocznosci to pelne zdanie pod przyciskami, a nie krotka etykieta w wierszu.
Domyslne .settings-list strong tnie go w jednej linii wielokropkiem - tutaj ma sie zawijac. */
.settings-list article.profile-visibility-card > strong {
overflow: visible;
text-align: left;
white-space: normal;
}
/* Dwie opcje widocznosci: publiczna i prywatna - kolumny dziela szerokosc po rowno. */
.profile-visibility-card .profile-visibility-options { .profile-visibility-card .profile-visibility-options {
display: grid; display: grid;
gap: 6px; gap: 6px;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 4px; margin-top: 4px;
width: 100%; width: 100%;
} }
@@ -19738,9 +19779,10 @@ svg {
border: 1px solid #d8e1ec; border: 1px solid #d8e1ec;
border-radius: 8px; border-radius: 8px;
color: #41566f; color: #41566f;
font-size: 9px; /* Etykiety byly scisniete do 9 px, zeby zmiescic trzy kolumny - przy dwoch jest miejsce. */
font-size: 11px;
font-weight: 900; font-weight: 900;
min-height: 28px; min-height: 30px;
overflow: hidden; overflow: hidden;
padding: 0 8px; padding: 0 8px;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -20738,10 +20780,7 @@ svg {
} }
.profile-photo-avatar { .profile-photo-avatar {
background: #eaf2ff;
border: 2px solid #d7e2f2;
border-radius: 999px; border-radius: 999px;
color: #2f6cd3;
display: grid; display: grid;
font-size: 22px; font-size: 22px;
font-weight: 900; font-weight: 900;
@@ -20766,21 +20805,41 @@ svg {
.profile-photo-actions { .profile-photo-actions {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 10px; flex-wrap: wrap;
margin-top: 8px; gap: 12px;
/* Przycisk stal tuz pod nazwa konta i wisial wysoko nad linia zamykajaca sekcje - odsuwamy go
od tekstu, zeby siedzial mniej wiecej w polowie miedzy nazwa a ta linia. */
margin-top: 16px;
} }
.profile-photo-actions label, /* Wybor pliku to <label>, a element inline ignoruje wysokosc i pionowe wysrodkowanie -
.profile-photo-actions button { stad inline-flex: bez tego napis siedzi na krawedzi ramki. */
background: #ffffff; .profile-photo-actions label {
border: 1px solid #d9e2ec; align-items: center;
border-radius: 6px; background: #12a764;
color: #344860; border: 1px solid #12a764;
border-radius: 8px;
color: #ffffff;
cursor: pointer; cursor: pointer;
font-size: 11px; display: inline-flex;
font-weight: 900; font-size: 12px;
min-height: 30px; font-weight: 800;
padding: 0 12px; height: 34px;
justify-content: center;
line-height: 1;
padding: 0 16px;
transition: background 0.15s ease, border-color 0.15s ease;
white-space: nowrap;
}
.profile-photo-actions label:hover {
background: #0f8f56;
border-color: #0f8f56;
}
.profile-photo-actions label:focus-within {
outline: 2px solid #0b7a49;
outline-offset: 2px;
} }
.profile-photo-actions label input { .profile-photo-actions label input {
@@ -20788,11 +20847,20 @@ svg {
} }
.profile-photo-actions button { .profile-photo-actions button {
background: none;
border: 0; border: 0;
color: #d14646; color: #d14646;
cursor: pointer;
font-size: 12px;
font-weight: 800;
line-height: 1;
padding: 0; padding: 0;
} }
.profile-photo-actions button:hover {
text-decoration: underline;
}
.settings-separator { .settings-separator {
border-top: 1px solid #edf1f5; border-top: 1px solid #edf1f5;
margin: 14px 0; margin: 14px 0;
@@ -21771,11 +21839,9 @@ svg {
.public-profile-avatar { .public-profile-avatar {
align-items: center; align-items: center;
background: #eaf2ff;
border: 3px solid #ffffff; border: 3px solid #ffffff;
border-radius: 999px; border-radius: 999px;
box-shadow: 0 6px 14px rgba(29, 52, 82, 0.16); box-shadow: 0 6px 14px rgba(29, 52, 82, 0.16);
color: #2f6cd3;
display: grid; display: grid;
font-size: 26px; font-size: 26px;
font-weight: 900; font-weight: 900;
@@ -30014,6 +30080,14 @@ a.listing-detail-back {
align-items: center; align-items: center;
} }
/* Wiersz z telefonem jest ostatni, wiec nie ma wlasnej kreski na dole (border-bottom zdejmuje
:last-child). Domyslny odstep separatora dokladal pod nim 14 px pustki, przez co tresc wiersza
wygladala, jakby wisiala w powietrzu: 15 px nad tekstem i 29 px pod nim. Separator idzie tuz
pod wiersz i zamyka go tak samo, jak kreska zamyka wiersz z e-mailem. */
.security-status-list + .settings-separator {
margin-top: 2px;
}
.status-pill { .status-pill {
border-radius: 999px; border-radius: 999px;
font-size: 10px; font-size: 10px;
@@ -30045,11 +30119,12 @@ a.listing-detail-back {
} }
/* Link podgladu profilu jest teraz czwartym dzieckiem karty widocznosci - bez tego /* Link podgladu profilu jest teraz czwartym dzieckiem karty widocznosci - bez tego
wpadalby do waskiej kolumny z ikona i wychodzil poza karte. */ wpadalby do waskiej kolumny z ikona i wychodzil poza karte. Stoi na dole po prawej,
pod opisem wybranej widocznosci - to domkniecie sekcji, a nie element wiersza. */
.settings-list article.profile-visibility-card > .profile-preview-cta { .settings-list article.profile-visibility-card > .profile-preview-cta {
grid-column: 2; grid-column: 2;
justify-self: start; justify-self: end;
margin-top: 4px; margin-top: 10px;
} }
.settings-danger-zone { .settings-danger-zone {
@@ -30168,3 +30243,253 @@ a.listing-detail-back {
} }
} }
/* --- Awatar uzytkownika: jeden wyglad w calej aplikacji ---------------------------------
Klasa z miejsca uzycia (.account-trigger-avatar, .account-avatar, .public-profile-avatar,
.profile-photo-avatar, .listing-detail-seller-avatar) nadaje rozmiar i pozycje, a ta -
ksztalt, kolor i wpasowanie zdjecia. Bez ustawionego zdjecia zostaja inicjaly: biale
litery na zielonym tle, tak jak awatar w prawym gornym rogu naglowka.
Reguly stoja na koncu arkusza, zeby kolor tla wygral z wczesniejszymi definicjami. */
.avatar {
align-items: center;
background: #12a764;
/* Ksztalt trzymamy tutaj, zeby kazde nowe uzycie bylo okragle bez dopisywania wlasnej reguly. */
border-radius: 999px;
color: #ffffff;
display: inline-flex;
flex: none;
font-weight: 800;
justify-content: center;
letter-spacing: 0.3px;
overflow: hidden;
text-transform: uppercase;
}
.avatar img {
border-radius: inherit;
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
/* --- Profil prywatny -------------------------------------------------------------------
Zamiast pustego szkieletu profilu pokazujemy jedna karte: czyj to profil i ze jest
prywatny. Ogloszen i danych nie ma tu do ukrycia - serwer ich nie przysyla. */
.private-profile-card {
align-items: center;
display: flex;
flex-direction: column;
gap: 10px;
padding: 40px 24px;
text-align: center;
}
.private-profile-avatar {
font-size: 24px;
height: 76px;
width: 76px;
}
.private-profile-card h1 {
color: #13243d;
font-size: 20px;
font-weight: 900;
margin: 4px 0 0;
}
.private-profile-card strong {
align-items: center;
color: #4a5c73;
display: inline-flex;
font-size: 13px;
font-weight: 800;
gap: 6px;
}
.private-profile-card p {
color: #77869a;
font-size: 13px;
line-height: 1.6;
margin: 0;
max-width: 46ch;
}
/* --- Zmiana hasla: podpowiedzi pod polami i licznik odstepu -----------------------------
Pola nowego hasla otwieraja sie dopiero po potwierdzeniu dotychczasowego, wiec formularz
musi na biezaco mowic, na czym stoi. */
.settings-password-form .field-hint {
color: #77869a;
display: block;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
margin-top: 4px;
}
.settings-password-form .field-hint.ok {
color: #12784f;
}
.settings-password-form .field-hint.error {
color: #c0392f;
}
.settings-password-form input:disabled {
background: #f4f6f9;
color: #9aa7b6;
cursor: not-allowed;
}
.settings-password-form button[type="submit"]:disabled {
cursor: not-allowed;
filter: grayscale(0.55);
opacity: 0.6;
}
.password-cooldown {
align-items: center;
background: #fdf6e8;
border: 1px solid #f0dcae;
border-radius: 8px;
color: #7a5a12;
display: flex;
font-size: 12px;
font-weight: 700;
gap: 8px;
margin: 0 0 4px;
padding: 10px 12px;
}
.password-cooldown svg {
flex: none;
height: 14px;
width: 14px;
}
.password-cooldown strong {
font-variant-numeric: tabular-nums;
font-weight: 900;
}
/* --- Pasek "filtry z Twoich preferencji" nad wynikami ----------------------------------
Wyszukiwarka startuje z preferencjami z /konto/ustawienia. Bez tego paska zawezona lista
wygladalaby jak brak ofert w serwisie, wiec musi byc widoczny i dawac wyjscie jednym kliknieciem. */
.preferences-note {
align-items: center;
background: #eef7f2;
border: 1px solid #cfe8dc;
border-radius: 8px;
color: #1c5c42;
display: flex;
flex-wrap: wrap;
font-size: 12px;
font-weight: 700;
gap: 10px;
margin: 0 0 14px;
padding: 10px 12px;
}
.preferences-note svg {
flex: none;
height: 14px;
width: 14px;
}
.preferences-note > span {
margin-right: auto;
}
.preferences-note button,
.preferences-note a {
background: none;
border: 0;
color: #12784f;
cursor: pointer;
font-size: 12px;
font-weight: 900;
padding: 0;
text-decoration: underline;
white-space: nowrap;
}
.preferences-note button:hover,
.preferences-note a:hover {
color: #0b5c3a;
}
/* Czyszczenie preferencji stoi obok zapisu, ale jest akcja drugorzedna - nie moze wygladac
tak samo jak zielony przycisk zapisu, bo to dwie rozne decyzje. */
.profile-form-actions button.settings-clear-button {
background: #ffffff;
border: 1px solid #d9e2ec;
color: #5c6b7f;
}
.profile-form-actions button.settings-clear-button:hover:not(:disabled) {
border-color: #c3d0de;
color: #33445c;
}
/* Lista podpowiedzi miejscowosci w preferencjach - uklad jak w wyszukiwarce ofert,
ale bez odnosnika do mapy: w ustawieniach nie prowadzimy wyszukiwania. */
.settings-place-suggest {
border: 1px solid #e3eaf3;
border-radius: 8px;
display: flex;
flex-direction: column;
margin-top: 8px;
max-height: 260px;
overflow-y: auto;
}
.settings-place-suggest button {
align-items: center;
background: none;
border: 0;
border-bottom: 1px solid #eff3f8;
cursor: pointer;
display: flex;
gap: 10px;
padding: 9px 12px;
text-align: left;
width: 100%;
}
.settings-place-suggest button:last-child {
border-bottom: 0;
}
.settings-place-suggest button:hover {
background: #f4f8fc;
}
.settings-place-suggest svg {
color: #12a764;
flex: none;
height: 14px;
width: 14px;
}
.settings-place-suggest strong {
color: #1c2c42;
display: block;
font-size: 12px;
font-weight: 800;
}
.settings-place-suggest small {
color: #7d8b9d;
display: block;
font-size: 11px;
font-weight: 600;
}
.settings-place-status {
color: #7d8b9d;
font-size: 11px;
font-weight: 700;
margin: 0;
padding: 10px 12px;
}