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:
2026-07-21 17:02:25 +02:00
parent e9673551ce
commit 12fcb60b4c
18 changed files with 2314 additions and 112 deletions
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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>
<App /> <NotificationsProvider>
<App />
</NotificationsProvider>
</AuthProvider> </AuthProvider>
</React.StrictMode>, </React.StrictMode>,
); );
+178
View File
@@ -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;
}
+802
View File
@@ -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;
}