diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index 3985db1..2967327 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -2,8 +2,10 @@ package pl.polskalokalnie.admin; import java.util.Comparator; import java.util.List; +import java.security.SecureRandom; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -11,9 +13,11 @@ 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.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; import pl.polskalokalnie.moderation.ForbiddenWordResponse; import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.auth.dto.UserResponse; @@ -36,23 +40,33 @@ import pl.polskalokalnie.user.UserRepository; @RequestMapping("/api/admin") public class AdminController { + private static final String PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%"; + private static final int TEMP_PASSWORD_LENGTH = 12; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private final UserRepository userRepository; private final BlockedEmailRepository blockedEmailRepository; private final ListingService listingService; private final ListingReportService listingReportService; private final MessageService messageService; private final TextModerationService textModerationService; + private final AdminStatsService adminStatsService; + private final PasswordEncoder passwordEncoder; public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, ListingService listingService, ListingReportService listingReportService, MessageService messageService, - TextModerationService textModerationService) { + TextModerationService textModerationService, + AdminStatsService adminStatsService, + PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.listingService = listingService; this.listingReportService = listingReportService; this.messageService = messageService; this.textModerationService = textModerationService; + this.adminStatsService = adminStatsService; + this.passwordEncoder = passwordEncoder; } // --- Uzytkownicy --- @@ -82,6 +96,50 @@ public class AdminController { return UserResponse.from(userRepository.save(user)); } + @PostMapping("/users/{id}/grant-admin") + public UserResponse grantAdmin(@PathVariable Long id) { + AppUser user = requireUser(id); + if (user.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Użytkownik już ma rolę administratora"); + } + user.setRole(Role.ADMIN); + user.setVerified(true); + return UserResponse.from(userRepository.save(user)); + } + + @PostMapping("/users/{id}/revoke-admin") + public UserResponse revokeAdmin(@PathVariable Long id, Authentication authentication) { + AppUser user = requireUser(id); + if (user.getRole() != Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Użytkownik nie ma roli administratora"); + } + + AppUser currentAdmin = requireAdmin(authentication); + if (currentAdmin.getId().equals(user.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie możesz odebrać roli administratora samemu sobie"); + } + + if (userRepository.countByRole(Role.ADMIN) <= 1) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można odebrać roli ostatniemu administratorowi"); + } + + user.setRole(Role.USER); + return UserResponse.from(userRepository.save(user)); + } + + @PostMapping("/users/{id}/reset-password") + public ResetPasswordResponse resetPassword(@PathVariable Long id) { + AppUser user = requireUser(id); + if (user.getRole() == Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można resetować hasła konta administratora z tego widoku"); + } + + String temporaryPassword = generateTemporaryPassword(); + user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); + userRepository.save(user); + return new ResetPasswordResponse(temporaryPassword); + } + @DeleteMapping("/users/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUser(@PathVariable Long id) { @@ -205,4 +263,27 @@ public class AdminController { public void deleteForbiddenWord(@PathVariable Long id) { textModerationService.removeForbiddenWord(id); } + + // --- Statystyki --- + + @GetMapping("/stats") + public AdminStatsResponse stats( + @RequestParam(name = "days", defaultValue = "1") int days, + @RequestParam(name = "from", required = false) LocalDate from, + @RequestParam(name = "to", required = false) LocalDate to + ) { + if (from != null || to != null) { + return adminStatsService.getStats(from, to); + } + return adminStatsService.getStats(days); + } + + private String generateTemporaryPassword() { + StringBuilder password = new StringBuilder(TEMP_PASSWORD_LENGTH); + for (int index = 0; index < TEMP_PASSWORD_LENGTH; index++) { + int randomIndex = SECURE_RANDOM.nextInt(PASSWORD_ALPHABET.length()); + password.append(PASSWORD_ALPHABET.charAt(randomIndex)); + } + return password.toString(); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsResponse.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsResponse.java new file mode 100644 index 0000000..9881b90 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsResponse.java @@ -0,0 +1,45 @@ +package pl.polskalokalnie.admin; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +public record AdminStatsResponse( + int periodDays, + LocalDate fromDate, + LocalDate toDate, + Instant generatedAt, + Summary summary, + Period period, + List daily +) { + public record Summary( + long activeListings, + long pendingListings, + long totalUsers, + long pendingVerificationUsers, + long openReports, + long messagesToAdminTotal, + long blockedUsers + ) { + } + + public record Period( + long messagesToAdmin, + long newListings, + long newReports, + long newPersonalAccounts, + long newCompanyAccounts + ) { + } + + public record DailyPoint( + LocalDate date, + long messagesToAdmin, + long newListings, + long newReports, + long newPersonalAccounts, + long newCompanyAccounts + ) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java new file mode 100644 index 0000000..863e6ef --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java @@ -0,0 +1,172 @@ +package pl.polskalokalnie.admin; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.springframework.stereotype.Service; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.message.Message; +import pl.polskalokalnie.message.MessageRepository; +import pl.polskalokalnie.report.ListingReport; +import pl.polskalokalnie.report.ListingReportRepository; +import pl.polskalokalnie.report.ListingReportStatus; +import pl.polskalokalnie.user.AccountType; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.Role; +import pl.polskalokalnie.user.UserRepository; + +@Service +public class AdminStatsService { + + private static final int MIN_PERIOD_DAYS = 1; + private static final int MAX_PERIOD_DAYS = 60; + private static final ZoneId ZONE_ID = ZoneId.of("Europe/Warsaw"); + + private final ListingRepository listingRepository; + private final UserRepository userRepository; + private final ListingReportRepository listingReportRepository; + private final MessageRepository messageRepository; + + public AdminStatsService( + ListingRepository listingRepository, + UserRepository userRepository, + ListingReportRepository listingReportRepository, + MessageRepository messageRepository + ) { + this.listingRepository = listingRepository; + this.userRepository = userRepository; + this.listingReportRepository = listingReportRepository; + this.messageRepository = messageRepository; + } + + public AdminStatsResponse getStats(int requestedPeriodDays) { + int periodDays = Math.max(MIN_PERIOD_DAYS, Math.min(MAX_PERIOD_DAYS, requestedPeriodDays)); + Instant now = Instant.now(); + LocalDate today = LocalDate.now(ZONE_ID); + LocalDate fromDate = today.minusDays(periodDays - 1L); + return buildStats(fromDate, today, periodDays, now); + } + + public AdminStatsResponse getStats(LocalDate requestedFromDate, LocalDate requestedToDate) { + LocalDate today = LocalDate.now(ZONE_ID); + LocalDate toDate = requestedToDate == null ? today : requestedToDate; + LocalDate fromDate = requestedFromDate == null ? toDate : requestedFromDate; + + if (fromDate.isAfter(toDate)) { + LocalDate swapped = fromDate; + fromDate = toDate; + toDate = swapped; + } + + if (toDate.isAfter(today)) { + toDate = today; + } + + if (fromDate.isAfter(toDate)) { + fromDate = toDate; + } + + long rawDays = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1L; + int periodDays = (int) Math.max(MIN_PERIOD_DAYS, Math.min(MAX_PERIOD_DAYS, rawDays)); + if (rawDays > MAX_PERIOD_DAYS) { + fromDate = toDate.minusDays(MAX_PERIOD_DAYS - 1L); + } + + Instant now = Instant.now(); + return buildStats(fromDate, toDate, periodDays, now); + } + + private AdminStatsResponse buildStats(LocalDate fromDate, LocalDate toDate, int periodDays, Instant generatedAt) { + Instant periodStart = fromDate.atStartOfDay(ZONE_ID).toInstant(); + Instant periodEndExclusive = toDate.plusDays(1L).atStartOfDay(ZONE_ID).toInstant(); + Instant periodEndInclusive = generatedAt.isBefore(periodEndExclusive) ? generatedAt : periodEndExclusive.minusNanos(1L); + + List listings = listingRepository.findAll(); + List users = userRepository.findAll(); + List reports = listingReportRepository.findAll(); + List messages = messageRepository.findAll(); + + Long adminId = users.stream() + .filter(user -> user.getRole() == Role.ADMIN) + .map(AppUser::getId) + .findFirst() + .orElse(null); + + List regularUsers = users.stream() + .filter(user -> user.getRole() != Role.ADMIN) + .toList(); + + List messagesToAdmin = adminId == null + ? List.of() + : messages.stream() + .filter(message -> message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId)) + .toList(); + + AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary( + listings.stream().filter(item -> item.getStatus() == ListingStatus.APPROVED).count(), + listings.stream().filter(item -> item.getStatus() == ListingStatus.PENDING).count(), + regularUsers.size(), + regularUsers.stream().filter(item -> !item.isVerified()).count(), + reports.stream().filter(item -> item.getStatus() == ListingReportStatus.OPEN).count(), + messagesToAdmin.size(), + regularUsers.stream().filter(AppUser::isBlocked).count() + ); + + AdminStatsResponse.Period period = new AdminStatsResponse.Period( + messagesToAdmin.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(), + listings.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(), + reports.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(), + regularUsers.stream() + .filter(item -> item.getAccountType() == AccountType.PERSONAL) + .filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)) + .count(), + regularUsers.stream() + .filter(item -> item.getAccountType() == AccountType.COMPANY) + .filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)) + .count() + ); + + List daily = new ArrayList<>(); + for (LocalDate current = fromDate; !current.isAfter(toDate); current = current.plusDays(1)) { + LocalDate nextDate = current.plusDays(1); + Instant dayStart = current.atStartOfDay(ZONE_ID).toInstant(); + Instant dayEnd = nextDate.atStartOfDay(ZONE_ID).toInstant(); + + daily.add(new AdminStatsResponse.DailyPoint( + current, + messagesToAdmin.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(), + listings.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(), + reports.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(), + regularUsers.stream() + .filter(item -> item.getAccountType() == AccountType.PERSONAL) + .filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)) + .count(), + regularUsers.stream() + .filter(item -> item.getAccountType() == AccountType.COMPANY) + .filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)) + .count() + )); + } + + daily.sort(Comparator.comparing(AdminStatsResponse.DailyPoint::date)); + + return new AdminStatsResponse(periodDays, fromDate, toDate, generatedAt, summary, period, daily); + } + + private static boolean inRange(Instant timestamp, Instant startInclusive, Instant endInclusive) { + return timestamp != null + && !timestamp.isBefore(startInclusive) + && !timestamp.isAfter(endInclusive); + } + + private static boolean inRangeExclusiveEnd(Instant timestamp, Instant startInclusive, Instant endExclusive) { + return timestamp != null + && !timestamp.isBefore(startInclusive) + && timestamp.isBefore(endExclusive); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/admin/ResetPasswordResponse.java b/backend/src/main/java/pl/polskalokalnie/admin/ResetPasswordResponse.java new file mode 100644 index 0000000..b2dae46 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/admin/ResetPasswordResponse.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.admin; + +public record ResetPasswordResponse( + String temporaryPassword +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java index 2f28404..d8e21c0 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingCreateRequest.java @@ -42,6 +42,7 @@ public record ListingCreateRequest( Double lng, List media, List amenities, - List photos + List photos, + String virtualTourUrl ) { } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 49420d4..1f16009 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -44,6 +44,7 @@ public record ListingDetailResponse( List media, List amenities, List photos, + String virtualTourUrl, String ownerEmail, ListingStatus status, Instant createdAt, @@ -86,6 +87,7 @@ public record ListingDetailResponse( List.copyOf(listing.getMedia()), List.copyOf(listing.getAmenities()), List.copyOf(listing.getPhotos()), + listing.getVirtualTourUrl(), listing.getOwnerEmail(), listing.getStatus(), listing.getCreatedAt(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 7d91867..a8c740f 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -3,6 +3,8 @@ package pl.polskalokalnie.listing; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.net.URI; +import java.net.URISyntaxException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -100,6 +102,7 @@ public class ListingService { List photos = limitPhotos(request.photos()); listing.setPhotos(photos); listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0)); + listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl())); listing.setOwnerEmail(ownerEmail); listing.setStatus(ListingStatus.PENDING); @@ -165,4 +168,29 @@ public class ListingService { } return result; } + + private static String normalizeVirtualTourUrl(String value) { + String normalized = trimToNull(value); + if (normalized == null) { + return null; + } + if (normalized.length() > 1200) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link do wirtualnego spaceru jest zbyt długi"); + } + + try { + URI uri = new URI(normalized); + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (scheme == null || host == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny link URL do wirtualnego spaceru"); + } + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link do wirtualnego spaceru musi zaczynać się od http:// lub https://"); + } + return uri.toString(); + } catch (URISyntaxException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny link URL do wirtualnego spaceru"); + } + } } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java index 74b5392..5e5dc9f 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java @@ -142,6 +142,9 @@ public class PropertyListing { @Column(columnDefinition = "text") private String coverPhoto; + @Column(length = 1200) + private String virtualTourUrl; + @Column(length = 180) private String ownerEmail; @@ -446,6 +449,14 @@ public class PropertyListing { this.coverPhoto = coverPhoto; } + public String getVirtualTourUrl() { + return virtualTourUrl; + } + + public void setVirtualTourUrl(String virtualTourUrl) { + this.virtualTourUrl = virtualTourUrl; + } + public String getOwnerEmail() { return ownerEmail; } diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java index 9f7712a..0c70c8a 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserRepository.java @@ -10,4 +10,6 @@ public interface UserRepository extends JpaRepository { boolean existsByEmailIgnoreCase(String email); Optional findFirstByRole(Role role); + + long countByRole(Role role); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 242ad4b..37a5291 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,7 @@ const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; const SMS_VERIFICATION_CODE = '123456'; -type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; +type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; type MessageThreadListing = { listingId: number; @@ -95,6 +95,7 @@ export type ApiListingDetail = { media: string[]; amenities: string[]; photos: string[]; + virtualTourUrl: string | null; ownerEmail: string | null; status: ApiListingStatus; createdAt: string; @@ -109,7 +110,7 @@ type LiveListingSummary = ApiListingSummary & { const LIVE_LISTINGS_REFRESH_MS = 15000; const PROTECTED_VIEWS = new Set([ - 'add', 'admin', 'account', 'accountPriceAlerts', 'accountMeetings', 'accountListings', 'accountSettings', + 'add', 'admin', 'account', 'accountSearches', 'accountPriceAlerts', 'accountMeetings', 'accountListings', 'accountSettings', 'accountSecurity', 'accountProfileEdit', 'accountPublicProfile', 'accountHelpContact', 'notifications', 'favorites', 'messages', 'comparison', ]); @@ -368,11 +369,31 @@ type FavoritePriceAlertEvent = { title: string; location: string; area: string; + offerType?: ApiOfferType; previousPrice: number; currentPrice: number; changedAt: string; }; +type CityPriceAlertSnapshot = { + dayKey: string; + perM2: number; + updatedAt: string; +}; + +type CityPriceAlertEvent = { + id: string; + subscriptionKey: string; + label: string; + city: string; + region: string; + propertyType: HistoryPropertyType; + marketType: HistoryMarketType; + previousPerM2: number; + currentPerM2: number; + changedAt: string; +}; + type CityPriceAlertSubscription = { key: string; label: string; @@ -383,10 +404,46 @@ type CityPriceAlertSubscription = { createdAt: string; }; +type ListingPriceAlertSubscription = { + listingId: number; + title: string; + location: string; + offerType: ApiOfferType; + createdAt: string; +}; + +type SavedSearchMode = 'buy' | 'rent'; + +type SavedSearchRecord = { + id: string; + mode: SavedSearchMode; + city: string; + propertyType: string; + priceMin: number | null; + priceMax: number | null; + areaMin: number | null; + areaMax: number | null; + rooms: number | null; + wtorny: boolean; + pierwotny: boolean; + noFeeOnly: boolean; + more: Record; + createdAt: string; + updatedAt: string; +}; + const FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX = 'favorite-price-alert-events'; const FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX = 'favorite-price-snapshots'; +const FAVORITE_LISTINGS_STORAGE_SUFFIX = 'favorite-listings'; +const FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX = 'favorites-preferred-tab'; const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions'; +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 CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX = 'city-price-alert-last-run'; +const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches'; const PRICE_ALERT_EVENTS_LIMIT = 40; +const SAVED_SEARCHES_LIMIT = 60; function readUserJsonStorage(user: AuthUser | null, suffix: string, fallback: T): T { if (typeof window === 'undefined') { @@ -429,6 +486,88 @@ function mergePriceAlertEvents( .slice(0, PRICE_ALERT_EVENTS_LIMIT); } +function currentLocalDayKey(date = new Date()): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function cityAlertStorageKey(subscription: CityPriceAlertSubscription): string { + return `${subscription.key}|${subscription.propertyType}|${subscription.marketType}`; +} + +function cityAlertPropertyTypeToApi(propertyType: HistoryPropertyType): ApiPropertyType | null { + if (propertyType === 'Mieszkanie') { + return 'APARTMENT'; + } + if (propertyType === 'Dom') { + return 'HOUSE'; + } + return null; +} + +function cityAlertMarketFilter(marketType: HistoryMarketType): 'wtorny' | 'pierwotny' | null { + if (marketType === 'Wtórny') { + return 'wtorny'; + } + if (marketType === 'Pierwotny') { + return 'pierwotny'; + } + return null; +} + +function calculateCityAlertPerM2(subscription: CityPriceAlertSubscription, listings: ApiListingSummary[]): number { + const targetPropertyType = cityAlertPropertyTypeToApi(subscription.propertyType); + const targetMarket = cityAlertMarketFilter(subscription.marketType); + const cityQuery = subscription.city.trim() || subscription.label.split(',')[0]?.trim() || ''; + + const filtered = listings.filter((item) => { + if (cityQuery && !cityStartsWith(item.city, cityQuery)) { + return false; + } + if (targetPropertyType && item.propertyType !== targetPropertyType) { + return false; + } + if (targetMarket && item.market !== targetMarket) { + return false; + } + return item.area > 0 && item.price > 0; + }); + + if (filtered.length === 0) { + const estimate = estimateHistoryLocationPrice(subscription.label); + const fallback = Math.round( + estimate.price + * HISTORY_PROPERTY_MULTIPLIERS[subscription.propertyType] + * HISTORY_MARKET_MULTIPLIERS[subscription.marketType], + ); + return Math.max(1, fallback); + } + + const averagePerM2 = filtered.reduce((sum, item) => sum + (item.price / item.area), 0) / filtered.length; + return Math.max(1, Math.round(averagePerM2)); +} + +function mergeCityPriceAlertEvents( + incoming: CityPriceAlertEvent[], + existing: CityPriceAlertEvent[], +): CityPriceAlertEvent[] { + if (incoming.length === 0) { + return existing; + } + + const known = new Set(existing.map((item) => item.id)); + const uniqueIncoming = incoming.filter((item) => !known.has(item.id)); + if (uniqueIncoming.length === 0) { + return existing; + } + + return [...uniqueIncoming, ...existing] + .sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime()) + .slice(0, PRICE_ALERT_EVENTS_LIMIT); +} + const favoriteListings: FavoriteListing[] = []; function favoriteListingKey(listingId: number): string { @@ -1833,8 +1972,101 @@ const INITIAL_EXTRA_INFO = { windows: 'Wybierz', exposure: 'Wybierz', roomHeight: '', + virtualTourUrl: '', }; +type VirtualTourEmbedInfo = { + embedUrl: string; + providerLabel: string; +}; + +function normalizeVirtualTourInput(rawValue: string): string { + const trimmed = rawValue.trim(); + if (!trimmed) { + throw new Error('Wklej pełny link do wirtualnego spaceru.'); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error('Podaj poprawny adres URL spaceru (http:// lub https://).'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Link do spaceru musi zaczynać się od http:// lub https://.'); + } + + return parsed.toString(); +} + +function getVirtualTourEmbed(url: string): VirtualTourEmbedInfo | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + + const host = parsed.hostname.toLowerCase(); + const pathParts = parsed.pathname.split('/').filter(Boolean); + + if (host === 'youtu.be' || host.endsWith('.youtu.be')) { + const id = pathParts[0]; + if (id) { + return { + embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(id)}`, + providerLabel: 'YouTube', + }; + } + } + + if (host === 'youtube.com' || host.endsWith('.youtube.com')) { + const videoId = parsed.searchParams.get('v') + || (pathParts[0] === 'embed' ? pathParts[1] : null) + || (pathParts[0] === 'shorts' ? pathParts[1] : null); + if (videoId) { + return { + embedUrl: `https://www.youtube.com/embed/${encodeURIComponent(videoId)}`, + providerLabel: 'YouTube', + }; + } + } + + if (host.endsWith('matterport.com')) { + const modelId = parsed.searchParams.get('m'); + if (modelId) { + return { + embedUrl: `https://my.matterport.com/show/?m=${encodeURIComponent(modelId)}`, + providerLabel: 'Matterport', + }; + } + if (pathParts[0] === 'show') { + return { + embedUrl: parsed.toString(), + providerLabel: 'Matterport', + }; + } + } + + if (host.endsWith('kuula.co')) { + if (pathParts[0] === 'post' && pathParts[1]) { + return { + embedUrl: `https://kuula.co/share/${encodeURIComponent(pathParts[1])}?fs=1&vr=1&thumbs=1&info=1&logo=1`, + providerLabel: 'Kuula', + }; + } + if (pathParts[0] === 'share' && pathParts[1]) { + return { + embedUrl: parsed.toString(), + providerLabel: 'Kuula', + }; + } + } + + return null; +} + const EDIT_LISTING_DRAFT_STORAGE_KEY = 'polskalokalnie-edit-listing-draft'; const addFooterBenefits = [ @@ -2390,6 +2622,7 @@ function App() { const [messageThreadTarget, setMessageThreadTarget] = useState(null); const [favoriteListingsState, setFavoriteListingsState] = useState(favoriteListings); const [favoritesPreferredTab, setFavoritesPreferredTab] = useState('buy'); + const [listingPriceAlertSubscriptionsState, setListingPriceAlertSubscriptionsState] = useState([]); const [unreadFavoriteIds, setUnreadFavoriteIds] = useState>(new Set()); const hydratedFavoriteIdsRef = useRef>(new Set()); const favoriteListingIdList = useMemo( @@ -2397,12 +2630,23 @@ function App() { [favoriteListingsState], ); const favoriteListingIdsSignature = useMemo(() => favoriteListingIdList.join(','), [favoriteListingIdList]); + const listingPriceAlertIdList = useMemo( + () => Array.from(new Set(listingPriceAlertSubscriptionsState.map((item) => item.listingId))).sort((left, right) => left - right), + [listingPriceAlertSubscriptionsState], + ); + const listingPriceAlertIdsSignature = useMemo(() => listingPriceAlertIdList.join(','), [listingPriceAlertIdList]); + const monitoredListingIdList = useMemo( + () => Array.from(new Set([...favoriteListingIdList, ...listingPriceAlertIdList])).sort((left, right) => left - right), + [favoriteListingIdList, listingPriceAlertIdList], + ); + const monitoredListingIdsSignature = useMemo(() => monitoredListingIdList.join(','), [monitoredListingIdList]); const translatedOriginalTextRef = useRef>(new Map()); const translatedOriginalAttrRef = useRef>>(new Map()); const translationObserverRef = useRef(null); const translationTimerRef = useRef(null); const translationInFlightRef = useRef(false); const filters = useFilterState(); + const [savedSearchesState, setSavedSearchesState] = useState([]); const clearLoginHash = () => { if (window.location.hash === '#login') { @@ -2452,6 +2696,125 @@ function App() { return () => window.removeEventListener('hashchange', onHashChange); }, []); + useEffect(() => { + if (!user) { + setSavedSearchesState([]); + return; + } + setSavedSearchesState(readUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, [])); + }, [user]); + + useEffect(() => { + setFavoriteListingsState(readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, [])); + setFavoritesPreferredTab(readUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, 'buy')); + setListingPriceAlertSubscriptionsState(readUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, [])); + }, [user]); + + useEffect(() => { + if (!user) { + return; + } + writeUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, savedSearchesState); + }, [savedSearchesState, user]); + + useEffect(() => { + writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState); + }, [favoriteListingsState, user]); + + useEffect(() => { + writeUserJsonStorage(user, FAVORITES_PREFERRED_TAB_STORAGE_SUFFIX, favoritesPreferredTab); + }, [favoritesPreferredTab, user]); + + useEffect(() => { + writeUserJsonStorage(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, listingPriceAlertSubscriptionsState); + }, [listingPriceAlertSubscriptionsState, user]); + + useEffect(() => { + if (!user) { + return; + } + + let cancelled = false; + let running = false; + + const runDailyCityAlertsUpdate = async () => { + if (cancelled || running) { + return; + } + + const todayKey = currentLocalDayKey(); + const lastRunKey = readUserJsonStorage(user, CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX, null); + if (lastRunKey === todayKey) { + return; + } + + running = true; + try { + const subscriptions = readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []); + if (subscriptions.length === 0) { + writeUserJsonStorage(user, CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX, todayKey); + return; + } + + const saleListings = await apiFetch('/listings?offerType=SALE'); + const previousSnapshots = readUserJsonStorage>(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, {}); + const previousEvents = readUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, []); + + const nowIso = new Date().toISOString(); + const nextSnapshots: Record = { ...previousSnapshots }; + const incomingEvents: CityPriceAlertEvent[] = []; + + subscriptions.forEach((subscription) => { + const storageKey = cityAlertStorageKey(subscription); + const currentPerM2 = calculateCityAlertPerM2(subscription, saleListings); + const previousPerM2 = previousSnapshots[storageKey]?.perM2; + + if (Number.isFinite(previousPerM2) && previousPerM2 !== currentPerM2) { + incomingEvents.push({ + id: `${storageKey}:${todayKey}:${previousPerM2}->${currentPerM2}`, + subscriptionKey: subscription.key, + label: subscription.label, + city: subscription.city, + region: subscription.region, + propertyType: subscription.propertyType, + marketType: subscription.marketType, + previousPerM2, + currentPerM2, + changedAt: nowIso, + }); + } + + nextSnapshots[storageKey] = { + dayKey: todayKey, + perM2: currentPerM2, + updatedAt: nowIso, + }; + }); + + writeUserJsonStorage(user, CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX, nextSnapshots); + if (incomingEvents.length > 0) { + const mergedEvents = mergeCityPriceAlertEvents(incomingEvents, previousEvents); + writeUserJsonStorage(user, CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX, mergedEvents); + } + writeUserJsonStorage(user, CITY_PRICE_ALERT_LAST_RUN_STORAGE_SUFFIX, todayKey); + } catch { + // Keep silent; updater retries on next interval. + } finally { + running = false; + } + }; + + void runDailyCityAlertsUpdate(); + const intervalId = window.setInterval(() => { + void runDailyCityAlertsUpdate(); + }, 60000); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [user]); + useEffect(() => { if (view === 'favorites') { setUnreadFavoriteIds(new Set()); @@ -2675,6 +3038,41 @@ function App() { navigate('messages'); }; + const toggleSavedSearch = (mode: SavedSearchMode) => { + if (!user) { + navigate('login'); + return; + } + + const draft = buildSavedSearchDraft(mode, filters); + setSavedSearchesState((current) => { + const existing = current.find((item) => sameSavedSearchCriteria(item, draft)); + if (existing) { + return current.filter((item) => item.id !== existing.id); + } + const nowIso = new Date().toISOString(); + const next: SavedSearchRecord = { + id: `saved-search-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + createdAt: nowIso, + updatedAt: nowIso, + ...draft, + }; + return [next, ...current].slice(0, SAVED_SEARCHES_LIMIT); + }); + }; + + const openSavedSearch = (savedSearch: SavedSearchRecord) => { + applySavedSearchToFilters(filters, savedSearch); + navigate(savedSearch.mode === 'rent' ? 'rent' : 'buy'); + }; + + const removeSavedSearch = (savedSearchId: string) => { + setSavedSearchesState((current) => current.filter((item) => item.id !== savedSearchId)); + }; + + const buySearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('buy', filters))); + const rentSearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('rent', filters))); + const isListingFavorite = (listingId: number) => ( favoriteListingsState.some((item) => item.id === favoriteListingKey(listingId)) ); @@ -2704,6 +3102,28 @@ function App() { }); }; + const isListingPriceAlertEnabled = (listingId: number) => ( + listingPriceAlertSubscriptionsState.some((item) => item.listingId === listingId) + ); + + const toggleListingPriceAlert = (listing: ApiListingDetail) => { + const listingLocation = [listing.city, listing.district].filter(Boolean).join(', '); + setListingPriceAlertSubscriptionsState((current) => { + const exists = current.some((item) => item.listingId === listing.id); + if (exists) { + return current.filter((item) => item.listingId !== listing.id); + } + const next: ListingPriceAlertSubscription = { + listingId: listing.id, + title: listing.title, + location: listingLocation || listing.city, + offerType: listing.offerType, + createdAt: new Date().toISOString(), + }; + return [next, ...current]; + }); + }; + const toggleSummaryFavorite = async (listing: ApiListingSummary) => { const listingKey = favoriteListingKey(listing.id); const exists = favoriteListingsState.some((item) => item.id === listingKey); @@ -2808,16 +3228,17 @@ function App() { }, [favoriteListingsState]); useEffect(() => { - if (!user || favoriteListingIdList.length === 0) { + if (!user || monitoredListingIdList.length === 0) { return; } let cancelled = false; let snapshots = readUserJsonStorage>(user, FAVORITE_PRICE_SNAPSHOTS_STORAGE_SUFFIX, {}); + const alertListingIdSet = new Set(listingPriceAlertIdList); const syncFavoritePrices = async () => { const refreshed = await Promise.all( - favoriteListingIdList.map(async (listingId) => { + monitoredListingIdList.map(async (listingId) => { try { return await apiFetch(`/listings/${listingId}`); } catch { @@ -2844,7 +3265,7 @@ function App() { const previousPrice = nextSnapshots[listingIdKey]; const currentPrice = Number(listing.price); if (Number.isFinite(currentPrice)) { - if (Number.isFinite(previousPrice) && previousPrice !== currentPrice) { + if (alertListingIdSet.has(listing.id) && Number.isFinite(previousPrice) && previousPrice !== currentPrice) { const location = [listing.city, listing.district].filter(Boolean).join(', '); incomingEvents.push({ id: `${listing.id}:${previousPrice}->${currentPrice}`, @@ -2852,6 +3273,7 @@ function App() { title: listing.title, location: location || listing.city, area: `${`${listing.area}`.replace('.', ',')} m²`, + offerType: listing.offerType, previousPrice, currentPrice, changedAt: nowIso, @@ -2897,7 +3319,7 @@ function App() { cancelled = true; window.clearInterval(intervalId); }; - }, [user, favoriteListingIdList, favoriteListingIdsSignature]); + }, [user, monitoredListingIdList, monitoredListingIdsSignature, listingPriceAlertIdList, listingPriceAlertIdsSignature]); return (
@@ -2905,7 +3327,16 @@ function App() {
{view === 'login' && } {view === 'admin' && } - {view === 'account' && } + {view === 'account' && } + {view === 'accountSearches' && ( + + )} {view === 'accountPriceAlerts' && } {view === 'comparison' && } {view === 'accountMeetings' && } @@ -2914,7 +3345,7 @@ function App() { {view === 'accountSecurity' && } {view === 'accountProfileEdit' && } {view === 'accountPublicProfile' && } - {view === 'accountHelpContact' && } + {view === 'accountHelpContact' && } {view === 'notifications' && } {view === 'messages' && ( toggleSavedSearch('buy')} /> )} {view === 'rent' && ( @@ -2951,6 +3384,8 @@ function App() { onOpenListing={openListing} isFavorite={isListingFavorite} onToggleFavorite={toggleSummaryFavorite} + savedSearchActive={rentSearchSaved} + onToggleSavedSearch={() => toggleSavedSearch('rent')} /> )} {view === 'sell' && } @@ -2973,6 +3408,8 @@ function App() { onStartChat={openMessagesWithTarget} isFavorite={isListingFavorite} onToggleFavorite={toggleListingFavorite} + isListingAlertEnabled={isListingPriceAlertEnabled} + onToggleListingAlert={toggleListingPriceAlert} /> )} {view === 'services' && } @@ -3030,6 +3467,48 @@ type ForbiddenWord = { word: string; }; +type AdminResetPasswordResponse = { + temporaryPassword: string; +}; + +type AdminStatsResponse = { + periodDays: number; + generatedAt: string; + summary: { + activeListings: number; + pendingListings: number; + totalUsers: number; + pendingVerificationUsers: number; + openReports: number; + messagesToAdminTotal: number; + blockedUsers: number; + }; + period: { + messagesToAdmin: number; + newListings: number; + newReports: number; + newPersonalAccounts: number; + newCompanyAccounts: number; + }; + daily: { + date: string; + messagesToAdmin: number; + newListings: number; + newReports: number; + newPersonalAccounts: number; + newCompanyAccounts: number; + }[]; +}; + +const ADMIN_STATS_PERIOD_OPTIONS = [ + { days: 1, label: 'Dziś' }, + { days: 7, label: '7 dni' }, + { days: 14, label: '14 dni' }, + { days: 30, label: '30 dni' }, +] as const; + +type AdminStatsPeriodMode = 'preset' | 'custom'; + const ADMIN_STATUS_LABELS: Record = { PENDING: 'Oczekuje', APPROVED: 'Zatwierdzone', @@ -3058,6 +3537,13 @@ function formatAdminDate(iso: string): string { } return date.toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric' }); } +function toDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} +type AdminStatsQuickRange = '' | 'today' | 'last7' | 'last14' | 'last30'; function accountStorageKey(suffix: string, user: AuthUser | null): string { return `accountProfile:${user?.id ?? 'guest'}:${suffix}`; @@ -3121,6 +3607,13 @@ type AdminMessage = { mine: boolean; }; +type AdminMessageThread = { + user: AuthUser; + lastMessage: AdminMessage; + totalMessages: number; + awaitingAdminReply: number; +}; + function AdminMessageModal({ user, onClose }: { user: AuthUser; onClose: () => void }) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); @@ -3248,6 +3741,17 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { const [messageUser, setMessageUser] = useState(null); const [previewListingId, setPreviewListingId] = useState(null); const [detailsReport, setDetailsReport] = useState(null); + const [messageThreads, setMessageThreads] = useState([]); + const [messageThreadsLoading, setMessageThreadsLoading] = useState(false); + const [messageThreadsError, setMessageThreadsError] = useState(null); + const [statsPeriodDays, setStatsPeriodDays] = useState(1); + const [statsPeriodMode, setStatsPeriodMode] = useState('preset'); + const [statsCustomFrom, setStatsCustomFrom] = useState(''); + const [statsCustomTo, setStatsCustomTo] = useState(''); + const [statsQuickRange, setStatsQuickRange] = useState(''); + const [adminStats, setAdminStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [statsError, setStatsError] = useState(null); const loadData = useCallback(async () => { setLoading(true); @@ -3297,6 +3801,127 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { } }, []); + const loadMessageThreads = useCallback(async (silent = false) => { + const regularUsers = users.filter((item) => item.role !== 'ADMIN'); + if (regularUsers.length === 0) { + setMessageThreads([]); + setMessageThreadsError(null); + setMessageThreadsLoading(false); + return; + } + + if (!silent) { + setMessageThreadsLoading(true); + } + setMessageThreadsError(null); + + try { + const conversations = await Promise.allSettled( + regularUsers.map((item) => apiFetch(`/admin/users/${item.id}/messages`)), + ); + + const mappedThreads: AdminMessageThread[] = []; + + conversations.forEach((result, index) => { + if (result.status !== 'fulfilled') { + return; + } + + const conversation = result.value; + if (conversation.length === 0) { + return; + } + + const lastMessage = conversation[conversation.length - 1]; + let awaitingAdminReply = 0; + for (let cursor = conversation.length - 1; cursor >= 0; cursor -= 1) { + if (conversation[cursor].mine) { + break; + } + awaitingAdminReply += 1; + } + + mappedThreads.push({ + user: regularUsers[index], + lastMessage, + totalMessages: conversation.length, + awaitingAdminReply, + }); + }); + + mappedThreads.sort((left, right) => new Date(right.lastMessage.createdAt).getTime() - new Date(left.lastMessage.createdAt).getTime()); + setMessageThreads(mappedThreads); + + if (conversations.every((entry) => entry.status === 'rejected')) { + setMessageThreadsError('Nie udało się pobrać wiadomości użytkowników.'); + } + } catch (err) { + setMessageThreadsError(err instanceof Error ? err.message : 'Nie udało się pobrać wiadomości użytkowników.'); + } finally { + if (!silent) { + setMessageThreadsLoading(false); + } + } + }, [users]); + + useEffect(() => { + if (tab !== 'messages' || loading) { + return; + } + + loadMessageThreads(); + const intervalId = window.setInterval(() => { + void loadMessageThreads(true); + }, 5000); + + return () => window.clearInterval(intervalId); + }, [tab, loading, loadMessageThreads]); + + const loadStats = useCallback(async (silent = false) => { + if (!silent) { + setStatsLoading(true); + } + setStatsError(null); + try { + const query = new URLSearchParams(); + if (statsPeriodMode === 'custom') { + if (statsCustomFrom) { + query.set('from', statsCustomFrom); + } + if (statsCustomTo) { + query.set('to', statsCustomTo); + } + if (!statsCustomFrom && !statsCustomTo) { + query.set('days', String(statsPeriodDays)); + } + } else { + query.set('days', String(statsPeriodDays)); + } + + const data = await apiFetch(`/admin/stats?${query.toString()}`); + setAdminStats(data); + } catch (err) { + setStatsError(err instanceof Error ? err.message : 'Nie udało się pobrać statystyk.'); + } finally { + if (!silent) { + setStatsLoading(false); + } + } + }, [statsPeriodDays, statsPeriodMode, statsCustomFrom, statsCustomTo]); + + useEffect(() => { + if (tab !== 'stats' || loading) { + return; + } + + loadStats(); + const intervalId = window.setInterval(() => { + void loadStats(true); + }, 10000); + + return () => window.clearInterval(intervalId); + }, [tab, loading, loadStats]); + 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; @@ -3323,6 +3948,37 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { ); const activeMeta = ADMIN_TAB_META[tab]; + const formattedGeneratedAt = adminStats + ? new Date(adminStats.generatedAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : null; + const applyQuickStatsRange = useCallback((range: AdminStatsQuickRange) => { + if (!range) { + return; + } + const today = new Date(); + const to = new Date(today); + const from = new Date(today); + if (range === 'last7') { + from.setDate(from.getDate() - 6); + } else if (range === 'last14') { + from.setDate(from.getDate() - 13); + } else if (range === 'last30') { + from.setDate(from.getDate() - 29); + } + + setStatsPeriodMode('custom'); + setStatsCustomFrom(toDateInputValue(from)); + setStatsCustomTo(toDateInputValue(to)); + setStatsQuickRange(range); + }, []); + const statsHeadingLabel = useMemo(() => { + if (!adminStats) { + return statsPeriodMode === 'custom' ? 'Wybrany okres' : (statsPeriodDays === 1 ? 'Dziś' : `Ostatnie ${statsPeriodDays} dni`); + } + return statsPeriodMode === 'custom' + ? 'Wybrany okres' + : (adminStats.periodDays === 1 ? 'Dziś' : `Ostatnie ${adminStats.periodDays} dni`); + }, [adminStats, statsPeriodDays, statsPeriodMode]); const handleAddForbiddenWord = async () => { const word = newForbiddenWord.trim(); @@ -3378,6 +4034,41 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { }) : users; + const handleGrantAdmin = async (item: AuthUser) => { + const shouldGrantAdmin = window.confirm(`Czy na pewno chcesz nadać rolę administratora użytkownikowi ${item.fullName || item.email}?`); + if (!shouldGrantAdmin) { + return; + } + await runAction(`grant-admin-${item.id}`, () => apiFetch(`/admin/users/${item.id}/grant-admin`, { method: 'POST' })); + }; + + const handleRevokeAdmin = async (item: AuthUser) => { + const shouldRevokeAdmin = window.confirm(`Czy na pewno chcesz odebrać rolę administratora użytkownikowi ${item.fullName || item.email}?`); + if (!shouldRevokeAdmin) { + return; + } + await runAction(`revoke-admin-${item.id}`, () => apiFetch(`/admin/users/${item.id}/revoke-admin`, { method: 'POST' })); + }; + + const handleResetPassword = async (item: AuthUser) => { + const shouldResetPassword = window.confirm(`Czy zresetować hasło użytkownika ${item.fullName || item.email}?`); + if (!shouldResetPassword) { + return; + } + + setBusyId(`reset-password-${item.id}`); + setError(null); + try { + const response = await apiFetch(`/admin/users/${item.id}/reset-password`, { method: 'POST' }); + window.alert(`Nowe hasło tymczasowe dla ${item.email}: ${response.temporaryPassword}`); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Nie udało się zresetować hasła.'); + } finally { + setBusyId(null); + } + }; + return (
@@ -3771,6 +4462,7 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { Dane Status Akcje + Konto
{filteredUsers.map((item) => (
@@ -3804,28 +4496,60 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { {item.blocked ? Zablokowany : Aktywny} {item.role === 'ADMIN' ? ( - Konto administratora + user?.id === item.id ? ( + Twoje konto administratora + ) : ( + + ) ) : ( - <> - + )} + + + {item.role === 'ADMIN' ? ( + + ) : ( +
+ - {item.blocked ? ( - - ) : ( - - )} - - + +
)}
@@ -3935,9 +4659,204 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { /> )} - {!loading && tab === 'messages' && } + {!loading && tab === 'messages' && ( +
+
+

Wiadomości z formularza „Napisz do nas”

+ +
+ + {messageThreadsError &&

{messageThreadsError}

} + + {messageThreadsLoading ? ( +

Wczytywanie wiadomości...

+ ) : messageThreads.length === 0 ? ( +

Brak wiadomości od użytkowników. Gdy ktoś wyśle formularz „Napisz do nas”, pojawi się tutaj.

+ ) : ( +
+
+ Użytkownik + Ostatnia wiadomość + Aktywność + Akcje +
+ {messageThreads.map((thread) => ( +
+ + {thread.user.fullName || thread.user.email} + {thread.user.email} + + + {thread.lastMessage.mine ? 'Administrator' : 'Użytkownik'} + {thread.lastMessage.content} + + + {new Date(thread.lastMessage.createdAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} + {thread.awaitingAdminReply > 0 ? `Nowe od użytkownika: ${thread.awaitingAdminReply}` : `Wiadomości: ${thread.totalMessages}`} + + + + +
+ ))} +
+ )} +
+ )} {!loading && tab === 'payments' && } - {!loading && tab === 'stats' && } + {!loading && tab === 'stats' && ( +
+
+
+ +
+ {adminStats?.summary.activeListings ?? 0} + Aktywne ogłoszenia +
+
+
+ +
+ {adminStats?.summary.pendingListings ?? 0} + Oczekujące ogłoszenia +
+
+
+ +
+ + +
+
+

{statsHeadingLabel}

+
+ + {statsError &&

{statsError}

} + {statsLoading &&

Wczytywanie statystyk...

} + + {adminStats && ( + <> +
+

Wiadomości do admina

{adminStats.period.messagesToAdmin}
+

Dodane ogłoszenia

{adminStats.period.newListings}
+

Nowe zgłoszenia

{adminStats.period.newReports}
+

Rejestracje - osoby prywatne

{adminStats.period.newPersonalAccounts}
+

Rejestracje - firmy

{adminStats.period.newCompanyAccounts}
+

Użytkownicy oczekujący weryfikacji

{adminStats.summary.pendingVerificationUsers}
+
+ +
+
+

Statystyki dzienne

+ {formattedGeneratedAt && Ostatnia aktualizacja: {formattedGeneratedAt}} +
+
+
+ Dzień + Wiadomości + Nowe ogłoszenia + Nowe zgłoszenia + Konta prywatne + Konta firmowe +
+ {adminStats.daily.map((item) => ( +
+ {new Date(`${item.date}T00:00:00`).toLocaleDateString('pl-PL')} + {item.messagesToAdmin} + {item.newListings} + {item.newReports} + {item.newPersonalAccounts} + {item.newCompanyAccounts} +
+ ))} +
+
+ + )} +
+
+
+ )} {!loading && tab === 'settings' && } {!loading && tab === 'moderation' && } {!loading && tab === 'forbiddenWords' && ( @@ -4415,6 +5334,8 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) = const isSecurityView = activeView === 'accountSecurity'; const isHelpContactView = activeView === 'accountHelpContact'; const isDashboardView = activeView === 'account'; + const isSearchesView = activeView === 'accountSearches'; + const isFavoritesView = activeView === 'favorites'; const isPriceAlertsView = activeView === 'accountPriceAlerts'; const isMeetingsView = activeView === 'accountMeetings'; const isListingsView = activeView === 'accountListings'; @@ -4423,6 +5344,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) = const isValuationView = activeView === 'valuation'; const isDistrictRankingView = activeView === 'districtRanking'; const isComparisonView = activeView === 'comparison'; + const [messagesUnreadCount, setMessagesUnreadCount] = useState(0); const allListingsCount = (() => { if (typeof window === 'undefined') { return 0; @@ -4441,6 +5363,45 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) = return 0; } })(); + const savedSearchCount = user + ? readUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, []).length + : 0; + const favoritesCount = user + ? readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length + : 0; + const priceAlertsCount = user + ? readUserJsonStorage(user, CITY_PRICE_ALERTS_STORAGE_SUFFIX, []).length + : 0; + + useEffect(() => { + if (!user || user.role === 'ADMIN') { + setMessagesUnreadCount(0); + return; + } + + let cancelled = false; + const loadUnread = () => { + apiFetch<{ count: number }>('/messages/unread-count') + .then((data) => { + if (!cancelled) { + setMessagesUnreadCount(data.count); + } + }) + .catch(() => { + if (!cancelled) { + setMessagesUnreadCount(0); + } + }); + }; + + loadUnread(); + const intervalId = window.setInterval(loadUnread, 25000); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [user]); return (

{channel.description}

- @@ -5085,22 +6387,47 @@ function AccountHelpContactPage({ onNavigate, activeView }: { onNavigate: (view: -
+

Napisz do nas

-
+
- - + handleContactFieldChange('fullName', event.target.value)} + /> + handleContactFieldChange('email', event.target.value)} + />
- handleContactFieldChange('topic', event.target.value)} + > -