From 12fcb60b4c178a53a790fc6e88157416333bdd6f Mon Sep 17 00:00:00 2001 From: pascal Date: Tue, 21 Jul 2026 17:02:25 +0200 Subject: [PATCH] =?UTF-8?q?Kalkulator=20zdolno=C5=9Bci,=20integracja=20Moi?= =?UTF-8?q?ch=20ofert=20i=20system=20powiadomie=C5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kalkulator zdolności kredytowej: nowy widok z live-obliczeniami (rata annuitetowa/malejąca, DTI/DSTI, rekomendacje, donut), wpięty w poradnik, panel i stronę główną; pasek boczny konta dla zalogowanych. - Moje oferty: prawdziwe ogłoszenia (/listings/mine) scalone z listą, realne akcje Wstrzymaj/Wznów (PATCH /status, nowy status PAUSED), Usuń (DELETE) z kontrolą właściciela; realne wyświetlenia (viewsCount) i zapisane z ulubionych. Fix CHECK constraintu enuma w SchemaFixer. - System powiadomień: encja Notification + serwis + kontroler + SSE realtime (token w query param w JwtAuthFilter). Podpięte źródła serwerowe (wiadomości, zmiana statusu ogłoszenia, zdarzenia konta) i klienckie (ulubione, spadek ceny, spotkania, weryfikacja telefonu). Centralny NotificationsProvider; dzwonek i strona bez zmian designu, klikalne z deep-linkami i oznaczaniem jako przeczytane. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../polskalokalnie/admin/AdminController.java | 24 +- .../pl/polskalokalnie/auth/JwtAuthFilter.java | 18 +- .../pl/polskalokalnie/config/SchemaFixer.java | 10 + .../listing/ListingController.java | 18 + .../listing/ListingResponse.java | 2 + .../listing/ListingService.java | 66 +- .../polskalokalnie/listing/ListingStatus.java | 3 +- .../message/MessageService.java | 35 +- .../CreateNotificationRequest.java | 19 + .../notification/Notification.java | 128 +++ .../notification/NotificationController.java | 71 ++ .../notification/NotificationRepository.java | 20 + .../notification/NotificationResponse.java | 27 + .../notification/NotificationService.java | 143 +++ frontend/src/App.tsx | 857 +++++++++++++++--- frontend/src/main.tsx | 5 +- frontend/src/notifications.tsx | 178 ++++ frontend/src/styles.css | 802 ++++++++++++++++ 18 files changed, 2314 insertions(+), 112 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/Notification.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java create mode 100644 backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java create mode 100644 frontend/src/notifications.tsx diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index 2967327..ee2b6d6 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -27,6 +27,7 @@ import pl.polskalokalnie.listing.ListingStatus; import pl.polskalokalnie.message.MessageResponse; import pl.polskalokalnie.message.MessageService; import pl.polskalokalnie.message.SendMessageRequest; +import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.report.ListingReportResponse; import pl.polskalokalnie.report.ListingReportService; import pl.polskalokalnie.report.ResolveListingReportRequest; @@ -52,13 +53,15 @@ public class AdminController { private final TextModerationService textModerationService; private final AdminStatsService adminStatsService; private final PasswordEncoder passwordEncoder; + private final NotificationService notificationService; public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, ListingService listingService, ListingReportService listingReportService, MessageService messageService, TextModerationService textModerationService, AdminStatsService adminStatsService, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + NotificationService notificationService) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.listingService = listingService; @@ -67,6 +70,7 @@ public class AdminController { this.textModerationService = textModerationService; this.adminStatsService = adminStatsService; this.passwordEncoder = passwordEncoder; + this.notificationService = notificationService; } // --- Uzytkownicy --- @@ -93,7 +97,12 @@ public class AdminController { public UserResponse verify(@PathVariable Long id) { AppUser user = requireUser(id); user.setVerified(true); - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + notificationService.create(saved.getEmail(), "system", "check", + "Konto zweryfikowane", + "Twoje konto zostało zweryfikowane przez administratora. Masz dostęp do wszystkich funkcji serwisu.", + "accountSecurity", "account-verified"); + return UserResponse.from(saved); } @PostMapping("/users/{id}/grant-admin") @@ -104,7 +113,12 @@ public class AdminController { } user.setRole(Role.ADMIN); user.setVerified(true); - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + notificationService.create(saved.getEmail(), "system", "shield", + "Nadano uprawnienia administratora", + "Twoje konto otrzymało rolę administratora.", + "account", null); + return UserResponse.from(saved); } @PostMapping("/users/{id}/revoke-admin") @@ -137,6 +151,10 @@ public class AdminController { String temporaryPassword = generateTemporaryPassword(); user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); userRepository.save(user); + notificationService.create(user.getEmail(), "system", "lock", + "Hasło zostało zresetowane", + "Administrator zresetował hasło do Twojego konta. Zaloguj się nowym hasłem i ustaw własne w ustawieniach bezpieczeństwa.", + "accountSecurity", null); return new ResetPasswordResponse(temporaryPassword); } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java index 6bb9446..28a847f 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java @@ -30,9 +30,8 @@ public class JwtAuthFilter extends OncePerRequestFilter { @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { - String header = request.getHeader("Authorization"); - if (header != null && header.startsWith("Bearer ")) { - String token = header.substring(7); + String token = resolveToken(request); + if (token != null) { try { Claims claims = jwtService.parse(token); String email = claims.getSubject(); @@ -48,4 +47,17 @@ public class JwtAuthFilter extends OncePerRequestFilter { } filterChain.doFilter(request, response); } + + // Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token. + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + String param = request.getParameter("access_token"); + if (param != null && !param.isBlank()) { + return param; + } + return null; + } } diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index 705aaa7..e88672b 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -48,6 +48,16 @@ public class SchemaFixer { "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT" ); + // 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( + "ALTER TABLE property_listings DROP CONSTRAINT IF EXISTS property_listings_status_check" + ); + jdbcTemplate.execute( + "ALTER TABLE property_listings ADD CONSTRAINT property_listings_status_check " + + "CHECK (status IN ('PENDING','APPROVED','REJECTED','PAUSED'))" + ); + String dataUrlType = jdbcTemplate.query( "SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'", rs -> rs.next() ? rs.getString(1) : null diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 26c8a4e..020ca46 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -5,7 +5,9 @@ import java.net.URI; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -55,4 +57,20 @@ public class ListingController { .created(URI.create("/api/listings/" + response.id())) .body(response); } + + // Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED). + @PatchMapping("/{id}/status") + public ListingResponse updateStatus( + @PathVariable Long id, + @RequestParam ListingStatus status, + Authentication authentication + ) { + return listingService.setOwnStatus(id, authentication.getName(), status); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, Authentication authentication) { + listingService.deleteOwn(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java index 7b30a4d..5da8292 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -26,6 +26,7 @@ public record ListingResponse( String coverPhoto, String ownerEmail, ListingStatus status, + Long viewsCount, Instant createdAt ) { public static ListingResponse from(PropertyListing listing) { @@ -48,6 +49,7 @@ public record ListingResponse( listing.getCoverPhoto(), listing.getOwnerEmail(), listing.getStatus(), + listing.getViewsCount(), 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 a8c740f..25b58ee 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.notification.NotificationService; @Service public class ListingService { @@ -18,10 +19,13 @@ public class ListingService { private final ListingRepository listingRepository; private final TextModerationService textModerationService; + private final NotificationService notificationService; - public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) { + public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, + NotificationService notificationService) { this.listingRepository = listingRepository; this.textModerationService = textModerationService; + this.notificationService = notificationService; } public List search(String city, OfferType offerType, PropertyType propertyType) { @@ -56,6 +60,39 @@ public class ListingService { .toList(); } + // --- Operacje wlasciciela na wlasnym ogloszeniu --- + + // Wstrzymanie/wznowienie widocznosci wlasnego ogloszenia (tylko miedzy APPROVED i PAUSED). + @Transactional + public ListingResponse setOwnStatus(Long id, String ownerEmail, ListingStatus status) { + if (status != ListingStatus.APPROVED && status != ListingStatus.PAUSED) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieobslugiwana zmiana statusu"); + } + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + requireOwner(listing, ownerEmail); + if (listing.getStatus() != ListingStatus.APPROVED && listing.getStatus() != ListingStatus.PAUSED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic"); + } + listing.setStatus(status); + return ListingResponse.from(listingRepository.save(listing)); + } + + @Transactional + public void deleteOwn(Long id, String ownerEmail) { + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + requireOwner(listing, ownerEmail); + listingRepository.delete(listing); + } + + private void requireOwner(PropertyListing listing, String ownerEmail) { + if (ownerEmail == null || listing.getOwnerEmail() == null + || !ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak uprawnien do tego ogloszenia"); + } + } + @Transactional public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) { textModerationService.validateOrThrow( @@ -123,7 +160,32 @@ public class ListingService { PropertyListing listing = listingRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); listing.setStatus(status); - return ListingResponse.from(listingRepository.save(listing)); + PropertyListing saved = listingRepository.save(listing); + notifyOwnerStatusChange(saved, status); + return ListingResponse.from(saved); + } + + // Powiadomienie dla wlasciciela po decyzji moderacji (approve/reject). + private void notifyOwnerStatusChange(PropertyListing listing, ListingStatus status) { + if (listing.getOwnerEmail() == null) { + return; + } + String title; + String body; + String icon; + if (status == ListingStatus.APPROVED) { + title = "Ogłoszenie opublikowane"; + body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało zatwierdzone i jest już widoczne w serwisie."; + icon = "check"; + } else if (status == ListingStatus.REJECTED) { + title = "Ogłoszenie odrzucone"; + body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało odrzucone przez moderację."; + icon = "warning"; + } else { + return; + } + notificationService.create(listing.getOwnerEmail(), "listing", icon, title, body, + "listing:" + listing.getId(), "status-" + listing.getId() + "-" + status); } public void delete(Long id) { diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java index 8a0c0d5..5e03db3 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java @@ -3,5 +3,6 @@ package pl.polskalokalnie.listing; public enum ListingStatus { PENDING, APPROVED, - REJECTED + REJECTED, + PAUSED } diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java index 1faefe8..f3de50c 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; 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.Role; import pl.polskalokalnie.user.UserRepository; @@ -15,15 +16,18 @@ public class MessageService { private final MessageRepository messageRepository; private final UserRepository userRepository; private final TextModerationService textModerationService; + private final NotificationService notificationService; public MessageService( MessageRepository messageRepository, UserRepository userRepository, - TextModerationService textModerationService + TextModerationService textModerationService, + NotificationService notificationService ) { this.messageRepository = messageRepository; this.userRepository = userRepository; this.textModerationService = textModerationService; + this.notificationService = notificationService; } public AppUser resolveAdmin() { @@ -46,9 +50,38 @@ public class MessageService { message.setRecipientId(recipientId); message.setContent(content.trim()); Message saved = messageRepository.save(message); + + notifyRecipient(senderId, recipientId, saved.getContent()); + return MessageResponse.from(saved, senderId); } + // Powiadomienie dla odbiorcy kazdej wiadomosci: admin->user ("Wiadomosc od obslugi"), + // user->admin traktujemy jako zapytanie/wiadomosc do obslugi. + private void notifyRecipient(Long senderId, Long recipientId, String content) { + AppUser recipient = userRepository.findById(recipientId).orElse(null); + if (recipient == null) { + return; + } + AppUser sender = userRepository.findById(senderId).orElse(null); + String senderName = sender != null && sender.getFullName() != null && !sender.getFullName().isBlank() + ? sender.getFullName() + : "użytkownika"; + String preview = content.length() > 90 ? content.substring(0, 90) + "…" : content; + + if (recipient.getRole() == Role.ADMIN) { + notificationService.create(recipient.getEmail(), "messages", "message", + "Nowa wiadomość od użytkownika", + senderName + ": " + preview, + "messages", null); + } else { + notificationService.create(recipient.getEmail(), "messages", "mail", + "Wiadomość od obsługi Mieszko", + preview, + "messages:admin", null); + } + } + public long countUnreadFrom(Long recipientId, Long senderId) { return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId); } diff --git a/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java b/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java new file mode 100644 index 0000000..b068e9c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java @@ -0,0 +1,19 @@ +package pl.polskalokalnie.notification; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Zadanie utworzenia powiadomienia dla zalogowanego uzytkownika (zdarzenia liczone po stronie klienta: + * alerty cenowe, spotkania, ulubione, weryfikacja telefonu, dopasowane oferty). + * userEmail NIE jest przyjmowany z ciala - serwer ustawia go z tokenu. + */ +public record CreateNotificationRequest( + @NotBlank @Size(max = 20) String category, + @Size(max = 40) String icon, + @NotBlank @Size(max = 200) String title, + @Size(max = 600) String body, + @Size(max = 200) String link, + @Size(max = 160) String dedupeKey +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/Notification.java b/backend/src/main/java/pl/polskalokalnie/notification/Notification.java new file mode 100644 index 0000000..5c1c028 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/Notification.java @@ -0,0 +1,128 @@ +package pl.polskalokalnie.notification; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notifications_user", columnList = "userEmail"), + @Index(name = "idx_notifications_user_dedupe", columnList = "userEmail,dedupeKey") +}) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userEmail; + + // Kategoria zgodna z frontendem: listing | messages | system. + @Column(nullable = false, length = 20) + private String category; + + @Column(nullable = false, length = 40) + private String icon; + + @Column(nullable = false, length = 200) + private String title; + + @Column(length = 600) + private String body; + + // Deskryptor deep-linku, np. "listing:12", "messages:admin", "priceAlerts", "meetings". + @Column(length = 200) + private String link; + + // Klucz idempotencji - blokuje duplikaty tego samego zdarzenia (np. jeden spadek ceny). + @Column(length = 160) + private String dedupeKey; + + @Column(nullable = false) + private boolean read = false; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); + + public Long getId() { + return id; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public String getDedupeKey() { + return dedupeKey; + } + + public void setDedupeKey(String dedupeKey) { + this.dedupeKey = dedupeKey; + } + + public boolean isRead() { + return read; + } + + public void setRead(boolean read) { + this.read = read; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java new file mode 100644 index 0000000..709ae73 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java @@ -0,0 +1,71 @@ +package pl.polskalokalnie.notification; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +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.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + public NotificationController(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @GetMapping + public List list(Authentication authentication) { + return notificationService.listOwn(authentication.getName()); + } + + @GetMapping("/unread-count") + public UnreadCountResponse unreadCount(Authentication authentication) { + return new UnreadCountResponse(notificationService.unreadCount(authentication.getName())); + } + + @PostMapping("/{id}/read") + public ResponseEntity markRead(@PathVariable Long id, Authentication authentication) { + notificationService.markRead(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/read-all") + public ResponseEntity markAllRead(Authentication authentication) { + notificationService.markAllRead(authentication.getName()); + return ResponseEntity.noContent().build(); + } + + // Zdarzenia liczone po stronie klienta (alerty cenowe, spotkania, ulubione, weryfikacja telefonu, + // dopasowane oferty). Powiadomienie zawsze trafia do zalogowanego uzytkownika (serwer ustawia odbiorce). + @PostMapping + public NotificationResponse create(@Valid @RequestBody CreateNotificationRequest request, Authentication authentication) { + Notification created = notificationService.create( + authentication.getName(), + request.category(), + request.icon(), + request.title(), + request.body(), + request.link(), + request.dedupeKey() + ); + return created == null ? null : NotificationResponse.from(created); + } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(Authentication authentication) { + return notificationService.subscribe(authentication.getName()); + } + + public record UnreadCountResponse(long count) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java new file mode 100644 index 0000000..018a293 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java @@ -0,0 +1,20 @@ +package pl.polskalokalnie.notification; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationRepository extends JpaRepository { + + List findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail); + + long countByUserEmailIgnoreCaseAndReadFalse(String userEmail); + + boolean existsByUserEmailIgnoreCaseAndDedupeKey(String userEmail, String dedupeKey); + + @Modifying + @Query("update Notification n set n.read = true where lower(n.userEmail) = lower(:email) and n.read = false") + int markAllRead(@Param("email") String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java new file mode 100644 index 0000000..03cefc6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java @@ -0,0 +1,27 @@ +package pl.polskalokalnie.notification; + +import java.time.Instant; + +public record NotificationResponse( + Long id, + String category, + String icon, + String title, + String body, + String link, + boolean read, + Instant createdAt +) { + public static NotificationResponse from(Notification n) { + return new NotificationResponse( + n.getId(), + n.getCategory(), + n.getIcon(), + n.getTitle(), + n.getBody(), + n.getLink(), + n.isRead(), + n.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java new file mode 100644 index 0000000..87615b6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java @@ -0,0 +1,143 @@ +package pl.polskalokalnie.notification; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Centralny serwis powiadomien. Zapisuje powiadomienia per uzytkownik i wypycha je w czasie + * rzeczywistym przez SSE. Inne moduly (wiadomosci, ogloszenia, administracja) wolaja {@link #create}. + */ +@Service +public class NotificationService { + + private static final long SSE_TIMEOUT_MS = 30L * 60L * 1000L; + + private final NotificationRepository repository; + + // email uzytkownika -> otwarte polaczenia SSE (moze byc kilka kart/urzadzen). + private final Map> emitters = new ConcurrentHashMap<>(); + + public NotificationService(NotificationRepository repository) { + this.repository = repository; + } + + @Transactional + public Notification create(String userEmail, String category, String icon, String title, + String body, String link, String dedupeKey) { + if (userEmail == null || userEmail.isBlank() || title == null || title.isBlank()) { + return null; + } + if (dedupeKey != null && !dedupeKey.isBlank() + && repository.existsByUserEmailIgnoreCaseAndDedupeKey(userEmail, dedupeKey)) { + return null; + } + Notification notification = new Notification(); + notification.setUserEmail(userEmail); + notification.setCategory(normalizeCategory(category)); + notification.setIcon(icon == null || icon.isBlank() ? defaultIcon(category) : icon); + notification.setTitle(title.trim()); + notification.setBody(body == null ? null : body.trim()); + notification.setLink(link == null || link.isBlank() ? null : link.trim()); + notification.setDedupeKey(dedupeKey == null || dedupeKey.isBlank() ? null : dedupeKey.trim()); + + Notification saved = repository.save(notification); + push(userEmail, NotificationResponse.from(saved)); + return saved; + } + + public List listOwn(String email) { + return repository.findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(email).stream() + .map(NotificationResponse::from) + .toList(); + } + + public long unreadCount(String email) { + return repository.countByUserEmailIgnoreCaseAndReadFalse(email); + } + + @Transactional + public void markRead(Long id, String email) { + Notification notification = repository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Powiadomienie nie istnieje")); + if (email == null || !email.equalsIgnoreCase(notification.getUserEmail())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do tego powiadomienia"); + } + if (!notification.isRead()) { + notification.setRead(true); + repository.save(notification); + } + } + + @Transactional + public void markAllRead(String email) { + repository.markAllRead(email); + } + + // --- SSE --- + + public SseEmitter subscribe(String email) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + CopyOnWriteArrayList list = emitters.computeIfAbsent(email, key -> new CopyOnWriteArrayList<>()); + list.add(emitter); + + emitter.onCompletion(() -> remove(email, emitter)); + emitter.onTimeout(() -> remove(email, emitter)); + emitter.onError(error -> remove(email, emitter)); + + try { + emitter.send(SseEmitter.event().name("ready").data("ok")); + } catch (IOException ex) { + remove(email, emitter); + } + return emitter; + } + + private void push(String email, NotificationResponse payload) { + CopyOnWriteArrayList list = emitters.get(email); + if (list == null) { + return; + } + for (SseEmitter emitter : list) { + try { + emitter.send(SseEmitter.event().name("notification").data(payload)); + } catch (Exception ex) { + remove(email, emitter); + } + } + } + + private void remove(String email, SseEmitter emitter) { + CopyOnWriteArrayList list = emitters.get(email); + if (list != null) { + list.remove(emitter); + if (list.isEmpty()) { + emitters.remove(email, list); + } + } + } + + private String normalizeCategory(String category) { + if ("listing".equals(category) || "messages".equals(category) || "system".equals(category)) { + return category; + } + return "system"; + } + + private String defaultIcon(String category) { + if ("messages".equals(category)) { + return "message"; + } + if ("listing".equals(category)) { + return "house"; + } + return "bell"; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e38c3db..05528d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,8 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as import './styles.css'; import { apiFetch, useAuth } from './auth'; import type { AuthUser, ContactPreference, PreferredLanguage } from './auth'; +import { useNotifications, toDisplayNotifications } from './notifications'; +import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; @@ -9,7 +11,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' | 'guideBuying' | 'map' | 'login' | 'account' | 'accountSearches' | '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' | 'guideBuying' | 'creditCalculator' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; type MessageThreadListing = { listingId: number; @@ -33,7 +35,7 @@ type MessageThreadTarget = { export type ApiOfferType = 'SALE' | 'RENT'; export type ApiPropertyType = 'APARTMENT' | 'HOUSE'; -export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; +export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED'; // Lekka reprezentacja z list (Kupuje/Wynajmuje, moje ogloszenia) - bez pelnej galerii. export type ApiListingSummary = { @@ -55,6 +57,7 @@ export type ApiListingSummary = { coverPhoto: string | null; ownerEmail: string | null; status: ApiListingStatus; + viewsCount: number; createdAt: string; }; @@ -2646,6 +2649,7 @@ function App() { const translationTimerRef = useRef(null); const translationInFlightRef = useRef(false); const filters = useFilterState(); + const notificationsCenter = useNotifications(); const [savedSearchesState, setSavedSearchesState] = useState([]); const clearLoginHash = () => { @@ -2685,6 +2689,17 @@ function App() { clearLoginHash(); }; + // Z kalkulatora zdolności: ustaw maksymalną cenę ofert na obliczony budżet i przejdź do wyszukiwarki. + const handleSearchWithBudget = (maxPrice: number) => { + if (maxPrice > 0) { + filters.setCity(''); + filters.setCityInput(''); + filters.setPriceMin(''); + filters.setPriceMax(String(Math.round(maxPrice))); + } + navigate('buy'); + }; + useEffect(() => { const onHashChange = () => { if (window.location.hash === '#login') { @@ -3038,6 +3053,48 @@ function App() { navigate('messages'); }; + // Klik w powiadomienie: oznacz jako przeczytane (znika z dzwonka, licznik -1) i przejdź do właściwego miejsca. + const handleOpenNotification = (notification: ServerNotification | DisplayNotification) => { + notificationsCenter.markRead(notification.id); + const link = notification.link; + if (!link) { + navigate('notifications'); + return; + } + if (link.startsWith('listing:')) { + const id = Number(link.slice('listing:'.length)); + if (Number.isFinite(id) && id > 0) { + openListing(id); + return; + } + } + if (link.startsWith('messages')) { + navigate('messages'); + return; + } + if (link === 'priceAlerts') { + navigate('accountPriceAlerts'); + return; + } + if (link === 'meetings') { + navigate('accountMeetings'); + return; + } + if (link === 'savedSearches') { + navigate('accountSearches'); + return; + } + if (link === 'favorites') { + navigate('favorites'); + return; + } + if (link === 'accountSecurity' || link === 'account' || link === 'accountListings') { + navigate(link as View); + return; + } + navigate('notifications'); + }; + const toggleSavedSearch = (mode: SavedSearchMode) => { if (!user) { navigate('login'); @@ -3079,6 +3136,17 @@ function App() { const toggleListingFavorite = (listing: ApiListingDetail) => { const listingKey = favoriteListingKey(listing.id); + const alreadyFavorite = favoriteListingsState.some((item) => item.id === listingKey); + if (!alreadyFavorite) { + notificationsCenter.notify({ + category: 'listing', + icon: 'heart', + title: 'Ogłoszenie zapisane', + body: `„${listing.title}” zostało zapisane do ulubionych.`, + link: `listing:${listing.id}`, + dedupeKey: `favorite-${listing.id}`, + }); + } setFavoriteListingsState((current) => { const exists = current.some((item) => item.id === listingKey); if (exists) { @@ -3292,6 +3360,21 @@ function App() { const existingEvents = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []); const merged = mergePriceAlertEvents(incomingEvents, existingEvents); writeUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, merged); + + // Powiadomienie tylko o spadku ceny obserwowanego ogloszenia. + incomingEvents.forEach((event) => { + if (event.currentPrice < event.previousPrice) { + const diff = event.previousPrice - event.currentPrice; + notificationsCenter.notify({ + category: 'listing', + icon: 'coin', + title: 'Zmiana ceny', + body: `Cena „${event.title}” spadła o ${diff.toLocaleString('pl-PL')} zł.`, + link: `listing:${event.listingId}`, + dedupeKey: `price-${event.listingId}-${event.currentPrice}`, + }); + } + }); } if (refreshedFavorites.size > 0) { @@ -3323,7 +3406,7 @@ function App() { return (
-
0} /> +
0} onOpenNotification={handleOpenNotification} />
{view === 'login' && } {view === 'admin' && } @@ -3346,7 +3429,7 @@ function App() { {view === 'accountProfileEdit' && } {view === 'accountPublicProfile' && } {view === 'accountHelpContact' && } - {view === 'notifications' && } + {view === 'notifications' && } {view === 'messages' && ( } {view === 'valuation' && } + {view === 'creditCalculator' && } {view === 'priceHistory' && } {view === 'districtRanking' && } {view === 'negotiation' && ( @@ -5343,6 +5427,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) = const isPriceHistoryView = activeView === 'priceHistory'; const isNegotiationView = activeView === 'negotiation'; const isValuationView = activeView === 'valuation'; + const isCreditCalculatorView = activeView === 'creditCalculator'; const isDistrictRankingView = activeView === 'districtRanking'; const isComparisonView = activeView === 'comparison'; const [messagesUnreadCount, setMessagesUnreadCount] = useState(0); @@ -5435,6 +5520,7 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) =

Moje narzędzia

+ @@ -5497,6 +5583,7 @@ function AccountDashboardPage({ const [activeAlerts, setActiveAlerts] = useState(() => alerts.map(() => true)); const tools = [ + { icon: 'money', title: 'Zdolność kredytowa', text: 'Sprawdź, na jaki kredyt Cię stać i jaka będzie rata', view: 'creditCalculator' as View }, { icon: 'chart', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny mieszkań w czasie', view: 'priceHistory' as View }, { icon: 'coin', title: 'Ile możesz utargować?', text: 'AI podpowie realistyczną cenę transakcyjną', view: 'negotiation' as View }, { icon: 'calculator', title: 'Wycena mieszkania', text: 'Darmowa wycena Twojego mieszkania online', view: 'valuation' as View }, @@ -7352,6 +7439,7 @@ function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNa } function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { + const { notify } = useNotifications(); type MeetingType = 'buy' | 'rent' | 'own'; type MeetingStatus = 'planned' | 'completed' | 'cancelled'; type MeetingItem = { @@ -7613,7 +7701,13 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi if (selectedDateIsPast) { return; } + const target = meetings.find((meeting) => meeting.id === id); setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting))); + if (target && status === 'cancelled') { + notify({ category: 'system', icon: 'calendar', title: 'Spotkanie odwołane', body: `Spotkanie „${target.title}” zostało odwołane.`, link: 'meetings', dedupeKey: `meeting-${id}-cancelled` }); + } else if (target && status === 'completed') { + notify({ category: 'system', icon: 'check', title: 'Spotkanie potwierdzone', body: `Spotkanie „${target.title}” zostało oznaczone jako zrealizowane.`, link: 'meetings', dedupeKey: `meeting-${id}-completed` }); + } }; const removeMeeting = (id: string) => { @@ -7696,6 +7790,10 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi ]); } + if (!editingMeetingId) { + notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” — ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' }); + } + setFormError(''); setSelectedDateKey(newMeeting.dateKey); const selectedDate = new Date(newMeeting.dateKey); @@ -7967,6 +8065,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi status: ListingStatus; pausedUntil?: string | null; promoted: boolean; + isApi?: boolean; + apiId?: number; + apiStatus?: ApiListingStatus; }; type ListingTab = 'all' | 'sale' | 'rent' | 'archived'; @@ -8226,6 +8327,73 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi window.localStorage.setItem(listingsStorageKey, JSON.stringify(listings)); }, [listings]); + // Prawdziwe ogloszenia uzytkownika z backendu (/listings/mine) - scalane z makietami ponizej. + const [apiListings, setApiListings] = useState([]); + useEffect(() => { + let cancelled = false; + apiFetch('/listings/mine') + .then((data) => { if (!cancelled) setApiListings(data); }) + .catch(() => { if (!cancelled) setApiListings([]); }); + return () => { cancelled = true; }; + }, []); + + const isApiId = (id: string) => id.startsWith('api-'); + const apiIdNum = (id: string) => Number(id.slice(4)); + + // Ulubione uzytkownika (localStorage) - do statystyki "zapisanych" dla realnych ogloszen. + const { user } = useAuth(); + const favoriteListingIds = useMemo( + () => new Set(readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).map((fav) => fav.listingId)), + [user], + ); + + const apiRows: ListingItem[] = apiListings.map((summary) => ({ + id: `api-${summary.id}`, + title: summary.title, + address: [summary.address, summary.district, summary.city].filter(Boolean).join(', ') || summary.city, + area: summary.area, + rooms: summary.rooms, + floor: summary.floor ? (parseInt(summary.floor, 10) || 0) : 0, + price: summary.price, + monthlyFees: 0, + views: summary.viewsCount ?? 0, + saves: favoriteListingIds.has(summary.id) ? 1 : 0, + addedDate: (summary.createdAt || '').slice(0, 10) || '2025-01-01', + image: summary.coverPhoto || heroImage, + imagePosition: 'center', + category: summary.offerType === 'RENT' ? 'rent' : 'sale', + status: summary.status === 'PAUSED' ? 'paused' : 'active', + pausedUntil: null, + promoted: false, + isApi: true, + apiId: summary.id, + apiStatus: summary.status, + })); + + // Prawdziwe ogloszenia na gorze listy, pod nimi makiety. + const allRows: ListingItem[] = [...apiRows, ...listings]; + + const apiSetStatus = (id: string, status: ApiListingStatus) => { + const numericId = apiIdNum(id); + apiFetch(`/listings/${numericId}/status?status=${status}`, { method: 'PATCH' }) + .then((updated) => { + setApiListings((current) => current.map((item) => (item.id === numericId ? { ...item, status: updated.status } : item))); + setFeedback(status === 'PAUSED' ? 'Ogłoszenie zostało wstrzymane. Wznowisz je ręcznie.' : 'Ogłoszenie zostało wznowione i jest znów widoczne.'); + }) + .catch((error) => setFeedback(error instanceof Error ? error.message : 'Nie udało się zmienić statusu ogłoszenia.')); + }; + + const apiDeleteListing = (id: string) => { + const numericId = apiIdNum(id); + apiFetch(`/listings/${numericId}`, { method: 'DELETE' }) + .then(() => { + setApiListings((current) => current.filter((item) => item.id !== numericId)); + setFeedback('Ogłoszenie zostało trwale usunięte z serwisu.'); + }) + .catch((error) => setFeedback(error instanceof Error ? error.message : 'Nie udało się usunąć ogłoszenia.')) + .finally(() => closeDeleteModal()); + }; + useEffect(() => { const resumePausedListings = () => { const nowMs = Date.now(); @@ -8291,13 +8459,13 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const tabCounts = { - all: listings.filter((item) => item.status !== 'archived').length, - sale: listings.filter((item) => item.category === 'sale' && item.status === 'active').length, - rent: listings.filter((item) => item.category === 'rent' && item.status === 'active').length, - archived: listings.filter((item) => item.status === 'paused').length, + all: allRows.filter((item) => item.status !== 'archived').length, + sale: allRows.filter((item) => item.category === 'sale' && item.status === 'active').length, + rent: allRows.filter((item) => item.category === 'rent' && item.status === 'active').length, + archived: allRows.filter((item) => item.status === 'paused').length, }; - const visibleListings = listings + const visibleListings = allRows .filter((item) => { if (activeTab === 'archived') { return item.status === 'paused'; @@ -8363,6 +8531,14 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const togglePause = (id: string) => { + if (isApiId(id)) { + const row = apiRows.find((item) => item.id === id); + if (!row) { + return; + } + apiSetStatus(id, row.status === 'paused' ? 'APPROVED' : 'PAUSED'); + return; + } const listing = listings.find((item) => item.id === id); if (!listing || listing.status === 'archived') { return; @@ -8433,6 +8609,10 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const removeListing = (id: string) => { + if (isApiId(id)) { + apiDeleteListing(id); + return; + } setListings((current) => current.filter((item) => item.id !== id)); const selectedReason = deleteReasons.find((reason) => reason.id === deleteReason); setFeedback(selectedReason ? `Ogłoszenie zostało usunięte (${selectedReason.title.toLowerCase()}).` : 'Ogłoszenie zostało usunięte.'); @@ -8474,8 +8654,6 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi

Zarządzaj swoimi ogłoszeniami. Edytuj, promuj lub wstrzymuj oferty.

- -
@@ -8501,7 +8679,13 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi {visibleListings.map((item) => (
-
+
onOpenListing(item.apiId as number) : undefined} + role={item.isApi ? 'button' : undefined} + aria-label={item.isApi ? `Podgląd ogłoszenia ${item.title}` : undefined} + />

{item.title}

@@ -8518,7 +8702,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi
- {item.status === 'paused' ? ( + {item.isApi && (item.apiStatus === 'PENDING' || item.apiStatus === 'REJECTED') ? ( + LISTING_STATUS_LABELS[item.apiStatus] + ) : item.status === 'paused' ? ( <> Wstrzymane
@@ -8527,6 +8713,7 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi ) : statusLabel(item.status)}
Dodane: {formatDate(item.addedDate)} + {item.isApi && W serwisie} {item.promoted && Promowane}
@@ -8542,9 +8729,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi
{item.status !== 'archived' && } - {item.status !== 'archived' && } + {(item.isApi ? (item.apiStatus === 'APPROVED' || item.apiStatus === 'PAUSED') : item.status !== 'archived') && } - + {!item.isApi && }
))} @@ -8934,6 +9121,7 @@ function AccountSecurityPage({ onNavigate, activeView }: { onNavigate: (view: Vi function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { const { user, updateProfile } = useAuth(); + const { notify } = useNotifications(); type UploadTarget = 'avatar' | 'cover'; type MessageTone = 'success' | 'error' | 'info'; @@ -9385,6 +9573,7 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: setVerificationCodeError(''); setIsPhoneVerified(true); setPhoneVerificationStep(3); + notify({ category: 'system', icon: 'check', title: 'Numer telefonu zweryfikowany', body: 'Twój numer telefonu został pomyślnie zweryfikowany.', link: 'accountSecurity', dedupeKey: 'phone-verified' }); return; } setVerificationCodeError('Kod jest nieprawidłowy. Spróbuj ponownie.'); @@ -12907,7 +13096,7 @@ function GuideBuyingPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Kalkulatory

- @@ -14108,6 +14297,528 @@ async function downloadValuationReportPdf(form: ValuationForm, valuation: Valuat } } +// ---- Kalkulator zdolności kredytowej ------------------------------------- + +type CreditMaritalStatus = 'Panna / kawaler' | 'W związku' | 'Małżeństwo' | 'Rozwiedziony/a' | 'Wdowiec/wdowa'; +type CreditIncomeSource = 'Umowa o pracę' | 'Umowa zlecenie' | 'Działalność gospodarcza' | 'B2B' | 'Emerytura' | 'Renta' | 'Dochód z najmu'; +type CreditSeniority = 'do 3 miesięcy' | '3-12 miesięcy' | '1-3 lata' | 'powyżej 3 lat'; +type CreditRepayment = 'równe' | 'malejące'; + +type CreditForm = { + age: string; + maritalStatus: CreditMaritalStatus; + adults: number; + children: number; + income: string; + partnerIncome: string; + incomeSource: CreditIncomeSource; + seniority: CreditSeniority; + loanInstallments: string; + leasing: string; + alimony: string; + otherObligations: string; + creditCardLimit: string; + accountLimit: string; + propertyPrice: string; + ownContribution: string; + loanYears: number; + interestRate: string; + repayment: CreditRepayment; +}; + +const CREDIT_MARITAL_OPTIONS: CreditMaritalStatus[] = ['Panna / kawaler', 'W związku', 'Małżeństwo', 'Rozwiedziony/a', 'Wdowiec/wdowa']; +const CREDIT_INCOME_SOURCES: CreditIncomeSource[] = ['Umowa o pracę', 'Umowa zlecenie', 'Działalność gospodarcza', 'B2B', 'Emerytura', 'Renta', 'Dochód z najmu']; +const CREDIT_SENIORITY_OPTIONS: CreditSeniority[] = ['do 3 miesięcy', '3-12 miesięcy', '1-3 lata', 'powyżej 3 lat']; + +const INITIAL_CREDIT_FORM: CreditForm = { + age: '', + maritalStatus: 'W związku', + adults: 1, + children: 0, + income: '', + partnerIncome: '', + incomeSource: 'Umowa o pracę', + seniority: '1-3 lata', + loanInstallments: '', + leasing: '', + alimony: '', + otherObligations: '', + creditCardLimit: '', + accountLimit: '', + propertyPrice: '', + ownContribution: '', + loanYears: 25, + interestRate: '7,2', + repayment: 'równe', +}; + +const LIVING_COST_FIRST_ADULT = 1500; +const LIVING_COST_NEXT_ADULT = 1000; +const LIVING_COST_CHILD = 800; +const CREDIT_MAX_DTI = 0.4; +const LIMIT_MONTHLY_CHARGE = 0.05; +const CREDIT_MIN_YEARS = 5; +const CREDIT_MAX_YEARS = 35; + +function creditNum(value: string): number { + const parsed = parseNumber(value); + return parsed == null || parsed < 0 ? 0 : parsed; +} + +// Rata annuitetowa (równa) dla danego kapitału. +function annuityInstallmentForPrincipal(principal: number, monthlyRate: number, months: number): number { + if (principal <= 0 || months <= 0) return 0; + if (monthlyRate <= 0) return principal / months; + return (principal * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -months)); +} + +// Pierwsza (najwyższa) rata malejąca dla danego kapitału. +function decreasingFirstInstallmentForPrincipal(principal: number, monthlyRate: number, months: number): number { + if (principal <= 0 || months <= 0) return 0; + return principal / months + principal * monthlyRate; +} + +// Odwrotność: maksymalny kapitał, jaki uniesie dana rata. +function principalForInstallment(installment: number, monthlyRate: number, months: number, decreasing: boolean): number { + if (installment <= 0 || months <= 0) return 0; + if (decreasing) { + return installment / (1 / months + monthlyRate); + } + if (monthlyRate <= 0) return installment * months; + return (installment * (1 - Math.pow(1 + monthlyRate, -months))) / monthlyRate; +} + +type CreditResult = ReturnType; + +function calculateCreditworthiness(form: CreditForm) { + const income = creditNum(form.income); + const partnerIncome = creditNum(form.partnerIncome); + const totalIncome = income + partnerIncome; + const adults = Math.max(1, Math.round(form.adults)); + const children = Math.max(0, Math.round(form.children)); + const livingCosts = LIVING_COST_FIRST_ADULT + Math.max(0, adults - 1) * LIVING_COST_NEXT_ADULT + children * LIVING_COST_CHILD; + const cardCharge = creditNum(form.creditCardLimit) * LIMIT_MONTHLY_CHARGE; + const accountCharge = creditNum(form.accountLimit) * LIMIT_MONTHLY_CHARGE; + const declaredObligations = creditNum(form.loanInstallments) + creditNum(form.leasing) + creditNum(form.alimony) + creditNum(form.otherObligations); + const totalObligations = declaredObligations + cardCharge + accountCharge; + const disposable = totalIncome - livingCosts - totalObligations; + const maxRateByDti = totalIncome * CREDIT_MAX_DTI; + const maxInstallment = Math.max(0, Math.min(maxRateByDti, disposable)); + + const years = Math.min(CREDIT_MAX_YEARS, Math.max(CREDIT_MIN_YEARS, Math.round(form.loanYears))); + const months = years * 12; + const monthlyRate = creditNum(form.interestRate) / 100 / 12; + const decreasing = form.repayment === 'malejące'; + + const maxLoan = principalForInstallment(maxInstallment, monthlyRate, months, decreasing); + const ownContribution = creditNum(form.ownContribution); + const maxPropertyPrice = maxLoan + ownContribution; + const recommendedOwnContribution = Math.round(maxPropertyPrice * 0.2); + + const propertyPrice = creditNum(form.propertyPrice); + const loanForPrice = Math.max(0, propertyPrice - ownContribution); + const projectedInstallment = decreasing + ? decreasingFirstInstallmentForPrincipal(loanForPrice, monthlyRate, months) + : annuityInstallmentForPrincipal(loanForPrice, monthlyRate, months); + const leftoverAfterInstallment = totalIncome - livingCosts - totalObligations - projectedInstallment; + const dsti = totalIncome > 0 ? projectedInstallment / totalIncome : 0; + const dti = totalIncome > 0 ? totalObligations / totalIncome : 0; + + const hasIncome = totalIncome > 0; + const ready = hasIncome && maxInstallment > 0; + const priceExceedsCapacity = propertyPrice > 0 && (loanForPrice > maxLoan + 1 || leftoverAfterInstallment < 0); + + let ratingKey: 'verysafe' | 'safe' | 'moderate' | 'high' | 'none' | 'empty'; + if (!hasIncome) { + ratingKey = 'empty'; + } else if (disposable <= 0) { + ratingKey = 'none'; + } else if (propertyPrice > 0) { + if (priceExceedsCapacity) { + ratingKey = 'high'; + } else if (dsti <= 0.2) { + ratingKey = 'verysafe'; + } else if (dsti <= 0.3) { + ratingKey = 'safe'; + } else if (dsti <= 0.4) { + ratingKey = 'moderate'; + } else { + ratingKey = 'high'; + } + } else { + const ratio = totalIncome > 0 ? maxInstallment / totalIncome : 0; + ratingKey = ratio <= 0.2 ? 'verysafe' : ratio <= 0.3 ? 'safe' : 'moderate'; + } + + return { + income, partnerIncome, totalIncome, adults, children, livingCosts, + cardCharge, accountCharge, declaredObligations, totalObligations, + disposable, maxInstallment, years, months, monthlyRate, decreasing, + maxLoan, ownContribution, maxPropertyPrice, recommendedOwnContribution, + propertyPrice, loanForPrice, projectedInstallment, leftoverAfterInstallment, + dsti, dti, hasIncome, ready, priceExceedsCapacity, ratingKey, + }; +} + +const CREDIT_RATING_META: Record = { + verysafe: { label: 'Bardzo bezpieczny poziom', tone: 'verysafe', stars: 5, note: 'Rata i koszty życia zostawiają duży zapas w budżecie.' }, + safe: { label: 'Bezpieczny poziom', tone: 'safe', stars: 4, note: 'Obciążenie mieści się w bezpiecznych granicach.' }, + moderate: { label: 'Umiarkowane obciążenie', tone: 'moderate', stars: 3, note: 'Rata jest odczuwalna – warto zachować poduszkę finansową.' }, + high: { label: 'Wysokie obciążenie', tone: 'high', stars: 2, note: 'Rata pochłania zbyt dużą część dochodu przy tej cenie.' }, + none: { label: 'Brak zdolności', tone: 'none', stars: 0, note: 'Koszty i zobowiązania przekraczają dochód – zdolność wynosi 0 zł.' }, + empty: { label: 'Uzupełnij dane', tone: 'empty', stars: 0, note: 'Podaj miesięczny dochód netto, aby obliczyć zdolność.' }, +}; + +function creditMaxPropertyPrice(form: CreditForm): number { + return calculateCreditworthiness(form).maxPropertyPrice; +} + +type CreditRecommendation = { icon: string; label: string; detail: string; delta: number }; + +// Każda rekomendacja liczona jest ponownie na danych użytkownika (żadnych kwot na sztywno). +function buildCreditRecommendations(form: CreditForm, base: number): CreditRecommendation[] { + const recs: CreditRecommendation[] = []; + const add = (icon: string, label: string, detail: string, next: CreditForm) => { + const delta = Math.round(creditMaxPropertyPrice(next) - base); + if (delta > 0) recs.push({ icon, label, detail, delta }); + }; + if (creditNum(form.creditCardLimit) > 0) { + add('credit-card', 'Zamknij kartę kredytową', 'Rezygnacja z limitu karty odciąża miesięczny budżet.', { ...form, creditCardLimit: '0' }); + } + if (creditNum(form.accountLimit) > 0) { + add('scale', 'Zlikwiduj limit w koncie', 'Limit w koncie obniża zdolność, nawet jeśli go nie używasz.', { ...form, accountLimit: '0' }); + } + if (creditNum(form.loanInstallments) > 0) { + add('check', 'Spłać bieżące kredyty', 'Zamknięcie rat uwalnia miejsce na ratę kredytu hipotecznego.', { ...form, loanInstallments: '0' }); + } + if (Math.round(form.loanYears) < CREDIT_MAX_YEARS) { + add('clock', `Wydłuż okres kredytu do ${CREDIT_MAX_YEARS} lat`, 'Dłuższy okres obniża ratę i podnosi maksymalną kwotę kredytu.', { ...form, loanYears: CREDIT_MAX_YEARS }); + } + if (creditNum(form.partnerIncome) === 0 && creditNum(form.income) > 0) { + add('group', 'Dodaj dochód partnera', 'Drugi dochód w gospodarstwie mocno zwiększa zdolność (symulacja: partner z dochodem jak Twój).', { ...form, partnerIncome: form.income }); + } + add('coin', 'Zwiększ wkład własny o 50 000 zł', 'Wyższy wkład własny wprost podnosi dostępną cenę nieruchomości.', { ...form, ownContribution: String(creditNum(form.ownContribution) + 50000) }); + return recs.sort((a, b) => b.delta - a.delta); +} + +function formatPercent(value: number): string { + return `${(value * 100).toLocaleString('pl-PL', { maximumFractionDigits: 1 })}%`; +} + +function CreditCalculatorPage({ onNavigate, onSearchWithBudget }: { onNavigate: (view: View) => void; onSearchWithBudget: (maxPrice: number) => void }) { + const { user } = useAuth(); + const [form, setForm] = useState(INITIAL_CREDIT_FORM); + const [highlight, setHighlight] = useState(false); + const resultRef = useRef(null); + const result = calculateCreditworthiness(form); + const rating = CREDIT_RATING_META[result.ratingKey]; + const recommendations = buildCreditRecommendations(form, result.maxPropertyPrice); + const fmtPln = (value: number) => formatPln(Math.round(value)); + + const update = (field: Key, value: CreditForm[Key]) => { + setForm((current) => ({ ...current, [field]: value })); + }; + const money = (field: keyof CreditForm) => (event: React.ChangeEvent) => + update(field, event.target.value.replace(/[^\d]/g, '') as CreditForm[typeof field]); + const stepAdults = (delta: number) => update('adults', Math.min(12, Math.max(1, form.adults + delta))); + const stepChildren = (delta: number) => update('children', Math.min(12, Math.max(0, form.children + delta))); + const reset = () => { setForm(INITIAL_CREDIT_FORM); }; + const handleCalculate = () => { + setHighlight(true); + window.setTimeout(() => setHighlight(false), 900); + resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + // Donut struktury dochodów. + const donutRadius = 52; + const donutCirc = 2 * Math.PI * donutRadius; + const yourLen = result.totalIncome > 0 ? (result.income / result.totalIncome) * donutCirc : 0; + const partnerLen = result.totalIncome > 0 ? (result.partnerIncome / result.totalIncome) * donutCirc : 0; + + const detailRows: { label: string; value: string; tone?: string }[] = [ + { label: 'Łączne dochody netto', value: fmtPln(result.totalIncome) }, + { label: 'Koszty utrzymania gospodarstwa', value: fmtPln(result.livingCosts) }, + { label: 'Łączne zobowiązania', value: fmtPln(result.totalObligations) }, + { label: 'Dochód po odliczeniach', value: fmtPln(Math.max(0, result.disposable)) }, + { label: 'Zalecana maksymalna rata', value: fmtPln(result.maxInstallment) }, + { label: 'Maksymalna kwota kredytu', value: fmtPln(result.maxLoan) }, + { label: 'Maksymalna cena nieruchomości', value: fmtPln(result.maxPropertyPrice) }, + { label: 'Przewidywana rata dla wpisanej ceny', value: fmtPln(result.projectedInstallment) }, + { label: 'Kwota po opłaceniu raty', value: fmtPln(result.leftoverAfterInstallment), tone: result.leftoverAfterInstallment < 0 ? 'negative' : 'positive' }, + { label: 'Wskaźnik DTI (zobowiązania / dochód)', value: formatPercent(result.dti) }, + { label: 'Wskaźnik DSTI (rata / dochód)', value: formatPercent(result.dsti) }, + ]; + + const pageBody = ( + <> +
+ +
+

Kalkulator zdolności kredytowej

+

Sprawdź, jaką kwotę kredytu hipotecznego możesz uzyskać i jakie będą orientacyjne warunki. Wynik przelicza się na bieżąco przy każdej zmianie.

+
+
+ +
+
+
+

1 Twoje dane

+
+ + +
+ Liczba osób w gospodarstwie +
+ + {form.adults} + +
+
+
+ Liczba dzieci +
+ + {form.children} + +
+
+
+
+ +
+

2 Dochody miesięczne (netto)

+
+ + + + +
+
+ +
+

3 Zobowiązania miesięczne

+
+ + + + + + +
+
+ +
+

4 Informacje o kredycie

+
+ + +
+ Okres kredytu: {result.years} lat + update('loanYears', Number(e.target.value))} className="credit-range" /> +
{CREDIT_MIN_YEARS} lat{CREDIT_MAX_YEARS} lat
+
+ +
+ Rodzaj rat +
+ + +
+
+
+
+ +
+ + +
+
+ +
+
+
+ Twoja szacunkowa zdolność kredytowa + {rating.label} +
+ {result.hasIncome ? fmtPln(result.maxLoan) : '— zł'} +
+ {[1, 2, 3, 4, 5].map((n) => )} +
+

{rating.note}

+
+ +
+
+ Maksymalna rata + {fmtPln(result.maxInstallment)} + {formatPercent(CREDIT_MAX_DTI)} Twoich dochodów +
+
+ Maksymalny kredyt + {fmtPln(result.maxLoan)} + {result.decreasing ? 'raty malejące' : 'raty równe'} · {result.years} lat +
+
+ Zalecany wkład własny + {fmtPln(result.recommendedOwnContribution)} + 20% wartości nieruchomości +
+
+ +
+
+

Szczegóły symulacji

+
+ + + {result.totalIncome > 0 && ( + + )} + {result.partnerIncome > 0 && ( + + )} + {Math.round(result.totalIncome / 1000)} tys. + łącznie + +
    +
  • Twój dochód {fmtPln(result.income)}
  • +
  • Dochód partnera {fmtPln(result.partnerIncome)}
  • +
+
+
+
+ {detailRows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ +
+
+ Znajdź nieruchomości w zasięgu Twojej zdolności + Pokażemy oferty do {fmtPln(result.maxPropertyPrice)} (kredyt + wkład własny). +
+ +
+ + {recommendations.length > 0 && ( +
+

Jak zwiększyć zdolność?

+
+ {recommendations.map((reco) => ( +
+ +
+ {reco.label} + {reco.detail} +
+ +{formatPln(reco.delta)} +
+ ))} +
+
+ )} + +

+ Wynik ma charakter orientacyjny i nie stanowi oferty ani decyzji banku. Rzeczywista zdolność zależy od zasad banku, historii kredytowej i indywidualnej sytuacji klienta. Nie zapisujemy Twoich danych finansowych bez Twojej zgody. +

+
+
+ + ); + + if (user) { + return ( +
+
+ +
+ {pageBody} +
+
+
+ ); + } + + return ( +
+
+
+ + + +
+ {pageBody} +
+
+ ); +} + function ValuationPage({ onNavigate }: { onNavigate: (view: View) => void }) { const [step, setStep] = useState(0); const [valuationForm, setValuationForm] = useState(INITIAL_VALUATION_FORM); @@ -17689,61 +18400,26 @@ function Header({ onNavigate, onLogout, hasUnreadFavorites, + onOpenNotification, }: { activeView: View; onNavigate: (view: View) => void; onLogout: () => void; hasUnreadFavorites: boolean; + onOpenNotification: (notification: DisplayNotification) => void; }) { const { user } = useAuth(); + const { notifications: serverNotifications, unreadCount } = useNotifications(); const isLoggedIn = Boolean(user); const isAdmin = user?.role === 'ADMIN'; const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); const notificationPopoverRef = useRef(null); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); const accountMenuRef = useRef(null); - const [adminUnreadCount, setAdminUnreadCount] = useState(0); - useEffect(() => { - if (!user || user.role === 'ADMIN') { - setAdminUnreadCount(0); - return; - } - - let cancelled = false; - apiFetch<{ count: number }>('/messages/unread-count') - .then((data) => { - if (!cancelled) { - setAdminUnreadCount(data.count); - } - }) - .catch(() => {}); - - return () => { - cancelled = true; - }; - }, [user]); - - const allNotifications = useMemo(() => { - const base = getAllNotifications(); - if (adminUnreadCount <= 0) { - return base; - } - const adminNotification: UserNotification = { - id: 'admin-message-live', - title: 'Nowa wiadomość od administracji', - description: `Masz ${adminUnreadCount} ${adminUnreadCount === 1 ? 'nieprzeczytaną wiadomość' : 'nieprzeczytane wiadomości'} od zespołu Mieszko.`, - timeLabel: 'Teraz', - dayLabel: 'Dzisiaj', - category: 'messages', - icon: 'message', - unread: true, - action: 'arrow', - }; - return [adminNotification, ...base]; - }, [adminUnreadCount]); - const unreadCount = allNotifications.filter((item) => item.unread).length; - const previewNotifications = allNotifications.slice(0, 3); + // Dane powiadomień z centralnego providera. W dzwonku pokazujemy nieprzeczytane (znikają po kliknięciu). + const displayNotifications = toDisplayNotifications(serverNotifications); + const previewNotifications = displayNotifications.filter((item) => item.unread).slice(0, 6); useEffect(() => { if (!isNotificationsOpen) { @@ -17879,8 +18555,17 @@ function Header({
+ {previewNotifications.length === 0 && ( +

Brak nowych powiadomień.

+ )} {previewNotifications.map((item) => ( -
+
{ setIsNotificationsOpen(false); onOpenNotification(item); }} + > @@ -17961,51 +18646,14 @@ function Header({ ); } -function NotificationsPage() { - const { user } = useAuth(); +function NotificationsPage({ onOpenNotification }: { onOpenNotification: (notification: DisplayNotification) => void }) { const [activeTab, setActiveTab] = useState<'all' | NotificationCategory>('all'); - const [adminUnreadCount, setAdminUnreadCount] = useState(0); + const { notifications: serverNotifications } = useNotifications(); - useEffect(() => { - if (!user || user.role === 'ADMIN') { - setAdminUnreadCount(0); - return; - } - - let cancelled = false; - apiFetch<{ count: number }>('/messages/unread-count') - .then((data) => { - if (!cancelled) { - setAdminUnreadCount(data.count); - } - }) - .catch(() => {}); - - return () => { - cancelled = true; - }; - }, [user]); - - const allNotifications = useMemo(() => { - const base = getAllNotifications(); - if (adminUnreadCount <= 0) { - return base; - } - const adminNotification: UserNotification = { - id: 'admin-message-live', - title: 'Nowa wiadomość od administracji', - description: `Masz ${adminUnreadCount} ${adminUnreadCount === 1 ? 'nieprzeczytaną wiadomość' : 'nieprzeczytane wiadomości'} od zespołu Mieszko.`, - timeLabel: 'Teraz', - dayLabel: 'Dzisiaj', - category: 'messages', - icon: 'message', - unread: true, - action: 'arrow', - }; - return [adminNotification, ...base]; - }, [adminUnreadCount]); + // Cała historia z backendu (przeczytane pozostają na liście); mapowanie do kształtu widoku bez zmiany designu. + const allNotifications = toDisplayNotifications(serverNotifications); const filteredNotifications = allNotifications.filter((item) => activeTab === 'all' || item.category === activeTab); - const groupedNotifications = filteredNotifications.reduce>((groups, item) => { + const groupedNotifications = filteredNotifications.reduce>((groups, item) => { groups[item.dayLabel].push(item); return groups; }, { Dzisiaj: [], Wczoraj: [] }); @@ -18034,7 +18682,13 @@ function NotificationsPage() {

{groupName}

{groupedNotifications[groupName].map((item) => ( -
+
onOpenNotification(item)} + > @@ -19830,6 +20484,7 @@ const LISTING_STATUS_LABELS: Record = { PENDING: 'Oczekuje na moderację', APPROVED: 'Opublikowane', REJECTED: 'Odrzucone', + PAUSED: 'Wstrzymane', }; function listingRoomsText(rooms: number): string { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9f89fe5..cc6c8d1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,11 +2,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { AuthProvider } from './auth'; +import { NotificationsProvider } from './notifications'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/frontend/src/notifications.tsx b/frontend/src/notifications.tsx new file mode 100644 index 0000000..81ebdef --- /dev/null +++ b/frontend/src/notifications.tsx @@ -0,0 +1,178 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { apiFetch, getToken, useAuth } from './auth'; + +export type NotificationCategoryKey = 'listing' | 'messages' | 'system'; + +// Powiadomienie w formie z backendu. +export type ServerNotification = { + id: number; + category: NotificationCategoryKey; + icon: string; + title: string; + body: string | null; + link: string | null; + read: boolean; + createdAt: string; +}; + +// Zdarzenia liczone po stronie klienta (alerty cen, spotkania, ulubione, telefon, dopasowania). +export type NotifyInput = { + category: NotificationCategoryKey; + icon?: string; + title: string; + body?: string; + link?: string; + dedupeKey?: string; +}; + +// Kształt do renderu (zgodny z istniejącym UI: title/description/timeLabel/dayLabel/category/icon/unread/action). +export type DisplayNotification = ServerNotification & { + description: string; + timeLabel: string; + dayLabel: 'Dzisiaj' | 'Wczoraj'; + unread: boolean; + action?: 'arrow'; +}; + +type NotificationsContextValue = { + notifications: ServerNotification[]; + unreadCount: number; + markRead: (id: number) => void; + markAllRead: () => void; + notify: (input: NotifyInput) => void; + refresh: () => void; +}; + +const NotificationsContext = createContext(null); + +function pad(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +// Mapowanie listy z backendu na kształt oczekiwany przez istniejący widok (bez zmiany designu). +export function toDisplayNotifications(list: ServerNotification[]): DisplayNotification[] { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + + return list.map((item) => { + const created = new Date(item.createdAt); + const createdMs = created.getTime(); + const time = `${pad(created.getHours())}:${pad(created.getMinutes())}`; + const isToday = createdMs >= startOfToday; + const isYesterday = createdMs >= startOfYesterday && createdMs < startOfToday; + + let timeLabel: string; + if (isToday) { + timeLabel = time; + } else if (isYesterday) { + timeLabel = `Wczoraj, ${time}`; + } else { + timeLabel = `${pad(created.getDate())}.${pad(created.getMonth() + 1)}, ${time}`; + } + + const unread = !item.read; + return { + ...item, + description: item.body ?? '', + timeLabel, + // Widok grupuje wyłącznie na "Dzisiaj"/"Wczoraj" — starsze trafiają do "Wczoraj". + dayLabel: isToday ? 'Dzisiaj' : 'Wczoraj', + unread, + action: !unread && item.link ? 'arrow' : undefined, + }; + }); +} + +export function NotificationsProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + const sentDedupeKeys = useRef>(new Set()); + + const unreadCount = notifications.reduce((count, item) => (item.read ? count : count + 1), 0); + + const refresh = useCallback(() => { + if (!getToken()) { + setNotifications([]); + return; + } + apiFetch('/notifications') + .then((data) => setNotifications(Array.isArray(data) ? data : [])) + .catch(() => { /* cicho - brak sieci/tokenu */ }); + }, []); + + // Ładowanie listy + realtime (SSE) gdy użytkownik zalogowany. Token w query param (EventSource nie ustawia nagłówków). + useEffect(() => { + const token = getToken(); + if (!user || !token) { + setNotifications([]); + sentDedupeKeys.current = new Set(); + return; + } + + refresh(); + + const source = new EventSource(`/api/notifications/stream?access_token=${encodeURIComponent(token)}`); + source.addEventListener('notification', (event) => { + try { + const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification; + setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current])); + } catch { + /* ignoruj nieparsowalne zdarzenie */ + } + }); + // onerror: EventSource sam ponawia połączenie. + + // Zapasowy polling na wypadek zerwanego SSE. + const pollId = window.setInterval(refresh, 60000); + + return () => { + source.close(); + window.clearInterval(pollId); + }; + }, [user, refresh]); + + const markRead = useCallback((id: number) => { + setNotifications((current) => current.map((item) => (item.id === id ? { ...item, read: true } : item))); + apiFetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {}); + }, []); + + const markAllRead = useCallback(() => { + setNotifications((current) => current.map((item) => ({ ...item, read: true }))); + apiFetch('/notifications/read-all', { method: 'POST' }).catch(() => {}); + }, []); + + const notify = useCallback((input: NotifyInput) => { + if (!getToken() || !input.title) { + return; + } + if (input.dedupeKey) { + if (sentDedupeKeys.current.has(input.dedupeKey)) { + return; + } + sentDedupeKeys.current.add(input.dedupeKey); + } + apiFetch('/notifications', { method: 'POST', body: JSON.stringify(input) }) + .then((created) => { + if (created && created.id) { + setNotifications((current) => (current.some((item) => item.id === created.id) ? current : [created, ...current])); + } + }) + .catch(() => {}); + }, []); + + return ( + + {children} + + ); +} + +export function useNotifications(): NotificationsContextValue { + const context = useContext(NotificationsContext); + if (!context) { + throw new Error('useNotifications musi być użyte wewnątrz NotificationsProvider'); + } + return context; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 70b441f..5f2daca 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -25842,3 +25842,805 @@ svg { align-items: flex-start; } } + +/* ---- Kalkulator zdolności kredytowej ---- */ +.credit-page { + background: #f4f7fb; + padding: 34px 20px 70px; +} + +.credit-shell { + margin: 0 auto; + max-width: 1240px; +} + +.credit-breadcrumb { + margin-bottom: 18px; +} + +.credit-title { + align-items: center; + display: flex; + gap: 18px; + margin-bottom: 26px; +} + +.credit-title-icon { + align-items: center; + background: #e6f6ee; + border-radius: 14px; + color: #0b9f5c; + display: flex; + flex: 0 0 auto; + font-size: 26px; + height: 56px; + justify-content: center; + width: 56px; +} + +.credit-title h1 { + color: #14223a; + font-size: 30px; + font-weight: 950; + margin: 0 0 6px; +} + +.credit-title p { + color: #526171; + font-size: 14px; + font-weight: 700; + line-height: 1.55; + margin: 0; + max-width: 780px; +} + +.credit-layout { + align-items: start; + display: grid; + gap: 22px; + grid-template-columns: minmax(320px, 430px) minmax(0, 1fr); +} + +.credit-form-column, +.credit-result-column { + display: grid; + gap: 18px; + min-width: 0; +} + +.credit-card { + background: #ffffff; + border: 1px solid #dfe7ef; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(24, 35, 57, 0.05); + padding: 22px 22px 20px; +} + +.credit-card h2 { + align-items: center; + color: #14223a; + display: flex; + font-size: 16px; + font-weight: 950; + gap: 12px; + margin: 0 0 18px; +} + +.credit-step-badge { + align-items: center; + background: #0b9f5c; + border-radius: 50%; + color: #ffffff; + display: inline-flex; + font-size: 13px; + font-weight: 950; + height: 26px; + justify-content: center; + width: 26px; +} + +.credit-grid { + display: grid; + gap: 16px; +} + +.credit-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.credit-field { + display: grid; + gap: 8px; + min-width: 0; +} + +.credit-field-wide { + grid-column: 1 / -1; +} + +.credit-field > span { + color: #27364d; + font-size: 13px; + font-weight: 900; +} + +.credit-field small { + color: #7a8798; + font-size: 11.5px; + font-weight: 800; +} + +.credit-field input, +.credit-field select { + background: #ffffff; + border: 1px solid #d5dee8; + border-radius: 8px; + color: #14223a; + font-size: 14px; + font-weight: 800; + min-height: 48px; + padding: 0 13px; + width: 100%; +} + +.credit-field input:focus, +.credit-field select:focus { + border-color: #0b9f5c; + box-shadow: 0 0 0 3px rgba(11, 159, 92, 0.14); + outline: none; +} + +.credit-input-suffix { + align-items: center; + background: #ffffff; + border: 1px solid #d5dee8; + border-radius: 8px; + display: flex; + min-height: 48px; + overflow: hidden; + padding-right: 12px; +} + +.credit-input-suffix:focus-within { + border-color: #0b9f5c; + box-shadow: 0 0 0 3px rgba(11, 159, 92, 0.14); +} + +.credit-input-suffix input { + border: none; + box-shadow: none; + min-height: 46px; +} + +.credit-input-suffix input:focus { + border: none; + box-shadow: none; +} + +.credit-input-suffix em { + color: #7a8798; + font-size: 13px; + font-style: normal; + font-weight: 900; +} + +.credit-stepper { + align-items: center; + border: 1px solid #d5dee8; + border-radius: 8px; + display: grid; + grid-template-columns: 48px minmax(0, 1fr) 48px; + min-height: 48px; + overflow: hidden; +} + +.credit-stepper button { + background: #f4f7fb; + border: none; + color: #0b9f5c; + font-size: 22px; + font-weight: 900; + height: 100%; +} + +.credit-stepper button:hover { + background: #e6f6ee; +} + +.credit-stepper strong { + color: #14223a; + font-size: 16px; + font-weight: 950; + text-align: center; +} + +.credit-segmented { + background: #eef2f7; + border-radius: 8px; + display: grid; + gap: 4px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 4px; +} + +.credit-segmented button { + background: transparent; + border: none; + border-radius: 6px; + color: #526171; + font-size: 13px; + font-weight: 900; + padding: 11px 8px; +} + +.credit-segmented button.active { + background: #ffffff; + box-shadow: 0 2px 8px rgba(24, 35, 57, 0.12); + color: #0b9f5c; +} + +.credit-range-value { + color: #0b9f5c; + font-weight: 950; +} + +.credit-range { + -webkit-appearance: none; + appearance: none; + background: linear-gradient(#d5dee8, #d5dee8) no-repeat; + background-size: 100% 6px; + background-position: 0 center; + border-radius: 6px; + height: 24px; + width: 100%; +} + +.credit-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #0b9f5c; + border: 3px solid #ffffff; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(11, 159, 92, 0.4); + height: 22px; + width: 22px; +} + +.credit-range::-moz-range-thumb { + background: #0b9f5c; + border: 3px solid #ffffff; + border-radius: 50%; + height: 22px; + width: 22px; +} + +.credit-range-scale { + color: #97a3b2; + display: flex; + font-size: 11px; + font-weight: 800; + justify-content: space-between; +} + +.credit-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.credit-btn-primary, +.credit-btn-ghost { + align-items: center; + border-radius: 10px; + display: inline-flex; + font-size: 14px; + font-weight: 900; + gap: 9px; + justify-content: center; + min-height: 50px; + padding: 0 22px; +} + +.credit-btn-primary { + background: #0b9f5c; + border: none; + color: #ffffff; + flex: 1 1 auto; +} + +.credit-btn-primary:hover { + background: #0a8b50; +} + +.credit-btn-ghost { + background: #ffffff; + border: 1px solid #d5dee8; + color: #27364d; +} + +.credit-btn-ghost:hover { + border-color: #b7c2cf; +} + +/* Wynik */ +.credit-result-hero { + border-radius: 14px; + color: #ffffff; + overflow: hidden; + padding: 26px 28px 24px; + position: relative; +} + +.credit-tone-verysafe, +.credit-tone-safe { + background: linear-gradient(135deg, #0b9f5c, #0a8b50); +} + +.credit-tone-moderate { + background: linear-gradient(135deg, #d98a13, #c2790a); +} + +.credit-tone-high { + background: linear-gradient(135deg, #e0592f, #c8461f); +} + +.credit-tone-none, +.credit-tone-empty { + background: linear-gradient(135deg, #4a5a70, #35435a); +} + +.credit-result-hero.pulse { + animation: creditPulse 0.9s ease; +} + +@keyframes creditPulse { + 0% { transform: scale(1); } + 40% { transform: scale(1.014); } + 100% { transform: scale(1); } +} + +.credit-hero-top { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.credit-hero-top > span:first-child { + font-size: 14px; + font-weight: 850; + opacity: 0.92; +} + +.credit-badge { + background: rgba(255, 255, 255, 0.22); + border-radius: 999px; + font-size: 12px; + font-weight: 900; + padding: 6px 12px; + white-space: nowrap; +} + +.credit-result-hero strong { + display: block; + font-size: 44px; + font-weight: 950; + letter-spacing: -0.5px; + line-height: 1; + margin: 18px 0 12px; +} + +.credit-stars { + display: flex; + gap: 3px; +} + +.credit-stars span { + color: rgba(255, 255, 255, 0.4); + font-size: 17px; +} + +.credit-stars span.on { + color: #ffd873; +} + +.credit-stars span svg { + fill: currentColor; + stroke: none; +} + +.credit-result-hero p { + font-size: 13px; + font-weight: 750; + line-height: 1.5; + margin: 14px 0 0; + opacity: 0.94; +} + +.credit-tiles { + display: grid; + gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.credit-tile { + background: #ffffff; + border: 1px solid #dfe7ef; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(24, 35, 57, 0.05); + display: grid; + gap: 6px; + padding: 16px 16px 15px; +} + +.credit-tile small { + color: #7a8798; + font-size: 12px; + font-weight: 850; +} + +.credit-tile strong { + color: #14223a; + font-size: 20px; + font-weight: 950; +} + +.credit-tile em { + color: #0b9f5c; + font-size: 11.5px; + font-style: normal; + font-weight: 850; +} + +.credit-details-head { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1fr) 250px; + margin-bottom: 18px; +} + +.credit-details h3 { + color: #14223a; + font-size: 16px; + font-weight: 950; + margin: 0; +} + +.credit-donut-wrap { + align-items: center; + display: grid; + gap: 6px; + justify-items: center; +} + +.credit-donut { + height: 128px; + width: 128px; +} + +.credit-donut circle { + fill: none; + stroke-width: 13; +} + +.credit-donut-track { + stroke: #eef2f7; +} + +.credit-donut-you { + stroke: #0b9f5c; + stroke-linecap: round; +} + +.credit-donut-partner { + stroke: #3f8bff; + stroke-linecap: round; +} + +.credit-donut-value { + dominant-baseline: middle; + fill: #14223a; + font-size: 20px; + font-weight: 900; + stroke: none; + text-anchor: middle; +} + +.credit-donut-label { + dominant-baseline: middle; + fill: #7a8798; + font-size: 10px; + font-weight: 800; + stroke: none; + text-anchor: middle; +} + +.credit-donut-legend { + display: grid; + gap: 6px; + list-style: none; + margin: 4px 0 0; + padding: 0; + width: 100%; +} + +.credit-donut-legend li { + align-items: center; + color: #526171; + display: flex; + font-size: 12px; + font-weight: 800; + gap: 7px; +} + +.credit-donut-legend b { + color: #14223a; + margin-left: auto; +} + +.credit-donut-legend i { + border-radius: 3px; + flex: 0 0 auto; + height: 11px; + width: 11px; +} + +.credit-dot-you { + background: #0b9f5c; +} + +.credit-dot-partner { + background: #3f8bff; +} + +.credit-detail-rows { + display: grid; + gap: 0; + margin: 0; +} + +.credit-detail-row { + align-items: center; + border-top: 1px solid #eef2f7; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 11px 0; +} + +.credit-detail-row dt { + color: #526171; + font-size: 13px; + font-weight: 750; +} + +.credit-detail-row dd { + color: #14223a; + font-size: 14px; + font-weight: 950; + margin: 0; + text-align: right; +} + +.credit-detail-positive dd { + color: #0b9f5c; +} + +.credit-detail-negative dd { + color: #d64545; +} + +.credit-cta-band { + align-items: center; + background: linear-gradient(135deg, #e6f6ee, #f0f9f4); + border: 1px solid #bfe6d2; + border-radius: 12px; + display: flex; + gap: 16px; + justify-content: space-between; + padding: 20px 22px; +} + +.credit-cta-band strong { + color: #14223a; + display: block; + font-size: 15px; + font-weight: 950; +} + +.credit-cta-band span { + color: #4b6a58; + display: block; + font-size: 13px; + font-weight: 750; + margin-top: 4px; +} + +.credit-cta-band button { + align-items: center; + background: #0b9f5c; + border: none; + border-radius: 10px; + color: #ffffff; + display: inline-flex; + flex: 0 0 auto; + font-size: 14px; + font-weight: 900; + gap: 8px; + min-height: 48px; + padding: 0 20px; +} + +.credit-cta-band button:hover:not(:disabled) { + background: #0a8b50; +} + +.credit-cta-band button:disabled { + background: #b7c2cf; + cursor: not-allowed; +} + +.credit-recos h3 { + align-items: center; + color: #14223a; + display: flex; + font-size: 16px; + font-weight: 950; + gap: 9px; + margin: 0 0 16px; +} + +.credit-reco-list { + display: grid; + gap: 10px; +} + +.credit-reco { + align-items: center; + background: #f8fafc; + border: 1px solid #e7edf3; + border-radius: 10px; + display: flex; + gap: 13px; + padding: 13px 15px; +} + +.credit-reco-icon { + align-items: center; + background: #e6f6ee; + border-radius: 9px; + color: #0b9f5c; + display: flex; + flex: 0 0 auto; + font-size: 17px; + height: 40px; + justify-content: center; + width: 40px; +} + +.credit-reco-body { + display: grid; + gap: 3px; + min-width: 0; +} + +.credit-reco-body strong { + color: #14223a; + font-size: 13.5px; + font-weight: 900; +} + +.credit-reco-body small { + color: #7a8798; + font-size: 12px; + font-weight: 700; + line-height: 1.4; +} + +.credit-reco-delta { + color: #0b9f5c; + flex: 0 0 auto; + font-size: 14px; + font-weight: 950; + margin-left: auto; + white-space: nowrap; +} + +.credit-disclaimer { + align-items: flex-start; + color: #7a8798; + display: flex; + font-size: 12px; + font-weight: 700; + gap: 9px; + line-height: 1.55; + margin: 4px 0 0; +} + +.credit-disclaimer svg { + color: #0b9f5c; + flex: 0 0 auto; + font-size: 16px; + margin-top: 1px; +} + +@media (max-width: 1080px) { + .credit-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + .credit-grid-2 { + grid-template-columns: minmax(0, 1fr); + } + + .credit-tiles { + grid-template-columns: minmax(0, 1fr); + } + + .credit-details-head { + grid-template-columns: minmax(0, 1fr); + } + + .credit-cta-band { + align-items: flex-start; + flex-direction: column; + } + + .credit-title h1 { + font-size: 24px; + } + + .credit-result-hero strong { + font-size: 36px; + } +} + +/* Prawdziwe ogloszenie z serwisu w liscie "Moje oferty" */ +.listing-photo.clickable { + cursor: pointer; +} + +.listing-status em.listing-live-tag { + background: #e6f6ee; + border-radius: 999px; + color: #0b9f5c; + font-size: 10.5px; + font-style: normal; + font-weight: 900; + letter-spacing: 0.02em; + padding: 2px 8px; + text-transform: uppercase; +} + +/* Kalkulator osadzony w panelu konta (z paskiem bocznym) */ +.credit-account-main { + min-width: 0; +} + +.credit-account-main .credit-title { + margin-top: 0; +} + +@media (max-width: 1280px) { + .credit-account-main .credit-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Powiadomienia - klikalnosc (logika, bez zmiany wygladu) */ +.notification-row, +.notification-popover-item[role="button"] { + cursor: pointer; +} + +.notification-popover-empty { + color: #7a8798; + font-size: 13px; + font-weight: 700; + margin: 0; + padding: 18px 4px; + text-align: center; +}