diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index ee2b6d6..c9245dc 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -31,6 +31,10 @@ import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.report.ListingReportResponse; import pl.polskalokalnie.report.ListingReportService; import pl.polskalokalnie.report.ResolveListingReportRequest; +import pl.polskalokalnie.report.UserReportResponse; +import pl.polskalokalnie.report.UserReportService; +import pl.polskalokalnie.support.SupportMessageResponse; +import pl.polskalokalnie.support.SupportMessageService; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.BlockedEmail; import pl.polskalokalnie.user.BlockedEmailRepository; @@ -49,6 +53,8 @@ public class AdminController { private final BlockedEmailRepository blockedEmailRepository; private final ListingService listingService; private final ListingReportService listingReportService; + private final UserReportService userReportService; + private final SupportMessageService supportMessageService; private final MessageService messageService; private final TextModerationService textModerationService; private final AdminStatsService adminStatsService; @@ -57,6 +63,8 @@ public class AdminController { public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, ListingService listingService, ListingReportService listingReportService, + UserReportService userReportService, + SupportMessageService supportMessageService, MessageService messageService, TextModerationService textModerationService, AdminStatsService adminStatsService, @@ -66,6 +74,8 @@ public class AdminController { this.blockedEmailRepository = blockedEmailRepository; this.listingService = listingService; this.listingReportService = listingReportService; + this.userReportService = userReportService; + this.supportMessageService = supportMessageService; this.messageService = messageService; this.textModerationService = textModerationService; this.adminStatsService = adminStatsService; @@ -263,6 +273,43 @@ public class AdminController { return listingReportService.deleteListingAndResolve(id, authentication.getName()); } + // --- Zgloszenia uzytkownikow (z rozmow w wiadomosciach) --- + + @GetMapping("/reports/users") + public List userReports() { + return userReportService.findAllForAdmin(); + } + + @PostMapping("/reports/users/{id}/resolve") + public UserReportResponse resolveUserReport( + @PathVariable Long id, + Authentication authentication, + @RequestBody(required = false) ResolveListingReportRequest request + ) { + return userReportService.resolve(id, authentication.getName(), request == null ? null : request.note()); + } + + @PostMapping("/reports/users/{id}/delete-user") + public UserReportResponse deleteUserFromReport(@PathVariable Long id, Authentication authentication) { + return userReportService.deleteReportedUserAndResolve(id, authentication.getName()); + } + + // --- Wiadomosci z formularza "Napisz do nas" (/konto/pomoc) --- + + @GetMapping("/support-messages") + public List supportMessages() { + return supportMessageService.findAllForAdmin(); + } + + @PostMapping("/support-messages/{id}/handled") + public SupportMessageResponse setSupportMessageHandled( + @PathVariable Long id, + @RequestParam(defaultValue = "true") boolean handled, + Authentication authentication + ) { + return supportMessageService.setHandled(id, handled, authentication.getName()); + } + // --- Slowa zabronione --- @GetMapping("/forbidden-words") diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index e88672b..9171ddb 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -48,6 +48,18 @@ public class SchemaFixer { "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT" ); + // Hibernate (ddl-auto=update) nie potrafi dodac kolumny NOT NULL do tabeli z danymi - + // dokladamy ja z wartoscia domyslna, zeby istniejace zgloszenia sie nie wywracaly. + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS user_reports ADD COLUMN IF NOT EXISTS reported_user_deleted BOOLEAN NOT NULL DEFAULT FALSE" + ); + + // Blokowanie konta ze zgloszenia zostalo usuniete - encja nie mapuje juz tej kolumny, + // wiec stare wiersze zostaja, a nowe wpisy wypelnia wartosc domyslna. + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE" + ); + // Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci. // Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika). jdbcTemplate.execute( diff --git a/backend/src/main/java/pl/polskalokalnie/listing/AreaRankingController.java b/backend/src/main/java/pl/polskalokalnie/listing/AreaRankingController.java new file mode 100644 index 0000000..6c0b20d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/AreaRankingController.java @@ -0,0 +1,148 @@ +package pl.polskalokalnie.listing; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Ranking miast i dzielnic liczony z aktualnie opublikowanych ogloszen. + */ +@RestController +@RequestMapping("/api/listings") +public class AreaRankingController { + + private static final int TREND_WINDOW_DAYS = 90; + private static final int NEW_WINDOW_DAYS = 30; + private static final int MIN_LISTINGS_FOR_TREND = 2; + + private final ListingRepository listingRepository; + private final ListingFavoriteRepository favoriteRepository; + + public AreaRankingController(ListingRepository listingRepository, ListingFavoriteRepository favoriteRepository) { + this.listingRepository = listingRepository; + this.favoriteRepository = favoriteRepository; + } + + @GetMapping("/areas") + public AreaRankingResponse areas( + @RequestParam(required = false) OfferType offerType, + @RequestParam(required = false) PropertyType propertyType + ) { + List listings = listingRepository.findAll().stream() + .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) + .filter(listing -> listing.getPrice() != null && listing.getPrice().doubleValue() > 0) + .filter(listing -> listing.getArea() != null && listing.getArea() > 0) + .filter(listing -> listing.getCity() != null && !listing.getCity().isBlank()) + .filter(listing -> offerType == null || listing.getOfferType() == offerType) + .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) + .toList(); + + Map savesByListing = savesFor(listings); + + List cities = group( + listings, listing -> listing.getCity().trim(), savesByListing, false); + List districts = group( + listings.stream().filter(listing -> listing.getDistrict() != null && !listing.getDistrict().isBlank()).toList(), + listing -> listing.getCity().trim() + "|" + listing.getDistrict().trim(), savesByListing, true); + + return new AreaRankingResponse(Instant.now(), listings.size(), cities, districts); + } + + private Map savesFor(List listings) { + List ids = listings.stream().map(PropertyListing::getId).toList(); + if (ids.isEmpty()) { + return Map.of(); + } + return favoriteRepository.findByListingIdIn(ids).stream() + .collect(Collectors.groupingBy(ListingFavorite::getListingId, Collectors.counting())); + } + + private List group( + List listings, + Function keyOf, + Map savesByListing, + boolean withDistrict + ) { + Map> grouped = listings.stream() + .collect(Collectors.groupingBy(keyOf, LinkedHashMap::new, Collectors.toList())); + + List stats = new ArrayList<>(); + Instant newSince = Instant.now().minus(NEW_WINDOW_DAYS, ChronoUnit.DAYS); + Instant trendSince = Instant.now().minus(TREND_WINDOW_DAYS, ChronoUnit.DAYS); + + grouped.forEach((key, items) -> { + String city = withDistrict ? key.substring(0, key.indexOf('|')) : key; + String district = withDistrict ? key.substring(key.indexOf('|') + 1) : null; + + List pricesPerM2 = items.stream().map(AreaRankingController::pricePerM2).sorted().toList(); + double avg = pricesPerM2.stream().mapToDouble(Double::doubleValue).average().orElse(0); + + long views = items.stream().mapToLong(item -> item.getViewsCount() == null ? 0L : item.getViewsCount()).sum(); + long saves = items.stream().mapToLong(item -> savesByListing.getOrDefault(item.getId(), 0L)).sum(); + int newCount = (int) items.stream().filter(item -> item.getCreatedAt() != null && item.getCreatedAt().isAfter(newSince)).count(); + + stats.add(new AreaRankingResponse.AreaStats( + city, + district, + items.size(), + Math.round(avg), + Math.round(pricesPerM2.get(0)), + Math.round(pricesPerM2.get(pricesPerM2.size() - 1)), + Math.round(items.stream().mapToDouble(PropertyListing::getArea).average().orElse(0) * 10) / 10.0, + views, + saves, + newCount, + trendPercent(items, trendSince), + averageCoordinate(items, PropertyListing::getLat), + averageCoordinate(items, PropertyListing::getLng) + )); + }); + + stats.sort(Comparator.comparingLong(AreaRankingResponse.AreaStats::avgPricePerM2).reversed()); + return stats; + } + + /** Zmiana sredniej ceny za m2 miedzy ofertami z ostatnich 90 dni a starszymi. */ + private static Double trendPercent(List items, Instant trendSince) { + List recent = items.stream() + .filter(item -> item.getCreatedAt() != null && item.getCreatedAt().isAfter(trendSince)) + .map(AreaRankingController::pricePerM2) + .toList(); + List older = items.stream() + .filter(item -> item.getCreatedAt() != null && !item.getCreatedAt().isAfter(trendSince)) + .map(AreaRankingController::pricePerM2) + .toList(); + + if (recent.size() < MIN_LISTINGS_FOR_TREND || older.size() < MIN_LISTINGS_FOR_TREND) { + return null; + } + double recentAvg = recent.stream().mapToDouble(Double::doubleValue).average().orElse(0); + double olderAvg = older.stream().mapToDouble(Double::doubleValue).average().orElse(0); + if (olderAvg <= 0) { + return null; + } + return Math.round(((recentAvg - olderAvg) / olderAvg) * 1000) / 10.0; + } + + private static Double averageCoordinate(List items, Function getter) { + List values = items.stream().map(getter).filter(java.util.Objects::nonNull).toList(); + if (values.isEmpty()) { + return null; + } + return values.stream().mapToDouble(Double::doubleValue).average().orElse(0); + } + + private static double pricePerM2(PropertyListing listing) { + return listing.getPrice().doubleValue() / listing.getArea(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/AreaRankingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/AreaRankingResponse.java new file mode 100644 index 0000000..b22f473 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/AreaRankingResponse.java @@ -0,0 +1,35 @@ +package pl.polskalokalnie.listing; + +import java.time.Instant; +import java.util.List; + +/** + * Ranking obszarow liczony na biezaco z opublikowanych ogloszen. Kazde dodane, zatwierdzone, + * wstrzymane lub usuniete ogloszenie zmienia wynik - nie ma tu zadnych danych wpisanych na sztywno. + */ +public record AreaRankingResponse( + Instant generatedAt, + int totalListings, + List cities, + List districts +) { + public record AreaStats( + String city, + // null dla wiersza opisujacego cale miasto + String district, + int listingsCount, + long avgPricePerM2, + long minPricePerM2, + long maxPricePerM2, + double avgArea, + long totalViews, + long totalSaves, + int newLast30Days, + // Zmiana sredniej ceny za m2: ostatnie 90 dni wobec starszych ofert. + // null, gdy po ktorejkolwiek stronie jest mniej niz 2 oferty - wtedy trend nic nie znaczy. + Double trendPercent, + Double lat, + Double lng + ) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 3ba9602..0c3ea96 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -40,12 +40,17 @@ public class ListingController { return listingService.findMine(authentication.getName()); } + // Endpoint jest publiczny - authentication bywa puste, a wtedy widac tylko ogloszenia opublikowane. @GetMapping("/{id}") public ListingDetailResponse getById( @PathVariable Long id, - @RequestParam(defaultValue = "false") boolean incrementView + @RequestParam(defaultValue = "false") boolean incrementView, + Authentication authentication ) { - return listingService.getById(id, incrementView); + String viewerEmail = authentication == null ? null : authentication.getName(); + boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream() + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin); } @PostMapping diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 036d76e..43c2431 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -46,12 +46,15 @@ public record ListingDetailResponse( List photos, String virtualTourUrl, String ownerEmail, + // Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym + // identyfikatorze frontend otwiera jego profil publiczny. + Long ownerId, ListingStatus status, Instant createdAt, Long viewsCount, Instant promotedUntil ) { - public static ListingDetailResponse from(PropertyListing listing) { + public static ListingDetailResponse from(PropertyListing listing, Long ownerId) { return new ListingDetailResponse( listing.getId(), listing.getTitle(), @@ -90,6 +93,7 @@ public record ListingDetailResponse( List.copyOf(listing.getPhotos()), listing.getVirtualTourUrl(), listing.getOwnerEmail(), + ownerId, listing.getStatus(), listing.getCreatedAt(), listing.getViewsCount(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavorite.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavorite.java new file mode 100644 index 0000000..e90a8f2 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavorite.java @@ -0,0 +1,67 @@ +package pl.polskalokalnie.listing; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; + +/** + * Zapisanie ogloszenia do ulubionych przez uzytkownika. Trzymane po stronie serwera, zeby + * wlasciciel ogloszenia widzial realna liczbe zapisan, a nie tylko stan swojej przegladarki. + */ +@Entity +@Table( + name = "listing_favorites", + uniqueConstraints = @UniqueConstraint(name = "uk_listing_favorite", columnNames = {"listing_id", "user_email"}) +) +public class ListingFavorite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "listing_id", nullable = false) + private Long listingId; + + @Column(name = "user_email", nullable = false, length = 180) + private String userEmail; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public Long getListingId() { + return listingId; + } + + public void setListingId(Long listingId) { + this.listingId = listingId; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java new file mode 100644 index 0000000..5f22035 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteController.java @@ -0,0 +1,108 @@ +package pl.polskalokalnie.listing; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI), + * a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte. + */ +@RestController +@RequestMapping("/api/listings") +public class ListingFavoriteController { + + private final ListingFavoriteRepository favoriteRepository; + private final ListingRepository listingRepository; + + public ListingFavoriteController(ListingFavoriteRepository favoriteRepository, ListingRepository listingRepository) { + this.favoriteRepository = favoriteRepository; + this.listingRepository = listingRepository; + } + + @PostMapping("/{id}/favorite") + @Transactional + public ResponseEntity addFavorite(@PathVariable Long id, Authentication authentication) { + if (!listingRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"); + } + save(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}/favorite") + @Transactional + public ResponseEntity removeFavorite(@PathVariable Long id, Authentication authentication) { + favoriteRepository.deleteByListingIdAndUserEmailIgnoreCase(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } + + /** + * Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie + * i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego, + * z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie. + */ + @PostMapping("/favorites/sync") + @Transactional + public ResponseEntity sync(@RequestBody SyncRequest request, Authentication authentication) { + if (request == null || request.listingIds() == null) { + return ResponseEntity.noContent().build(); + } + String userEmail = authentication.getName(); + List wanted = request.listingIds().stream() + .filter(java.util.Objects::nonNull) + .distinct() + .filter(listingRepository::existsById) + .toList(); + + favoriteRepository.findByUserEmailIgnoreCase(userEmail).stream() + .filter(favorite -> !wanted.contains(favorite.getListingId())) + .forEach(favoriteRepository::delete); + + wanted.forEach(listingId -> save(listingId, userEmail)); + return ResponseEntity.noContent().build(); + } + + /** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */ + @GetMapping("/mine/saves") + public Map mySaves(Authentication authentication) { + List myListingIds = listingRepository.findAll().stream() + .filter(listing -> authentication.getName().equalsIgnoreCase(listing.getOwnerEmail())) + .map(PropertyListing::getId) + .toList(); + + Map counts = new HashMap<>(); + myListingIds.forEach(listingId -> counts.put(listingId, 0L)); + if (myListingIds.isEmpty()) { + return counts; + } + favoriteRepository.findByListingIdIn(myListingIds) + .forEach(favorite -> counts.merge(favorite.getListingId(), 1L, Long::sum)); + return counts; + } + + private void save(Long listingId, String userEmail) { + if (favoriteRepository.existsByListingIdAndUserEmailIgnoreCase(listingId, userEmail)) { + return; + } + ListingFavorite favorite = new ListingFavorite(); + favorite.setListingId(listingId); + favorite.setUserEmail(userEmail); + favoriteRepository.save(favorite); + } + + public record SyncRequest(List listingIds) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteRepository.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteRepository.java new file mode 100644 index 0000000..f3e7f1c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingFavoriteRepository.java @@ -0,0 +1,17 @@ +package pl.polskalokalnie.listing; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ListingFavoriteRepository extends JpaRepository { + + boolean existsByListingIdAndUserEmailIgnoreCase(Long listingId, String userEmail); + + void deleteByListingIdAndUserEmailIgnoreCase(Long listingId, String userEmail); + + long countByListingId(Long listingId); + + List findByUserEmailIgnoreCase(String userEmail); + + List findByListingIdIn(List listingIds); +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java index 796ba56..5eaea11 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java @@ -1,6 +1,9 @@ package pl.polskalokalnie.listing; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface ListingRepository extends JpaRepository { + + List findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 1cd0762..a3307e4 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -11,6 +11,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.notification.NotificationService; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; @Service public class ListingService { @@ -20,12 +22,22 @@ public class ListingService { private final ListingRepository listingRepository; private final TextModerationService textModerationService; private final NotificationService notificationService; + private final UserRepository userRepository; public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, - NotificationService notificationService) { + NotificationService notificationService, UserRepository userRepository) { this.listingRepository = listingRepository; this.textModerationService = textModerationService; this.notificationService = notificationService; + this.userRepository = userRepository; + } + + // Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow. + private Long ownerIdOf(PropertyListing listing) { + if (listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) { + return null; + } + return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).map(AppUser::getId).orElse(null); } // Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania. @@ -45,17 +57,28 @@ public class ListingService { .toList(); } + /** + * Szczegoly ogloszenia. Publicznie dostepne sa wylacznie ogloszenia opublikowane - wstrzymane, + * oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku. + */ @Transactional - public ListingDetailResponse getById(Long id, boolean incrementView) { + public ListingDetailResponse getById(Long id, boolean incrementView, String viewerEmail, boolean viewerIsAdmin) { PropertyListing listing = listingRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); - if (incrementView) { + boolean isOwner = viewerEmail != null && listing.getOwnerEmail() != null + && viewerEmail.equalsIgnoreCase(listing.getOwnerEmail()); + if (listing.getStatus() != ListingStatus.APPROVED && !isOwner && !viewerIsAdmin) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"); + } + + // Podglad wlasnego ogloszenia nie zawyza licznika wyswietlen. + if (incrementView && !isOwner) { listing.setViewsCount((listing.getViewsCount() == null ? 0L : listing.getViewsCount()) + 1L); listing = listingRepository.save(listing); } - return ListingDetailResponse.from(listing); + return ListingDetailResponse.from(listing, ownerIdOf(listing)); } public List findMine(String ownerEmail) { @@ -110,7 +133,8 @@ public class ListingService { listing.setOwnerEmail(ownerEmail); listing.setStatus(ListingStatus.PENDING); - return ListingDetailResponse.from(listingRepository.save(listing)); + PropertyListing saved = listingRepository.save(listing); + return ListingDetailResponse.from(saved, ownerIdOf(saved)); } // Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu - @@ -128,7 +152,8 @@ public class ListingService { applyRequest(listing, request); // ownerEmail i status pozostaja bez zmian. - return ListingDetailResponse.from(listingRepository.save(listing)); + PropertyListing saved = listingRepository.save(listing); + return ListingDetailResponse.from(saved, ownerIdOf(saved)); } // Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn). diff --git a/backend/src/main/java/pl/polskalokalnie/report/CreateUserReportRequest.java b/backend/src/main/java/pl/polskalokalnie/report/CreateUserReportRequest.java new file mode 100644 index 0000000..c99e369 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/CreateUserReportRequest.java @@ -0,0 +1,22 @@ +package pl.polskalokalnie.report; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +public record CreateUserReportRequest( + @NotBlank String reportedName, + // Wypelniony tylko dla rozmow z istniejacym kontem - wtedy admin moze je zablokowac. + String reportedEmail, + String conversationId, + // Ogloszenie, w sprawie ktorego toczyla sie rozmowa (jesli watek jest z nim powiazany). + Long listingId, + String listingTitle, + @NotBlank String reasonId, + @NotBlank String reasonTitle, + String details, + List attachmentNames, + List attachmentFiles, + // Migawka rozmowy dolaczona do zgloszenia. + List conversation +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java index ba715ed..5823295 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportController.java @@ -12,9 +12,11 @@ import org.springframework.web.bind.annotation.RestController; public class ListingReportController { private final ListingReportService listingReportService; + private final UserReportService userReportService; - public ListingReportController(ListingReportService listingReportService) { + public ListingReportController(ListingReportService listingReportService, UserReportService userReportService) { this.listingReportService = listingReportService; + this.userReportService = userReportService; } @PostMapping("/listings") @@ -24,4 +26,13 @@ public class ListingReportController { ) { return listingReportService.create(request, authentication.getName()); } + + // Zgloszenie uzytkownika z rozmowy w wiadomosciach. + @PostMapping("/users") + public UserReportResponse createUserReport( + @Valid @RequestBody CreateUserReportRequest request, + Authentication authentication + ) { + return userReportService.create(request, authentication.getName()); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java index d6fdb24..85d4d09 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java +++ b/backend/src/main/java/pl/polskalokalnie/report/ListingReportService.java @@ -1,7 +1,6 @@ package pl.polskalokalnie.report; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -15,11 +14,6 @@ import pl.polskalokalnie.user.UserRepository; @Service public class ListingReportService { - private static final int MAX_ATTACHMENTS = 5; - private static final int MAX_FILE_NAME_LENGTH = 255; - private static final int MAX_FILE_TYPE_LENGTH = 120; - private static final int MAX_DATA_URL_LENGTH = 16_000_000; - private final ListingReportRepository listingReportRepository; private final ListingRepository listingRepository; private final UserRepository userRepository; @@ -47,7 +41,7 @@ public class ListingReportService { report.setReasonId(request.reasonId().trim()); report.setReasonTitle(request.reasonTitle().trim()); report.setDetails(trimToNull(request.details())); - report.setAttachments(cleanAttachments(request.attachmentNames(), request.attachmentFiles())); + report.setAttachments(ReportAttachments.clean(request.attachmentNames(), request.attachmentFiles())); report.setReporterEmail(reporterEmail); report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); report.setStatus(ListingReportStatus.OPEN); @@ -96,72 +90,6 @@ public class ListingReportService { } private static String trimToNull(String value) { - if (value == null) { - return null; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - private static List cleanAttachments( - List names, - List files - ) { - List cleaned = new ArrayList<>(); - - if (files != null) { - for (ListingReportAttachmentPayload payload : files) { - if (payload == null) { - continue; - } - String fileName = truncate(trimToNull(payload.fileName()), MAX_FILE_NAME_LENGTH); - if (fileName == null) { - continue; - } - ListingReportAttachment attachment = new ListingReportAttachment(); - attachment.setFileName(fileName); - attachment.setFileType(truncate(trimToNull(payload.fileType()), MAX_FILE_TYPE_LENGTH)); - String dataUrl = trimToNull(payload.dataUrl()); - if (dataUrl != null && dataUrl.length() > MAX_DATA_URL_LENGTH) { - dataUrl = null; - } - attachment.setDataUrl(dataUrl); - cleaned.add(attachment); - if (cleaned.size() >= MAX_ATTACHMENTS) { - return cleaned; - } - } - } - - if (names != null) { - for (String name : names) { - String trimmed = truncate(trimToNull(name), MAX_FILE_NAME_LENGTH); - if (trimmed == null) { - continue; - } - boolean exists = cleaned.stream().anyMatch(item -> trimmed.equalsIgnoreCase(item.getFileName())); - if (exists) { - continue; - } - ListingReportAttachment attachment = new ListingReportAttachment(); - attachment.setFileName(trimmed); - cleaned.add(attachment); - if (cleaned.size() >= MAX_ATTACHMENTS) { - break; - } - } - } - - return cleaned; - } - - private static String truncate(String value, int maxLength) { - if (value == null) { - return null; - } - if (value.length() <= maxLength) { - return value; - } - return value.substring(0, maxLength); + return ReportAttachments.trimToNull(value); } } diff --git a/backend/src/main/java/pl/polskalokalnie/report/ReportAttachments.java b/backend/src/main/java/pl/polskalokalnie/report/ReportAttachments.java new file mode 100644 index 0000000..a5a0202 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/ReportAttachments.java @@ -0,0 +1,87 @@ +package pl.polskalokalnie.report; + +import java.util.ArrayList; +import java.util.List; + +/** + * Czyszczenie zalacznikow zgloszenia - wspolne dla zgloszen ogloszen i uzytkownikow. + * Nazwy plikow przycinamy do dlugosci kolumny, zbyt duzy podglad (dataUrl) odrzucamy, + * a liczbe zalacznikow ograniczamy do MAX_ATTACHMENTS. + */ +final class ReportAttachments { + + private static final int MAX_ATTACHMENTS = 5; + private static final int MAX_FILE_NAME_LENGTH = 255; + private static final int MAX_FILE_TYPE_LENGTH = 120; + private static final int MAX_DATA_URL_LENGTH = 16_000_000; + + private ReportAttachments() { + } + + static List clean(List names, List files) { + List cleaned = new ArrayList<>(); + + if (files != null) { + for (ListingReportAttachmentPayload payload : files) { + if (payload == null) { + continue; + } + String fileName = truncate(trimToNull(payload.fileName()), MAX_FILE_NAME_LENGTH); + if (fileName == null) { + continue; + } + ListingReportAttachment attachment = new ListingReportAttachment(); + attachment.setFileName(fileName); + attachment.setFileType(truncate(trimToNull(payload.fileType()), MAX_FILE_TYPE_LENGTH)); + String dataUrl = trimToNull(payload.dataUrl()); + if (dataUrl != null && dataUrl.length() > MAX_DATA_URL_LENGTH) { + dataUrl = null; + } + attachment.setDataUrl(dataUrl); + cleaned.add(attachment); + if (cleaned.size() >= MAX_ATTACHMENTS) { + return cleaned; + } + } + } + + if (names != null) { + for (String name : names) { + String trimmed = truncate(trimToNull(name), MAX_FILE_NAME_LENGTH); + if (trimmed == null) { + continue; + } + boolean exists = cleaned.stream().anyMatch(item -> trimmed.equalsIgnoreCase(item.getFileName())); + if (exists) { + continue; + } + ListingReportAttachment attachment = new ListingReportAttachment(); + attachment.setFileName(trimmed); + cleaned.add(attachment); + if (cleaned.size() >= MAX_ATTACHMENTS) { + break; + } + } + } + + return cleaned; + } + + static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String truncate(String value, int maxLength) { + if (value == null) { + return null; + } + if (value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReport.java b/backend/src/main/java/pl/polskalokalnie/report/UserReport.java new file mode 100644 index 0000000..e14fe0d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReport.java @@ -0,0 +1,243 @@ +package pl.polskalokalnie.report; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Zgloszenie uzytkownika wyslane z rozmowy w wiadomosciach. Zgloszony jest opisany nazwa z watku; + * e-mail wypelniamy tylko wtedy, gdy rozmowa dotyczy istniejacego konta - dopiero wtedy admin moze + * zablokowac konto prosto ze zgloszenia. + */ +@Entity +@Table(name = "user_reports") +public class UserReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 160) + private String reportedName; + + @Column(length = 180) + private String reportedEmail; + + // Identyfikator rozmowy, z ktorej wyszlo zgloszenie - kontekst dla obslugi. + @Column(length = 120) + private String conversationId; + + // Ogloszenie, w sprawie ktorego toczyla sie rozmowa (jesli watek jest z nim powiazany). + private Long listingId; + + @Column(length = 180) + private String listingTitle; + + // Migawka rozmowy z chwili zgloszenia - dowod dla obslugi. + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "user_report_messages", joinColumns = @JoinColumn(name = "report_id")) + @OrderColumn(name = "position") + private List conversation = new ArrayList<>(); + + @Column(nullable = false, length = 80) + private String reasonId; + + @Column(nullable = false, length = 180) + private String reasonTitle; + + @Column(length = 1000) + private String details; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "user_report_attachments", joinColumns = @JoinColumn(name = "report_id")) + private List attachments = new ArrayList<>(); + + @Column(nullable = false, length = 180) + private String reporterEmail; + + @Column(length = 120) + private String reporterName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ListingReportStatus status = ListingReportStatus.OPEN; + + @Column(nullable = false) + private boolean reportedUserDeleted = false; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + private Instant resolvedAt; + + @Column(length = 180) + private String resolvedByEmail; + + @Column(length = 1000) + private String resolutionNote; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getReportedName() { + return reportedName; + } + + public void setReportedName(String reportedName) { + this.reportedName = reportedName; + } + + public String getReportedEmail() { + return reportedEmail; + } + + public void setReportedEmail(String reportedEmail) { + this.reportedEmail = reportedEmail; + } + + public String getConversationId() { + return conversationId; + } + + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + public Long getListingId() { + return listingId; + } + + public void setListingId(Long listingId) { + this.listingId = listingId; + } + + public String getListingTitle() { + return listingTitle; + } + + public void setListingTitle(String listingTitle) { + this.listingTitle = listingTitle; + } + + public List getConversation() { + return conversation; + } + + public void setConversation(List conversation) { + this.conversation = conversation == null ? new ArrayList<>() : new ArrayList<>(conversation); + } + + public boolean isReportedUserDeleted() { + return reportedUserDeleted; + } + + public void setReportedUserDeleted(boolean reportedUserDeleted) { + this.reportedUserDeleted = reportedUserDeleted; + } + + public String getReasonId() { + return reasonId; + } + + public void setReasonId(String reasonId) { + this.reasonId = reasonId; + } + + public String getReasonTitle() { + return reasonTitle; + } + + public void setReasonTitle(String reasonTitle) { + this.reasonTitle = reasonTitle; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments); + } + + public String getReporterEmail() { + return reporterEmail; + } + + public void setReporterEmail(String reporterEmail) { + this.reporterEmail = reporterEmail; + } + + public String getReporterName() { + return reporterName; + } + + public void setReporterName(String reporterName) { + this.reporterName = reporterName; + } + + public ListingReportStatus getStatus() { + return status; + } + + public void setStatus(ListingReportStatus status) { + this.status = status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getResolvedAt() { + return resolvedAt; + } + + public void setResolvedAt(Instant resolvedAt) { + this.resolvedAt = resolvedAt; + } + + public String getResolvedByEmail() { + return resolvedByEmail; + } + + public void setResolvedByEmail(String resolvedByEmail) { + this.resolvedByEmail = resolvedByEmail; + } + + public String getResolutionNote() { + return resolutionNote; + } + + public void setResolutionNote(String resolutionNote) { + this.resolutionNote = resolutionNote; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportMessage.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportMessage.java new file mode 100644 index 0000000..b6e3385 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportMessage.java @@ -0,0 +1,58 @@ +package pl.polskalokalnie.report; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.time.Instant; + +/** + * Pojedyncza wiadomosc z rozmowy dolaczona do zgloszenia uzytkownika. To migawka z chwili + * zgloszenia - obsluga widzi dokladnie te tresc, ktora zgloszil uzytkownik, nawet gdy rozmowa + * zostanie pozniej usunieta przez ktoras ze stron. + */ +@Embeddable +public class UserReportMessage { + + @Column(name = "from_reporter", nullable = false) + private boolean fromReporter; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "sent_at") + private Instant sentAt; + + @Column(name = "has_photo", nullable = false) + private boolean hasPhoto; + + public boolean isFromReporter() { + return fromReporter; + } + + public void setFromReporter(boolean fromReporter) { + this.fromReporter = fromReporter; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } + + public boolean isHasPhoto() { + return hasPhoto; + } + + public void setHasPhoto(boolean hasPhoto) { + this.hasPhoto = hasPhoto; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportMessagePayload.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportMessagePayload.java new file mode 100644 index 0000000..c8b3c79 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportMessagePayload.java @@ -0,0 +1,11 @@ +package pl.polskalokalnie.report; + +import java.time.Instant; + +public record UserReportMessagePayload( + boolean fromReporter, + String content, + Instant sentAt, + boolean hasPhoto +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportRepository.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportRepository.java new file mode 100644 index 0000000..3f7f309 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.report; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserReportRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java new file mode 100644 index 0000000..1e397c8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportResponse.java @@ -0,0 +1,61 @@ +package pl.polskalokalnie.report; + +import java.time.Instant; +import java.util.List; + +public record UserReportResponse( + Long id, + String reportedName, + String reportedEmail, + String conversationId, + Long listingId, + String listingTitle, + String reasonId, + String reasonTitle, + String details, + List attachmentNames, + List attachments, + List conversation, + String reporterEmail, + String reporterName, + ListingReportStatus status, + boolean reportedUserDeleted, + // Czy zgloszony ma konto w serwisie - od tego zalezy, czy admin moze je zablokowac lub usunac. + boolean reportedUserExists, + Instant createdAt, + Instant resolvedAt, + String resolvedByEmail, + String resolutionNote +) { + public static UserReportResponse from(UserReport report, boolean reportedUserExists) { + return new UserReportResponse( + report.getId(), + report.getReportedName(), + report.getReportedEmail(), + report.getConversationId(), + report.getListingId(), + report.getListingTitle(), + report.getReasonId(), + report.getReasonTitle(), + report.getDetails(), + report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(), + report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(), + report.getConversation().stream() + .map(message -> new UserReportMessagePayload( + message.isFromReporter(), + message.getContent(), + message.getSentAt(), + message.isHasPhoto())) + .toList(), + report.getReporterEmail(), + report.getReporterName(), + report.getStatus(), + report.isReportedUserDeleted(), + reportedUserExists, + report.getCreatedAt(), + report.getResolvedAt(), + report.getResolvedByEmail(), + report.getResolutionNote() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java new file mode 100644 index 0000000..50b217d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java @@ -0,0 +1,137 @@ +package pl.polskalokalnie.report; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.Role; +import pl.polskalokalnie.user.UserRepository; + +@Service +public class UserReportService { + + // Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem. + private static final int MAX_CONVERSATION_MESSAGES = 50; + private static final int MAX_MESSAGE_LENGTH = 2000; + + private final UserReportRepository userReportRepository; + private final UserRepository userRepository; + + public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository) { + this.userReportRepository = userReportRepository; + this.userRepository = userRepository; + } + + @Transactional + public UserReportResponse create(CreateUserReportRequest request, String reporterEmail) { + UserReport report = new UserReport(); + report.setReportedName(request.reportedName().trim()); + report.setConversationId(ReportAttachments.trimToNull(request.conversationId())); + report.setListingId(request.listingId()); + report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle())); + report.setConversation(cleanConversation(request.conversation())); + report.setReasonId(request.reasonId().trim()); + report.setReasonTitle(request.reasonTitle().trim()); + report.setDetails(ReportAttachments.trimToNull(request.details())); + report.setAttachments(ReportAttachments.clean(request.attachmentNames(), request.attachmentFiles())); + report.setReporterEmail(reporterEmail); + report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); + report.setStatus(ListingReportStatus.OPEN); + + // Konto zgloszonego wiazemy tylko wtedy, gdy naprawde istnieje - rozmowy demonstracyjne + // nie maja odpowiednika w bazie i zostaja opisane sama nazwa z watku. + AppUser reported = findReported(request.reportedEmail()); + if (reported != null) { + report.setReportedEmail(reported.getEmail()); + report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName()); + } + + UserReport saved = userReportRepository.save(report); + return UserReportResponse.from(saved, reported != null); + } + + public List findAllForAdmin() { + return userReportRepository.findAllByOrderByCreatedAtDesc().stream() + .map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()) != null)) + .toList(); + } + + @Transactional + public UserReportResponse resolve(Long reportId, String adminEmail, String note) { + UserReport report = requireReport(reportId); + report.setStatus(ListingReportStatus.RESOLVED); + report.setResolvedAt(Instant.now()); + report.setResolvedByEmail(adminEmail); + report.setResolutionNote(ReportAttachments.trimToNull(note)); + return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()) != null); + } + + /** Usuniecie konta zgloszonego uzytkownika wraz z zamknieciem zgloszenia. */ + @Transactional + public UserReportResponse deleteReportedUserAndResolve(Long reportId, String adminEmail) { + UserReport report = requireReport(reportId); + AppUser reported = findReported(report.getReportedEmail()); + if (reported == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Zgłoszony użytkownik nie ma konta w serwisie - nie ma czego usunąć"); + } + if (reported.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora"); + } + + userRepository.delete(reported); + + report.setReportedUserDeleted(true); + report.setStatus(ListingReportStatus.RESOLVED); + report.setResolvedAt(Instant.now()); + report.setResolvedByEmail(adminEmail); + if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) { + report.setResolutionNote("Konto usunięte przez administratora po zgłoszeniu."); + } + return UserReportResponse.from(userReportRepository.save(report), false); + } + + // Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia. + private static List cleanConversation(List payloads) { + if (payloads == null || payloads.isEmpty()) { + return List.of(); + } + List tail = payloads.size() > MAX_CONVERSATION_MESSAGES + ? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size()) + : payloads; + + List cleaned = new ArrayList<>(); + for (UserReportMessagePayload payload : tail) { + if (payload == null) { + continue; + } + String content = payload.content() == null ? "" : payload.content().trim(); + if (content.length() > MAX_MESSAGE_LENGTH) { + content = content.substring(0, MAX_MESSAGE_LENGTH); + } + UserReportMessage message = new UserReportMessage(); + message.setFromReporter(payload.fromReporter()); + message.setContent(content); + message.setSentAt(payload.sentAt()); + message.setHasPhoto(payload.hasPhoto()); + cleaned.add(message); + } + return cleaned; + } + + private AppUser findReported(String email) { + if (email == null || email.isBlank()) { + return null; + } + return userRepository.findByEmailIgnoreCase(email).orElse(null); + } + + private UserReport requireReport(Long id) { + return userReportRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Report not found")); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/support/CreateSupportMessageRequest.java b/backend/src/main/java/pl/polskalokalnie/support/CreateSupportMessageRequest.java new file mode 100644 index 0000000..63fe875 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/support/CreateSupportMessageRequest.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.support; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateSupportMessageRequest( + @NotBlank @Size(max = 160) String fullName, + @NotBlank @Email @Size(max = 180) String email, + @NotBlank @Size(max = 120) String topic, + @NotBlank @Size(max = 4000) String content +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/support/SupportMessage.java b/backend/src/main/java/pl/polskalokalnie/support/SupportMessage.java new file mode 100644 index 0000000..119de1f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/support/SupportMessage.java @@ -0,0 +1,130 @@ +package pl.polskalokalnie.support; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Wiadomosc z formularza "Napisz do nas" (/konto/pomoc). Osobny kanal od czatu z administracja - + * obsluga widzi tu zgloszenia z tematem i danymi kontaktowymi podanymi w formularzu. + */ +@Entity +@Table(name = "support_messages") +public class SupportMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Dane wpisane w formularzu - moga sie roznic od danych konta. + @Column(nullable = false, length = 160) + private String fullName; + + @Column(nullable = false, length = 180) + private String email; + + @Column(nullable = false, length = 120) + private String topic; + + @Column(nullable = false, length = 4000) + private String content; + + // Konto, z ktorego wyslano formularz. + @Column(nullable = false, length = 180) + private String accountEmail; + + @Column(nullable = false) + private boolean handled = false; + + private Instant handledAt; + + @Column(length = 180) + private String handledByEmail; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getAccountEmail() { + return accountEmail; + } + + public void setAccountEmail(String accountEmail) { + this.accountEmail = accountEmail; + } + + public boolean isHandled() { + return handled; + } + + public void setHandled(boolean handled) { + this.handled = handled; + } + + public Instant getHandledAt() { + return handledAt; + } + + public void setHandledAt(Instant handledAt) { + this.handledAt = handledAt; + } + + public String getHandledByEmail() { + return handledByEmail; + } + + public void setHandledByEmail(String handledByEmail) { + this.handledByEmail = handledByEmail; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/support/SupportMessageController.java b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageController.java new file mode 100644 index 0000000..2d47f1f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageController.java @@ -0,0 +1,28 @@ +package pl.polskalokalnie.support; + +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** Formularz "Napisz do nas" z /konto/pomoc. */ +@RestController +@RequestMapping("/api/support") +public class SupportMessageController { + + private final SupportMessageService service; + + public SupportMessageController(SupportMessageService service) { + this.service = service; + } + + @PostMapping("/messages") + @ResponseStatus(HttpStatus.CREATED) + public SupportMessageResponse create(@Valid @RequestBody CreateSupportMessageRequest request, Authentication authentication) { + return service.create(request, authentication.getName()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/support/SupportMessageRepository.java b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageRepository.java new file mode 100644 index 0000000..b7b088b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.support; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SupportMessageRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/support/SupportMessageResponse.java b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageResponse.java new file mode 100644 index 0000000..6e100a0 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageResponse.java @@ -0,0 +1,31 @@ +package pl.polskalokalnie.support; + +import java.time.Instant; + +public record SupportMessageResponse( + Long id, + String fullName, + String email, + String topic, + String content, + String accountEmail, + boolean handled, + Instant handledAt, + String handledByEmail, + Instant createdAt +) { + public static SupportMessageResponse from(SupportMessage message) { + return new SupportMessageResponse( + message.getId(), + message.getFullName(), + message.getEmail(), + message.getTopic(), + message.getContent(), + message.getAccountEmail(), + message.isHandled(), + message.getHandledAt(), + message.getHandledByEmail(), + message.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/support/SupportMessageService.java b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageService.java new file mode 100644 index 0000000..c2630c7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/support/SupportMessageService.java @@ -0,0 +1,49 @@ +package pl.polskalokalnie.support; + +import java.time.Instant; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.moderation.TextModerationService; + +@Service +public class SupportMessageService { + + private final SupportMessageRepository repository; + private final TextModerationService textModerationService; + + public SupportMessageService(SupportMessageRepository repository, TextModerationService textModerationService) { + this.repository = repository; + this.textModerationService = textModerationService; + } + + @Transactional + public SupportMessageResponse create(CreateSupportMessageRequest request, String accountEmail) { + textModerationService.validateOrThrow(request.fullName(), request.content()); + + SupportMessage message = new SupportMessage(); + message.setFullName(request.fullName().trim()); + message.setEmail(request.email().trim()); + message.setTopic(request.topic().trim()); + message.setContent(request.content().trim()); + message.setAccountEmail(accountEmail); + return SupportMessageResponse.from(repository.save(message)); + } + + public List findAllForAdmin() { + return repository.findAllByOrderByCreatedAtDesc().stream().map(SupportMessageResponse::from).toList(); + } + + /** Oznaczenie wiadomosci jako obsluzonej (lub cofniecie tego oznaczenia). */ + @Transactional + public SupportMessageResponse setHandled(Long id, boolean handled, String adminEmail) { + SupportMessage message = repository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Wiadomość nie istnieje")); + message.setHandled(handled); + message.setHandledAt(handled ? Instant.now() : null); + message.setHandledByEmail(handled ? adminEmail : null); + return SupportMessageResponse.from(repository.save(message)); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java new file mode 100644 index 0000000..9f069fd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileController.java @@ -0,0 +1,63 @@ +package pl.polskalokalnie.user; + +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.listing.PropertyListing; + +/** + * Profil uzytkownika widziany przez innych zalogowanych - otwierany m.in. z rozmowy w wiadomosciach. + * Zwraca tylko dane jawne: nazwe, typ konta, date dolaczenia i opublikowane ogloszenia. + */ +@RestController +@RequestMapping("/api/users") +public class PublicProfileController { + + private final UserRepository userRepository; + private final ListingRepository listingRepository; + + public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository) { + this.userRepository = userRepository; + this.listingRepository = listingRepository; + } + + @GetMapping("/{id}/profile") + public PublicProfileResponse profile(@PathVariable Long id) { + AppUser user = userRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")); + + List listings = user.getEmail() == null + ? List.of() + : listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED); + + return new PublicProfileResponse( + user.getId(), + user.getFullName(), + user.getAccountType(), + user.isVerified(), + user.getCreatedAt(), + listings.size(), + listings.stream() + .map(listing -> new PublicProfileResponse.PublicProfileListing( + listing.getId(), + listing.getTitle(), + listing.getCity(), + listing.getDistrict(), + listing.getPrice(), + listing.getOfferType().name(), + listing.getArea(), + listing.getRooms(), + listing.getFloor(), + listing.getBuildingFloors(), + listing.getCoverPhoto(), + listing.getCreatedAt())) + .toList() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java new file mode 100644 index 0000000..1042eaf --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/user/PublicProfileResponse.java @@ -0,0 +1,35 @@ +package pl.polskalokalnie.user; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** + * Publiczny profil uzytkownika ogladany przez innych (np. z rozmowy w wiadomosciach). + * Swiadomie nie zawiera danych kontaktowych - e-mail, telefon i adres zostaja po stronie serwera. + */ +public record PublicProfileResponse( + Long id, + String fullName, + AccountType accountType, + boolean verified, + Instant memberSince, + int listingsCount, + List listings +) { + public record PublicProfileListing( + Long id, + String title, + String city, + String district, + BigDecimal price, + String offerType, + Double area, + Integer rooms, + String floor, + String buildingFloors, + String coverPhoto, + Instant createdAt + ) { + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d5d3e4..0748036 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,10 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, listingPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, listingPath, publicProfilePath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { ProtectedRoute } from './ProtectedRoute'; import { apiFetch, useAuth } from './auth'; -import type { AuthUser, ContactPreference, PreferredLanguage, RegisterPending } from './auth'; +import type { AccountType, AuthUser, ContactPreference, PreferredLanguage, RegisterPending } from './auth'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; @@ -46,6 +46,10 @@ type MessageThreadTarget = { id: string; name: string; status: string; + // Konto rozmowcy (wlasciciel ogloszenia) - dzieki niemu zgloszenie z rozmowy + // wiaze sie z realnym kontem, a nie tylko z nazwa wyswietlana. + contactEmail: string | null; + contactUserId: number | null; listing: MessageThreadListing; }; @@ -117,6 +121,7 @@ export type ApiListingDetail = { photos: string[]; virtualTourUrl: string | null; ownerEmail: string | null; + ownerId: number | null; status: ApiListingStatus; createdAt: string; viewsCount?: number; @@ -137,6 +142,8 @@ type RecentlyViewedItem = { }; const RECENTLY_VIEWED_KEY = 'polskalokalnie-recently-viewed'; +// Ile ostatnio otwartych ofert pokazuje panel "Ostatnio przeglądane". +const RECENTLY_VIEWED_PANEL_LIMIT = 5; function readRecentlyViewed(): RecentlyViewedItem[] { if (typeof window === 'undefined') { @@ -503,6 +510,9 @@ const LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions'; const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots'; const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events'; const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches'; +// Lokalizacje, ktorych cene za m2 sprawdzil uzytkownik - najnowsza pierwsza, przechowywane per konto. +const PRICE_HISTORY_MARKETS_SUFFIX = 'price-history-markets'; +const PRICE_HISTORY_MARKETS_LIMIT = 5; const PRICE_ALERT_EVENTS_LIMIT = 40; const SAVED_SEARCHES_LIMIT = 60; @@ -721,6 +731,8 @@ function buildMessageThreadTargetFromListing(listing: ApiListingDetail, user: Au id: threadId, name: sellerName, status: sellerRole, + contactEmail: listing.ownerEmail ?? null, + contactUserId: listing.ownerId ?? null, listing: { listingId: listing.id, title: listing.title, @@ -808,105 +820,40 @@ const insightCards = [ }, ]; -type DistrictRankingMetric = 'overall' | 'price' | 'popularity' | 'rating'; -type DistrictPropertyType = 'flat' | 'house' | 'commercial'; -type DistrictMarketType = 'all' | 'secondary' | 'primary'; -type DistrictPeriod = '12' | '6' | '2026'; - -type DistrictRankingSource = { - district: string; - city: keyof typeof VALUATION_PRICE_PER_SQM_BY_CITY; - lat: number; - lng: number; - price: number; - monthlyChange: number; - demand: number; - liquidity: number; - residentScore: number; - investmentScore: number; +// Ranking obszarow liczony przez backend z opublikowanych ogloszen (/api/listings/areas). +type AreaStats = { + city: string; + district: string | null; + listingsCount: number; + avgPricePerM2: number; + minPricePerM2: number; + maxPricePerM2: number; + avgArea: number; + totalViews: number; + totalSaves: number; + newLast30Days: number; + trendPercent: number | null; + lat: number | null; + lng: number | null; }; -const districtRankingSources: DistrictRankingSource[] = [ - { district: 'Młyniska', city: 'Gdańsk', lat: 54.3792, lng: 18.6568, price: 20842, monthlyChange: 1.6, demand: 82, liquidity: 76, residentScore: 72, investmentScore: 89 }, - { district: 'Karlikowo', city: 'Sopot', lat: 54.4411, lng: 18.5652, price: 20678, monthlyChange: 4.46, demand: 91, liquidity: 82, residentScore: 88, investmentScore: 95 }, - { district: 'Śródmieście', city: 'Warszawa', lat: 52.235, lng: 21.0122, price: 20625, monthlyChange: 2.62, demand: 98, liquidity: 86, residentScore: 82, investmentScore: 98 }, - { district: 'Zacisze - Zalesie - Szczytniki', city: 'Wrocław', lat: 51.1133, lng: 17.098, price: 20475, monthlyChange: 2.07, demand: 78, liquidity: 74, residentScore: 95, investmentScore: 86 }, - { district: 'Orłowo', city: 'Gdynia', lat: 54.4793, lng: 18.5588, price: 19874, monthlyChange: 1.68, demand: 85, liquidity: 82, residentScore: 96, investmentScore: 88 }, - { district: 'Dolny Sopot', city: 'Sopot', lat: 54.445, lng: 18.5683, price: 19544, monthlyChange: 4.5, demand: 93, liquidity: 79, residentScore: 95, investmentScore: 92 }, - { district: 'Świniary', city: 'Wrocław', lat: 51.1678, lng: 16.9355, price: 19170, monthlyChange: 2.07, demand: 62, liquidity: 60, residentScore: 73, investmentScore: 63 }, - { district: 'Żoliborz', city: 'Warszawa', lat: 52.2682, lng: 20.9858, price: 18782, monthlyChange: 8, demand: 91, liquidity: 89, residentScore: 94, investmentScore: 90 }, - { district: 'Dzielnica I Stare Miasto', city: 'Kraków', lat: 50.0619, lng: 19.9373, price: 18685, monthlyChange: 3.11, demand: 96, liquidity: 68, residentScore: 86, investmentScore: 94 }, - { district: 'Śródmieście', city: 'Gdańsk', lat: 54.3519, lng: 18.6466, price: 18132, monthlyChange: 1.6, demand: 95, liquidity: 86, residentScore: 84, investmentScore: 92 }, - { district: 'Dzielnica VII Zwierzyniec', city: 'Kraków', lat: 50.0562, lng: 19.8985, price: 18087, monthlyChange: 3.11, demand: 84, liquidity: 78, residentScore: 96, investmentScore: 86 }, - { district: 'Wola', city: 'Warszawa', lat: 52.2338, lng: 20.9616, price: 17985, monthlyChange: 2.62, demand: 97, liquidity: 92, residentScore: 80, investmentScore: 97 }, - { district: 'Górny Sopot', city: 'Sopot', lat: 54.4378, lng: 18.5492, price: 17208, monthlyChange: 4.5, demand: 82, liquidity: 74, residentScore: 88, investmentScore: 81 }, - { district: 'Letnica', city: 'Gdańsk', lat: 54.3962, lng: 18.6386, price: 16813, monthlyChange: 1.6, demand: 88, liquidity: 84, residentScore: 76, investmentScore: 93 }, - { district: 'Dzielnica II Grzegórzki', city: 'Kraków', lat: 50.0606, lng: 19.9688, price: 16699, monthlyChange: 3.11, demand: 87, liquidity: 83, residentScore: 86, investmentScore: 90 }, - { district: 'Kamienna Góra', city: 'Gdynia', lat: 54.5066, lng: 18.5487, price: 16595, monthlyChange: 3.35, demand: 76, liquidity: 72, residentScore: 93, investmentScore: 78 }, - { district: 'Świemirowo', city: 'Sopot', lat: 54.4302, lng: 18.5451, price: 16530, monthlyChange: 12.85, demand: 74, liquidity: 62, residentScore: 81, investmentScore: 83 }, - { district: 'Śródmieście', city: 'Gdynia', lat: 54.5211, lng: 18.5392, price: 16269, monthlyChange: 3.35, demand: 94, liquidity: 80, residentScore: 84, investmentScore: 82 }, - { district: 'Strachocin - Swojczyce - Wojnów', city: 'Wrocław', lat: 51.1152, lng: 17.1477, price: 13669, monthlyChange: 2.07, demand: 70, liquidity: 72, residentScore: 87, investmentScore: 75 }, - { district: 'Ławica', city: 'Poznań', lat: 52.4229, lng: 16.7921, price: 13302, monthlyChange: 1.53, demand: 72, liquidity: 70, residentScore: 88, investmentScore: 74 }, -]; - -const DISTRICT_PROPERTY_MULTIPLIERS: Record = { - flat: 1, - house: 0.86, - commercial: 1.12, +type AreaRanking = { + generatedAt: string; + totalListings: number; + cities: AreaStats[]; + districts: AreaStats[]; }; -const DISTRICT_MARKET_MULTIPLIERS: Record = { - all: 1, - secondary: 0.97, - primary: 1.08, +type RankingLevel = 'districts' | 'cities'; +type RankingMetric = 'price' | 'popularity' | 'offers' | 'trend'; + +const RANKING_METRIC_LABELS: Record = { + price: 'Najwyższa cena za m²', + popularity: 'Największe zainteresowanie', + offers: 'Najwięcej ofert', + trend: 'Największy wzrost cen', }; -const DISTRICT_PERIOD_MULTIPLIERS: Record = { - '12': 1, - '6': 0.58, - '2026': 0.46, -}; - -const DISTRICT_POPULARITY_PERIOD_ADJUSTMENT: Record = { - '12': 0, - '6': -2.5, - '2026': 1.5, -}; - -const DISTRICT_RANKING_LABELS: Record = { - overall: 'Ogólny ranking', - price: 'Średnia cena za m²', - popularity: 'Popularność', - rating: 'Ocena mieszkańców', -}; - -function districtModelRow(source: DistrictRankingSource, propertyType: DistrictPropertyType, marketType: DistrictMarketType, period: DistrictPeriod) { - const price = Math.round((source.price * DISTRICT_PROPERTY_MULTIPLIERS[propertyType] * DISTRICT_MARKET_MULTIPLIERS[marketType]) / 10) * 10; - const cityBenchmark = VALUATION_PRICE_PER_SQM_BY_CITY[source.city]; - const pricePremiumVsCity = cityBenchmark > 0 ? ((price - cityBenchmark) / cityBenchmark) * 100 : 0; - const marketHeatIndex = clampNumber(50 + source.monthlyChange * 5.2 + pricePremiumVsCity * 0.44 + source.liquidity * 0.12, 35, 100); - const popularity = Math.round(clampNumber( - source.demand * 0.44 + source.liquidity * 0.31 + marketHeatIndex * 0.25 + DISTRICT_POPULARITY_PERIOD_ADJUSTMENT[period], - 40, - 99, - )); - const rating = Number((source.residentScore / 20).toFixed(1)); - const affordability = clampNumber(100 - ((price - 6500) / 17000) * 48, 45, 100); - const changeValue = clampNumber((source.monthlyChange * 2.2 + source.demand * 0.025 + source.investmentScore * 0.018 + (marketType === 'primary' ? 0.8 : marketType === 'secondary' ? -0.2 : 0)) * DISTRICT_PERIOD_MULTIPLIERS[period], 1.2, 13.8); - const score = Math.round(clampNumber(popularity * 0.28 + source.investmentScore * 0.27 + source.residentScore * 0.25 + affordability * 0.12 + source.liquidity * 0.08, 45, 99)); - - return { - ...source, - price, - changeValue, - change: `+${changeValue.toFixed(1).replace('.', ',')}%`, - popularity, - rating, - ratingText: rating.toFixed(1).replace('.', ','), - score, - }; -} - const listings = [ { location: 'Kraków, Zabłocie', @@ -2818,6 +2765,24 @@ function App() { writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState); }, [favoriteListingsState, user]); + // Ulubione zyja lokalnie (szybkie UI), ale wlasciciel ogloszenia musi widziec, ile osob je zapisalo. + // Po kazdej zmianie wysylamy caly zbior - dziala niezaleznie od tego, gdzie uzytkownik kliknal serce. + useEffect(() => { + if (!user) { + return; + } + const listingIds = favoriteListingsState + .map((favorite) => favorite.listingId) + .filter((listingId): listingId is number => typeof listingId === 'number'); + + apiFetch('/listings/favorites/sync', { + method: 'POST', + body: JSON.stringify({ listingIds }), + }).catch(() => { + // Brak polaczenia - licznik zapisan dogoni stan przy nastepnej zmianie ulubionych. + }); + }, [favoriteListingsState, user]); + useEffect(() => { writeUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, favoritesPreferredTab); }, [favoritesPreferredTab, user]); @@ -3651,6 +3616,7 @@ function App() { } /> } /> } /> + } /> } /> } /> @@ -3781,6 +3747,119 @@ type AdminListingReportAttachment = { dataUrl: string | null; }; +/** + * Wspolny ksztalt dla okna "Szczegoly zgloszenia" - to samo okno obsluguje zgloszenia ogloszen + * i uzytkownikow, rozni je tylko opis zglaszanego obiektu (subject*) i notatka o jego stanie. + */ +type AdminReportDetails = { + id: number; + status: 'OPEN' | 'RESOLVED'; + reasonId: string; + reasonTitle: string; + reporterName: string | null; + reporterEmail: string; + createdAt: string; + details: string | null; + attachmentNames: string[]; + attachments?: AdminListingReportAttachment[]; + // Kafelki opisujace zgloszenie - roznia sie miedzy zgloszeniem ogloszenia a uzytkownika. + facts: { label: string; title: string; meta: string }[]; + listingId: number | null; + conversation?: AdminUserReportMessage[]; + conversationPartner?: string; +}; + +function toReportDetails(report: AdminListingReport): AdminReportDetails { + return { + ...report, + facts: [ + { label: 'Ogłoszenie', title: report.listingTitle, meta: `${report.listingCity} • ID: ${report.listingId}` }, + { + label: 'Data zgłoszenia', + title: formatAdminDate(report.createdAt), + meta: report.listingDeleted ? 'Ogłoszenie zostało usunięte.' : 'Ogłoszenie nadal aktywne w systemie.', + }, + ], + // Usunietego ogloszenia nie ma juz czego podgladac - wtedy okno nie pokazuje tego przycisku. + listingId: report.listingDeleted ? null : report.listingId, + }; +} + +function toUserReportDetails(report: AdminUserReport): AdminReportDetails { + const accountState = report.reportedUserDeleted + ? 'Konto zostało usunięte.' + : (report.reportedUserExists ? 'Konto nadal aktywne w serwisie.' : 'Zgłoszony nie ma konta w serwisie.'); + + const facts = [ + { + label: 'Zgłoszony użytkownik', + title: report.reportedName, + meta: report.reportedEmail ?? 'Brak konta powiązanego z rozmową', + }, + { label: 'Data zgłoszenia', title: formatAdminDate(report.createdAt), meta: accountState }, + ]; + + if (report.listingTitle || report.listingId !== null) { + facts.push({ + label: 'Ogłoszenie z rozmowy', + title: report.listingTitle ?? 'Ogłoszenie bez tytułu', + meta: report.listingId !== null ? `ID: ${report.listingId}` : 'Rozmowa bez powiązanego ogłoszenia', + }); + } + + return { + ...report, + facts, + listingId: report.listingId, + conversation: report.conversation, + conversationPartner: report.reportedName, + }; +} + +type AdminSupportMessage = { + id: number; + fullName: string; + email: string; + topic: string; + content: string; + accountEmail: string; + handled: boolean; + handledAt: string | null; + handledByEmail: string | null; + createdAt: string; +}; + +type AdminUserReportMessage = { + fromReporter: boolean; + content: string | null; + sentAt: string | null; + hasPhoto: boolean; +}; + +type AdminUserReport = { + id: number; + reportedName: string; + reportedEmail: string | null; + conversationId: string | null; + listingId: number | null; + listingTitle: string | null; + conversation: AdminUserReportMessage[]; + reportedUserDeleted: boolean; + reasonId: string; + reasonTitle: string; + details: string | null; + attachmentNames: string[]; + attachments?: AdminListingReportAttachment[]; + reporterEmail: string; + reporterName: string | null; + status: 'OPEN' | 'RESOLVED'; + reportedUserExists: boolean; + createdAt: string; + resolvedAt: string | null; + resolvedByEmail: string | null; + resolutionNote: string | null; +}; + type AdminListingReport = { id: number; listingId: number; @@ -3897,6 +3976,7 @@ const ADMIN_TAB_META: Record = { listings: { title: 'Ogłoszenia', subtitle: 'Przeglądaj, zatwierdzaj i usuwaj ogłoszenia użytkowników.' }, users: { title: 'Użytkownicy', subtitle: 'Zarządzaj kontami zarejestrowanych użytkowników.' }, messages: { title: 'Wiadomości', subtitle: 'Podgląd wiadomości wymienianych między użytkownikami.' }, + support: { title: 'Pomoc i kontakt', subtitle: 'Wiadomości wysłane przez formularz „Napisz do nas” na stronie pomocy.' }, reports: { title: 'Zgłoszenia', subtitle: 'Zgłoszenia dotyczące ogłoszeń i użytkowników wymagające uwagi.' }, payments: { title: 'Płatności i promowanie', subtitle: 'Plany i pakiety promowania ofert, konfiguracja AutoPay oraz historia płatności.' }, stats: { title: 'Statystyki', subtitle: 'Statystyki aktywności i ruchu w serwisie.' }, @@ -3917,6 +3997,7 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'listings', icon: 'document', label: 'Ogłoszenia' }, { id: 'users', icon: 'user', label: 'Użytkownicy' }, { id: 'messages', icon: 'message', label: 'Wiadomości' }, + { id: 'support', icon: 'mail', label: 'Pomoc i kontakt' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, { id: 'leads', icon: 'user', label: 'Leady' }, { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, @@ -4079,6 +4160,7 @@ function AdminPage() { const [users, setUsers] = useState([]); const [listings, setListings] = useState([]); const [reports, setReports] = useState([]); + const [userReports, setUserReports] = useState([]); const [forbiddenWords, setForbiddenWords] = useState([]); const [newForbiddenWord, setNewForbiddenWord] = useState(''); const [savingForbiddenWord, setSavingForbiddenWord] = useState(false); @@ -4091,10 +4173,12 @@ function AdminPage() { const [verificationUser, setVerificationUser] = useState(null); const [messageUser, setMessageUser] = useState(null); const [previewListingId, setPreviewListingId] = useState(null); - const [detailsReport, setDetailsReport] = useState(null); + const [detailsReport, setDetailsReport] = useState(null); const [messageThreads, setMessageThreads] = useState([]); const [messageThreadsLoading, setMessageThreadsLoading] = useState(false); const [messageThreadsError, setMessageThreadsError] = useState(null); + // Wiadomosci z formularza "Napisz do nas" (/konto/pomoc) - osobna lista od czatu z uzytkownikiem. + const [supportMessages, setSupportMessages] = useState([]); const [statsPeriodDays, setStatsPeriodDays] = useState(1); const [statsPeriodMode, setStatsPeriodMode] = useState('preset'); const [statsCustomFrom, setStatsCustomFrom] = useState(''); @@ -4108,15 +4192,17 @@ function AdminPage() { setLoading(true); setError(null); try { - const [loadedUsers, loadedListings, loadedReports, loadedForbiddenWords] = await Promise.all([ + const [loadedUsers, loadedListings, loadedReports, loadedUserReports, loadedForbiddenWords] = await Promise.all([ apiFetch('/admin/users'), apiFetch('/admin/listings'), apiFetch('/admin/reports'), + apiFetch('/admin/reports/users'), apiFetch('/admin/forbidden-words'), ]); setUsers(loadedUsers); setListings(loadedListings); setReports(loadedReports); + setUserReports(loadedUserReports); setForbiddenWords(loadedForbiddenWords); } catch (err) { setError(err instanceof Error ? err.message : 'Nie udało się pobrać danych panelu.'); @@ -4152,6 +4238,18 @@ function AdminPage() { } }, []); + const loadSupportMessages = useCallback(async () => { + try { + setSupportMessages(await apiFetch('/admin/support-messages')); + } catch { + setSupportMessages([]); + } + }, []); + + useEffect(() => { + void loadSupportMessages(); + }, [loadSupportMessages]); + const loadMessageThreads = useCallback(async (silent = false) => { const regularUsers = users.filter((item) => item.role !== 'ADMIN'); if (regularUsers.length === 0) { @@ -4275,7 +4373,14 @@ function AdminPage() { const pendingCount = listings.filter((item) => item.status === 'PENDING').length; const pendingVerificationCount = users.filter((item) => item.role !== 'ADMIN' && !item.verified).length; - const pendingReportsCount = reports.filter((item) => item.status === 'OPEN').length; + // Licznik na zakladce obejmuje oba rodzaje zgloszen - ogloszen i uzytkownikow. + const pendingReportsCount = reports.filter((item) => item.status === 'OPEN').length + + userReports.filter((item) => item.status === 'OPEN').length; + const newSupportMessagesCount = supportMessages.filter((item) => !item.handled).length; + // Konto, z ktorego wyslano formularz - po nim otwieramy czat z uzytkownikiem. + const supportMessageAuthor = (message: AdminSupportMessage) => users.find( + (item) => item.email.toLowerCase() === message.accountEmail.toLowerCase(), + ) ?? null; const isImageReportAttachment = (attachment: AdminListingReportAttachment) => { if (attachment.fileType && attachment.fileType.toLowerCase().startsWith('image/')) { @@ -4442,6 +4547,7 @@ function AdminPage() { {item.id === 'listings' && pendingCount > 0 && {pendingCount}} {item.id === 'users' && pendingVerificationCount > 0 && {pendingVerificationCount}} {item.id === 'reports' && pendingReportsCount > 0 && {pendingReportsCount}} + {item.id === 'support' && newSupportMessagesCount > 0 && {newSupportMessagesCount}} ))} @@ -4591,8 +4697,9 @@ function AdminPage() { {!loading && tab === 'reports' && (
+

Zgłoszenia ogłoszeń

{reports.length === 0 ? ( -

Brak zgłoszeń.

+

Brak zgłoszeń ogłoszeń.

) : (
@@ -4672,7 +4779,7 @@ function AdminPage() { @@ -4690,6 +4797,100 @@ function AdminPage() { ))}
)} + +

Zgłoszenia użytkowników

+ {userReports.length === 0 ? ( +

Brak zgłoszeń użytkowników.

+ ) : ( +
+
+ Zgłoszenie + Zgłoszony + Zgłaszający + Status + Akcje +
+ {userReports.map((item) => ( +
+ + {item.reasonTitle} + + #{item.id} • {formatAdminDate(item.createdAt)} + {item.details ? ` • ${item.details}` : ''} + {item.attachmentNames.length > 0 ? ` • Załączniki: ${item.attachmentNames.join(', ')}` : ''} + + + + {item.reportedName} + + {item.reportedEmail ?? 'Brak konta powiązanego z rozmową'} + {item.listingTitle ? ` • Ogłoszenie: ${item.listingTitle}` : ''} + {item.reportedUserDeleted ? ' • Konto usunięte' : ''} + + + + {item.reporterName || item.reporterEmail} + {item.reporterEmail} + + + + {ADMIN_REPORT_STATUS_LABELS[item.status]} + + {item.resolvedAt && ( + + {formatAdminDate(item.resolvedAt)}{item.resolvedByEmail ? ` • ${item.resolvedByEmail}` : ''} + {item.resolutionNote ? ` • ${item.resolutionNote}` : ''} + + )} + + + + {item.status !== 'RESOLVED' && ( + + )} + {item.listingId !== null && ( + + )} + {item.reportedUserExists && ( + + )} + +
+ ))} +
+ )}
)} @@ -5004,7 +5205,7 @@ function AdminPage() { setDetailsReport(null)} - onPreviewListing={detailsReport.listingDeleted ? null : () => setPreviewListingId(detailsReport.listingId)} + onPreviewListing={detailsReport.listingId === null ? null : () => setPreviewListingId(detailsReport.listingId as number)} isImageAttachment={isImageReportAttachment} /> )} @@ -5055,6 +5256,81 @@ function AdminPage() { ))}
)} + + + )} + {!loading && tab === 'support' && ( +
+ {supportMessages.length === 0 ? ( +

Brak wiadomości z formularza pomocy.

+ ) : ( +
+
+ Nadawca + Temat + Treść + Status + Akcje +
+ {supportMessages.map((item) => ( +
+ + {item.fullName} + + {item.email} + {item.email.toLowerCase() !== item.accountEmail.toLowerCase() && ` • konto: ${item.accountEmail}`} + + + + {item.topic} + #{item.id} • {formatAdminDate(item.createdAt)} + + {item.content} + + + {item.handled ? 'Obsłużone' : 'Nowe'} + + {item.handled && item.handledAt && ( + + {formatAdminDate(item.handledAt)}{item.handledByEmail ? ` • ${item.handledByEmail}` : ''} + + )} + + + {/* Rozmowa idzie do konta, z ktorego wyslano formularz - ten sam czat + co w zakladce Wiadomosci, wiec uzytkownik dostaje odpowiedz w aplikacji. */} + {supportMessageAuthor(item) ? ( + + ) : ( + Brak konta + )} + + Odpowiedz e-mailem + + + +
+ ))} +
+ )} +
)} {!loading && tab === 'payments' && } @@ -5305,51 +5581,68 @@ function AdminPage() { function DistrictRankingPage() { const navigate = useNavigate(); - const [rankingMetric, setRankingMetric] = useState('overall'); - const [propertyType, setPropertyType] = useState('flat'); - const [marketType, setMarketType] = useState('all'); + const [ranking, setRanking] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [level, setLevel] = useState('districts'); + // Domyslnie sprzedaz - mieszanie cen ofertowych z czynszami dawaloby bezsensowne zl/m². + const [offerType, setOfferType] = useState('SALE'); + const [propertyType, setPropertyType] = useState<'all' | ApiPropertyType>('all'); + const [metric, setMetric] = useState('price'); const [cityFilter, setCityFilter] = useState('all'); - const [period, setPeriod] = useState('12'); - const [districtMapCenter, setDistrictMapCenter] = useState(POLAND_CENTER); - const [districtMapZoom, setDistrictMapZoom] = useState(6); - const cityOptions = Array.from(new Set(districtRankingSources.map((item) => item.city))).sort((left, right) => left.localeCompare(right, 'pl')); - const allModeledDistricts = districtRankingSources.map((source) => districtModelRow(source, propertyType, marketType, period)); - const filteredDistricts = allModeledDistricts.filter((row) => cityFilter === 'all' || row.city === cityFilter); - const sortedDistricts = [...filteredDistricts].sort((left, right) => { - if (rankingMetric === 'price') { - return right.price - left.price; + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(''); + + const params = new URLSearchParams({ offerType }); + if (propertyType !== 'all') { + params.set('propertyType', propertyType); } - if (rankingMetric === 'popularity') { - return right.popularity - left.popularity || right.score - left.score; + + apiFetch(`/listings/areas?${params.toString()}`) + .then((data) => { + if (!cancelled) { + setRanking(data); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Nie udało się wczytać rankingu.'); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [offerType, propertyType]); + + const areas = level === 'districts' ? ranking?.districts ?? [] : ranking?.cities ?? []; + const cityOptions = Array.from(new Set(areas.map((item) => item.city))).sort((left, right) => left.localeCompare(right, 'pl')); + const filtered = areas.filter((item) => cityFilter === 'all' || item.city === cityFilter); + + const sorted = [...filtered].sort((left, right) => { + if (metric === 'popularity') { + return (right.totalViews + right.totalSaves * 5) - (left.totalViews + left.totalSaves * 5); } - if (rankingMetric === 'rating') { - return right.rating - left.rating || right.residentScore - left.residentScore; + if (metric === 'offers') { + return right.listingsCount - left.listingsCount || right.avgPricePerM2 - left.avgPricePerM2; } - return right.score - left.score || right.popularity - left.popularity; + if (metric === 'trend') { + return (right.trendPercent ?? -Infinity) - (left.trendPercent ?? -Infinity); + } + return right.avgPricePerM2 - left.avgPricePerM2; }); - const visibleDistricts = sortedDistricts.slice(0, 20); - const ratingLeaders = [...filteredDistricts] - .sort((left, right) => right.rating - left.rating || right.residentScore - left.residentScore || right.score - left.score) - .slice(0, 5); - const districtMapMarkers: MapMarker[] = visibleDistricts.map((row, index) => ({ - id: index + 1, - lat: row.lat, - lng: row.lng, - label: String(index + 1), - ariaLabel: `#${index + 1} ${row.district}, ${row.city}`, - offer: { - title: `${row.city}, ${row.district}`, - city: row.city, - district: `${row.city}, ${row.district}`, - rooms: 3, - area: 55, - priceValue: row.price, - market: 'wtorny', - tags: ['Ranking'], - image: 'city', - meta: `Cena średnia: ${formatPln(row.price)} / m²`, - }, - })); + + const podium = sorted.slice(0, 3); + const areaName = (item: AreaStats) => (item.district ? `${item.district}, ${item.city}` : item.city); + const perM2 = (value: number) => `${Math.round(value).toLocaleString('pl-PL')} zł/m²${offerType === 'RENT' ? ' / mies.' : ''}`; + const generatedLabel = ranking + ? new Date(ranking.generatedAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) + : ''; return (
@@ -5361,108 +5654,182 @@ function DistrictRankingPage() {
-

Ranking dzielnic w Polsce

-

Top 20 dzielnic i osiedli na podstawie median cen SonarHome z czerwca 2026 oraz wskaźników popytu, płynności i jakości życia liczonych dla 2026.

+

Ranking dzielnic i miast

+

+ Zestawienie liczone na bieżąco z ogłoszeń opublikowanych w serwisie. Każda nowa, wstrzymana + lub usunięta oferta zmienia wynik - nie ma tu danych wpisanych na sztywno. +

-
+
+ - -
-
-
-

Top {visibleDistricts.length} dzielnic: {DISTRICT_RANKING_LABELS[rankingMetric]}

-
- - - - - - - - - - - - - - - {visibleDistricts.map((row, index) => ( - - - - - - - - - - - ))} - -
#DzielnicaMiastoŚrednia cena za m²TrendPopularnośćOcena mieszkańcówWynik ogólny
{index + 1}{row.district}{row.city}{formatPln(row.price)}{row.change} {row.popularity} / 100{row.ratingText} {row.score}/100
+ {loading &&

Wczytywanie rankingu...

} + {!loading && error &&

{error}

} + + {!loading && !error && ( + <> +
+
+ Ofert w zestawieniu + {ranking?.totalListings ?? 0} +
+
+ {level === 'districts' ? 'Dzielnic z ofertami' : 'Miast z ofertami'} + {filtered.length} +
+
+ Nowe oferty (30 dni) + {filtered.reduce((sum, item) => sum + item.newLast30Days, 0)} +
+
+ Zaktualizowano + {generatedLabel} +
- -
- -
- -
- -

Ceny za m² bazują na medianach dzielnic i osiedli publikowanych przez SonarHome dla czerwca 2026.Popularność jest liczona z danych 2026 (popyt, płynność, dynamika cen i relacja do benchmarku miasta), a ocena mieszkańców bezpośrednio z residentScore 2026 (residentScore / 20); wynik ogólny agreguje te wskaźniki z premią inwestycyjną.

- -
+ + )}
); @@ -5690,20 +6057,22 @@ function AccountSidebar() { || location.pathname === ROUTES.accountProfileEdit || location.pathname === ROUTES.accountPublicProfile; const [messagesUnreadCount, setMessagesUnreadCount] = useState(0); - const allListingsCount = (() => { + // Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty. + const [allListingsCount, setAllListingsCount] = useState(0); + // Spotkania zaplanowane na dzisiaj (odwolane sie nie licza). + const todayMeetingsCount = (() => { if (typeof window === 'undefined') { return 0; } try { - const raw = window.localStorage.getItem(listingsStorageKey); - if (!raw) { - return 0; - } - const parsed = JSON.parse(raw); + const raw = window.localStorage.getItem(meetingsStorageKey); + const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) { return 0; } - return parsed.filter((item) => item && item.status !== 'archived').length; + const now = new Date(); + const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return parsed.filter((item) => item && item.dateKey === todayKey && item.status !== 'cancelled').length; } catch { return 0; } @@ -5751,6 +6120,30 @@ function AccountSidebar() { }; }, [user]); + useEffect(() => { + if (!user) { + setAllListingsCount(0); + return; + } + + let cancelled = false; + apiFetch('/listings/mine') + .then((listings) => { + if (!cancelled) { + setAllListingsCount(listings.length); + } + }) + .catch(() => { + if (!cancelled) { + setAllListingsCount(0); + } + }); + + return () => { + cancelled = true; + }; + }, [user, location.pathname]); + return (
@@ -18016,10 +18503,6 @@ function historyTicks(minPrice: number, maxPrice: number) { return [minPrice, maxPrice]; } -function historyRecentMeta(propertyType: HistoryPropertyType) { - return `${propertyType} · cena za m²`; -} - function historyShortMarketLabel(market: PriceHistoryMarket) { const cityPrefix = `${market.city}, `; if (!market.label.startsWith(cityPrefix)) { @@ -18046,7 +18529,12 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const [suggestOpen, setSuggestOpen] = useState(false); const [suggestLoading, setSuggestLoading] = useState(false); const [hoveredHistoryIndex, setHoveredHistoryIndex] = useState(null); - const [recentMarkets, setRecentMarkets] = useState([HISTORY_PRICE_MARKETS[0], HISTORY_PRICE_MARKETS[6], HISTORY_PRICE_MARKETS[8]]); + // Historia sprawdzonych lokalizacji tego konta - najnowsza na poczatku, maksymalnie 5 pozycji. + const [recentMarkets, setRecentMarkets] = useState( + () => readUserJsonStorage(user, PRICE_HISTORY_MARKETS_SUFFIX, []), + ); + // Ostatnio otwarte oferte (sprzedaz i wynajem) - wspolny rejestr zasilany przy wejsciu na oferte. + const recentListings = useMemo(() => readRecentlyViewed().slice(0, RECENTLY_VIEWED_PANEL_LIMIT), []); const skipNextFetch = useRef(false); const locationQuery = locationInput.trim(); const localSuggestions = locationQuery.length >= 2 ? historyLocalSuggestions(locationQuery) : HISTORY_POPULAR_MARKETS.map((label, index) => { @@ -18074,17 +18562,8 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const chartTicks = tickValues; const chartAxisMin = minPrice; const chartAxisMax = maxPrice; - const compareMarkets = activeMarket - ? HISTORY_PRICE_MARKETS - .filter((market) => market.key !== activeMarket.key) - .sort((left, right) => { - const leftScore = (left.city === activeMarket.city ? -10000 : 0) + Math.abs(left.apartmentM2 - activeMarket.apartmentM2); - const rightScore = (right.city === activeMarket.city ? -10000 : 0) + Math.abs(right.apartmentM2 - activeMarket.apartmentM2); - return leftScore - rightScore; - }) - .slice(0, 4) - : HISTORY_PRICE_MARKETS.slice(0, 4); - const displayedComparisons = activeMarket ? [activeMarket, ...compareMarkets] : compareMarkets; + // Tabela porownania to ostatnie sprawdzone lokalizacje tego konta - bez podpowiedzi z listy. + const displayedComparisons = recentMarkets; const trendLabel = changePercent >= 6 ? 'Trend wzrostowy' : changePercent >= 1 ? 'Rynek stabilny z lekkim wzrostem' : 'Korekta cenowa'; const trendText = !hasSelectedMarket ? 'Wpisz i wybierz lokalizację, żeby zobaczyć realny przebieg cen za m² w modelu 2026.' @@ -18185,13 +18664,33 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { setLocationInput(market.label); setSuggestions([]); setSuggestOpen(false); - setRecentMarkets((current) => [market, ...current.filter((item) => item.key !== market.key)].slice(0, 3)); + // Nowa lokalizacja wchodzi na poczatek listy, najstarsza wypada po przekroczeniu limitu. + setRecentMarkets((current) => { + const next = [market, ...current.filter((item) => item.key !== market.key)].slice(0, PRICE_HISTORY_MARKETS_LIMIT); + writeUserJsonStorage(user, PRICE_HISTORY_MARKETS_SUFFIX, next); + return next; + }); }; const selectSuggestion = (place: PlaceSuggestion) => { applyMarket(resolveHistoryMarket(place.label, place.sublabel)); }; + // Usuwanie pojedynczej lokalizacji z porownania i czyszczenie calej listy - zapis leci od razu + // do pamieci konta, zeby stan zgadzal sie po odswiezeniu strony. + const removeComparison = (marketKey: string) => { + setRecentMarkets((current) => { + const next = current.filter((item) => item.key !== marketKey); + writeUserJsonStorage(user, PRICE_HISTORY_MARKETS_SUFFIX, next); + return next; + }); + }; + + const clearComparisons = () => { + setRecentMarkets([]); + writeUserJsonStorage(user, PRICE_HISTORY_MARKETS_SUFFIX, []); + }; + // Wejscie z adresu z parametrami (np. "Zobacz historie cen" przy alercie lokalizacji) // otwiera stronę od razu na wybranym miejscu, zamiast na pustym formularzu. useEffect(() => { @@ -18273,6 +18772,19 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { }); }; + // Alert da sie usunac tam, gdzie zostal dodany - bez wchodzenia w zakladke alertow cenowych. + const removeCityAlert = () => { + if (!user || !selectedMarket) { + return; + } + setCityAlertSubscriptions((current) => { + const next = current.filter((item) => item.key !== selectedMarket.key); + writeUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, next); + setCreateAlertFeedback(`Alert cenowy dla lokalizacji ${selectedMarket.label} został usunięty.`); + return next; + }); + }; + return (
@@ -18294,8 +18806,8 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {
+
+ +

Chcesz być na bieżąco?Ustaw alert cenowy i otrzymuj powiadomienia o zmianach cen w wybranej lokalizacji.

+ {selectedMarketAlreadyAlerted ? ( +
+ Alert aktywny + +
+ ) : ( + + )} + {createAlertFeedback && {createAlertFeedback}} +
+

Wnioski z analizy

@@ -18417,34 +18945,62 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {

Warto obserwować{hasSelectedMarket ? `${propertyType} na rynku „${marketType}” ma aktualnie średnią ${formatM2(latestPrice)} w lokalizacji ${activeLocationLabel}.` : 'Po wyborze lokalizacji pokażemy średnią cenę za m², zmianę i punkty minimum/maksimum dla modelu 2026.'}

- -
- -

Chcesz być na bieżąco?Ustaw alert cenowy i otrzymuj powiadomienia o zmianach cen w wybranej lokalizacji.

- - {createAlertFeedback && {createAlertFeedback}} -
@@ -20311,6 +20867,271 @@ function NotificationsPage({ onOpenNotification }: { onOpenNotification: (notifi ); } +// Wizytowka kontaktowa wysylana w rozmowie. Dane biora sie z konta (/auth/me): +// imie i nazwisko oraz e-mail zawsze, telefon tylko wtedy, gdy uzytkownik zapisal go w profilu. +type BusinessCard = { + fullName: string; + phone: string | null; + email: string; +}; + +// Watek z administracja jest trzymany na serwerze jako zwykly tekst wiadomosci, wiec wizytowke +// zapisujemy w czytelnym formacie z markerem i odczytujemy z powrotem przy wczytywaniu rozmowy. +const BUSINESS_CARD_MARKER = '[wizytówka]'; +const BUSINESS_CARD_TEXT = 'Przesyłam wizytówkę kontaktową.'; + +function formatBusinessCard(card: BusinessCard): string { + const lines = [ + `${BUSINESS_CARD_MARKER} Wizytówka kontaktowa`, + `Imię i nazwisko: ${card.fullName}`, + `E-mail: ${card.email}`, + ]; + if (card.phone) { + lines.push(`Telefon: ${card.phone}`); + } + return lines.join('\n'); +} + +function parseBusinessCard(content: string): BusinessCard | null { + if (!content.startsWith(BUSINESS_CARD_MARKER)) { + return null; + } + + const lines = content.split('\n'); + const readLine = (label: string) => { + const line = lines.find((item) => item.startsWith(`${label}: `)); + return line ? line.slice(label.length + 2).trim() : ''; + }; + + const email = readLine('E-mail'); + if (!email) { + return null; + } + return { fullName: readLine('Imię i nazwisko'), email, phone: readLine('Telefon') || null }; +} + +// Tresc wiadomosci z serwera zamieniona na czesci widoku: wizytowka renderuje sie jako karta, +// pozostale wiadomosci jako zwykly tekst. +function chatContentParts(content: string): { text: string; businessCard?: BusinessCard } { + const card = parseBusinessCard(content); + return card ? { text: BUSINESS_CARD_TEXT, businessCard: card } : { text: content }; +} + +type PublicProfile = { + id: number; + fullName: string; + accountType: AccountType; + verified: boolean; + memberSince: string; + listingsCount: number; + listings: { + id: number; + title: string; + city: string; + district: string | null; + price: number; + offerType: ApiOfferType; + area: number | null; + rooms: number | null; + floor: string | null; + buildingFloors: string | null; + coverPhoto: string | null; + createdAt: string; + }[]; +}; + +/** + * Profil innego uzytkownika (/profil/:id) - otwierany z rozmowy w wiadomosciach. + * Pokazuje wylacznie dane jawne; kontakt odbywa sie przez wiadomosci, nie przez e-mail. + */ +function PublicProfilePage() { + const { id } = useParams(); + const navigate = useNavigate(); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(''); + + apiFetch(`/users/${id}/profile`) + .then((data) => { + if (!cancelled) { + setProfile(data); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Nie udało się wczytać profilu.'); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [id]); + + const initials = (profile?.fullName ?? '') + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((chunk) => chunk[0]?.toUpperCase() ?? '') + .join('') || '?'; + + const memberSinceLabel = profile + ? new Date(profile.memberSince).toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' }) + : ''; + + const forSaleCount = profile?.listings.filter((listing) => listing.offerType === 'SALE').length ?? 0; + const forRentCount = profile?.listings.filter((listing) => listing.offerType === 'RENT').length ?? 0; + + return ( +
+
+ + + {loading &&

Wczytywanie profilu...

} + {!loading && error &&

{error}

} + + {!loading && !error && profile && ( + <> +
+
+
+
{initials}
+
+

{profile.fullName}

+
+ W serwisie od {memberSinceLabel} + {profile.accountType === 'COMPANY' ? 'Firma' : 'Osoba prywatna'} +
+ {/* Ocen jeszcze nie zbieramy - slot zostaje, zeby uklad byl ten sam co na wlasnym profilu. */} +
+ Brak ocen +
+
+
+ {profile.verified + ? 'Konto zweryfikowane przez serwis Polska Lokalnie.' + : 'Konto nie przeszło jeszcze weryfikacji.'} +
+
+
{profile.listingsCount}Ogłoszenia
+
{forSaleCount}Na sprzedaż
+
{forRentCount}Na wynajem
+
+
+
+ +
+
+
+

Ogłoszenia ({profile.listingsCount})

+
+ + {profile.listings.length === 0 ? ( +

Ten użytkownik nie ma obecnie opublikowanych ogłoszeń.

+ ) : ( +
+ {profile.listings.map((listing) => ( + +
+ + {listing.offerType === 'RENT' ? 'Na wynajem' : 'Na sprzedaż'} + +
+
+ {formatPln(listing.price)}{listing.offerType === 'RENT' ? ' / mies.' : ''} +

{listing.title}

+ {[listing.city, listing.district].filter(Boolean).join(', ')} +
+ {listing.area !== null && {`${listing.area}`.replace('.', ',')} m²} + {listing.rooms !== null && {listingRoomsText(listing.rooms)}} + {listing.floor && {listingFloorText(listing.floor, listing.buildingFloors)} piętro} +
+
+ {listingRelativeDate(listing.createdAt) || 'Dodane niedawno'} +
+
+ + ))} +
+ )} +
+ +
+
+

Opinie o użytkowniku

+
+

Ten użytkownik nie ma jeszcze opinii.

+
+
+ +
+

O użytkowniku

+

Ten użytkownik nie dodał jeszcze opisu swojego profilu.

+
+ {profile.accountType === 'COMPANY' ? 'Konto firmowe' : 'Konto prywatne'} + W serwisie od {memberSinceLabel} + {profile.verified && Zweryfikowany użytkownik} + Kontakt przez wiadomości +
+
+ +
+ Dane kontaktowe użytkownika (e-mail, telefon) nie są widoczne publicznie. + Napisz do niego przez wiadomości w serwisie. +
+ + )} +
+
+ ); +} + +// Odczyt pliku jako dataURL - uzywane przy zalacznikach zgloszen i zdjeciach profilu. +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error('Nie udało się odczytać pliku.')); + return; + } + resolve(reader.result); + }; + reader.onerror = () => reject(new Error('Nie udało się odczytać pliku.')); + reader.readAsDataURL(file); + }); +} + +// Etykieta czasu na liscie rozmow i przy wiadomosci: dzis sama godzina, starsze - sam dzien i miesiac. +// Krotki zapis miesci sie w waskiej kolumnie listy i jest zgodny z pozostalymi watkami. +function chatTimeLabel(date: Date): string { + const isToday = date.toDateString() === new Date().toDateString(); + return isToday + ? date.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit' }); +} + +// Numer dopisywany z okna wizytowki: 9 cyfr w formacie polskim. Ten sam zapis co w ustawieniach +// konta ("+48 500100200"), zeby oba widoki pokazywaly identyczny numer. +const BUSINESS_CARD_PHONE_PREFIX = '+48'; +const BUSINESS_CARD_PHONE_DIGITS = 9; + +function formatPhoneDigits(digits: string): string { + return digits.replace(/(\d{3})(?=\d)/g, '$1 ').trim(); +} + function MessagesPage({ onOpenListing, }: { @@ -20324,12 +21145,6 @@ function MessagesPage({ const onThreadTargetHandled = useCallback(() => { navigate(location.pathname, { replace: true, state: null }); }, [navigate, location.pathname]); - type BusinessCard = { - fullName: string; - phone: string; - email: string; - }; - type ChatMessage = { id: string; text: string; @@ -20339,7 +21154,7 @@ function MessagesPage({ readByRecipient?: boolean; unread?: boolean; attachmentName?: string; - attachmentModeration?: 'pending'; + attachmentUrl?: string; businessCard?: BusinessCard; }; @@ -20348,6 +21163,9 @@ function MessagesPage({ name: string; avatarImage: string; status: string; + // Konto rozmowcy - ustawione tylko dla rozmow zalozonych z realnego ogloszenia. + contactEmail?: string | null; + contactUserId?: number | null; listing: MessageThreadListing | null; messages: ChatMessage[]; }; @@ -20483,9 +21301,17 @@ function MessagesPage({ const [activeFilter, setActiveFilter] = useState('all'); const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [composerValue, setComposerValue] = useState(''); - const [selectedAttachment, setSelectedAttachment] = useState(null); + // Wybrane zdjecie trzymamy juz jako dataURL, zeby pokazac podglad w oknie i w wiadomosci. + const [selectedAttachment, setSelectedAttachment] = useState<{ name: string; url: string } | null>(null); + // Zdjecie z rozmowy otwarte na pelnym ekranie. + const [previewPhoto, setPreviewPhoto] = useState<{ name: string; url: string } | null>(null); const [moderationFeedback, setModerationFeedback] = useState(''); const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); + // Pytanie o numer telefonu przed wyslaniem wizytowki z konta, ktore go nie ma. + const [isPhonePromptOpen, setIsPhonePromptOpen] = useState(false); + const [phonePromptDigits, setPhonePromptDigits] = useState(''); + const [phonePromptError, setPhonePromptError] = useState(''); + const [isSavingPhonePrompt, setIsSavingPhonePrompt] = useState(false); const [isDeleteChatModalOpen, setIsDeleteChatModalOpen] = useState(false); const [blockedThreadIds, setBlockedThreadIds] = useState([]); const [isReportModalOpen, setIsReportModalOpen] = useState(false); @@ -20503,7 +21329,7 @@ function MessagesPage({ const [activeThreadId, setActiveThreadId] = useState('anna'); const attachmentInputRef = useRef(null); const reportAttachmentInputRef = useRef(null); - const { user } = useAuth(); + const { user, updateProfile } = useAuth(); const ADMIN_THREAD_ID = 'admin-support'; useEffect(() => { @@ -20522,8 +21348,8 @@ function MessagesPage({ const messages: ChatMessage[] = data.map((item) => ({ id: `admin-msg-${item.id}`, - text: item.content, - time: new Date(item.createdAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }), + ...chatContentParts(item.content), + time: chatTimeLabel(new Date(item.createdAt)), mine: item.mine, sentAt: new Date(item.createdAt).getTime(), })); @@ -20622,6 +21448,22 @@ function MessagesPage({ } }, [activeThreadId, filteredThreads]); + // Powiekszone zdjecie zamykamy takze klawiszem Escape - tak samo jak podglad w ogloszeniu. + useEffect(() => { + if (!previewPhoto) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setPreviewPhoto(null); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [previewPhoto]); + useEffect(() => { if (!moderationFeedback) { return; @@ -20651,6 +21493,8 @@ function MessagesPage({ name: initialThreadTarget.name, status: initialThreadTarget.status, avatarImage: initialThreadTarget.listing.thumbnail || thread.avatarImage, + contactEmail: initialThreadTarget.contactEmail, + contactUserId: initialThreadTarget.contactUserId, listing: initialThreadTarget.listing, } : thread @@ -20663,6 +21507,8 @@ function MessagesPage({ name: initialThreadTarget.name, avatarImage: initialThreadTarget.listing.thumbnail || cityImage, status: initialThreadTarget.status, + contactEmail: initialThreadTarget.contactEmail, + contactUserId: initialThreadTarget.contactUserId, listing: initialThreadTarget.listing, messages: [], }, @@ -20732,7 +21578,7 @@ function MessagesPage({ setThreads((current) => current.map((thread) => ( thread.id === ADMIN_THREAD_ID - ? { ...thread, messages: [...thread.messages, { id: tempId, text: value, time: nowTimeLabel(), mine: true, sentAt, readByRecipient: true }] } + ? { ...thread, messages: [...thread.messages, { id: tempId, ...chatContentParts(value), time: nowTimeLabel(), mine: true, sentAt, readByRecipient: true }] } : thread ))); @@ -20748,7 +21594,7 @@ function MessagesPage({ ...thread, messages: thread.messages.map((message) => ( message.id === tempId - ? { id: `admin-msg-${sent.id}`, text: sent.content, time: nowTimeLabel(), mine: true, sentAt: new Date(sent.createdAt).getTime(), readByRecipient: true } + ? { id: `admin-msg-${sent.id}`, ...chatContentParts(sent.content), time: nowTimeLabel(), mine: true, sentAt: new Date(sent.createdAt).getTime(), readByRecipient: true } : message )), } @@ -20759,18 +21605,19 @@ function MessagesPage({ } }; - const blockedAttachmentPatterns = ['nude', 'nago', 'porn', 'porno', 'sex', 'xxx', 'erot', 'onlyfans']; - const myBusinessCard: BusinessCard = { - fullName: 'Mateusz Kaczmarek', - phone: '+48 501 240 315', - email: 'mateusz.kaczmarek@polskalokalnie.pl', - }; + // Wizytowka zawsze z aktualnego profilu konta. Telefon trafia do niej tylko wtedy, + // gdy uzytkownik go poda - inaczej wysylamy sam adres e-mail. + const myBusinessCard: BusinessCard | null = user + ? { fullName: user.fullName, email: user.email, phone: user.phone } + : null; const onChooseAttachment = () => { attachmentInputRef.current?.click(); }; - const onAttachmentSelected = (event: ReactChangeEvent) => { + // Zdjecie idzie w wiadomosci od razu, bez wstrzymywania na moderacje - naduzycia zalatwia + // blokada rozmowcy i zgloszenie uzytkownika. + const onAttachmentSelected = async (event: ReactChangeEvent) => { const file = event.target.files?.[0] ?? null; event.target.value = ''; setModerationFeedback(''); @@ -20779,29 +21626,28 @@ function MessagesPage({ return; } - const lowerName = file.name.toLowerCase(); - const hasBlockedKeyword = blockedAttachmentPatterns.some((pattern) => lowerName.includes(pattern)); - if (!file.type.startsWith('image/')) { setSelectedAttachment(null); setModerationFeedback('Możesz dodać tylko plik zdjęciowy.'); return; } - if (hasBlockedKeyword) { - setSelectedAttachment(null); - setModerationFeedback('Zdjęcie zostało zablokowane przez filtr bezpieczeństwa.'); - return; - } - if (file.size > 6 * 1024 * 1024) { setSelectedAttachment(null); setModerationFeedback('Maksymalny rozmiar zdjęcia to 6 MB.'); return; } - setSelectedAttachment(file); - setModerationFeedback('Zdjęcie dodane. Podgląd jest ukryty do czasu moderacji.'); + setSelectedAttachment({ name: file.name, url: await compressImageFile(file, 1024) }); + }; + + const removeSelectedAttachment = () => { + setSelectedAttachment(null); + setModerationFeedback(''); + }; + + const closePhotoPreview = () => { + setPreviewPhoto(null); }; const sendMessage = async () => { @@ -20835,7 +21681,7 @@ function MessagesPage({ const messageId = `${activeThread.id}-${Date.now()}`; const sentAt = Date.now(); const timeLabel = nowTimeLabel(); - const attachmentName = selectedAttachment?.name; + const attachment = selectedAttachment; setThreads((current) => current.map((thread) => { if (thread.id !== activeThread.id) { @@ -20853,8 +21699,8 @@ function MessagesPage({ mine: true, readByRecipient: false, sentAt, - attachmentName, - attachmentModeration: attachmentName ? 'pending' : undefined, + attachmentName: attachment?.name, + attachmentUrl: attachment?.url, }, ], }; @@ -20906,9 +21752,11 @@ function MessagesPage({ }, 9000); }; - const sendBusinessCard = () => { - if (isActiveThreadBlocked) { - setModerationFeedback('Ten użytkownik jest zablokowany. Odblokuj go, aby ponownie pisać wiadomości.'); + const deliverBusinessCard = (card: BusinessCard) => { + // Rozmowa z administracja jest zapisywana na serwerze - wizytowka idzie ta sama droga + // co zwykla wiadomosc, dzieki czemu zostaje w historii po odswiezeniu strony. + if (activeThread.id === ADMIN_THREAD_ID) { + void sendAdminMessage(formatBusinessCard(card)); return; } @@ -20927,12 +21775,12 @@ function MessagesPage({ ...thread.messages, { id: messageId, - text: 'Wysyłam wizytówkę kontaktową.', + text: BUSINESS_CARD_TEXT, time: timeLabel, mine: true, readByRecipient: false, sentAt, - businessCard: myBusinessCard, + businessCard: card, }, ], }; @@ -20954,6 +21802,83 @@ function MessagesPage({ }, 4500); }; + const sendBusinessCard = () => { + if (isActiveThreadBlocked) { + setModerationFeedback('Ten użytkownik jest zablokowany. Odblokuj go, aby ponownie pisać wiadomości.'); + return; + } + + if (!myBusinessCard) { + setModerationFeedback('Zaloguj się, aby wysłać wizytówkę z danymi swojego konta.'); + return; + } + + // Konto bez numeru telefonu: pytamy, czy dopisac numer, czy wyslac sam adres e-mail. + if (!myBusinessCard.phone) { + setPhonePromptDigits(''); + setPhonePromptError(''); + setIsPhonePromptOpen(true); + return; + } + + deliverBusinessCard(myBusinessCard); + }; + + const closePhonePrompt = () => { + if (isSavingPhonePrompt) { + return; + } + setIsPhonePromptOpen(false); + }; + + const sendBusinessCardWithoutPhone = () => { + if (!myBusinessCard) { + return; + } + setIsPhonePromptOpen(false); + deliverBusinessCard(myBusinessCard); + }; + + // Numer trafia na konto (PUT /auth/me), a nie tylko do tej jednej wiadomosci - kolejna + // wizytowka wyjdzie juz z telefonem bez pytania. + const savePhoneAndSendBusinessCard = async () => { + if (!user || !myBusinessCard) { + return; + } + if (phonePromptDigits.length !== BUSINESS_CARD_PHONE_DIGITS) { + setPhonePromptError(`Numer telefonu musi mieć ${BUSINESS_CARD_PHONE_DIGITS} cyfr.`); + return; + } + + const phone = `${BUSINESS_CARD_PHONE_PREFIX} ${phonePromptDigits}`; + setIsSavingPhonePrompt(true); + setPhonePromptError(''); + + try { + // Pozostale pola profilu przekazujemy bez zmian - PUT /auth/me nadpisuje caly profil. + const updated = await updateProfile( + user.fullName, + phone, + user.birthDate ?? undefined, + user.address ?? undefined, + user.contactPreference, + user.preferredLanguage, + ); + + // Ustawienia konta czytaja numer takze z pamieci przegladarki - trzymamy oba zrodla zgodne. + window.localStorage.setItem(accountStorageKey('PhonePrefix', updated), BUSINESS_CARD_PHONE_PREFIX); + window.localStorage.setItem(accountStorageKey('PhoneDigits', updated), phonePromptDigits); + window.localStorage.setItem(accountStorageKey('Phone', updated), phone); + + setIsPhonePromptOpen(false); + deliverBusinessCard({ fullName: updated.fullName, email: updated.email, phone: updated.phone }); + } catch (error) { + setPhonePromptError(error instanceof Error ? error.message : 'Nie udało się zapisać numeru telefonu.'); + } finally { + setIsSavingPhonePrompt(false); + } + }; + const applyFilter = (filter: ThreadFilter) => { setActiveFilter(filter); setIsFiltersOpen(false); @@ -21128,16 +22053,51 @@ function MessagesPage({ setIsSubmittingReport(true); try { - await new Promise((resolve) => { - window.setTimeout(() => resolve(), 850); + // Podglad zapisujemy tylko dla obrazow - typ rozpoznajemy tak samo jak przy zgloszeniu + // ogloszenia (MIME albo rozszerzenie, bo nie kazda przegladarka ustawia typ pliku). + const attachmentFiles = await Promise.all(reportAttachments.map(async (file) => { + const lowerName = file.name.toLowerCase(); + const isImage = file.type.toLowerCase().startsWith('image/') + || lowerName.endsWith('.png') || lowerName.endsWith('.jpg') + || lowerName.endsWith('.jpeg') || lowerName.endsWith('.webp'); + return { + fileName: file.name, + fileType: file.type || null, + dataUrl: isImage ? await readFileAsDataUrl(file) : null, + }; + })); + + // Zgloszenie trafia do panelu administratora (/admin/zgloszenia) - numer sprawy + // i date bierzemy z odpowiedzi serwera, zeby zgadzaly sie z tym, co widzi obsluga. + const submitted = await apiFetch<{ id: number; createdAt: string; reasonTitle: string }>('/reports/users', { + method: 'POST', + body: JSON.stringify({ + reportedName: activeThread.name, + // Konto zgloszonego - tylko rozmowy z realnego ogloszenia je maja. Bez niego panel + // nie pokaze akcji na koncie, bo nie ma czego blokowac ani usuwac. + reportedEmail: activeThread.contactEmail ?? null, + conversationId: activeThread.id, + // Ogloszenie, w sprawie ktorego toczyla sie rozmowa - obsluga widzi kontekst zgloszenia. + listingId: activeThreadListing?.listingId ?? null, + listingTitle: activeThreadListing?.title ?? null, + reasonId: selectedReportReason, + reasonTitle: selectedReportReasonDetails.title, + details: normalizedReportDetails || null, + attachmentNames: reportAttachments.map((file) => file.name), + attachmentFiles, + // Migawka rozmowy dolaczona do zgloszenia - dowod dla obslugi (serwer bierze ostatnie 50). + conversation: activeThreadMessages.map((message) => ({ + fromReporter: message.mine, + content: message.text, + sentAt: new Date(message.sentAt).toISOString(), + hasPhoto: Boolean(message.attachmentUrl), + })), + }), }); - const submittedAt = new Date(); - const nextCaseNumber = String(submittedAt.getTime()).slice(-6); - - setReportCaseNumber(nextCaseNumber); - setReportSubmittedReasonTitle(selectedReportReasonDetails.title); - setReportSubmittedAt(submittedAt.toLocaleString('pl-PL', { + setReportCaseNumber(String(submitted.id).padStart(6, '0')); + setReportSubmittedReasonTitle(submitted.reasonTitle); + setReportSubmittedAt(new Date(submitted.createdAt).toLocaleString('pl-PL', { day: 'numeric', month: 'long', year: 'numeric', @@ -21145,8 +22105,8 @@ function MessagesPage({ minute: '2-digit', })); setReportStep(3); - } catch { - setReportSubmitError('Nie udało się wysłać zgłoszenia. Spróbuj ponownie.'); + } catch (error) { + setReportSubmitError(error instanceof Error ? error.message : 'Nie udało się wysłać zgłoszenia. Spróbuj ponownie.'); } finally { setIsSubmittingReport(false); } @@ -21205,6 +22165,16 @@ function MessagesPage({