Kalkulator zdolności, integracja Moich ofert i system powiadomień
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import pl.polskalokalnie.listing.ListingStatus;
|
|||||||
import pl.polskalokalnie.message.MessageResponse;
|
import pl.polskalokalnie.message.MessageResponse;
|
||||||
import pl.polskalokalnie.message.MessageService;
|
import pl.polskalokalnie.message.MessageService;
|
||||||
import pl.polskalokalnie.message.SendMessageRequest;
|
import pl.polskalokalnie.message.SendMessageRequest;
|
||||||
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
import pl.polskalokalnie.report.ListingReportResponse;
|
import pl.polskalokalnie.report.ListingReportResponse;
|
||||||
import pl.polskalokalnie.report.ListingReportService;
|
import pl.polskalokalnie.report.ListingReportService;
|
||||||
import pl.polskalokalnie.report.ResolveListingReportRequest;
|
import pl.polskalokalnie.report.ResolveListingReportRequest;
|
||||||
@@ -52,13 +53,15 @@ public class AdminController {
|
|||||||
private final TextModerationService textModerationService;
|
private final TextModerationService textModerationService;
|
||||||
private final AdminStatsService adminStatsService;
|
private final AdminStatsService adminStatsService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
||||||
ListingService listingService, ListingReportService listingReportService,
|
ListingService listingService, ListingReportService listingReportService,
|
||||||
MessageService messageService,
|
MessageService messageService,
|
||||||
TextModerationService textModerationService,
|
TextModerationService textModerationService,
|
||||||
AdminStatsService adminStatsService,
|
AdminStatsService adminStatsService,
|
||||||
PasswordEncoder passwordEncoder) {
|
PasswordEncoder passwordEncoder,
|
||||||
|
NotificationService notificationService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
this.listingService = listingService;
|
this.listingService = listingService;
|
||||||
@@ -67,6 +70,7 @@ public class AdminController {
|
|||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
this.adminStatsService = adminStatsService;
|
this.adminStatsService = adminStatsService;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.notificationService = notificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Uzytkownicy ---
|
// --- Uzytkownicy ---
|
||||||
@@ -93,7 +97,12 @@ public class AdminController {
|
|||||||
public UserResponse verify(@PathVariable Long id) {
|
public UserResponse verify(@PathVariable Long id) {
|
||||||
AppUser user = requireUser(id);
|
AppUser user = requireUser(id);
|
||||||
user.setVerified(true);
|
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")
|
@PostMapping("/users/{id}/grant-admin")
|
||||||
@@ -104,7 +113,12 @@ public class AdminController {
|
|||||||
}
|
}
|
||||||
user.setRole(Role.ADMIN);
|
user.setRole(Role.ADMIN);
|
||||||
user.setVerified(true);
|
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")
|
@PostMapping("/users/{id}/revoke-admin")
|
||||||
@@ -137,6 +151,10 @@ public class AdminController {
|
|||||||
String temporaryPassword = generateTemporaryPassword();
|
String temporaryPassword = generateTemporaryPassword();
|
||||||
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
||||||
userRepository.save(user);
|
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);
|
return new ResetPasswordResponse(temporaryPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
@NonNull HttpServletResponse response,
|
@NonNull HttpServletResponse response,
|
||||||
@NonNull FilterChain filterChain
|
@NonNull FilterChain filterChain
|
||||||
) throws ServletException, IOException {
|
) throws ServletException, IOException {
|
||||||
String header = request.getHeader("Authorization");
|
String token = resolveToken(request);
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
if (token != null) {
|
||||||
String token = header.substring(7);
|
|
||||||
try {
|
try {
|
||||||
Claims claims = jwtService.parse(token);
|
Claims claims = jwtService.parse(token);
|
||||||
String email = claims.getSubject();
|
String email = claims.getSubject();
|
||||||
@@ -48,4 +47,17 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
}
|
}
|
||||||
filterChain.doFilter(request, response);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ public class SchemaFixer {
|
|||||||
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT"
|
"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(
|
String dataUrlType = jdbcTemplate.query(
|
||||||
"SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'",
|
"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
|
rs -> rs.next() ? rs.getString(1) : null
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import java.net.URI;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@@ -55,4 +57,20 @@ public class ListingController {
|
|||||||
.created(URI.create("/api/listings/" + response.id()))
|
.created(URI.create("/api/listings/" + response.id()))
|
||||||
.body(response);
|
.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<Void> delete(@PathVariable Long id, Authentication authentication) {
|
||||||
|
listingService.deleteOwn(id, authentication.getName());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public record ListingResponse(
|
|||||||
String coverPhoto,
|
String coverPhoto,
|
||||||
String ownerEmail,
|
String ownerEmail,
|
||||||
ListingStatus status,
|
ListingStatus status,
|
||||||
|
Long viewsCount,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
public static ListingResponse from(PropertyListing listing) {
|
public static ListingResponse from(PropertyListing listing) {
|
||||||
@@ -48,6 +49,7 @@ public record ListingResponse(
|
|||||||
listing.getCoverPhoto(),
|
listing.getCoverPhoto(),
|
||||||
listing.getOwnerEmail(),
|
listing.getOwnerEmail(),
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
|
listing.getViewsCount(),
|
||||||
listing.getCreatedAt()
|
listing.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ListingService {
|
public class ListingService {
|
||||||
@@ -18,10 +19,13 @@ public class ListingService {
|
|||||||
|
|
||||||
private final ListingRepository listingRepository;
|
private final ListingRepository listingRepository;
|
||||||
private final TextModerationService textModerationService;
|
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.listingRepository = listingRepository;
|
||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
|
this.notificationService = notificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
||||||
@@ -56,6 +60,39 @@ public class ListingService {
|
|||||||
.toList();
|
.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
|
@Transactional
|
||||||
public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) {
|
public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) {
|
||||||
textModerationService.validateOrThrow(
|
textModerationService.validateOrThrow(
|
||||||
@@ -123,7 +160,32 @@ public class ListingService {
|
|||||||
PropertyListing listing = listingRepository.findById(id)
|
PropertyListing listing = listingRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||||
listing.setStatus(status);
|
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) {
|
public void delete(Long id) {
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ package pl.polskalokalnie.listing;
|
|||||||
public enum ListingStatus {
|
public enum ListingStatus {
|
||||||
PENDING,
|
PENDING,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
REJECTED
|
REJECTED,
|
||||||
|
PAUSED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
@@ -15,15 +16,18 @@ public class MessageService {
|
|||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final TextModerationService textModerationService;
|
private final TextModerationService textModerationService;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
public MessageService(
|
public MessageService(
|
||||||
MessageRepository messageRepository,
|
MessageRepository messageRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
TextModerationService textModerationService
|
TextModerationService textModerationService,
|
||||||
|
NotificationService notificationService
|
||||||
) {
|
) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
|
this.notificationService = notificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser resolveAdmin() {
|
public AppUser resolveAdmin() {
|
||||||
@@ -46,9 +50,38 @@ public class MessageService {
|
|||||||
message.setRecipientId(recipientId);
|
message.setRecipientId(recipientId);
|
||||||
message.setContent(content.trim());
|
message.setContent(content.trim());
|
||||||
Message saved = messageRepository.save(message);
|
Message saved = messageRepository.save(message);
|
||||||
|
|
||||||
|
notifyRecipient(senderId, recipientId, saved.getContent());
|
||||||
|
|
||||||
return MessageResponse.from(saved, senderId);
|
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) {
|
public long countUnreadFrom(Long recipientId, Long senderId) {
|
||||||
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
|
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NotificationResponse> 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<Void> markRead(@PathVariable Long id, Authentication authentication) {
|
||||||
|
notificationService.markRead(id, authentication.getName());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/read-all")
|
||||||
|
public ResponseEntity<Void> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Notification, Long> {
|
||||||
|
|
||||||
|
List<Notification> 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);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, CopyOnWriteArrayList<SseEmitter>> 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<NotificationResponse> 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<SseEmitter> 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<SseEmitter> 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<SseEmitter> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+756
-101
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,14 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { AuthProvider } from './auth';
|
import { AuthProvider } from './auth';
|
||||||
|
import { NotificationsProvider } from './notifications';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<NotificationsProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</NotificationsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<NotificationsContextValue | null>(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<ServerNotification[]>([]);
|
||||||
|
const sentDedupeKeys = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const unreadCount = notifications.reduce((count, item) => (item.read ? count : count + 1), 0);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
setNotifications([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiFetch<ServerNotification[]>('/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<ServerNotification | null>('/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 (
|
||||||
|
<NotificationsContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, notify, refresh }}>
|
||||||
|
{children}
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications(): NotificationsContextValue {
|
||||||
|
const context = useContext(NotificationsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotifications musi być użyte wewnątrz NotificationsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -25842,3 +25842,805 @@ svg {
|
|||||||
align-items: flex-start;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user